You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit fb18504
Browse filesBrowse the repository at this point in the historyBrowse files
Merge origin/main into claude/issue-15547-config-refusal-throws
Conflict: packages/cli/src/commands/generate.ts, import block only.
#16887 (9cdffbe) added `numericColumnFor` to the `@objectstack/spec/data`
import on L20; this branch added `isReportedError` to the `utils/format.js`
import on L21. Adjacent lines, two different modules, no shared logic — both
sides kept. Verified after resolving: the resolved file differs from
origin/main's copy by exactly this branch's four edits and nothing else, and
all three `isReportedError` guards still sit on the catch blocks that enclose
a `loadConfig()` call (841->868, 983->1011, 2112->2149) after #16887's hunks
shifted the line numbers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
One physical representation for the NUMERIC column family, read by every producer of DDL
8
+
9
+
`packages/spec` now states, per field type, what column a numeric field gets, and all three
10
+
producers read it: `SqlDriver.createColumn`, `os generate migration --format sql` and
11
+
`os generate migration --format typescript`. Measured on live PostgreSQL 16.13, one object
12
+
through all three producers, before and after:
13
+
14
+
```
15
+
BEFORE AFTER
16
+
driver sql gen ts gen all three
17
+
number real numeric(18,2) numeric(8,2) numeric(65,30)
18
+
currency real numeric(18,2) numeric(8,2) numeric(65,30)
19
+
percent real numeric(5,2) numeric(8,2) numeric(65,30)
20
+
slider real numeric(18,2) numeric(8,2) numeric(65,30)
21
+
summary real numeric(18,2) numeric(8,2) numeric(65,30)
22
+
progress real numeric(5,2) numeric(8,2) numeric(65,30)
23
+
rating real integer integer integer
24
+
```
25
+
26
+
7 of 7 columns diverged before, 0 of 7 after. Every arm of the old split lost data in its own
27
+
direction: `real` is IEEE-754 binary32, so a `currency` of `1234567.89` read back `1234567.9`;
28
+
`numeric(5,2)` and `numeric(18,2)` silently ROUND a legitimate `33.333` to `33.33` (round
29
+
half-up — executed, not inferred); `numeric(8,2)` refused `1234567.89` outright. `65,30` is
30
+
MySQL's documented `DECIMAL` maximum and therefore the portable one, and it is the only
31
+
candidate measured to lose nothing on a nine-value corpus.
32
+
33
+
Both migration formats also take the physical `NOT NULL` from `storage.notNull` and never from
34
+
`required`, which is where `SqlDriver.createColumn` has taken it since ADR-0113: `required` is
35
+
the write-time contract the record validator enforces, and binding the DDL to it made every
36
+
post-deploy tightening a destructive migration.
37
+
38
+
**BREAKING** — new columns only; no existing column is retyped, no migration is planned, and no
39
+
backfill runs. Four consequences to know before creating new tables:
40
+
41
+
-`rating` is an INTEGER column, and the two server dialects dispose of a fractional star count
42
+
DIFFERENTLY — do not read one answer for both. PostgreSQL REFUSES `4.5` outright, where a
43
+
`real` column accepted it. MySQL does NOT refuse: it ROUNDS, and `4.5` becomes `5` with no
44
+
error, which is a silent alteration and the reason to declare a `slider` (in the exact-decimal
45
+
set) for anything that wants fractional values. SQLite refuses nothing either: it stores `4.5`
46
+
as a REAL in an INTEGER-affinity column, unchanged from today.
47
+
- An exact-decimal column is bounded where a float is not, in BOTH directions. It keeps 30
48
+
fractional digits: a magnitude whose significant digits run past the 30th decimal place loses
49
+
the tail silently — `1.2345678901234567e-15` stores as `0.000000000000001234567890123457`, so
50
+
the loss begins around |x| < 1e-13 and is total below 1e-30 — and magnitudes at or above 1e35
51
+
are REFUSED, where `real` kept about seven significant digits out to ~1e38. A refusal is loud;
52
+
the rounding it replaces was not.
53
+
- Reads are bounded by the wire contract, not by the column. `find()` hands back a JS number
54
+
(`z.number().finite()`), so a value that was never a JS double does not survive the round trip
55
+
exactly — `1234567890123456.123` reads back `1234567890123456`, and 2^53+1 reads back 2^53.
56
+
The fidelity this buys is an exact COLUMN read through a double: values written by this
57
+
platform round-trip exactly, and SQL-side writers, `summary` roll-ups computed in SQL and any
58
+
magnitude at or above 2^53 are bounded by the read seam. Widening that is a wire-contract
59
+
change and is not in this release.
60
+
- A generated migration no longer emits `NOT NULL` for a field marked only `required: true`.
61
+
Declare `storage: { notNull: true }` for a physical constraint — which is what the platform's
62
+
own table has always done since ADR-0113, and what `os migrate meta` deliberately does NOT
63
+
supply on your behalf (the conversion that stamped it was withdrawn by maintainer ruling on
64
+
2026-09-08). A source author who wants the column they had must write that block themselves;
65
+
`required: true` keeps its own meaning, the write-time contract the record validator enforces.
66
+
67
+
SQLite emits byte-identical DDL for the six exact-decimal members: knex compiles both
68
+
`table.decimal(name, p, s)` and `table.float(name)` to the same `float` column there.
69
+
70
+
<!-- adr-0087: not-required (no-migration-prescription) Claimed on a POSITIVE argument, not on the detector finding nothing — the failure mode this gate's own docblock names (#8277). Stated plainly: bullet 4 (the `NOT NULL` one) IS a prescription, and it is a prescription for a SOURCE AUTHOR, not for a metadata upgrader, which is the distinction ADR-0087's D8 addendum says this category cannot mechanically tell apart. The ledger serves `objectstack migrate meta`; the only ledger entry this change could carry is the `field-required-notnull-explicit` conversion, and that conversion was WITHDRAWN by maintainer ruling on 2026-09-08 (decision batch #85, #16693/#16890) on the ground that stamping `storage.notNull` wherever `required: true` appears is the implication ADR-0113 abolished — `packages/spec/src/conversions/registry.ts` now carries a tombstone saying re-adding one is the mistake it exists to stop. So `registered` is FORBIDDEN here, not merely unnecessary. The other four are closed on facts: the bumped packages publish (not `unpublished`); no id pre-dates the base (not `already-registered`); no named symbol is a non-metadata runtime interface (not `runtime-interface-only`); and `type-surface-only` fails its predicate 2, since this diff adds a module under `packages/spec/**`. The numeric half prescribes nothing at all — no spec key, no export and no config field is removed or renamed, existing sources parse and publish unchanged, and existing columns are untouched by the ruling that authorized this (「不考虑现有数据」). ⚠️ The residual is declared rather than hidden: the vocabulary has no category for a source-author prescription the ledger must not carry, which is D8's blind spot reached from a second direction; raised for the maintainer in the PR report rather than resolved by dropping the BREAKING banner. -->
`PluginSchema.version` now accepts the whole of the SemVer 2.0.0 grammar, and `version` becomes the ninth declared key `kernel.use()` enforces.
7
+
8
+
Two declarations in this repository disagreed about what a plugin `version` is, and the disagreement became load-bearing the moment the boot path started running the schema:
|`PluginSchema.version` (`@objectstack/spec`, `kernel/plugin.zod.ts`), described `"Semantic Version"`|`/^\d+\.\d+\.\d+$/`|**no**|
13
+
|`PluginLoader.isValidSemanticVersion` (`@objectstack/core`), the check the boot path has always run |`/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/`|**yes**|
14
+
15
+
SemVer 2.0.0 defines prerelease and build metadata as **parts of** a semantic version, so the key's own `describe()` — `"Semantic Version"`, no qualifier — claimed the wide grammar while its regex implemented a subset of it. The spec key was the one that was wrong, and it is the one that moved.
16
+
17
+
**The spec adopts the loader's grammar character for character**, deliberately, rather than a third spelling: that is the check the boot path has always run, so the two declarations now converge exactly and nothing that loaded before is refused now.
18
+
19
+
**`@objectstack/spec` — a WIDENING of a published contract.**`Plugin.json`'s `pattern` in the shipped `json-schema/` tree changes from `^\d+\.\d+\.\d+$` to `^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$`. This is a strict superset — same three-segment core, two **optional** suffix groups — so every string that validated before still validates. A tool that mirrors this schema to validate plugin manifests should widen with it; one that does not will merely keep refusing prerelease versions the platform accepts.
20
+
21
+
**`@objectstack/core` — `version` joins the enforced set, which NARROWS `LiteKernel`.****BREAKING** accept-set narrowing on a published runtime entry point, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **A plugin object `LiteKernel` accepted before can be refused now.**`assertPluginContract` filtered `version` issues out while the two spellings disagreed; that stopgap is gone. The full enforced set is now **NINE** keys, each refused with the offending key named in the message:
22
+
23
+
-**`id`** — a non-string, or the empty string.
24
+
-**`type`** — any value outside the closed set `standard`, `ui`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`.
25
+
-**`staticPath`** — a non-string.
26
+
-**`slug`** — a non-string, or a string that does not match `/^[a-z0-9-_]+$/`.
27
+
-**`default`** — a non-boolean.
28
+
-**`version`** — a non-string, or a string outside the SemVer grammar above. **New in this release.**
29
+
-**`description`** — a non-string.
30
+
-**`author`** — a non-string.
31
+
-**`homepage`** — a non-string, or a string that is not a URL.
32
+
33
+
**`null` is refused on every one of the nine**, and a `type: 'ui'` plugin missing `staticPath` or `slug` is still refused with `PLUGIN_UI_REQUIRED_KEY_MISSING` inside the same envelope.
34
+
35
+
⚠️ **This supersedes the eight-key enumeration published in `@objectstack/core@17.4.0`.** Both of that release's entries — the `kernel.use()` and the `LiteKernel.use()` enforcement notes — say the enforced set is eight keys and that `version` is excluded, and both point at reconciling the two `version` spellings as separate spec work. This is that work. Those entries stay as written, because they describe what 17.4.0 did; **nine is the current set**, and `version` is no longer excluded from anything.
36
+
37
+
**What actually changes behaviour, stated narrowly.** On **`ObjectKernel`** nothing moves: `PluginLoader.validatePluginStructure` already judged `version` with this exact grammar and still runs first, so a malformed `version` is still refused as `Invalid semantic version`, never as `PLUGIN_CONTRACT_VIOLATION`. On **`LiteKernel`** a plugin object with a malformed `version` — `version: 'v1.0.0'`, say — was **registered** before and is **refused** now, with `PLUGIN_CONTRACT_VIOLATION` at `'version'`. `LiteKernel` has never run the loader's structural checks, so `version` was the one declared key it did not judge at all: such a plugin was green in vitest and refused by `ObjectKernel` at production boot. That is exactly the split the `LiteKernel` convergence closed for the other eight keys, closed now for the ninth.
38
+
39
+
**What is unchanged.**`1.0.0-alpha.1`, `1.0.0+20230101` and `0.0.0-fixture` load on **both** kernels, as they did before — measured, not assumed, and pinned per kernel. A version-less plugin still loads; `version` is `.optional()`. Unknown keys still pass (`PluginSchema` carries no `.strict()`, and the parse output is discarded, so the stored object is the object that was passed in). A class-based plugin keeps its identity, prototype and prototype methods.
40
+
41
+
⚠️ **The accepted grammar is wider than SemVer 2.0.0 itself**, and this release neither introduced nor widened that fringe: leading zeroes in the numeric core (`01.1.1`) were accepted by **both** spellings before this change and are accepted by both after it, and the loader's prerelease/build classes admit degenerate identifiers SemVer forbids (`1.0.0-alpha..1`, `1.0.0-0123`, `1.0.0+.`). Tightening to the official SemVer regex would have **narrowed** this key rather than widening it, so it is deliberately not done here.
42
+
43
+
**Migration.** Nothing to rename, and nothing to do if your plugin's `version` is a real semantic version. If you register plugins on `LiteKernel` with a `version` string that is not one — a leading `v`, a two-segment `1.0` — spell it `MAJOR.MINOR.PATCH` with optional `-prerelease` and `+build`, or drop the key. The refusal names the plugin and the key.
44
+
45
+
<!-- adr-0087: not-required (no-migration-prescription) A regex widening on one declared key of `PluginSchema`, plus the removal of a runtime filter that had excluded that key from an existing check. No metadata key, spec symbol, Zod schema, object definition or stored representation is added, removed or given a different name, so `objectstack migrate meta` has nothing to visit and there is no tombstone to mint. Stored metadata is untouched; what moves is which plugin OBJECTS a boot accepts — strictly more of them at the schema, and on `LiteKernel` the malformed-`version` objects `ObjectKernel` already refused. The channel that reaches an affected plugin author is the refusal itself, which names the plugin and the offending key at `use()`, and which value a malformed `version` should carry is authoring intent no ledger entry can decide. -->
0 commit comments