Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .changeset/numeric-column-representation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
---
'@objectstack/spec': minor
'@objectstack/driver-sql': minor
'@objectstack/cli': minor
---

One physical representation for the NUMERIC column family, read by every producer of DDL

`packages/spec` now states, per field type, what column a numeric field gets, and all three
producers read it: `SqlDriver.createColumn`, `os generate migration --format sql` and
`os generate migration --format typescript`. Measured on live PostgreSQL 16.13, one object
through all three producers, before and after:

```
BEFORE AFTER
driver sql gen ts gen all three
number real numeric(18,2) numeric(8,2) numeric(65,30)
currency real numeric(18,2) numeric(8,2) numeric(65,30)
percent real numeric(5,2) numeric(8,2) numeric(65,30)
slider real numeric(18,2) numeric(8,2) numeric(65,30)
summary real numeric(18,2) numeric(8,2) numeric(65,30)
progress real numeric(5,2) numeric(8,2) numeric(65,30)
rating real integer integer integer
```

7 of 7 columns diverged before, 0 of 7 after. Every arm of the old split lost data in its own
direction: `real` is IEEE-754 binary32, so a `currency` of `1234567.89` read back `1234567.9`;
`numeric(5,2)` and `numeric(18,2)` silently ROUND a legitimate `33.333` to `33.33` (round
half-up — executed, not inferred); `numeric(8,2)` refused `1234567.89` outright. `65,30` is
MySQL's documented `DECIMAL` maximum and therefore the portable one, and it is the only
candidate measured to lose nothing on a nine-value corpus.

Both migration formats also take the physical `NOT NULL` from `storage.notNull` and never from
`required`, which is where `SqlDriver.createColumn` has taken it since ADR-0113: `required` is
the write-time contract the record validator enforces, and binding the DDL to it made every
post-deploy tightening a destructive migration.

**BREAKING** — new columns only; no existing column is retyped, no migration is planned, and no
backfill runs. Four consequences to know before creating new tables:

- `rating` is an INTEGER column, and the two server dialects dispose of a fractional star count
DIFFERENTLY — do not read one answer for both. PostgreSQL REFUSES `4.5` outright, where a
`real` column accepted it. MySQL does NOT refuse: it ROUNDS, and `4.5` becomes `5` with no
error, which is a silent alteration and the reason to declare a `slider` (in the exact-decimal
set) for anything that wants fractional values. SQLite refuses nothing either: it stores `4.5`
as a REAL in an INTEGER-affinity column, unchanged from today.
- An exact-decimal column is bounded where a float is not, in BOTH directions. It keeps 30
fractional digits: a magnitude whose significant digits run past the 30th decimal place loses
the tail silently — `1.2345678901234567e-15` stores as `0.000000000000001234567890123457`, so
the loss begins around |x| < 1e-13 and is total below 1e-30 — and magnitudes at or above 1e35
are REFUSED, where `real` kept about seven significant digits out to ~1e38. A refusal is loud;
the rounding it replaces was not.
- Reads are bounded by the wire contract, not by the column. `find()` hands back a JS number
(`z.number().finite()`), so a value that was never a JS double does not survive the round trip
exactly — `1234567890123456.123` reads back `1234567890123456`, and 2^53+1 reads back 2^53.
The fidelity this buys is an exact COLUMN read through a double: values written by this
platform round-trip exactly, and SQL-side writers, `summary` roll-ups computed in SQL and any
magnitude at or above 2^53 are bounded by the read seam. Widening that is a wire-contract
change and is not in this release.
- A generated migration no longer emits `NOT NULL` for a field marked only `required: true`.
Declare `storage: { notNull: true }` for a physical constraint — which is what the platform's
own table has always done since ADR-0113, and what `os migrate meta` deliberately does NOT
supply on your behalf (the conversion that stamped it was withdrawn by maintainer ruling on
2026-09-08). A source author who wants the column they had must write that block themselves;
`required: true` keeps its own meaning, the write-time contract the record validator enforces.

SQLite emits byte-identical DDL for the six exact-decimal members: knex compiles both
`table.decimal(name, p, s)` and `table.float(name)` to the same `float` column there.

<!-- 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. -->
56 changes: 48 additions & 8 deletions content/docs/protocol/objectql/types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -263,9 +263,19 @@ quantity:
- `min`/`max`: Range validation

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

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

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

---
Expand All @@ -331,7 +350,11 @@ discount_rate:

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

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

