From aed1937025ba2c57a1b0a1d04b7b46ddffc50b8a Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:54:09 +0300 Subject: [PATCH] feat(provision): add the log_scale_on_start column SemiPlot draws a pen on a log10 Y axis when the operator asks for one (Semiteq/SemiPlot#72). The axis type a pen opens with is a start value stored beside the pen's other settings, so it lives in semiplot_tags. Add log_scale_on_start boolean NOT NULL DEFAULT false after the start scale pair and put it in plotEditableTagColumns, so the semiplot column grant and the tail check's write probe cover it. The registrar needs no change: a registered pen takes the default and opens on a linear axis. No CHECK ties the flag to a positive minimum; SemiPlot's editor refuses that combination. The CI register step now also probes the table as semiplot: a paired scale is accepted, a half-set or inverted pair fails the scale-pair CHECK, and a NULL flag fails NOT NULL. The grant and probe tests build their expected text from plotEditableTagColumns, so a new column touches the SQL, that list and TestSemiplotTagsColumns. The docs state what a start value is, that "Restore initial scale" returns to the scale pair and the log flag only, that the schema version rule starts with the first installation, and that a database provisioned before then is recreated rather than provisioned again. --- .github/workflows/ci.yml | 33 ++- CLAUDE.md | 15 +- docs/architecture/README.md | 2 +- docs/architecture/provisioning.md | 34 +-- docs/deployment.md | 24 +- .../20260916-semiplot-role-and-pen-schema.md | 11 +- .../completed/20261006-on-start-columns.md | 237 ++++++++++++++++++ internal/provision/check_test.go | 12 +- internal/provision/schema.go | 1 + internal/provision/schema_test.go | 8 +- sql/semiplot_tags.sql | 1 + 11 files changed, 328 insertions(+), 50 deletions(-) create mode 100644 docs/plans/completed/20261006-on-start-columns.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6b71b9..4dc7778 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,7 +72,7 @@ jobs: # Every other step runs semiplot_register_new_pens() over an empty # public.trends, so its INSERT adds nothing. - - name: Register pens from the keys SCADA wrote + - name: Register pens and probe the semiplot_tags constraints env: PGHOST: localhost PGDATABASE: semiplot_dev @@ -87,13 +87,40 @@ jobs: (13, 0, '2026-01-01 00:00:00', 20, 192), (40, 1, '2026-01-01 00:01:00', 7, 192)" first=$(as_plot 'SELECT semiplot_register_new_pens()') second=$(as_plot 'SELECT semiplot_register_new_pens()') - pens=$(as_plot "SELECT string_agg(concat_ws(' ', id, name, color, enabled_on_start), ',' + pens=$(as_plot "SELECT string_agg(concat_ws(' ', id, name, color, enabled_on_start, log_scale_on_start), ',' ORDER BY id) FROM semiplot_tags") - want='0 0 #4E79A7 f,13 13 #F28E2B f,40 40 #59A14F f' + want='0 0 #4E79A7 f f,13 13 #F28E2B f f,40 40 #59A14F f f' if [[ "$first" != 3 || "$second" != 0 || "$pens" != "$want" ]]; then echo "::error::first call '$first' (want 3), second '$second' (want 0), pens '$pens' (want '$want')" exit 1 fi + constraints=$(cat <<'SQL' + DO $$ + BEGIN + UPDATE semiplot_tags SET scale_min_on_start = 1, scale_max_on_start = 10 WHERE id = 0; + IF NOT FOUND THEN + RAISE EXCEPTION 'pen 0 is missing'; + END IF; + BEGIN + UPDATE semiplot_tags SET scale_min_on_start = 5, scale_max_on_start = NULL WHERE id = 0; + RAISE EXCEPTION 'semiplot_tags accepted the scale bounds (5, NULL)'; + EXCEPTION WHEN check_violation THEN NULL; + END; + BEGIN + UPDATE semiplot_tags SET scale_min_on_start = 5, scale_max_on_start = 1 WHERE id = 0; + RAISE EXCEPTION 'semiplot_tags accepted the scale bounds (5, 1)'; + EXCEPTION WHEN check_violation THEN NULL; + END; + BEGIN + UPDATE semiplot_tags SET log_scale_on_start = NULL WHERE id = 0; + RAISE EXCEPTION 'semiplot_tags accepted a NULL log_scale_on_start'; + EXCEPTION WHEN not_null_violation THEN NULL; + END; + END + $$ + SQL + ) + as_plot "$constraints" - name: Provision a PostgreSQL 14 container, where the REVOKE bites env: diff --git a/CLAUDE.md b/CLAUDE.md index 0bc0ace..8253727 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,10 +87,12 @@ place `REVOKE CREATE ON SCHEMA public FROM PUBLIC` bites, because 15 and later w by engine default — provisions a third container with `bench` as a `postgres` image init script over the unix socket, and builds the container image, so a broken `Dockerfile` fails on the pull request rather than at tag time. CI pushes nothing. Between the 17 and the 14 steps, the -`Register pens from the keys SCADA wrote` step writes `trends` rows as `scada_writer`, calls -`semiplot_register_new_pens()` twice as `semiplot` and requires 3, then 0, and the three rows it -added: the only place the function body runs over a non-empty archive. It needs `psql` on the -runner; to run it locally, point `psql` at a container. +`Register pens and probe the semiplot_tags constraints` step writes `trends` rows as +`scada_writer`, calls `semiplot_register_new_pens()` twice as `semiplot` and requires 3, then 0, +and the three rows it added: the only place the function body runs over a non-empty archive. As +`semiplot` it then updates pen 0 and requires the scale-pair `CHECK` to refuse unpaired and +inverted bounds (23514) and the log flag to refuse `NULL` (23502). It needs `psql` on the runner; +to run it locally, point `psql` at a container. Unit tests live beside the source (`cmd/semibase/*_test.go`, `internal/provision/*_test.go`), table-driven. There are no database-touching tests in the repository; the integration checks are @@ -125,7 +127,7 @@ in a rolled-back transaction, requiring 42501 for `INSERT`, `DELETE` and `UPDATE on the archive or on `semiplot_meta` and no `CREATE` on schema `public` (revoked from `PUBLIC` by `create`, since 14 still grants it). A failed check is a non-zero exit. -The `semiplot` grant on `semiplot_tags` is column-level: `SELECT` and `UPDATE` on the eight settings +The `semiplot` grant on `semiplot_tags` is column-level: `SELECT` and `UPDATE` on the nine settings columns, never `id`, so `has_table_privilege(..., 'UPDATE')` answers false for that table and no check may ask it. @@ -137,7 +139,8 @@ DEFINER` with `search_path = pg_catalog, pg_temp` and a schema-qualified body; t the operator acting through `semiplot`, not the SCADA (`docs/architecture/provisioning.md#trust`). `semiplot_meta.schema_version` is `1` in this release and is a floor: a viewer refuses a database below the version it needs and accepts one -above. +above. Until the first installation, a schema change, a rename included, edits the `CREATE TABLE` +alone and keeps `1` (`docs/architecture/provisioning.md#the-schema-version-is-a-floor`). Passwords come from flags, env, or a `.env` file in the working directory (flag > env > `.env`; template `.env.example`): `SEMIBASE_SUPER_PASSWORD`, `SEMIBASE_WRITER_PASSWORD`, diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 8435335..5f2fa25 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -25,7 +25,7 @@ Declarative architecture docs (English, present tense). These describe the syste | Command surface | Two commands, `site` and `bench`, named for the situation rather than for the tool's internal steps; they differ in one thing, the memory tuning, which only `site` applies | | Archive read access | `ALTER DEFAULT PRIVILEGES FOR ROLE scada_writer` set **before** `public.trends` is created, and before the writer first runs. The `semiplot_*` tables are granted table by table instead, since the superuser creates them | | Objects we add | `semiplot_tags`, `semiplot_groups`, `semiplot_pen_groups`, `semiplot_meta`, the `semiplot_register_new_pens()` function the viewer calls, and the vendor-shaped `public.trends` — no triggers, scheduled jobs, extensions, or other functions; `messages` is not created, the SCADA makes it | -| SemiPlot schema version | `semiplot_meta.schema_version`, `1` in this release, written by `provision` alone. A floor, not an equality: a viewer refuses a database below the version it needs and accepts one above, because the versions this repository issues grow by addition. The rule starts with the first installation: until then a schema change, a rename included, edits the `CREATE TABLE` alone and keeps `1`. | +| SemiPlot schema version | `semiplot_meta.schema_version`, `1` in this release, written by `provision` alone. A floor, not an equality: a viewer refuses a database below the version it needs and accepts one above, because the versions this repository issues grow by addition. When the rule starts, and what a schema change does before then: [provisioning.md](./provisioning.md#the-schema-version-is-a-floor) | | Archive schema | Owned by Simple-Scada 2 and documented in the SemiPlot repository. This tool creates `public.trends` once, with the vendor's shape, connected as `scada_writer`, and never alters it afterwards; day partitions and every later change to the schema remain the vendor's | | Distribution | Binaries as GitHub release assets; the Linux binary also as `ghcr.io/semiteq/semibase`, `FROM scratch`, one file, pushed after the release exists; benches track `:latest` and a prerelease never moves it | | Backup method | `UNDECIDED` — commissioning-time, needs the customer's archive size | diff --git a/docs/architecture/provisioning.md b/docs/architecture/provisioning.md index 8af2298..50e7aba 100644 --- a/docs/architecture/provisioning.md +++ b/docs/architecture/provisioning.md @@ -173,7 +173,7 @@ Four roles take part, and the tool creates three of them. | The superuser (`--superuser`, default `postgres`) | the engine install | Every connection this tool makes. It owns the four `semiplot_*` tables, because it is the connection that applies their DDL | | `semiplot_registrar` | `semibase` | `NOLOGIN`. Owns `semiplot_register_new_pens()` and holds what its body needs: `SELECT` on `public.trends`, `SELECT (id)` and `INSERT (id, name, color, enabled_on_start)` on `semiplot_tags` ([Registering new pens](#registering-new-pens)) | | `scada_writer` | `semibase` | Owns `public.trends`, its day partitions and `messages`, and writes them. `CREATE` on schema `public`. Nothing on any `semiplot_*` table | -| `semiplot` | `semibase` | `SELECT` on `trends` and `messages`; `SELECT` on `semiplot_tags` and `UPDATE` on its eight settings columns, never on `id`; `SELECT, INSERT, UPDATE, DELETE` on `semiplot_groups` and `semiplot_pen_groups`; `SELECT` on `semiplot_meta`; `EXECUTE` on `semiplot_register_new_pens()`. No `CREATE` on schema `public` | +| `semiplot` | `semibase` | `SELECT` on `trends` and `messages`; `SELECT` on `semiplot_tags` and `UPDATE` on its nine settings columns, never on `id`; `SELECT, INSERT, UPDATE, DELETE` on `semiplot_groups` and `semiplot_pen_groups`; `SELECT` on `semiplot_meta`; `EXECUTE` on `semiplot_register_new_pens()`. No `CREATE` on schema `public` | `semiplot` edits its own pen catalogue and cannot touch the archive. Both facts are grants on individual tables, so the second does not weaken when the first is given: the viewer's editor @@ -182,11 +182,11 @@ saves a pen through the same connection it reads history on, and a bug in it can A `semiplot_tags` row is keyed by `id`, the SCADA variable number. SemiPlot owns the settings in the row and none of the keys: its editor changes a pen and never adds one, deletes one or moves one onto -another variable. The grant on that table is therefore column-level, -`GRANT SELECT, UPDATE (name, unit, format, color, line_style, enabled_on_start, scale_min_on_start, scale_max_on_start)`, -and a table-level `UPDATE` would not do: it would let `UPDATE semiplot_tags SET id = ...` re-key a -pen. A column grant is not a table grant, so `has_table_privilege('semiplot', 'semiplot_tags', -'UPDATE')` answers false, and no check asks it. A pen row is added only by +another variable. The grant on that table is therefore column-level, `GRANT SELECT, UPDATE (name, +unit, format, color, line_style, enabled_on_start, scale_min_on_start, scale_max_on_start, +log_scale_on_start)`, and a table-level `UPDATE` would not do: it would let `UPDATE semiplot_tags +SET id = ...` re-key a pen. A column grant is not a table grant, so `has_table_privilege('semiplot', +'semiplot_tags', 'UPDATE')` answers false, and no check asks it. A pen row is added only by `semiplot_register_new_pens()` ([Registering new pens](#registering-new-pens)), and only the superuser deletes a pen. @@ -235,7 +235,7 @@ checks a `LANGUAGE sql` body against the relations it names when the function is | Table | Holds | | --- | --- | -| `semiplot_tags` | One pen: `id` matching `trends.id`, `name`, `unit`, `format` as a .NET numeric format string the viewer applies and the server does not validate, `color` as `#RRGGBB`, `line_style` (0 interpolated, 1 stepped), `enabled_on_start`, and the `scale_min_on_start`/`scale_max_on_start` pair that bounds the pen's own Y axis. Both bounds `NULL` means autoscale. A column with the `_on_start` suffix holds a start value: what the pen opens with in a new window and what the viewer's "Restore initial scale" returns to. Editing the pen on the chart changes the current view and never a start value, and a changed start value never changes a chart already open. Every other settings column is a live setting, which a running chart applies at its next catalogue read | +| `semiplot_tags` | One pen: `id` matching `trends.id`, `name`, `unit`, `format` as a .NET numeric format string the viewer applies and the server does not validate, `color` as `#RRGGBB`, `line_style` (0 interpolated, 1 stepped), `enabled_on_start`, the `scale_min_on_start`/`scale_max_on_start` pair that bounds the pen's own Y axis (both `NULL` means autoscale), and `log_scale_on_start`, the type of that axis: `false` is linear, `true` is log10. A column with the `_on_start` suffix holds a start value: what the pen opens with in a new window; for the scale pair and the log flag, also what the viewer's "Restore initial scale" returns to. Editing the pen on the chart changes the current view and never a start value, and a changed start value never changes a chart already open. Every other settings column is a live setting, which a running chart applies at its next catalogue read | | `semiplot_groups` | A group name. `GENERATED ALWAYS AS IDENTITY` rather than `serial`, so `semiplot` needs no `USAGE` on a sequence to insert one | | `semiplot_pen_groups` | Membership, `(pen_id, group_id)`. A pen may sit in several groups and in none: a pen with no row here is legal and the viewer shows it ungrouped | | `semiplot_meta` | One row: `schema_version`. A `singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton)` admits no second row, and the value is written by one `INSERT ... ON CONFLICT (singleton) DO UPDATE`, so a failure cannot leave the table empty and a re-run cannot leave two rows | @@ -254,12 +254,12 @@ arriving uneditable. The archive carries no variable catalogue of its own, so `trends.id` is the only reliable source of pen keys. `semiplot_register_new_pens()` (`sql/semiplot_register.sql`) inserts one `semiplot_tags` row for every key in `trends` that has none and returns how many it added. The viewer calls it only -from its pen editor's "Refresh pen list" button, never at start. A new row is named by its number, takes a colour -from a fixed twelve-colour palette by `id % 12`, which assumes SCADA variable numbers are -non-negative (a negative `id` gets a `NULL` colour, and the viewer picks one), starts with -`enabled_on_start = false`, so a SCADA -with 500 variables does not draw 500 lines at the next start, and autoscales. A variable SCADA -stops writing keeps its row, because its history is still in `trends`. A trigger on `trends` was +from its pen editor's "Refresh pen list" button, never at start. A new row is named by its number, +takes a colour from a fixed twelve-colour palette by `id % 12`, which assumes SCADA variable numbers +are non-negative (a negative `id` gets a `NULL` colour, and the viewer picks one), starts with +`enabled_on_start = false`, so a SCADA with 500 variables does not draw 500 lines at the next start, +autoscales, and opens on a linear axis (`log_scale_on_start = false`). A variable SCADA stops +writing keeps its row, because its history is still in `trends`. A trigger on `trends` was rejected: it would run on every sample SCADA writes, on the one table SemiPlot does not own. The function is `SECURITY DEFINER`, so `semiplot` adds a pen through it and holds no `INSERT` on @@ -320,8 +320,10 @@ storing a **lower** one; a **higher** one is accepted, because the versions this grow by addition and a database carrying more than a viewer needs still carries what it needs. The rule starts with the first installation. Until a site runs a provisioned database, a schema change, a column rename included, edits the `CREATE TABLE` alone, issues no `ALTER`, and leaves the -number at `1`. When `semiplot_markers` arrives it bumps the number to 2, and a viewer that reads only the pen -tables has to keep working against it, which an equality check would break. +number at `1`. A database provisioned before such a change is recreated, not provisioned again: +`CREATE TABLE IF NOT EXISTS` keeps its old table, and the column grant then fails with 42703 on the +column that table lacks. When `semiplot_markers` arrives it bumps the number to 2, and a viewer that +reads only the pen tables has to keep working against it, which an equality check would break. A removal is not signalled by this number. An older viewer meeting a dropped column gets 42703, which SemiPlot maps to an unexpected-shape failure naming the column. @@ -424,7 +426,7 @@ promises. They are knowable at exit precisely because this tool creates `public. body still reads `public.trends` and the check passes (both measured on 17). The qualifiers are what keeps the shadow out. Then `INSERT`, `UPDATE` and `DELETE` on `semiplot_groups` and `semiplot_pen_groups`, and one - `UPDATE` setting all eight settings columns of `semiplot_tags` to themselves (`WHERE id = -1`, + `UPDATE` setting all nine settings columns of `semiplot_tags` to themselves (`WHERE id = -1`, which matches no row), issued as the role, in the order the foreign keys need. The membership `INSERT` pairs the probe group with whatever pen exists (`INSERT ... SELECT tag.id, grp.id FROM semiplot_tags tag, semiplot_groups grp ... LIMIT diff --git a/docs/deployment.md b/docs/deployment.md index 1f278b6..1746042 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -70,10 +70,11 @@ SemiBase разворачивается одной утилитой — `semibas не может — эту проверку утилита выполняет на каждом запуске. Строка `semiplot_tags` привязана к номеру переменной SCADA. SemiPlot меняет в ней только -настройки пера: имя, единицы, формат, цвет, стиль линии, показ при старте и начальные границы шкалы. -Добавить перо напрямую, удалить его или сменить ему `id` роль не может, и утилита на каждом запуске -проверяет, что эти три команды ей запрещены. Новые перья добавляет только функция -`semiplot_register_new_pens()`, и только для переменных, которые SCADA уже записала в `trends`. +настройки пера: имя, единицы, формат, цвет, стиль линии, показ при старте, границы шкалы при старте +и логарифмическая шкала при старте. Добавить перо напрямую, удалить его или сменить ему `id` роль +не может, и утилита на каждом запуске проверяет, что эти три команды ей запрещены. Новые перья +добавляет только функция `semiplot_register_new_pens()`, и только для переменных, которые SCADA уже +записала в `trends`. Таблицы `semiplot_*` принадлежат суперпользователю: их создаёт то соединение, которое выполняет DDL. Права `CREATE` в схеме `public` у роли `semiplot` нет — утилита забирает его у `PUBLIC` @@ -98,18 +99,19 @@ DDL. Права `CREATE` в схеме `public` у роли `semiplot` нет | Таблица | Что хранит | | --------------------- | ------------------------------------------------------------------------------------------- | -| `semiplot_tags` | Перо: `id` из `trends`, имя, единицы, формат числа, цвет `#RRGGBB`, стиль линии, показ при старте, начальные границы шкалы | +| `semiplot_tags` | Перо: `id` из `trends`, имя, единицы, формат числа, цвет `#RRGGBB`, стиль линии, показ при старте, границы шкалы при старте, логарифмическая шкала при старте | | `semiplot_groups` | Имена групп перьев | | `semiplot_pen_groups` | Принадлежность пера группам. Перо может входить в несколько групп и ни в одну | | `semiplot_meta` | Одну строку: версию схемы | Перья появляются в `semiplot_tags` по кнопке «Обновить список перьев» в редакторе перьев SemiPlot, -при запуске SemiPlot их не добавляет. Кнопка вызывает функцию `semiplot_register_new_pens()`: она добавляет по строке на каждый номер -переменной из `trends`, у которого строки ещё нет, и возвращает число добавленных. Новое перо -называется своим номером, получает цвет из фиксированной палитры, по умолчанию скрыто при старте -и масштабируется автоматически. Перо переменной, которую SCADA перестала писать, остаётся: её -история по-прежнему лежит в `trends`. Номера переменных SCADA считаются неотрицательными: перо с -отрицательным номером остаётся без цвета, и цвет выбирает SemiPlot. +при запуске SemiPlot их не добавляет. Кнопка вызывает функцию `semiplot_register_new_pens()`: она +добавляет по строке на каждый номер переменной из `trends`, у которого строки ещё нет, и возвращает +число добавленных. Новое перо называется своим номером, получает цвет из фиксированной палитры, по +умолчанию скрыто при старте, масштабируется автоматически и открывается на линейной шкале. Перо +переменной, которую SCADA перестала писать, остаётся: её история по-прежнему лежит в `trends`. +Номера переменных SCADA считаются неотрицательными: перо с отрицательным номером остаётся без +цвета, и цвет выбирает SemiPlot. Функцией владеет отдельная роль `semiplot_registrar`, под которой нельзя войти. Функция выполняется с её правами, а не с правами суперпользователя: чтение `trends` и добавление строк в diff --git a/docs/plans/completed/20260916-semiplot-role-and-pen-schema.md b/docs/plans/completed/20260916-semiplot-role-and-pen-schema.md index af81cb2..b4c5c2e 100644 --- a/docs/plans/completed/20260916-semiplot-role-and-pen-schema.md +++ b/docs/plans/completed/20260916-semiplot-role-and-pen-schema.md @@ -822,9 +822,9 @@ its row: its history is still in `trends`. PostgreSQL grants `EXECUTE` on a new OWNER TO` in the same invocation, which succeeds or aborts the run, so it guarded an unreachable state; the checks after it are renumbered. A failed pen-registration call no longer prescribes `GRANT EXECUTE`: a 42501 raised inside the body is not a missing `EXECUTE` -- [x] + review fix: the CI step `Register pens from the keys SCADA wrote` runs the function body over - three keys written as `scada_writer` and requires 3, then 0, and each row's name, colour and - `enabled_on_start = false` +- [x] + review fix: the CI step `Register pens and probe the semiplot_tags constraints` runs the + function body over three keys written as `scada_writer` and requires 3, then 0, and each + row's name, colour and `enabled_on_start = false` - [x] + review fix: the temporary-trends replay stays although `create` writes the function from embedded bytes that `TestRegisterFunctionIgnoresTheCallersSchemas` pins. It is the only execution proof that `semiplot` cannot make the function add a key absent from `public.trends`, @@ -959,8 +959,9 @@ the allowed writes, and the refused `semiplot_tags` writes. `UPDATE semiplot_tags SET id = id` each fail with `permission denied` (42501). On `v0.3.0` the INSERT and DELETE succeeded. 2. **A key SCADA writes becomes a hidden pen.** As `scada_writer`, create a day partition and write rows for - ids 0, 13, 40 (the CI step "Register pens from the keys SCADA wrote" in `.github/workflows/ci.yml` has the - exact statements). As `semiplot`: `SELECT semiplot_register_new_pens()` returns 3, a second call 0, and + ids 0, 13, 40 (the CI step "Register pens and probe the semiplot_tags constraints" in + `.github/workflows/ci.yml` has the exact statements). As `semiplot`: + `SELECT semiplot_register_new_pens()` returns 3, a second call 0, and `SELECT id, name, color, enabled_on_start FROM semiplot_tags ORDER BY id` shows `0 0 #4E79A7 f`, `13 13 #F28E2B f`, `40 40 #59A14F f`. 3. **`semiplot` cannot register a key it invents.** As `semiplot`: diff --git a/docs/plans/completed/20261006-on-start-columns.md b/docs/plans/completed/20261006-on-start-columns.md new file mode 100644 index 0000000..d1c0e58 --- /dev/null +++ b/docs/plans/completed/20261006-on-start-columns.md @@ -0,0 +1,237 @@ +# On-start pen columns: the scale pair renamed, the log-scale flag added + +## Overview + +A `semiplot_tags` column holds either a live pen setting or a start value. A live setting (name, +unit, format, colour, line style) changes a running SemiPlot chart at the next catalogue read. A +start value is what a pen opens with in a new window and what "Restore initial scale" goes back to; +editing the pen on the chart changes the current view and never the start value, and a changed start +value never changes a chart already open. + +Start values carry the `_on_start` suffix. Today only `enabled_on_start` does, while the start scale +pair is `scale_min`/`scale_max`. SemiPlot#72 adds a third start value, the log10 axis type. This +plan makes the naming uniform and adds the flag, in two pull requests and one release: + +1. Rename `scale_min`, `scale_max` to `scale_min_on_start`, `scale_max_on_start`. +2. Add `log_scale_on_start boolean NOT NULL DEFAULT false`, which `semiplot` may `UPDATE`. + +No installation runs a database provisioned by an earlier release, so both changes go into the +`CREATE TABLE` alone, with no `ALTER` for an existing table, and `semiplot_meta.schema_version` +stays `1`. The docs that state the version rule ("the versions this repository issues grow by +addition") say that the rule starts with the first installation, so a rename before it carries no +bump. The SemiPlot half is `SemiPlot/docs/plans/20261006-log10-y-axis.md`; it runs its container +tests only after `v0.5.0` is tagged. + +## Context (from discovery) + +- `sql/semiplot_tags.sql:3-17`: nine columns ending in `scale_min`, `scale_max` (`:11-12`), then + `semiplot_tags_scale_paired` (`:13-15`), whose body names both, and `semiplot_tags_color_hex`. +- `internal/provision/schema.go:22-31`: `plotEditableTagColumns`, which builds the column grant at + `:38`. +- `internal/provision/check.go:29-30`: the tail check's write probe assigns every + `plotEditableTagColumns` entry; `check.go` needs no change for a renamed or new entry. +- `sql/semiplot_register.sql:11-15`: the registrar inserts `(id, name, color, enabled_on_start)`; + neither change touches it or its grant. +- Tests that spell the column set out: + - `internal/provision/schema_test.go:60-86` (`TestSemiplotTagsColumns`, the list at `:64-74`); + - `:147-153` (`TestSemiplotGrantStatements`, the text at `:152-153`); + - `internal/provision/check_test.go:37-45` (`TestPlotTagsUpdateProbeWritesEverySettingsColumn`, + `:38-40`) and `:85-100` (`TestWriteProbeFailure`, `wantTags` at `:95-96`). + - `:217-229` (`TestTagsUpdateGrantCoversEverySettingsColumn`) derives its list from the SQL and + needs no edit. +- `.github/workflows/ci.yml:65-96`: the service container is provisioned with `site` twice, then + three pens (ids 0, 13, 40) are registered and `concat_ws(' ', id, name, color, enabled_on_start)` + of each is compared with a fixed string. +- Docs naming the columns or their count, cited by text because task 1 shifts the line numbers + task 2 reads: + - the grant text "`GRANT SELECT, UPDATE (name, unit, ... scale_min, scale_max)`" and the + `semiplot_tags` table row, `docs/architecture/provisioning.md`; + - "eight settings columns", twice in `docs/architecture/provisioning.md` (the roles table and tail + check step 3) and once in `CLAUDE.md`; + - the defaults a registered pen starts with ("starts with `enabled_on_start = false` ... and + autoscales"), `docs/architecture/provisioning.md#registering-new-pens`; + - "границы шкалы" in the `semiplot_tags` row of `docs/deployment.md` (Russian). +- The version rule, "grow by addition": `docs/architecture/README.md:28`, + `docs/architecture/provisioning.md:316-320`. +- Latest release tag: `v0.4.0`. + +## Development Approach + +- **testing approach**: Regular (code first, then tests in the same task) +- each of tasks 1 and 2 is its own branch and pull request, off `origin/master`, in that order +- every task ends with `go test ./...`, `go vet ./...` and `golangci-lint run` green; the next task + starts only after that + +## Testing Strategy + +- unit tests: `internal/provision/*_test.go`, table-driven, beside the source +- database behaviour: the repository has no database-touching Go tests (`CLAUDE.md`, "There are + no database-touching tests"); the CI `site`, register and `bench` steps run every statement + against PostgreSQL 14 and 17 + +## Acceptance Evidence + +Reproduce today, against a database provisioned by `v0.4.0`: + +```powershell +psql -U semiplot -d semiplot_dev -c "SELECT scale_min_on_start, log_scale_on_start FROM semiplot_tags LIMIT 1" +# ERROR: column "scale_min_on_start" does not exist +``` + +After both pull requests: + +1. `go test ./...` passes: `TestSemiplotTagsColumns` lists + `..., enabled_on_start, scale_min_on_start, scale_max_on_start, log_scale_on_start`; + `TestSemiplotGrantStatements` and `TestWriteProbeFailure` name the same three in the `UPDATE` + list; `TestPlotTagsUpdateProbeWritesEverySettingsColumn` assigns them. +2. The CI register step passes with `want='0 0 #4E79A7 f f,13 13 #F28E2B f f,40 40 #59A14F f f'`. +3. The CI `site` twice step and the `bench` steps on 14 and 17 pass: the tail check's write probe + has updated all three columns as `semiplot` on both server versions, and the scale-pair + constraint was created over the renamed columns. +4. `git grep -nw "scale_min\|scale_max" -- ':!docs/plans'` prints nothing. + +Checks 1 and 4 passed on the unmerged `on-start-columns` branch. Checks 2 and 3 are pending the +pull-request CI run: they need the `linux` job's live PostgreSQL 14 and 17. + +## Solution Overview + +- The suffix marks a start value; a live setting has none. The constraint keeps its name, + `semiplot_tags_scale_paired`, which names the rule rather than the columns. +- `log_scale_on_start` is `NOT NULL DEFAULT false`: a pen opens on a linear axis until the operator + sets the flag, and the registrar needs no change. +- No `CHECK` ties the flag to `scale_min_on_start > 0`. SemiPlot's editor refuses that combination; + a constraint would make the order of two single-column writes matter to the operator. + +## Technical Details + +```sql +CREATE TABLE IF NOT EXISTS semiplot_tags ( + id integer PRIMARY KEY, + ... + enabled_on_start boolean NOT NULL DEFAULT true, + scale_min_on_start double precision, + scale_max_on_start double precision, + log_scale_on_start boolean NOT NULL DEFAULT false, + CONSTRAINT semiplot_tags_scale_paired CHECK ( + (scale_min_on_start IS NULL) = (scale_max_on_start IS NULL) + AND (scale_min_on_start IS NULL OR scale_min_on_start < scale_max_on_start)), + CONSTRAINT semiplot_tags_color_hex CHECK (...) +); +``` + +The grant becomes `GRANT SELECT, UPDATE (name, unit, format, color, line_style, enabled_on_start, +scale_min_on_start, scale_max_on_start, log_scale_on_start) ON semiplot_tags TO semiplot`. + +## What Goes Where + +- Implementation Steps: SQL, the column list, tests, CI and docs in this repository +- Post-Completion: the release tag, the order against SemiPlot, the benches + +## Implementation Steps + +### Task 1: Rename the start scale pair (pull request 1) + +**Files:** +- Modify: `sql/semiplot_tags.sql` +- Modify: `internal/provision/schema.go` +- Modify: `internal/provision/schema_test.go`, `internal/provision/check_test.go` +- Modify: `docs/architecture/provisioning.md`, `docs/architecture/README.md`, `docs/deployment.md` + +- [x] rename both columns in the `CREATE` and in the `semiplot_tags_scale_paired` body; re-align the + column block to the longer names +- [x] rename both entries of `plotEditableTagColumns` +- [x] update `TestSemiplotTagsColumns`, `TestSemiplotGrantStatements`, + `TestPlotTagsUpdateProbeWritesEverySettingsColumn` and `TestWriteProbeFailure` +- [x] docs: the grant text and the `semiplot_tags` row in `provisioning.md` use the new names and + say what a start value is; `docs/deployment.md` reads "границы шкалы при старте"; + `README.md:28` and `provisioning.md:316-320` start the version rule at the first installation +- [x] run `go test ./...`, `go vet ./...`, `golangci-lint run` (the `linux` job is open under + Post-Completion) + +### Task 2: Add the log-scale flag (pull request 2) + +**Files:** +- Modify: `sql/semiplot_tags.sql` +- Modify: `internal/provision/schema.go` +- Modify: `internal/provision/schema_test.go`, `internal/provision/check_test.go` +- Modify: `.github/workflows/ci.yml` +- Modify: `docs/architecture/provisioning.md`, `CLAUDE.md`, `docs/deployment.md` + +- [x] declare `log_scale_on_start boolean NOT NULL DEFAULT false` after `scale_max_on_start` +- [x] append `"log_scale_on_start"` to `plotEditableTagColumns` +- [x] extend the four tests of task 1 with the column +- [x] register step: `concat_ws(' ', id, name, color, enabled_on_start, log_scale_on_start)` and + `want='0 0 #4E79A7 f f,13 13 #F28E2B f f,40 40 #59A14F f f'` +- [x] docs: the grant text and the `semiplot_tags` row in `provisioning.md` name the column (the + axis type a pen opens with, `false` is linear); the three "eight settings columns" say "nine"; + `#registering-new-pens` adds that a registered pen opens on a linear axis; + `docs/deployment.md` appends "логарифмическая шкала при старте" +- [x] run `go test ./...`, `go vet ./...`, `golangci-lint run` (the `linux` job is open under + Post-Completion) + +### Task 3: Verify acceptance criteria + +- [x] run acceptance checks 1 and 4 on the clean `on-start-columns` branch (checks 2 and 3, and the + run from `master` after both merges, are open under Post-Completion) + +## Post-Completion + +**Delivery, open**: these were not done by the execution run. + +- [ ] split the branch into the two pull requests the plan names: task 1's commit, then task 2's + commit with the review fixes; the plan-only commit goes with the second, and no review-fix + header (`fix(docs): address review findings` among them) ships +- [ ] push each pull request and read its `linux` job; task 1's must pass before task 2's merges +- [ ] acceptance checks 2 and 3 pass in that CI run; then all four from a clean `master` after both + merges +- [ ] move this plan to `docs/plans/completed/` + +**Release**: tag `v0.5.0` after both merges, with a `**New Features**` entry for the log-scale flag +and a `**Breaking Changes**` entry for the renamed pair. The workflow pushes +`ghcr.io/semiteq/semibase:latest`. + +**SemiPlot**: its container tests pull `:latest` before every build +(`SemiPlot/SemiPlot.Tests.Integration/DockerCli.cs:8-14`), so its pull requests run them after the +tag. Nothing merges into SemiPlot between the tag and its rename pull request. + +**Benches**: rebuild every long-lived bench container after the tag. `CREATE TABLE IF NOT EXISTS` +leaves a table provisioned by `v0.4.0` as it is, the new grant then fails on the missing column, and +`converge` clones the stale `semiplot_provisioned` rather than provisioning it again. + +**Executed by exec:** + +- branch: on-start-columns + +## Verify it yourself + +1. Unit tests and the rename, on the branch: + + ```powershell + cd C:\Users\admin\projects\SemiBase + go test -count=1 ./... + git grep -nw "scale_min\|scale_max" -- ':!docs/plans' # prints nothing + ``` + + On `origin/master` the grep prints the old names in `sql/semiplot_tags.sql`, + `internal/provision/schema.go` and the docs; on `a7ed621` and later it prints nothing. + +2. The schema on a real server (what CI's `linux` job proves after the push): + + ```powershell + go build -o semibase.exe ./cmd/semibase + docker run -d --name sb17 -e POSTGRES_PASSWORD=super -p 15435:5432 postgres:17-alpine + $env:SEMIBASE_SUPER_PASSWORD="super"; $env:SEMIBASE_WRITER_PASSWORD="w"; $env:SEMIBASE_PLOT_PASSWORD="p" + .\semibase.exe bench --host 127.0.0.1 --port 15435 --database semiplot_dev --expected-major 17 + psql "host=127.0.0.1 port=15435 dbname=semiplot_dev user=semiplot password=p" -c ` + "SELECT scale_min_on_start, scale_max_on_start, log_scale_on_start FROM semiplot_tags LIMIT 1" + docker rm -f sb17 + ``` + + Before (a `v0.4.0` binary): `column "scale_min_on_start" does not exist`. After: the query + runs, and `bench` ends on a passing access check whose write probe sets all nine settings + columns as `semiplot`. + +3. The constraint probe: the CI step `Register pens and probe the semiplot_tags constraints` runs + on the pull request. It fails if `(5, NULL)` or `(5, 1)` is accepted as a scale pair, or a + `NULL` `log_scale_on_start` is accepted; four mutated schemas were run against it locally and + each failed the step. diff --git a/internal/provision/check_test.go b/internal/provision/check_test.go index b7cb376..268f11c 100644 --- a/internal/provision/check_test.go +++ b/internal/provision/check_test.go @@ -35,9 +35,11 @@ func TestPlotWriteProbesCoverTheWritableTables(t *testing.T) { } func TestPlotTagsUpdateProbeWritesEverySettingsColumn(t *testing.T) { - want := "UPDATE semiplot_tags SET name = name, unit = unit, format = format, color = color, " + - "line_style = line_style, enabled_on_start = enabled_on_start, " + - "scale_min_on_start = scale_min_on_start, scale_max_on_start = scale_max_on_start WHERE id = -1" + assignments := make([]string, 0, len(plotEditableTagColumns)) + for _, column := range plotEditableTagColumns { + assignments = append(assignments, column+" = "+column) + } + want := "UPDATE semiplot_tags SET " + strings.Join(assignments, ", ") + " WHERE id = -1" probes := plotWriteProbes() if got := probes[probePosition(t, probes, "semiplot_tags", "UPDATE")].statement; got != want { t.Errorf("the semiplot_tags probe is %q, want %q", got, want) @@ -92,9 +94,7 @@ func TestWriteProbeFailure(t *testing.T) { } tags := writeProbeFailure(plotWriteProbe{"semiplot_tags", "UPDATE", ""}, errors.New("permission denied")) - wantTags := "GRANT SELECT, UPDATE (name, unit, format, color, line_style, enabled_on_start, " + - "scale_min_on_start, scale_max_on_start) ON semiplot_tags TO semiplot" - if !strings.Contains(tags.Error(), wantTags) { + if !strings.Contains(tags.Error(), tagsColumnGrant()) { t.Errorf("the semiplot_tags repair does not prescribe the column grant: %q", tags) } } diff --git a/internal/provision/schema.go b/internal/provision/schema.go index ce5717f..679e7d1 100644 --- a/internal/provision/schema.go +++ b/internal/provision/schema.go @@ -28,6 +28,7 @@ var plotEditableTagColumns = []string{ "enabled_on_start", "scale_min_on_start", "scale_max_on_start", + "log_scale_on_start", } const ( diff --git a/internal/provision/schema_test.go b/internal/provision/schema_test.go index 7572d7d..1a80e25 100644 --- a/internal/provision/schema_test.go +++ b/internal/provision/schema_test.go @@ -71,6 +71,7 @@ func TestSemiplotTagsColumns(t *testing.T) { "enabled_on_start", "scale_min_on_start", "scale_max_on_start", + "log_scale_on_start", } got := columnNamesOf(semibase.SemiplotTagsSQL) if len(got) != len(want) { @@ -144,13 +145,16 @@ func TestSemiplotTablesMatchTheEmbeddedFiles(t *testing.T) { } } +func tagsColumnGrant() string { + return "GRANT SELECT, UPDATE (" + strings.Join(plotEditableTagColumns, ", ") + ") ON semiplot_tags TO semiplot" +} + func TestSemiplotGrantStatements(t *testing.T) { want := []struct { object string statement string }{ - {"semiplot_tags", "GRANT SELECT, UPDATE (name, unit, format, color, line_style, enabled_on_start, " + - "scale_min_on_start, scale_max_on_start) ON semiplot_tags TO semiplot"}, + {"semiplot_tags", tagsColumnGrant()}, {"semiplot_groups", "GRANT SELECT, INSERT, UPDATE, DELETE ON semiplot_groups TO semiplot"}, {"semiplot_pen_groups", "GRANT SELECT, INSERT, UPDATE, DELETE ON semiplot_pen_groups TO semiplot"}, {"semiplot_meta", "GRANT SELECT ON semiplot_meta TO semiplot"}, diff --git a/sql/semiplot_tags.sql b/sql/semiplot_tags.sql index bf0b0e1..c6600a3 100644 --- a/sql/semiplot_tags.sql +++ b/sql/semiplot_tags.sql @@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS semiplot_tags ( enabled_on_start boolean NOT NULL DEFAULT true, scale_min_on_start double precision, scale_max_on_start double precision, + log_scale_on_start boolean NOT NULL DEFAULT false, CONSTRAINT semiplot_tags_scale_paired CHECK ( (scale_min_on_start IS NULL) = (scale_max_on_start IS NULL) AND (scale_min_on_start IS NULL OR scale_min_on_start < scale_max_on_start)),