Skip to content

Commit c6f26ea

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-25012-cbp-write-path-master-edit
Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
2 parents 2f80e0d + dfe60e3 commit c6f26ea

35 files changed

Lines changed: 2025 additions & 405 deletions

‎.changeset/24924-template-channel-email-sms.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22
'@objectstack/service-messaging': patch
33
---
44

5-
fix(service-messaging): a `sys_notification_template` saved for a channel other than `email` or `sms` is now warned about, because no channel ever sends it
5+
fix(service-messaging): the `sys_notification_template` copy now says that only the email and SMS channels use a template row, and that the in-app inbox never does
66

77
A notification template row is read by exactly two channels: the email channel (when the notification names no email template) and the SMS channel, which also renders the phone OTP and invitation texts. The in-app inbox reads no row on any path. `channel` is free text, so a row saved for `inbox`, `push` or any other value was stored and then rendered nowhere, with nothing to say so.
88

9-
The object now carries a `channel_renders_template` rule at `severity: 'warning'`. A write naming another channel is still accepted and stored, and it now reports the warning. A single-record create, clone or update answers it in the write answer's `warnings` (`rule`, `severity`, `field: 'channel'`, `code`, `message`), and the server logs it once per write. Bulk updates and imports log it. The message points in-app content to an email template (`email_template`) named in a notify step's `template`. The object's description, its empty state and the `channel` / `subject` help now say which channels use a row and that the inbox never does.
9+
This entry first added a `channel_renders_template` warning for such a write; in the same release #24983's refusal superseded it, so a write naming another channel is refused rather than warned about, as that entry describes. The object's description, its empty state and the `channel` / `subject` help now say which channels use a row and that the inbox never does.
1010

