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)),