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 9cdffbe
Browse filesBrowse the repository at this point in the historyBrowse files
feat(spec,driver-sql,cli): one physical representation for the NUMERIC column family, read by all three producers (#16887)
* wip: inherited unverified draft from the predecessor session (#16318)
The container restart that killed the previous os-dev left this in the
worktree, uncommitted. Committed verbatim so it is not the only copy;
every number in it is re-measured before anything is claimed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
* feat(spec,driver-sql,cli): one per-field-type physical representation for the NUMERIC column family
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
* fix(driver-sql): the numeric read coercion runs on every dialect
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
* test(spec,driver-sql,cli): pin the numeric representation on all three producers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
* test(cli): the multiple-JSON pin takes NOT NULL from storage.notNull (ADR-0113)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
* chore(spec): regenerate api-surface and export-origins for the numeric column table
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
* chore: changeset for the numeric column representation table
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
* fix(docs,driver-sql,spec): the numeric column's documentation, its live-dialect pins and the read-path bound
The protocol doc stated the opposite of the DDL this branch emits, and three
claims the branch makes had no instrument behind them.
- content/docs/protocol/objectql/types.mdx: `number`, `currency` and `percent`
said "a floating-point column (REAL / FLOAT)" and the type-conversion matrix
said `REAL` / `FLOAT` / `REAL` for the family and for `summary`. All of it now
states the exact-decimal column, the "new columns only" bound, and the SQLite
and per-dialect `rating` consequences, with `slider` / `progress` / `rating`
given rows the matrix never carried.
- packages/spec/src/api/sortability.zod.ts (and its generated
content/docs/references/api/sortability.mdx): the `summary` fossil said
"an engine-maintained `table.float`".
- packages/services/service-analytics/src/measure-result-type.ts: the same
fossil, as the reason `summary` needs no correction. The reason is unchanged;
only the column it names moved.
New live-dialect cell (`sql-driver-16318-numeric-representation-live-dialects.test.ts`):
`numeric_precision` / `numeric_scale` read off the server's own catalog and
compared against the spec table, the `typeof === 'number'` read that SQLite
cannot exercise, and `rating`'s fractional disposition asserted PER DIALECT —
PostgreSQL refuses, MySQL rounds. The PG half was executed against a live
PostgreSQL 16.13; the MySQL half runs in the "Temporal Conformance (live PG +
MySQL)" job.
`formatOutput`'s numeric read coercion is scoped per dialect. It reads the
authorable `NUMERIC_VALUE_TYPES` half on the server dialects and keeps the wider
`NUMERIC_SCALAR_TYPES` set on SQLite, where the legacy TEXT-affinity repair
lives. The aliases `integer` / `int` / `float` are how an external, introspected
column reaches the driver, and node-postgres hands `bigint` back as a string
precisely because it does not fit a JS double: coercing it would round above
2^53 on a table this change never created, outside the "new columns only" bound.
Changeset: `rating`'s two dialects stated separately, the residual bound
restated as "30 fractional digits are kept", the binary64 read seam named, and
the ADR-0087 disposition argued positively instead of resting on the detector
miss the gate's own docblock warns about.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5
* docs(spec): state the residual bound the changeset states, and point the ADR-0087 marker at the right bullet
F1 — `packages/spec/src/data/numeric-column-representation.ts`'s "residual
bound" section said only that magnitudes below 1e-30 round to zero, while the
shipped changeset states the column keeps 30 fractional digits, that precision
loss BEGINS around |x| < 1e-13 and is TOTAL below 1e-30, and that magnitudes at
or above 1e35 are refused. That docblock is the canonical home both
`sql-driver.ts` and `os generate migration` point readers at ("the spec module
carries the measurements, the ruling and the residual bound"), and it says of
itself that the bound is "stated here so no reader has to rediscover it" — so
it now states both ends of the bound in the changeset's own terms.
F2 — `.changeset/numeric-column-representation.md` said "Three consequences"
above four bullets, and its ADR-0087 marker claimed "bullet 3 IS a
prescription" where bullet 3 is the read-seam bullet and the prescription is
bullet 4 (the `NOT NULL` / `storage.notNull` one). The marker is the auditable
artifact the gate prints verbatim, so a wrong internal pointer defeats its
purpose. Exactly one `adr-0087:` marker, same category and same argument.
Docblock and changeset prose only: no behaviour change, no schema move, no
test edit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Js5kTpTtxieBjPyScgxJ3
---------
Co-authored-by: Claude <noreply@anthropic.com>
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. -->
0 commit comments