Skip to content

Commit 970f966

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-24956-boot-drift-line-names-ceremony
2 parents bdd1ac5 + 6251f59 commit 970f966

39 files changed

Lines changed: 2562 additions & 185 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/service-datasource': patch
3+
---
4+
5+
fix(service-datasource)!: Sync (remote tables, object draft, import, catalog refresh) refuses a managed datasource with the existing `400`, because ADR-0015 §4.2 reserves federation for a datasource whose `schemaMode` is not `managed`
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- 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, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer four runtime REST doors give a caller who asks them to list, draft, import or catalog the tables of a datasource the platform owns; the remedy is to sync from a datasource declared `external` or `validate-only`, never a rewrite of a package source or a stored row. Objects an earlier import already bound to a managed datasource are not touched. -->
11+
12+
**BREAKING** (four REST doors' accept set narrows). ADR-0015 §1 puts a federated object on a datasource whose `schemaMode` is not `managed`, and §4.2 allows `object.external` only there; `managed` is the spec's default. The Sync doors refused only the platform's primary datasource (`default`). Every other managed datasource answered, including the runtime datasources an administrator creates in Setup → Datasources, which are saved with no `schemaMode` and so are managed: their tables were listed, drafted and imported as federated objects. The engine's write gate (ADR-0015 §5.3) gates only non-managed datasources, so nothing stood behind such an import. One guard in the federation service now refuses before anything is introspected, and every door that reaches the service inherits it.
13+
14+
- **FROM:** on a datasource other than `default` whose `schemaMode` is `managed`, declared or by default, `GET /api/v1/datasources/:name/remote-tables` and `GET /api/v1/datasources/:name/external/tables` answered `200` with its tables; `POST /api/v1/datasources/:name/object-draft` and `POST /api/v1/datasources/:name/external/tables/:remote/draft` answered `200` with a federated object draft bound to it; `POST /api/v1/datasources/:name/external/tables/:remote/import` answered `201` and saved that object; `POST /api/v1/datasources/:name/external/refresh-catalog` answered `200` and stored an `external_catalog` for it. A name with no datasource definition was introspected whenever a driver was registered under it.
15+
- **TO:** each of those requests answers `400` with the code its door already serves for a refusal, `EXTERNAL_DATASOURCE_ERROR` (the import: `EXTERNAL_IMPORT_ERROR`), and a message naming the rule and the remedy. Nothing is introspected, drafted, saved or stored. A name with no datasource definition gets the same refusal: nothing declares it a federation source.
16+
- **Unchanged:** `default` keeps its own refusal and message. A datasource declared `schemaMode: 'external'` or `'validate-only'` lists, drafts, imports and refreshes as before. Test (`POST /api/v1/datasources/:name/test`) and validation (`POST /api/v1/datasources/:name/external/validate`) answer as before; validation already skipped a managed datasource, and it now asks the same predicate the guard asks. No status code or error code is added.
17+
- **The fix for an integrator:** sync from a datasource the platform does not own. Declare it with `schemaMode: 'external'` (or `'validate-only'`) and its `external` settings; for a runtime datasource, send both in `PATCH /api/v1/datasources/:name`. A managed datasource's tables are the platform's own objects: define objects on it directly instead of importing them.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
fix(lint)!: `os build` and the object save door refuse a `validations[]` rule whose predicate reads `parent` (#24957)
6+
7+
Clause-②: no (narrowing: `os build` and the object save door refuse a `validations[]` rule whose predicate reads `parent`, which the server's rule evaluator never binds)
8+
Metadata-protocol: narrowed
9+
10+
A `validations[]` rule's predicates are a `script` or `cross_field` rule's `condition`, a `conditional` rule's `when`, and the same slots on the rules nested in its `then` / `otherwise`. The server evaluates them against `record` and `previous` only. The master-detail header is bound as `parent` for a field's own `readonlyWhen` and `requiredWhen` alone, on an object with exactly one `master_detail`. A rule that reads `parent`, such as `parent.status == 'paid'`, faults (`Unknown variable: parent`) on every write it judges, and the server refuses that write as unevaluable, the seed's own rows at boot included.
11+
12+
The build's expression rule (`validateStackExpressions`) reported such a predicate at `warning` (#24720). This entry supersedes that entry's sentence "Nothing new is refused in 18.x" and its plan to refuse in 19.0: the refusal ships now, in this release, and this entry is the current statement. The rule now refuses such a predicate at `error`, at the same location (`object 'NAME' · validation 'RULE'`, `… when`, or the nested rule's label), with the same rule id (`expression-invalid`). The message no longer says it is a warning. The object save door runs the same pass, so its verdict is the build's finding.
13+
14+
**BREAKING — what moves for consumers.**
15+
16+
- `os build`, `os validate` and `os lint` fail on a `validations[]` rule whose predicate reads `parent`. The rule is judged as written, so this also covers a rule marked `active: false`, a rule whose `events` a stack never fires, and a `conditional` branch whose `when` is never true.
17+
- An object write in publish mode that carries such a rule answered 200, with the finding among its advisories. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at the rule. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`).
18+
19+
**Remedy.** To lock or require a field on the header's state, write the predicate on that field's own `readonlyWhen` or `requiredWhen`, where `parent` is bound. To refuse a whole write on the header's state (for example a new line under a paid invoice), use a lifecycle hook (`beforeInsert` / `beforeUpdate` / `beforeDelete`) that reads the header. For any other check, read a column the detail object declares, and denormalise the header value you need onto the detail. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged.
20+
21+
**Unchanged.**
22+
23+
- The runtime is unchanged: a `validations[]` rule still binds `record` and `previous` and no `parent`, and such a rule already refused every write it judged. If the runtime comes to bind `parent` there, this refusal is withdrawn in that same change.
24+
- `parent` on a field's own `readonlyWhen` / `requiredWhen` is still accepted with no finding, and so is a `record` column spelled like the root (`record.parent_code`).
25+
- Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged.
26+
- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
27+
- No example, template or package object in this repository authors `parent` in a `validations[]` rule.
28+
- No public export or signature moves.
29+
30+
<!-- adr-0087: not-required (no-migration-prescription) a refusal at `os build` and at the object save door of a validations[] rule predicate that reads `parent`, which the server's rule evaluator never binds: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. A stored object carrying such a rule keeps loading until it is next saved, and the repair is the author's rewrite of the predicate (a field rule, a lifecycle hook, or a denormalised column), which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and the change is a build and door verdict, not a declaration (not runtime-interface-only or type-surface-only). -->

‎.changeset/25078-csv-text-cell.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest)!: the CSV export writes a text cell its consumer would interpret in a form that consumer reads as text, and the CSV import reverses exactly that
6+
7+
Clause-②: no (narrowing) — the CSV export serves different bytes for some text cells, and the CSV import parse reads a cell of the same shape differently; nothing is widened.
8+
Metadata-protocol: none (a REST export's bytes and a REST import's parse; nothing a metadata author writes changes)
9+
10+
<!-- 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, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the bytes one REST export format serves for some text cells and how the CSV import parse reads a cell of that same shape; stored rows are not touched. 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). -->
11+
12+
**BREAKING** (the CSV export's bytes change for some text cells, and the CSV import reads a user-authored cell of the same shape differently), shipped as `patch` because the metadata protocol does not move.
13+
14+
The CSV export (`GET /api/v1/data/:object/export?format=csv`) wrote every text cell exactly as stored, so a cell whose leading character the program the file is opened in interprets was read there as something other than text. The rule below is defined once, in `csvTextCellNeedsMark` (`packages/rest/src/export-format.ts`), and both directions read it.
15+
16+
- **The export.** A CSV text cell whose first character is one of a small fixed set that its consumer interprets is written with one apostrophe (U+0027) in front of its content, before the usual RFC-4180 quoting. A text cell is a string in a column whose field is not declared numeric, boolean, date or datetime, a header label, or a reference field's display title; with no field metadata (the raw path) every string cell is one. A cell made only of digits, spaces and the characters a signed number or a phone number is written with is never marked, so phone numbers and signed amounts are byte-identical.
17+
- **The import.** `parseCsvToRows` removes exactly one leading apostrophe from a cell, header row included, when the rest of the cell is one the export would mark. It serves the sync import (`POST /api/v1/data/:object/import`), the async import job (`POST /api/v1/data/:object/import/jobs`) and plugin-auth's admin user import (`POST /api/v1/auth/admin/import-users`), with an inline mapping or a named one. So a CSV exported and imported back returns the stored value, with the one exception in the next point.
18+
- **A cell that begins with an apostrophe** followed by a cell the export would mark now imports without that apostrophe; the file cannot tell it from an exported one. That holds for a user-authored cell, and for a stored text value of that shape, which the export writes unchanged, so it comes back without its leading apostrophe. A cell beginning with an apostrophe followed by anything else imports unchanged.
19+
- **Measured.** A value beginning with TAB or CR is marked; the import removes the mark, and the default `trimWhitespace: true` then trims the leading TAB or CR exactly as it did before. With `trimWhitespace: false` it returns exactly.
20+
- **Unchanged.** Every other cell; numeric, boolean, date and datetime cells; the xlsx and json export formats; json and xlsx import payloads.
21+
- **What to do.** Nothing for a file that goes back through these import doors. A program that reads the CSV export some other way and needs the stored text applies the same reversal: remove one leading apostrophe from a cell whose rest the export would mark, by the rule in this repository's `packages/rest/src/export-format.ts`.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
feat(cli): `os migrate plan` lists the tenant columns still nullable and names the voluntary organization-ownership ceremony; the ceremony reports a rotation shard already NOT NULL as such (#25134)
6+
7+
Clause-②: yes (widening) — `os migrate plan --json` gains the `nullableTenantColumns` field, and the ceremony's `not-null` step gains the `already-not-null` outcome and the `alreadyNotNull` summary count. No exit code, drift entry, op, refusal or accepted input moves.
8+
9+
Metadata-protocol: none
10+
11+
A database the published 18.0.0 created is certified at birth with its tenant columns (`organization_id`) nullable, and `os migrate organization-ownership`, which tightens them to `NOT NULL`, is voluntary on it. Nothing told the operator that.
12+
13+
- **`os migrate plan`: a new advisory section.** It lists every tenant column the database still holds nullable (table and column) and names `os migrate organization-ownership` as the remedy. Each column says whether the ceremony tightens it or leaves it, and why. The ceremony leaves a rotation shard created before shards were created `NOT NULL`, `sys_email_template` (its rows are mirrors the ceremony deletes, and the table retires), a table its inventory exempts, and every table on a database whose transport cannot rebuild a table (the Turso remote face). When the ceremony would tighten none of the listed columns, the section says so and does not offer it. A fresh install lists nothing. A column named `organization_id` on an object with tenancy disabled (`sys_sso_provider`) is not a tenant column and is not listed.
14+
- **Advisory only.** It is not drift: it proposes no change, adds nothing to `total` or `changes`, and the exit code does not change. Under `--json` it is the separate field `nullableTenantColumns`: `{ status: 'read', adr, remedy, columns: [{ table, column, database?, ceremony: 'tightens' | 'leaves', reason? }] }`, or `{ status: 'unreadable', adr, detail }` when the plan could not read it.
15+
- **The ceremony's `not-null` step.** A rotation shard that the driver created `NOT NULL` used to be reported as `unsupported`, with a note saying each new shard is created nullable. That has not been true since the driver began creating shards `NOT NULL`. Such a shard is now `already-not-null` (counted in `alreadyNotNull`) and gets no note. A shard rotated in before then stays `unsupported`, and its note says it is dropped when its period leaves the base table's retention.
16+
17+
**What to do.** Nothing is required: the server starts either way. To tighten the columns the plan lists, run `os migrate organization-ownership --out PLAN`, review it, then run `os migrate organization-ownership --apply --plan-file PLAN --backup-taken`.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/cli': patch
3+
'create-objectstack': patch
4+
---
5+
6+
fix(cli,create-objectstack): new projects declare vitest `~4.1.12`, outside GHSA-qxg6-89mg-rqv8, and still install on npm 10
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
The vitest range every scaffold declares moves from `~4.0.0` to `~4.1.12`: the `create-objectstack` blank template, `os init -t plugin` and `os create plugin` (in `devDependencies`), and the npm `overrides` block that every standalone scaffold emits, `os init -t app` and `-t empty` included. `~4.0.0` resolved to vitest and `@vitest/mocker` 4.0.18, inside GHSA-qxg6-89mg-rqv8 (`@vitest/mocker` from 2.1.0-beta.7 up to the fix in 4.1.12), which has no 4.0.x fix. A new project now resolves vitest and `@vitest/mocker` 4.1.12 or a later 4.1.x patch, and `npm audit` of a fresh scaffold lists no vitest or `@vitest/*` advisory.
12+
13+
npm 10, the npm Node 22 bundles, still installs every scaffold. The `overrides.vitest` entry, which equals `devDependencies.vitest`, is what makes that hold: it settles vitest 4.1.12's contradictory optional peer (`@vitest/browser-webdriverio`), which npm 10 otherwise crashes on with `Cannot read properties of null (reading 'edgesOut')`. Measured on npm 10.9.4, 10.9.9 and 11.21.1. pnpm ignores npm's `overrides` field.
14+
15+
**An existing project made by an earlier `create-objectstack`, `os init` or `os create plugin`** keeps its own `~4.0.0`. To move it, set `overrides.vitest` to `~4.1.12` in `package.json`, and `devDependencies.vitest` too where the project declares it, then run `npm install`. npm refuses an override that differs from the direct dependency (`EOVERRIDE`), so the two move together.

‎content/docs/deployment/cli.mdx‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,37 @@ without `NODE_ENV=development` describes a production `os serve`, which keeps th
986986
objects in the primary unless `OS_TELEMETRY_DB` names a file. A `plan` against a
987987
sibling that does not exist yet lists its tables to create and leaves no file behind.
988988
989+
#### Tenant columns still nullable (advisory)
990+
991+
`os migrate plan` lists every tenant column the database still holds nullable, in a
992+
section of its own, and names `os migrate organization-ownership` as the remedy. A
993+
database the published 18.0.0 created is certified at birth with its tenant columns
994+
nullable, and the [organization-ownership ceremony](#os-migrate-organization-ownership)
995+
that tightens them is voluntary on it: the server starts either way. A database the
996+
ceremony upgraded keeps only the few the ceremony leaves, and each of those is listed
997+
with the reason it stays nullable:
998+
999+
```text
1000+
ℹ 2 tenant column(s) are still nullable (ADR-0131 D1). This is advisory: it is not drift, and nothing in this plan or in "os migrate apply" changes it.
1001+
• sys_activity__r20261020.organization_id — the ceremony leaves it nullable: a rotation shard of sys_activity, created before shards were created NOT NULL: the ceremony rebuilds no shard, and it is dropped when its period leaves sys_activity's retention
1002+
• sys_email_template.organization_id — the ceremony leaves it nullable: its rows are mirrors the ceremony deletes, and the table retires (ADR-0131 D10 fate 2, D13), so the ceremony does not tighten it
1003+
None of them is one the organization-ownership ceremony tightens; each line says why it stays nullable.
1004+
```
1005+
1006+
When the ceremony would tighten any listed column, the section's last line names its
1007+
two commands instead. On a Turso remote database every listed column is one the
1008+
ceremony leaves, because that transport cannot rebuild a table. A fresh install creates
1009+
every tenant column `NOT NULL` and the section prints nothing. A column named
1010+
`organization_id` on an object with tenancy disabled (`sys_sso_provider`) is not a
1011+
tenant column and is not listed.
1012+
1013+
The section is advisory only. It is not drift: it proposes no change, adds nothing to
1014+
the change count, and does not change the exit code. Under `--json` it is the separate
1015+
`nullableTenantColumns` field: `{ status: "read", adr, remedy, columns }`, where each
1016+
column is `{ table, column, database?, ceremony: "tightens" | "leaves", reason? }`. It is
1017+
`{ status: "unreadable", detail }` when the plan could not read it; `columns: []` means
1018+
it read and found none.
1019+
9891020
#### Nothing is applied before you confirm
9901021
9911022
Both commands boot your app to read its metadata. That boot writes no row and no

0 commit comments

Comments
 (0)