11-
Clause-②: no — nothing a write could send before is refused now; a save naming another channel gains one advisory in the write answer's existing `warnings`.
11+
Clause-②: no — this entry now describes copy only (the object's description, its empty state and the field help); the narrowing of what a write may send is #24983's, declared in its own entry.
1212

13-
- **Unchanged.** Every write that was accepted is still accepted, and stored rows are not read, rewritten or flagged by anything but a write that names their channel. `email` and `sms` rows, how every channel renders, and the column and its width are unchanged.
13+
- **Unchanged.** Nothing this entry describes changes which writes are accepted. `email` and `sms` rows, how every channel renders, and the column and its width are unchanged.
1414
- **What to do.** For in-app content, write an email template and name it in the notify step's `template`; the inbox and email both render it. A row for another channel was never sent, so delete it if you no longer want it listed.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): a unique refusal's user-facing sentence names the object and the field by label
6+
7+
Clause-②: no (text only: the user-facing sentence names the object and field by label, and `developerMessage` opens with the API names; no code, status, outcome or export moves)
8+
9+
`DuplicateRecordError.message` is the sentence every door keeps as thrown, so a record save and a screen flow refused by a unique constraint show it to the end user word for word. It named the object, and the field when the driver reported one, by API name: `Duplicate record refused on 'crm_account': …`.
10+
11+
- **The sentence now uses the declared `label`.** The engine looks up the object's and the field's labels in its registry on every write door that raises the refusal: insert (single and batch), and update by id and by predicate. Example: `Duplicate record refused on 'Account': a unique constraint on 'Account Name' already holds this value. No record was written.` Where no label is declared, the sentence uses the API name, exactly as before. The opening words and `No record was written.` are unchanged.
12+
- **The API names stay available for diagnostics.** They are still on `object` and `field`, and `developerMessage` now opens with them: `Duplicate record refused on object 'crm_account', field 'name'. …`.
13+
- **Unchanged:** the code (`DUPLICATE_RECORD` in the engine, `UNIQUE_VIOLATION` on every wire route), the 409 status and the outcome. `DuplicateRecordError`'s constructor takes an optional fourth argument with the labels. Callers that pass three arguments get the API-name sentence.
14+
- **For HTTP clients:** on the REST data routes the curated `error` text is unchanged. The wire `developerMessage` (which carries the engine's sentence) now names the labels, and the body's `object` and `field` keys still carry the API names.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os migrate plan` and `os migrate apply` name the command each held drop waits for, never `--allow-destructive`, for every drop `apply` refuses (#24958)
6+
7+
Clause-②: no
8+
9+
`os migrate apply --allow-destructive` refuses the whole run, whatever flag is passed, while it would drop one of three columns before its precondition holds: `sys_account.issuer` while its retirement pre-flight finds `(provider_id, account_id)` keys held by more than one row (or cannot read `sys_account`), `sys_comment.reactions` while no verified run of `os migrate comment-reactions --apply` is recorded, and an `organization_id` column while the organization-ownership ceremony has not verified. Yet `os migrate plan` offered `--allow-destructive` for the first two, and `os migrate apply` without the flag listed all three with `"os migrate apply --allow-destructive" to drop it.` and closed with `N destructive change(s) will be SKIPPED (re-run with --allow-destructive to include them).` and `Skipped N destructive change(s) (re-run with --allow-destructive to include them).` That re-run was refused, exit 1.
10+
11+
- **Now.** Each such drop is still listed and counted as destructive drift, in both commands. Its line names what it waits for: the ceremony, `os migrate comment-reactions`, or the retirement pre-flight `os migrate account-issuer`.
12+
- **The footer.** One line per gate that holds a drop names that gate's commands and says `--allow-destructive` is refused until they have run. `os migrate plan` offers `--allow-destructive` for nothing while a drop is held. `os migrate apply` prints `N destructive change(s) will be SKIPPED, and a re-run with --allow-destructive cannot include them yet:` before those lines, and `Skipped N destructive change(s); a re-run with --allow-destructive cannot include them yet (see above).` after the apply.
13+
- **Unchanged.** A database with no held drop prints what it printed before, byte for byte, in both commands. The `--json` documents, the exit codes and every refusal of `os migrate apply --allow-destructive` (its rule and its text) are unchanged.
14+
15+
**What to do.** Nothing. Follow the footer: run the command each held drop names, then re-run `os migrate plan`.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/driver-sql': minor
3+
'@objectstack/service-datasource': minor
4+
---
5+
6+
fix(driver-sql): a SQLite file whose bytes are not a SQLite database fails `connect()` at once, naming the file, and a development boot no longer moves it aside and replaces it with an empty database (#24960)
7+
8+
`SqlDriver.connect()` runs `PRAGMA auto_vacuum` as the connection's first statement. On a file that is not a SQLite database (plain text, another format, a file encrypted by another engine) SQLite answers `SQLITE_NOTADB`, which `connect()` logged as "Failed to set PRAGMA auto_vacuum=INCREMENTAL" and swallowed. It resolved over a file it could not read. In production the first query failed later. On a development boot, `resolveSqliteDriver` (`@objectstack/service-datasource`) answered the failed probe by stepping down to wasm SQLite, whose open renamed the file to `<file>.corrupt-<timestamp>`, wrote an empty database in its place and read `connected`, under a warning that blamed the native addon.
9+
10+
`connect()` now refuses such a file with a `SqliteDatabaseFileError` (`code: 'SQLITE_NOTADB'`, `filename`, a message naming the file), as it already did for `SQLITE_CANTOPEN`, in every mode and before anything is written. A declared datasource's boot prints `datasource '<name>': connect failed — <reason>`. `resolveSqliteDriver` classifies `SQLITE_NOTADB` as a file failure and re-throws it instead of stepping down, so the operator's file is left exactly as it was found. A real database file, an empty file and an absent file connect as before, and a native addon that cannot load still steps down to wasm SQLite.
11+
12+
Clause-②: no (narrowing)
13+
14+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed (`SqliteDatabaseFileError` keeps its shape; only the set of codes it is raised with grows by one), so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer `SqlDriver.connect()` gives for a SQLite file that is not a database: a rejection where it resolved. The remedy is the datasource's file, never a rewrite of a package source or a stored row. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
15+
16+
**BREAKING** (`SqlDriver.connect()`'s accept set narrows): a connect that resolved over a SQLite file that is not a database now rejects. It ships as `minor` under the launch-window convention for accept-set narrowings: during the launch window a breaking change does not take `major`, so the level does not say it breaks, and breaking-ness is carried by this banner and the disposition above.
17+
18+
- **Before:** `connect()` resolved; in production the first query failed with `DATABASE_ERROR`, and on a development boot the file was renamed to `<file>.corrupt-<timestamp>` and an empty database was written in its place.
19+
- **Now:** `connect()` rejects with `SqliteDatabaseFileError`, `code: 'SQLITE_NOTADB'`, naming the file; the file is untouched.
20+
- **Fix:** point the datasource at a SQLite database file, or move the file aside yourself (an absent file is created empty on the next connect).
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
fix(core): the import resolves a lookup cell by the target object's stored title field
6+
7+
Clause-②: no
8+
9+
- **Before.** A lookup cell was matched against the target by its id, then by the lookup's own `displayField`, then by a fixed list of keys (`name`, `title`, `label`, `full_name`, `email`, `username`). The target object's declared title field was never asked. So a cell that named the record by its title, such as `Wren` for a target whose title is `nickname`, answered `reference_not_found`, although the list export writes lookup cells by that same title.
10+
- **Now.** After the id and the lookup's `displayField`, the import asks the target's title field, resolved by ADR-0079's one rule (`resolveDisplayField`: `nameField`, then the deprecated `displayNameField` alias, then the derived title). It is read from the same object declaration the import already reads to check the target's exposure, so no extra metadata read is made. Two records that share the title answer `reference_ambiguous`, like the import's other ambiguous matches. The cell is never linked to the first hit.
11+
- **A formula title is not matched.** A `formula` field has no stored column, and the engine refuses a filter over it. The import no longer sends that query, for the title or for any other candidate the target declares as a formula, so a cell against a formula title still answers `reference_not_found`. How a formula title should be matched is still open.
12+
- **Unchanged.** The fixed fallback keys and their order. The exposure check still runs first: a target whose declared exposure does not serve `list` answers `reference_not_found` for every cell and is never read.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/service-messaging': minor
3+
---
4+
5+
fix(service-messaging)!: `sys_notification_template` refuses a write that leaves a row naming a channel other than `email` or `sms`, because no channel ever sends such a row
6+
7+
A notification template row is read by exactly two channels: the email channel (when the notification names no email template) and the SMS channel, which also renders the phone OTP and invitation texts. The in-app inbox reads no row on any path. `channel` is free text, so a row for `inbox`, `push` or any other value was stored and then rendered nowhere; the `channel_renders_template` rule only warned about it. That rule now refuses. It is an invariant judged over the stored row overlaid with the write, so it answers rows already stored with another channel as well as new ones: such a row is refused on any edit that leaves its channel out of the set, until an edit moves it to `email` or `sms`, and it is still read, listed and deleted. No stored row is rewritten or deleted by this change.
8+
9+
Clause-②: no (narrowing)
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. The one definition that changes is a validation rule on `sys_notification_template`, a platform object this package ships and owns, and what narrows is the write accept set of that object's rows, which are admin-authored data rather than metadata: no ADR-0087 conversion reaches a stored data row. The remedy is an operator's edit or delete of the row, never a rewrite of a metadata source, and this change rewrites or deletes no stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
12+
13+
**BREAKING** (a platform object's write accept set narrows, stored rows included), shipped as `minor` under the launch-window convention for breaking changes.
14+
15+
- **FROM:** a create, update, import row or bulk-update row that left a `sys_notification_template` row naming a channel other than `email` or `sms` was accepted and stored, with at most a `channel_renders_template` advisory in the write answer's `warnings`. A row already stored with such a channel took every edit.
16+
- **TO:** that write is refused. The REST data door answers `400` with `code: 'VALIDATION_FAILED'` and one finding on `channel` (`code: 'rule_violation'`); an import commit fails that row with `field: 'channel'` and `code: 'rule_violation'`; a bulk update fails that row with `code: 'VALIDATION_FAILED'` and `httpStatus: 400`, and a predicate update over a set holding such a row writes no row. Every refusal carries the prescription: set the channel to `email` or `sms`, or delete the template; in-app content is an email template (`email_template`) named in a notify step's `template`.
17+
- **Unchanged:** `email` and `sms` rows and every write to them; reading, listing and deleting any row; how every channel renders, so a row for another channel is still sent by none; and the `channel` column and its width.
18+
- **The fix for an operator:** a row for another channel was never sent. Delete it, or set its channel to `email` or `sms` if its text belongs there. For in-app content, write an email template and name it in the notify step's `template`; the inbox and email both render it.

‎content/docs/protocol/kernel/error-handling.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ class — keeps its own code.
265265
(on `DELETE_RESTRICTED`, `CONCURRENT_UPDATE` and `VALIDATION_FAILED`). The `message` is the
266266
thrown one and can differ from the flat door's `error`: an unregistered object reads
267267
`Object 'ghost' not found` here and `Object 'ghost' is not registered` there, and a repeated
268-
unique value reads the engine's `Duplicate record refused on 'account': …` here, the same
268+
unique value reads the engine's `Duplicate record refused on 'Account': …` (the object's label) here, the same
269269
sentence `details.developerMessage` carries, where the flat door's `error` reads
270270
`A record with this email already exists`.
271271

0 commit comments

Comments
 (0)