‡ **The numeric family is NEW COLUMNS ONLY.** The size is one per-field-type
table in `packages/spec` (`NUMERIC_COLUMN_REPRESENTATION`) that
`SqlDriver.createColumn` and both `os generate migration` formats read, so the
three producers no longer disagree; before it they emitted `REAL`,
`NUMERIC(18,2)`/`NUMERIC(5,2)` and `NUMERIC(8,2)` for the same declaration.
`65,30` is MySQL's documented `DECIMAL` maximum and therefore the portable one.
Nothing retypes an existing column, plans a migration, or reports drift over the
difference — a table created before this keeps its `REAL`/`FLOAT` columns, and a
new numeric field added to it gets an exact-decimal column beside them.
On SQLite the exact-decimal members compile to the same `float` column the
driver emitted before (knex's SQLite `decimal` compiler is the literal `float`),
so SQLite keeps REAL affinity and gains no exactness; `rating` moves to INTEGER
affinity there and SQLite still accepts a fractional value as a REAL. The
refusal `rating` gains is a PostgreSQL/MySQL effect: PostgreSQL refuses a
fractional star count outright, and MySQL **rounds** it (4.5 arrives as 5).

Any field flagged `multiple: true` becomes a `JSON` column regardless of its
type. Relationship columns are plain id strings with no database `FOREIGN KEY`
constraint (see `lookup` above). The MongoDB driver is schemaless — it issues no
Expand Down
12 changes: 8 additions & 4 deletions content/docs/references/api/sortability.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,14 @@ measured degradation is not refused; the projection covers all four:
## Considered and deliberately NOT members

- `summary` / `autonumber` — the other two `COMPUTED_VALUE_TYPES`. They sort
CORRECTLY (`summary` is an engine-maintained `table.float`, `autonumber`
an engine-assigned `table.string`; measured on #6924), which is exactly
why virtuality is judged by the storage predicate and never by the write
contract — widening would refuse the two types that work.
CORRECTLY (`summary` is an engine-maintained numeric column — `table.float`
when #6924 measured it, an exact `table.decimal` on new tables since
#16318's stated representation — and `autonumber` an engine-assigned
`table.string`), which is exactly why virtuality is judged by the storage
predicate and never by the write contract — widening would refuse the two
types that work. ⚠️ The column TYPE is not what makes them sortable —
having a PROVISIONED column is — which is why #16318's retype of the
numeric family moved nothing in this projection.
- `encrypted` / `secret` / `json` / `vector` and the other heavy or masked
types — every one has a stored column, neither door refuses an ORDER BY
over one, and the drivers execute it. Marking them unsortable here would
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,15 +207,29 @@ describe('#14829 — `multiple: true` is one answer across all three surfaces',
expect(tsColumn('multi_text')).toBe("table.jsonb('multi_text')");
});

it('nullability still comes from `required`, not from the flag', () => {
const out = generateMigrationSql({
objects: { probe: { name: 'probe', fields: { tags_req: { type: 'lookup', multiple: true, required: true } } } },
});
expect(out).toContain('"tags_req" JSONB NOT NULL');
const ts = generateMigrationTs({
objects: { probe: { name: 'probe', fields: { tags_req: { type: 'lookup', multiple: true, required: true } } } },
});
expect(ts).toContain("table.jsonb('tags_req').notNullable();");
/**
* ⚠️ [#16318] The VEHICLE changed, the subject did not. This pin is about
* `multiple` not deciding nullability; `required` was merely how a NOT NULL
* was spelled when it was written. Both generators now take the physical NOT
* NULL from `storage.notNull` and never from `required` (ADR-0113, which took
* `SqlDriver.createColumn` off `required` because binding the DDL to it made
* every post-deploy tightening a destructive migration) — so the constrained
* case is spelled the new way, and the `required`-only case is asserted
* BESIDE it: it must now be nullable in both formats, which is the half that
* would have caught this change silently reverting.
*/
it('nullability comes from `storage.notNull`, not from the flag and not from `required`', () => {
const constrained = { type: 'lookup', multiple: true, storage: { notNull: true } };
const writeOnly = { type: 'lookup', multiple: true, required: true };
const config = { objects: { probe: { name: 'probe', fields: { tags_nn: constrained, tags_req: writeOnly } } } };

const out = generateMigrationSql(config);
expect(out).toContain('"tags_nn" JSONB NOT NULL');
expect(out).toMatch(/"tags_req" JSONB(?! NOT NULL)/);

const ts = generateMigrationTs(config);
expect(ts).toContain("table.jsonb('tags_nn').notNullable();");
expect(ts).toContain("table.jsonb('tags_req').nullable();");
});

// ── The authority, read where it lives ──────────────────────────────────
Expand Down
Loading
Loading