Skip to content

Commit ea9309b

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20821-migrate-plan-deferred-reads
2 parents c6aff7c + 2821e9f commit ea9309b

130 files changed

Lines changed: 7369 additions & 2252 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): writing a retired key now fails `tsc` with an error that says the key was retired and where its migration is printed (#20621)
6+
7+
Clause-②: yes
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) this changes only the declared TypeScript type of every `retiredKey()` tombstone, from `undefined` to an uninhabited mark carrying the retirement sentence, on both the input and the parsed side. No authorable key, spelling, export or stored shape moves: the parse, its prescription, the JSON schemas and the authorable surface are unchanged. Every tombstoned key's own retirement is already ledgered under its own ADR-0087 id, and this diff retires nothing new, so `objectstack migrate meta` has nothing to rewrite. The body carries no migration prescription. The only party affected is a TypeScript consumer that reads a tombstoned key into a typed slot, and the compiler shows that consumer the dead read at its own call site. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers this type mark and this diff adds none (not `registered` / `already-registered`); `retiredKey` is referenced in code at 287 sites (not `runtime-interface-only`); and the diff touches `packages/spec` (not `type-surface-only`). -->
10+
11+
**What an author sees.** Every key a `retiredKey()` tombstone declares used to be typed `undefined`, so writing one failed `tsc` with an error that named no retirement:
12+
13+
```
14+
error TS2322: Type 'string[]' is not assignable to type 'undefined'.
15+
```
16+
17+
Upgrading authors read those errors as typing bugs. The same line now fails like this, for arrays, objects and primitive values alike:
18+
19+
```
20+
error TS2741: Property ''[REMOVED] Key retired: run `os validate` for its migration.'' is missing in type 'string[]' but required in type '{ '[REMOVED] Key retired: run `os validate` for its migration.': never; }'.
21+
error TS2322: Type 'string' is not assignable to type '{ '[REMOVED] Key retired: run `os validate` for its migration.': never; }'.
22+
```
23+
24+
A hover on the key shows the same text. `os validate` and the parse print the key's own prescription, which says what replaced the key and gives the one-line fix.
25+
26+
**What changed.** The declared type of each tombstoned key, on both the input side (`z.input`, the bare `X` aliases) and the parsed side (`z.infer`, the `XParsed` aliases), is now `` { '[REMOVED] Key retired: run `os validate` for its migration.': never } | undefined `` instead of `undefined`. No value can have that object type, because its one property is typed `never`. So `tsc` still accepts only absence, as before. Both sides carry the same type, which keeps the ADR-0122 isomorphism pins true.
27+
28+
**What did not change.** Runtime behaviour is the same. Each tombstone is still `z.never().optional()`. The parse error and its prescription, the text `os validate` prints, the ADR-0087 conversions, the JSON schemas and the authorable-surface artifacts are all unchanged. No export was added or removed.
29+
30+
**BREAKING**: a read of a tombstoned key into a slot typed `undefined`, or typed with the key's old type, no longer compiles, because the key's declared type is now its tombstone mark. The key never holds a value, so delete the dead read. It ships as `minor` under the launch-window convention.
31+
32+
**Who might notice.** Code that assigns a tombstoned key's value to a slot typed exactly `undefined` (for example `const x: undefined = page.assignedProfiles`) no longer compiles. The key never holds a value, so delete the read. The published declaration files are about 7.5% larger, because the declaration emitter writes the type out at every site of the 287 `retiredKey()` calls.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): the published bundles' text equals the source again — the build marks pure calls only, never a string (#20686)
6+
7+
The build annotates each call of `lazySchema`, `strictObject` and `defineForm` as pure so a consumer's bundler can drop the schemas it never reaches. The rule that placed those annotations also rewrote a marked name quoted inside a string, so one D3 migration entry of protocol 18 (`dashboard-widget-stage-order-non-funnel-refused`) shipped a build-time comment marker inside its `acceptanceCriteria` text in `@objectstack/spec/migrations`, where its source reads `` `z.strictObject(DashboardWidgetSchema.shape)` ``. `os migrate meta` prints that text as the entry's `verify:` line when protocol 18 is the migration target. The published value now equals the source, character for character.
8+
9+
The annotations now come from the TypeScript parse of each file, so a marked name inside a string, template text, a comment or a declaration is never touched. Every call that carried an annotation still carries one, and the published code is otherwise unchanged. One call, `z.strictObject(…)` in the Turso driver config schema, now has its annotation in front of the call rather than after the `z.`, where esbuild had been dropping it. Measured with esbuild, a consumer that imports one schema from `@objectstack/spec/data` or `defineStack` from the root gets a bundle of the same size as before.
10+
11+
Clause-②: no
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/driver-sql': minor
3+
'@objectstack/driver-sqlite-wasm': patch
4+
'@objectstack/driver-turso': patch
5+
'@objectstack/plugin-security': patch
6+
'@objectstack/spec': patch
7+
---
8+
9+
fix(driver-sql, driver-turso, plugin-security, spec)!: `SqlDriver` and `TursoDriver` compile the filter they are handed — their copies of the whole-day bound and of the NULL-safe `$not` rewrite are deleted, and the RLS compile seam lowers type-blind when it cannot read the declared types (ADR-0053 D-D1 items 5, 7 and 9, #20822)
10+
11+
Clause-②: no (narrowing)
12+
13+
<!-- adr-0087: registered driver-sql-calendar-day-methods-removed -->
14+
15+
**BREAKING**: `SqlDriver` in `@objectstack/driver-sql` loses three `protected` methods: `calendarDayExclusiveUpperBound`, `calendarDayUpperBoundRewrite` and `calendarDayBetweenRewrite`. They were the driver's copy of the whole-day bound, which the shared lowering now applies once, at the seams, before a driver sees the filter. A subclass of `SqlDriver` that calls one of them, or overrides one with the `override` modifier, no longer compiles (TS2339, TS4113). That includes a subclass of `SqliteWasmDriver` or `TursoDriver`, which extend `SqlDriver`. A subclass that re-declares one without `override` still compiles, but the driver never calls it, so the rule it carried stops applying. It ships as `minor` under the launch-window convention. The class's public methods are unchanged.
16+
17+
FROM → TO: a `SqlDriver` subclass that called `this.calendarDayUpperBoundRewrite(table, field, op, value)`, `this.calendarDayBetweenRewrite(table, field, value)` or `this.calendarDayExclusiveUpperBound(table, field, value)`, or overrode one of them, lowers the filter with `lowerFilterCondition` from `@objectstack/spec/data` instead, before the driver compiles it: `lowerFilterCondition(where, { isDatetimeColumn })`, where `isDatetimeColumn` answers which columns get the whole-day bound.
18+
19+
**Supersedes two sentences of this release's shared-lowering entry** (`lowerFilterCondition`, #5930), which this change makes false:
20+
21+
- "A guard without that set treats no column as `datetime`." Now: when the RLS compile seam has no field guard, or one without a `datetime` set, it cannot read which columns are `datetime`, so it applies the whole-day rule to every column (the `@objectstack/plugin-security` entry below).
22+
- "Each driver keeps its own copy of these rules, and every copy gives the same answer on lowered input." Now: `SqlDriver`, `SqliteWasmDriver` and `TursoDriver` keep no copy of the whole-day bound or of the NULL-safe `$not` rewrite. A read through the engine or the RLS compile seam gets the lowered answer, and a call on the driver itself gets the comparison it wrote (the `@objectstack/driver-sql` and `@objectstack/driver-turso` entries below).
23+
24+
- **`@objectstack/plugin-security` — an RLS policy compiled with no field guard is lowered type-blind.** The RLS compile seam runs the shared `lowerFilterCondition` on every compiled `using` and `check` filter. When the security plugin could not resolve the object's declared fields (no field guard), or a caller of `RLSCompiler.compileFilter` passes a guard without a `datetime` set, the seam cannot read which columns are `datetime`, and it now applies the whole-day rule to every column (a bare-day upper bound becomes `$lt` the next day), as ADR-0053 D-D1 item 7 rules for a seam that cannot read the type. It used to read no column as `datetime`, which left the bound to each driver's own copy of the rule. Visible on a `using` policy such as `record.signed_on <= '2026-01-05'` on such an object: every row of that day is kept on every driver, including `InMemoryDriver`, which had compared it as written since its own copy was deleted. A guard with a `datetime` set is unchanged, and so are the NULL-polarity guards.
25+
- **`@objectstack/driver-sql` — `SqlDriver` keeps no copy of the rules the seams apply.** Deleted: the whole-day rewrite of a bare-day `$lte` and of a `$between` maximum on a `datetime` column, on the plain and the legacy-normalised column paths, including the last supported day (the protected methods `calendarDayExclusiveUpperBound`, `calendarDayUpperBoundRewrite` and `calendarDayBetweenRewrite` are removed from the class); and the NULL-safe rewrite of a `$not` operand (`nullSafeNegationOperand` and its polarity tables, module-private). A read through the engine or the RLS compile seam is unchanged: the seam hands the driver a filter the shared lowering has already rewritten, and the deleted copies gave the same answer on that input. A caller that passes no seam — `find`, `findOne`, `count`, `aggregate`, `distinct`, `updateMany`, `deleteMany` or `findWithWindowFunctions` called on the driver itself — now gets the comparison it wrote: a bare-day `$lte` compares against that day's midnight, a `$between` is inclusive at both ends, `$lte '9999-12-31'` compares against that midnight, and a `$not` is SQL's three-valued negation, so a row whose compared column is NULL is not returned by it. `$ne`, `$nin` and `$notContains` keep their NULL-safe form, which this emitter spells for the operator itself. The refusal of an `undefined` comparand (`INVALID_FILTER` / 400) is kept: without it some positions would answer instead of refusing. To keep the seam's reading on a direct call, lower the filter first: `driver.find(object, { where: lowerFilterCondition(where, { isDatetimeColumn }) })`, with `lowerFilterCondition` from `@objectstack/spec/data`. A subclass that called or overrode one of the three removed methods: see **BREAKING** above.
26+
- **`@objectstack/driver-sqlite-wasm` — `SqliteWasmDriver` inherits the `SqlDriver` change above**, with the same answers on a seamed read and on a direct call.
27+
- **`@objectstack/driver-turso` — both faces of `TursoDriver` compile the filter they are handed.** Local and replica mode inherit the `SqlDriver` change. Remote mode: `toRemoteFilter` no longer widens a bare-day `$lte` or a `$between` maximum (it still splits a two-bound `$between` into the `$gte` / `$lte` pair the remote transport compiles, both ends inclusive, and still converts each comparand to storage form), and `RemoteTransport` no longer rewrites a `$not` operand (its copy of the polarity tables is deleted). The two faces still answer every filter alike, on a seamed read and on a direct call. The remote transport keeps its refusal of an `undefined` comparand, worded as `driver-sql`'s, so both faces refuse it in one sentence. The same one line keeps the seam's reading on a direct call.
28+
- **`@objectstack/spec` — the ADR-0087 ledger records the removal.** The protocol-18 step of `MIGRATIONS_BY_MAJOR` gains the semantic entry `driver-sql-calendar-day-methods-removed`, which names the three removed methods with their replacement and acceptance criteria. Every upgrade channel that projects protocol 18 carries it. `spec-changes.json` and the generated upgrade guide stop at the current protocol, 17, so neither changes in this release. A subclass that re-declares one of the methods without `override` still compiles and is never called, so the ledger, not the compiler, is the notice that reaches it.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
feat(cli)!: `objectstack validate` and `objectstack build` refuse a `picklistExtensions` entry whose `extend` names no picklist the stack declares (#20825)
6+
7+
Clause-②: no (narrowing — `objectstack validate` / `objectstack build` newly refuse a `picklistExtensions[].extend` that names no picklist the stack declares; nothing is accepted that was refused before)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable changes spelling or type: `packages/spec` is untouched, and `picklistExtensions[].extend` stays the snake_case name it was. What changes is that two authoring commands now refuse one authored shape, an extension whose `extend` names no picklist in the stack. `objectstack migrate meta` could not rewrite that shape even in principle, because which list the author meant is not in the metadata. Nothing here judges a stored row. -->
10+
11+
**BREAKING** — an accept-set narrowing on two authoring commands, shipped as
12+
`minor` under the launch-window convention. A stack with a `picklistExtensions`
13+
entry whose `extend` names no picklist the stack declares — `extend: 'industy'`
14+
beside a `picklists: [{ name: 'industry', … }]` — used to pass `objectstack
15+
validate` and `objectstack build` (which wrote the artifact). Both now exit 1 and
16+
name the extension and the list it names (`picklist-reference-unknown`, the rule a
17+
field's dangling `picklist` already gets).
18+
**One-line fix:** correct `extend` to the picklist the entry adds options to, declare
19+
the list it names (`picklists: [{ name, label, options }]`, or a `*.picklist.ts` file
20+
the stack imports), or remove the entry.
21+
22+
**Which extensions are judged.** The ones the load path registers: the top-level
23+
`picklistExtensions` of a one-package stack, or each `packages[]` entry's own. An
24+
`extend` resolves against every picklist the stack declares, including one a sibling
25+
package in the same artifact owns.
26+
27+
**A list from a package outside the stack is reported, not refused.** When the
28+
package declaring the extension lists a `manifest.dependencies` entry the stack does
29+
not carry, the list may live there, and these commands cannot read it. That
30+
extension is an `info` notice (`picklist-reference-unverified`) in `warnings` and on
31+
the console, naming the extension, the list and the dependencies — never a failure,
32+
not even under `--strict`.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/driver-sql': patch
4+
'@objectstack/service-analytics': patch
5+
---
6+
7+
fix: the analytics native-SQL path answers a measure its response declares `number` as a number on every dialect, presented by the one rule `driver-sql`'s `aggregate()` applies, which `@objectstack/core` now exports as `AGGREGATE_ANSWER_KIND` and `presentAsNumber` (#20889)
8+
9+
Clause-②: yes (widening)
10+
11+
**New exports.** `@objectstack/core` exports two names, moved here unchanged
12+
from `@objectstack/driver-sql`, which now imports them instead of keeping them
13+
private:
14+
15+
- `AGGREGATE_ANSWER_KIND`: what each declared aggregate function answers.
16+
`count`, `count_distinct`, `sum` and `avg` answer `'number'`; `min` and `max`
17+
answer `'column'`, a value of the aggregated column.
18+
- `presentAsNumber(value)`: the `'number'` presentation. A string `Number()`
19+
reads as a number becomes that number. Any other value is returned as given:
20+
a number, `null`, a boolean, empty or blank text, or text that reads as NaN.
21+
22+
**What changed.** On PostgreSQL, `POST /api/v1/analytics/query` and
23+
`POST /api/v1/analytics/dataset/query` answered through `NativeSQLStrategy`
24+
returned count, count_distinct, sum, avg, and min / max over a numeric column
25+
as strings, such as `count: "2"` and
26+
`sum: "500.000000000000000000000000000000"`, while `fields[]` declared
27+
`number`. A dataset's `row_count` did the same, and a measure-scoped count
28+
mixed `"1"` with the number `0` in one column. SQLite answered numbers. The
29+
strategy now presents each measure column by its declared aggregate function,
30+
through the same table and presenter as `SqlDriver.aggregate()`. `min` / `max`
31+
are presented only when their column is declared numeric, so `max` over a text
32+
column, every dimension, and expression measures keep the value the database
33+
returned.
34+
35+
**Precision.** The answer is one JS number, the policy `driver-sql`'s
36+
`aggregate()` already applies. A total that needs more digits than a double
37+
holds, such as `9007199254740993`, answers the nearest double
38+
(`9007199254740992`), which is also what SQLite and the engine path answer.
39+
40+
**What did not move.** `@objectstack/driver-sql`'s behaviour is unchanged: its
41+
`aggregate()` reads the same table, and its read presenter calls the same
42+
function. The answers on SQLite are byte-identical. The arithmetic of the
43+
analytics native statement did not change either. On PostgreSQL its `sum` and
44+
`avg` still add exact decimals, so `0.1 + 0.2` answers `0.3` where the engine
45+
path answers `0.30000000000000004`.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): every refusal of an in-place edit of a packaged flow, action or permission set names that type's own sanctioned path, at every door, and a packaged action's removal names one too, not a redeploy or `OS_METADATA_WRITABLE` (#20910)
6+
7+
Clause-②: no
8+
9+
ADR-0126 puts `flow`, `action` and `permission` in Regime C. The packaged base is locked, and the refusal names the sanctioned path. Until now only a flow's package-less refusal did, and only at one of the three places that refuse such a write. The other places prescribed "Edit the source artifact and redeploy, or set OS_METADATA_WRITABLE …" or "Set OS_METADATA_WRITABLE to enable additional types at runtime". A packaged action's removal named no path at all.
10+
11+
There is now one regime table, and each type's row names only the primitives that type has:
12+
13+
- a packaged flow: clone it under a new name with `POST /api/v1/automation/:name/clone` and `{ name, label }`, or switch it off with `POST /api/v1/automation/:name/toggle` and `{ enabled: false }`;
14+
- a packaged action: switch it off with `POST /api/v1/actions/_activation/:object/:action` and `{ enabled: false }` (`:object` is `global` for an object-less action). No clone is named, because cloning an action is not a sanctioned path;
15+
- a packaged permission set: clone it under a new name, with the "Clone" action on the permission set or `POST /api/v1/data/sys_permission_set` with a new name. This is the same wording the permission-set lock in `@objectstack/plugin-security` already uses.
16+
17+
The switches are operator-only where one install serves several organizations. Every refusal cites ADR-0126. All three places that refuse such a write read the same table:
18+
19+
- **`403 NOT_OVERRIDABLE` on an environment-scoped kernel.** This covers `PUT /api/v1/meta/:type/:name` without `?package=`, and for a flow or an action `DELETE` too.
20+
- **`403 NOT_OVERRIDABLE` where the `/meta` protocol is not environment-scoped**, for example the default local `pnpm dev` boot. The metadata repository refuses that write one layer down, and now with the same sentence.
21+
- **`403 ITEM_LOCKED` for a write that names the read-only package with `?package=`, while `OS_METADATA_WRITABLE` is not set for the type.** The sentence now opens "Cannot overlay 'TYPE' in package 'ID': that package is read-only, and its packaged base is locked against in-place edits." and then names the path.
22+
23+
A packaged flow's package-less sentence is byte-for-byte unchanged. Statuses, codes, `lockSource`, `packageId`, `docs` and which writes are refused are unchanged too. `OS_METADATA_WRITABLE` still opens the lock for a write that names no package. The `ITEM_LOCKED` refusal given while the variable IS set reads exactly as before. Removing a permission set's overlay row is still allowed, as repair. Every type with no declared regime reads exactly as before, at every door.

0 commit comments

Comments
 (0)