Skip to content

Commit fb18504

Browse files
committed
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
2 parents 2004e5a + ce7bae8 commit fb18504

23 files changed

Lines changed: 2221 additions & 157 deletions
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/driver-sql': minor
4+
'@objectstack/cli': minor
5+
---
6+
7+
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. -->
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/core": minor
4+
---
5+
6+
`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:
9+
10+
| Declaration | Grammar | Accepted `1.0.0-alpha.1` / `1.0.0+20230101` |
11+
|---|---|---|
12+
| `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. -->

‎content/docs/protocol/objectql/types.mdx‎

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -263,9 +263,19 @@ quantity:
263263
- `min`/`max`: Range validation
264264

265265
**Database mapping:**
266-
- SQL driver: a floating-point column (`REAL` on PostgreSQL/SQLite, `FLOAT` on
267-
MySQL). `precision`/`scale` are validation and display metadata — the DDL does
268-
**not** emit `NUMERIC(precision, scale)`.
266+
- SQL driver: an **exact decimal** column at a fixed, portable size —
267+
`NUMERIC(65,30)` on PostgreSQL, `DECIMAL(65,30)` on MySQL, `float` on SQLite
268+
(which applies neither precision nor scale). The size comes from one
269+
per-field-type table in `packages/spec`
270+
(`NUMERIC_COLUMN_REPRESENTATION`), which the driver and both
271+
`os generate migration` formats all read, so one declaration produces one
272+
column whoever builds the table.
273+
⚠️ The field's own `precision`/`scale` remain validation and display
274+
metadata: the DDL does **not** emit `NUMERIC(precision, scale)` from *your*
275+
declared numbers — the column is the fixed pair above whatever the field
276+
declares.
277+
⚠️ **New columns only.** Nothing retypes a column that already exists; a
278+
table created before this keeps its `REAL`/`FLOAT` columns and their values.
269279
- MongoDB: `Number`
270280

271281
**Use cases:**
@@ -309,8 +319,17 @@ deprecated in the spec.
309319
- `precision` (0–10, default 2) for decimal places
310320

311321
**Database mapping:**
312-
- SQL driver: a floating-point column (`REAL` / `FLOAT`) — one column, no
313-
companion currency column and no JSON blob
322+
- SQL driver: the same **exact decimal** column as `number`
323+
(`NUMERIC(65,30)` / `DECIMAL(65,30)`; `float` on SQLite) — one column, no
324+
companion currency column and no JSON blob. Money is the member where the
325+
binary32 `REAL` this replaced lost a correctness question rather than a
326+
display one: `1234567.89` read back `1234567.9` from a `REAL` column.
327+
⚠️ The column is **not** a blanket `DECIMAL(18,2)`: the platform's own CLDR
328+
table carries 0-digit currencies (JPY, KRW) and 3-digit ones (BHD, KWD), and
329+
the currency-code schema fails open for crypto and custom codes, so a money
330+
column that fixes two decimals is wrong for a set the platform declines to
331+
close.
332+
⚠️ **New columns only** — see `number` above.
314333
- MongoDB: `Number`
315334

316335
---
@@ -331,7 +350,11 @@ discount_rate:
331350

332351
**Storage:** the percentage **number itself** — `25.5` means 25.5%, matching the
333352
`min: 0` / `max: 100` bounds above. It is *not* rescaled to a 0–1 ratio on write.
334-
Physically it is the same floating-point column as `number`.
353+
Physically it is the same **exact decimal** column as `number`
354+
(`NUMERIC(65,30)` / `DECIMAL(65,30)`; `float` on SQLite), on new tables only.
355+
That width is what holds a legitimate `33.333` — the narrow `NUMERIC(5,2)` the
356+
`--format sql` generator used to emit rounded it half-up to `33.33`, and
357+
rounded the 0–1 fraction storage of the same value to `0.33`.
335358

