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
13 changes: 13 additions & 0 deletions .changeset/signature-qrcode-bounded-text-family.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
---
"@objectstack/spec": minor
"@objectstack/objectql": minor
"@objectstack/driver-sql": minor
---

`signature` and `qrcode` join the bounded-string family end to end, closing the last measured hole #11794 left open (#11875, maintainer ruling 2026-08-25, option 1). Three seams move together, in the order that keeps declared = enforced at every step:

- **Authoring (`@objectstack/spec`)**: `maxLength` / `minLength` become authorable on `signature` and `qrcode` — both types join `BOUNDED_STRING_FIELD_TYPES`, so `Field.signature({ maxLength: 64 })`, refused at the authoring seam since #11566, now parses. The refusal message for the remaining out-of-set types enumerates the set itself instead of a hand-written copy of it, and both authoring forms show the key for the same set.
- **Write seam (`@objectstack/objectql`)**: the record-validator's `max_length` / `min_length` branch now reads the spec's `BOUNDED_STRING_FIELD_TYPES` instead of a hand-copied ten-type list, so a declared bound on `signature` / `qrcode` refuses an over-long value with a field-named ADR-0112 `max_length` envelope — boundary measured: exactly `maxLength` characters is accepted, one past it is refused, on insert and update. `secret` and `color` are deliberately NOT covered (opaque `sys_secret` ref per ADR-0100; short by construction — the ruling's explicit carve-outs).
- **Storage (`@objectstack/driver-sql`)**: both types move from the catch-all's `varchar(255)` into the TEXT family, under exactly the invariant #11794 established — an unbounded TEXT column is permitted precisely because the write seam now enforces the declared bound. Measured on live MySQL 8.0.46 (`STRICT_TRANS_TABLES`) and Postgres 16: a 1000-character data-URI signature, previously refused by the server (`ER_DATA_TOO_LONG` / `22001`), lands in a column that reads back as `text` from `information_schema.COLUMNS` on both dialects and round-trips byte-identically. The #11374 keyed-and-bounded rule applies to them unchanged: a keyed, bounded column is emitted `varchar(maxLength)` and the server refuses exactly one character past the declared bound.

Nothing about existing tables changes — `createColumn` runs on `CREATE TABLE` and `ALTER TABLE ADD COLUMN`, so the column it sizes is always empty; a pre-existing `signature` / `qrcode` column stays `varchar(255)` until an operator migrates it, and the additive sync never rewrites a column's type on its own.
2 changes: 1 addition & 1 deletion content/docs/references/data/field.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,7 +65,7 @@ const result = CurrencyConfigSchema.parse(data);
| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (#9447, maintainer ruling 2026-08-18). |
| **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization' |
| **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes (#7127), discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. |
| **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code. |
| **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. |
| **minLength** | `number` | optional | Min character length |
| **precision** | `integer` | optional | Total digits (non-negative integer) |
| **scale** | `integer` | optional | Decimal places (non-negative integer) |
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,22 +18,32 @@
*
* ## Which types moved, and the test that decided it
*
* `code` moved with `richtext`. `signature` and `qrcode` did NOT, and that is
* the load-bearing half of this file rather than an omission.
* `code` moved with `richtext`. `signature` and `qrcode` did NOT move in
* #11794, and this file asserted that half out loud as an OPEN defect.
*
* An unbounded TEXT column is correct for a type exactly when the WRITE SEAM
* enforces that type's declared `maxLength` — the invariant `schema-drift.ts`
* already states ("A TEXT column refuses nothing a `maxLength` allows … the
* bound is enforced at the write seam"). objectql's record-validator applies
* its `max_length` branch to `text` / `textarea` / `email` / `url` / `phone` /
* `password` / `markdown` / `html` / `richtext` / `code` — and to no other
* type. Measured: a `maxLength: 64` field of each of those refuses a
* 100-character value; the same field declared `signature` or `qrcode`
* ACCEPTS it. So for those two an unbounded column would accept values the
* declaration forbids — a physical surface WIDER than the contract, where
* `richtext` and `code` are a restoration of it. Their own defect (a data-URI
* signature capped at 255) is real and is asserted here as an open one, so
* this file records the state rather than hiding it.
* bound is enforced at the write seam"). At #11794 objectql's record-validator
* applied its `max_length` branch to `text` / `textarea` / `email` / `url` /
* `phone` / `password` / `markdown` / `html` / `richtext` / `code` — and to no
* other type — so for `signature` / `qrcode` an unbounded column would have
* accepted values the declaration forbids: a physical surface WIDER than the
* contract, where `richtext` and `code` were a restoration of it.
*
* ## #11875 closed that half (maintainer ruling 2026-08-25, option 1)
*
* `signature` and `qrcode` joined the spec's BOUNDED_STRING_FIELD_TYPES, the
* authoring seam admits `maxLength` on them, and the record-validator's
* `max_length` branch reads that same set — so the write seam now enforces
* their declared bound and the invariant above licenses their TEXT column.
* The former "STILL-OPEN half" cases below are the same measurements in their
* CLOSED shape: the data-URI that was refused `22001` / `ER_DATA_TOO_LONG` at
* varchar(255) is accepted and round-trips byte-identically, and the #11374
* keyed-and-bounded rule applies to them the way it applies to every other
* text-family member (keyed + bounded ⇒ varchar(maxLength), physically
* enforced at exactly the declared bound; otherwise TEXT, bound enforced at
* the write seam).
*
* ## What each block is worth
*
Expand All@@ -54,7 +64,7 @@ import { MYSQL_CELL, PG_CELL, dialectCell, declareDialectCell } from './live-dia

const T = 'os11794_text_family';

/** The two this card moves, their siblings, and the stay-put controls. */
/** The types the two cards move, their siblings, and the stay-put controls. */
const FIELDS = {
// Moved by #11794: varchar(255) → TEXT.
body_rich: { type: 'richtext' },
Expand All@@ -63,11 +73,13 @@ const FIELDS = {
// already honoured for two of the three Rich Content members.
body_md: { type: 'markdown' },
body_html: { type: 'html' },
// Measured and deliberately NOT moved: no write seam enforces their
// `maxLength`, so TEXT would accept what the declaration forbids.
// Moved by #11875, once the write seam gained their declared bound
// (BOUNDED_STRING_FIELD_TYPES): varchar(255) → TEXT.
body_sig: { type: 'signature' },
body_qr: { type: 'qrcode' },
// Negative controls: the catch-all and the string family.
// Negative controls: the catch-all and the string family. `color` and
// `secret` are the #11875 ruling's explicit carve-outs (short by
// construction; opaque `sys_secret` ref per ADR-0100).
c_string: { type: 'string' },
c_select: { type: 'select' },
c_color: { type: 'color' },
Expand All@@ -79,9 +91,13 @@ const OPTS = { bypassTenantAudit: true } as any;
/** A rich-text body nobody would call exotic — four times the old cap. */
const LONG_BODY = `<p>${'a rich-text body well past the old varchar(255) cap — '.repeat(20)}</p>`;

/** The value the #11875 half is about: an ordinary data-URI signature. */
const DATA_URI = `data:image/png;base64,${'A'.repeat(1000)}`;

const MOVED = ['body_rich', 'body_code'] as const;
const SIBLINGS = ['body_md', 'body_html'] as const;
const NOT_MOVED = ['body_sig', 'body_qr', 'c_string', 'c_select', 'c_color', 'c_secret'] as const;
const MOVED_11875 = ['body_sig', 'body_qr'] as const;
const NOT_MOVED = ['c_string', 'c_select', 'c_color', 'c_secret'] as const;

/**
* Every FieldType that takes an UNBOUNDED column when no index keys it —
Expand All@@ -95,8 +111,10 @@ const NOT_MOVED = ['body_sig', 'body_qr', 'c_string', 'c_select', 'c_color', 'c_
*/
const UNBOUNDED_UNKEYED = [
// text family (`createColumn`) — every member must satisfy the write-seam
// invariant in this file's header.
// invariant in this file's header. `signature`/`qrcode` joined at #11875,
// when the write seam gained their declared bound.
'text', 'textarea', 'html', 'markdown', 'richtext', 'code',
'signature', 'qrcode',
// JSON columns and the virtual/non-varchar types: not a varchar either, for
// reasons that have nothing to do with this card.
'multiselect', 'checkboxes', 'tags', 'composite', 'repeater', 'record', 'json',
Expand All@@ -117,13 +135,13 @@ describe('richtext joins the TEXT family (#11794) — physical shape on SQLite',
await driver?.disconnect().catch(() => {});
});

it('lands richtext/code as TEXT beside markdown/html — and moves nothing else', async () => {
it('lands richtext/code and signature/qrcode as TEXT beside markdown/html — and moves nothing else', async () => {
driver = new SqlDriver(dialectCell('sqlite').config());
await driver.initObjects([{ name: T, fields: FIELDS }]);
// The PRAGMA, not the emitter: knex's columnInfo() reads table_info.
const info: ColumnInfo = await (driver as any).knex(T).columnInfo();

for (const moved of MOVED) {
for (const moved of [...MOVED, ...MOVED_11875]) {
expect(isTexty(info[moved]?.type), `${moved} landed ${String(info[moved]?.type)}`).toBe(true);
}
for (const sibling of SIBLINGS) {
Expand All@@ -150,6 +168,16 @@ describe('richtext joins the TEXT family (#11794) — physical shape on SQLite',
expect(row.body_md).toBe(LONG_BODY); // the sibling that always worked
});

it('round-trips a >255-character data-URI signature/qrcode byte-identically (#11875)', async () => {
driver = new SqlDriver(dialectCell('sqlite').config());
await driver.initObjects([{ name: T, fields: FIELDS }]);
expect(DATA_URI.length).toBeGreaterThan(255);
await driver.create(T, { id: 's1', body_sig: DATA_URI, body_qr: DATA_URI }, OPTS);
const [row] = await driver.find(T, { where: { id: 's1' } }, OPTS);
expect(row.body_sig).toBe(DATA_URI);
expect(row.body_qr).toBe(DATA_URI);
});

it('pins the whole unbounded-when-unkeyed SET, so the case list cannot drift again', () => {
driver = new SqlDriver(dialectCell('sqlite').config());
const mirror = (type: string) =>
Expand All@@ -162,8 +190,11 @@ describe('richtext joins the TEXT family (#11794) — physical shape on SQLite',
// The card's minimum, spelled out: the spec's three-member "Rich Content"
// group is whole again.
for (const t of ['markdown', 'html', 'richtext']) expect(mirror(t)).toBeNull();
// And the two that measured as wideners stay bounded.
for (const t of ['signature', 'qrcode']) expect(mirror(t)).toBe(255);
// #11875: the two former wideners moved once the write seam gained their
// bound — TEXT when unkeyed, like every other text-family member.
for (const t of ['signature', 'qrcode']) expect(mirror(t)).toBeNull();
// The ruling's explicit carve-outs stay bounded in the catch-all.
for (const t of ['color', 'secret']) expect(mirror(t)).toBe(255);
});

it('keeps #11374 keyed-and-bounded semantics for the new members', () => {
Expand All@@ -180,6 +211,16 @@ describe('richtext joins the TEXT family (#11794) — physical shape on SQLite',
// Keyed and unbounded: still TEXT — MySQL then refuses the key BY NAME
// (explainUnkeyableTextColumn), never a silently weaker constraint.
expect(mirror({ type: 'richtext' }, { unique: true })).toBeNull();
// #11875: the same four corners for the two new members. The declared
// bound's ENFORCEMENT never depends on the index — the write seam holds it
// in every corner — the index only decides whether the COLUMN also
// enforces it (varchar(n) keyed, TEXT otherwise), exactly as for `code`.
for (const type of ['signature', 'qrcode']) {
expect(mirror({ type })).toBeNull();
expect(mirror({ type, maxLength: 64 })).toBeNull();
expect(mirror({ type, maxLength: 64 }, { unique: true })).toBe(64);
expect(mirror({ type }, { unique: true })).toBeNull();
}
});
});

Expand All@@ -203,7 +244,7 @@ for (const liveCell of [PG_CELL, MYSQL_CELL]) {
// information_schema.columns, not the emitter: that is what knex's
// columnInfo() reads on both of these dialects.
const info: ColumnInfo = await (driver as any).knex(T).columnInfo();
for (const moved of MOVED) {
for (const moved of [...MOVED, ...MOVED_11875]) {
expect(isTexty(info[moved]?.type), `${moved} landed ${String(info[moved]?.type)}`).toBe(
true,
);
Expand DownExpand Up@@ -241,24 +282,72 @@ for (const liveCell of [PG_CELL, MYSQL_CELL]) {
expect(said).toMatch(/ER_DATA_TOO_LONG|22001|too long/i);
});

it('records the STILL-OPEN half: an oversized signature is refused by the server', async () => {
// ⛔ Not a wish and not a quarantine — the current, deliberate state.
// `signature` stays varchar(255) because nothing enforces its declared
// `maxLength` at the write seam, so TEXT would accept what the
// declaration forbids. This asserts the cost of that choice out loud:
// a data-URI signature IS refused today. When the write seam gains a
// bound for it, this test is what turns red and gets updated.
it('closes the formerly-open half (#11875): an oversized data-URI signature/qrcode is accepted and round-trips', async () => {
// The #11794 version of this case asserted the COST of leaving
// `signature`/`qrcode` at varchar(255) out loud: the data-URI below
// was refused BY THE SERVER (`ER_DATA_TOO_LONG` / `22001`). The write
// seam has since gained their declared bound (#11875,
// BOUNDED_STRING_FIELD_TYPES), the column is TEXT, and the same value
// is accepted — this is that red turned green, not a deleted control.
// Non-vacuity for this cell is carried by the c_color refusal in the
// test above: the same session refuses an oversized write into a
// column that stayed varchar(255).
driver = new SqlDriver(cell.config());
await driver.execute(`drop table if exists ${T}`).catch(() => {});
await driver.initObjects([{ name: T, fields: FIELDS }]);
const dataUri = `data:image/png;base64,${'A'.repeat(1000)}`;
await driver.create(T, { id: 's1', body_sig: DATA_URI, body_qr: DATA_URI }, OPTS);
const [row] = await driver.find(T, { where: { id: 's1' } }, OPTS);
expect(row.body_sig).toBe(DATA_URI);
expect(row.body_qr).toBe(DATA_URI);
});

it('keeps #11374 keyed-and-bounded semantics live for the new members (#11875)', async () => {
// A KEYED bounded signature/qrcode column is varchar(maxLength) — the
// physical catalog says so — and the server enforces EXACTLY the
// declared bound: the boundary value fits, one past it is refused.
// The declared bound's enforcement therefore never diverges by shape:
// unkeyed columns are TEXT with the same bound enforced at the write
// seam (record-validator, pinned in objectql), keyed columns enforce
// it physically too. Both directions measured, boundary included.
const KT = `${T}_keyed`;
// Hoisted (not an inline literal) the way #11374's `boundedObject()`
// is: `indexes` rides through `initObjects` beyond its narrow
// parameter type, exactly as the platform objects declare it.
const keyedObject = {
name: KT,
fields: {
sig: { type: 'signature', maxLength: 64 },
qr: { type: 'qrcode', maxLength: 64 },
},
indexes: [
{ fields: ['sig'], unique: true },
{ fields: ['qr'], unique: false },
],
};
driver = new SqlDriver(cell.config());
await driver.execute(`drop table if exists ${KT}`).catch(() => {});
await driver.initObjects([keyedObject]);
const info: ColumnInfo = await (driver as any).knex(KT).columnInfo();
for (const col of ['sig', 'qr']) {
expect(
/varchar|character varying/i.test(String(info[col]?.type)),
`${col} landed ${String(info[col]?.type)}`,
).toBe(true);
expect(Number(info[col]?.maxLength)).toBe(64);
}
// Boundary value: exactly maxLength characters is ACCEPTED.
await driver.create(KT, { id: 'b1', sig: 'x'.repeat(64), qr: 'y'.repeat(64) }, OPTS);
const [row] = await driver.find(KT, { where: { id: 'b1' } }, OPTS);
expect(row.sig).toBe('x'.repeat(64));
// One past the boundary: refused by the SERVER at the declared bound.
const refusal = await driver
.create(T, { id: 's1', body_sig: dataUri }, OPTS)
.create(KT, { id: 'b2', qr: 'y'.repeat(65) }, OPTS)
.then(() => null)
.catch((e: unknown) => e);
expect(refusal).toBeInstanceOf(Error);
const said = `${String((refusal as { code?: string })?.code ?? '')} ${String((refusal as Error).message)}`;
expect(said).toMatch(/ER_DATA_TOO_LONG|22001|too long/i);
await driver.execute(`drop table if exists ${KT}`).catch(() => {});
});
});
});
Expand Down
Loading
Loading