336359
<Callout>
337360
The separate `percent` **template filter** (`{{ record.rate | percent }}`) does
@@ -1174,15 +1197,16 @@ The column each type gets from the SQL driver, per dialect:
11741197
|---------------|------------|-------|--------|
11751198
| `text` / `textarea` / `html` | `TEXT` \* | `TEXT` \* | `TEXT` \* |
11761199
| `email` / `url` / `phone` / `password` | `VARCHAR(maxLength)` † | `VARCHAR(maxLength)` † | `VARCHAR(maxLength)` † |
1177-
| `number` / `currency` / `percent` | `REAL` | `FLOAT` | `REAL` |
1200+
| `number` / `currency` / `percent` / `slider` / `progress` | `NUMERIC(65,30)` ‡ | `DECIMAL(65,30)` ‡ | `float` ‡ |
1201+
| `rating` | `INTEGER` ‡ | `INT` ‡ | `INTEGER` ‡ |
11781202
| `date` | `DATE` | `DATE` | `TEXT` (`YYYY-MM-DD`) |
11791203
| `datetime` | `TIMESTAMPTZ` | `DATETIME(3)` | `TEXT` (canonical `…Z`) |
11801204
| `time` | `TIME` | `TIME(3)` | `TEXT` (`HH:MM:SS[.fff]`) |
11811205
| `boolean` / `toggle` | `BOOLEAN` | `BOOLEAN` | `INTEGER` `0`/`1` |
11821206
| `select` / `radio` | `VARCHAR(255)` | `VARCHAR(255)` | `VARCHAR(255)` |
11831207
| `multiselect` / `tags` | `JSON` | `JSON` | `TEXT` (JSON) |
11841208
| `lookup` / `master_detail` / `tree` | `VARCHAR(255)` | `VARCHAR(255)` | `VARCHAR(255)` |
1185-
| `summary` | `REAL` | `FLOAT` | `REAL` |
1209+
| `summary` | `NUMERIC(65,30)` ‡ | `DECIMAL(65,30)` ‡ | `float` ‡ |
11861210
| `autonumber` | `VARCHAR(255)` | `VARCHAR(255)` | `VARCHAR(255)` |
11871211
| `formula` | *(no column — virtual)* | *(no column)* | *(no column)* |
11881212
| `json` / `location` / `address` | `JSON` | `JSON` | `TEXT` (JSON) |
@@ -1209,6 +1233,22 @@ Note the neighbouring rows that deliberately do **not** follow this rule:
12091233
runtime-issued number — in none of those is the stored string the value the
12101234
field's `maxLength` describes, so all of them keep `VARCHAR(255)`.
12111235

1236+
‡ **The numeric family is NEW COLUMNS ONLY.** The size is one per-field-type
1237+
table in `packages/spec` (`NUMERIC_COLUMN_REPRESENTATION`) that
1238+
`SqlDriver.createColumn` and both `os generate migration` formats read, so the
1239+
three producers no longer disagree; before it they emitted `REAL`,
1240+
`NUMERIC(18,2)`/`NUMERIC(5,2)` and `NUMERIC(8,2)` for the same declaration.
1241+
`65,30` is MySQL's documented `DECIMAL` maximum and therefore the portable one.
1242+
Nothing retypes an existing column, plans a migration, or reports drift over the
1243+
difference — a table created before this keeps its `REAL`/`FLOAT` columns, and a
1244+
new numeric field added to it gets an exact-decimal column beside them.
1245+
On SQLite the exact-decimal members compile to the same `float` column the
1246+
driver emitted before (knex's SQLite `decimal` compiler is the literal `float`),
1247+
so SQLite keeps REAL affinity and gains no exactness; `rating` moves to INTEGER
1248+
affinity there and SQLite still accepts a fractional value as a REAL. The
1249+
refusal `rating` gains is a PostgreSQL/MySQL effect: PostgreSQL refuses a
1250+
fractional star count outright, and MySQL **rounds** it (4.5 arrives as 5).
1251+
12121252
Any field flagged `multiple: true` becomes a `JSON` column regardless of its
12131253
type. Relationship columns are plain id strings with no database `FOREIGN KEY`
12141254
constraint (see `lookup` above). The MongoDB driver is schemaless — it issues no

0 commit comments

Comments
 (0)