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
26 changes: 26 additions & 0 deletions .changeset/20513-driver-sql-runtime-strings-state-the-decision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@objectstack/driver-sql': patch
---

driver-sql refusals, drift reports and log lines no longer cite tracker numbers; each states the reason in words

Clause-②: no

Many messages the SQL driver shows to authors and operators ended with an issue-tracker number where
the reason belonged. The number goes, and where the sentence did not already say what was decided, it
now does:

- Filter refusals (`INVALID_FILTER`): the withheld-detail wording ("withheld from the message; the full
diagnostic is in the server log"), the JSON-column, zero-operator, `$null` / `$exists`, undefined
comparand and unknown-combinator refusals, and the filter-array refusal.
- Schema and index messages: the `reference_to` DDL refusal (the FOREIGN KEY DDL that key used to gate
is retired, because it could never fire for a spec-conformant lookup), the MySQL TEXT-key and
row-size explanations, the hash-shadow UNIQUE messages, and the `os migrate plan` drift entries.
- The NULL-safe UNIQUE messages now say why rows without an organization were never constrained: SQL
UNIQUE is NULL-distinct.
- Boot log lines for the SQLite datetime, time and json canonicalisation and the MySQL `TIMESTAMP` /
`TIME` widening now say what the conversion is for.

Text only: no error code, field name, status or behaviour changes. Three aggregate refusals keep their
citation for now, because a test in `@objectstack/driver-turso` compares them byte for byte with the
Turso remote transport's copies; they change together with those copies.
12 changes: 6 additions & 6 deletions packages/drivers/driver-sql/src/cross-field-conformance-cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ export const CROSS_FIELD_CASES: readonly CrossFieldCase[] = [
name: '$not of a cross-field $gt returns exactly the rows $gt does not',
filter: { $not: { amount: { $gt: { $field: 'budget' } } } },
expected: ['2', '3', '4', '5', '6'],
note: '#5146 made `$not` NULL-safe by totalising its leaves; a cross-field leaf is total by construction, so the negation is the exact complement — including the NULL rows the JS evaluator returns.',
note: '`$not` is NULL-safe because each of its leaves is totalised (TRUE or FALSE, never NULL), as the JS evaluator reads it; a cross-field leaf is total by construction, so the negation is the exact complement — including the NULL rows the JS evaluator returns.',
},
{
name: '$not of a cross-field $eq returns exactly the rows $eq does not',
Expand Down Expand Up @@ -235,7 +235,7 @@ export const CROSS_FIELD_CASES: readonly CrossFieldCase[] = [
},
},
expected: ['2', '4', '5'],
note: 'The complement of {1} ∪ {3,6}. A guard hoisted to the top of the `$not` instead of sitting on each leaf re-admits rows here — the failure #5146 wrote its rewrite to avoid.',
note: 'The complement of {1} ∪ {3,6}. A guard hoisted to the top of the `$not` instead of sitting on each leaf re-admits rows here — the failure that totalising each leaf, rather than guarding the whole `$not`, exists to avoid.',
},
{
name: 'a cross-field comparison nested two combinators deep',
Expand Down Expand Up @@ -314,7 +314,7 @@ export const CROSS_FIELD_AUTHORED_CASES: readonly CrossFieldAuthoredCase[] = [
authored: ['amount', '>', { $field: 'budget' }],
loweredTo: { amount: { $gt: { $field: 'budget' } } },
expected: ['1'],
note: 'Untouched by #7597 and asserted anyway: if this moves, the harness moved rather than the lowering.',
note: 'Untouched by the fix that lowers the `=` spelling of a field reference to `$eq`, and asserted anyway: if this moves, the harness moved rather than the lowering.',
},

// ── The sugar's own structures, carrying a reference leaf ────────────────
Expand Down Expand Up @@ -393,7 +393,7 @@ export const CROSS_FIELD_REFUSALS: readonly CrossFieldRefusalCase[] = [
name: 'an undeclared column is refused at compile time',
filter: { amount: { $gt: { $field: 'no_such_column' } } },
diagnosticIncludes: ['not a declared field'],
note: 'The `$field` value lands in a SQL IDENTIFIER position. cloud#1051: letting it through unchecked is dismantling the guard rail — and a compile-time refusal is what makes AI-authored metadata wrong at authoring time rather than in the database.',
note: 'The `$field` value lands in a SQL IDENTIFIER position, and letting it through unchecked would dismantle the guard rail that only a declared column reaches SQL — and a compile-time refusal is what makes AI-authored metadata wrong at authoring time rather than in the database.',
},
{
name: 'an undeclared TARGET field is refused too',
Expand Down Expand Up @@ -446,7 +446,7 @@ export const CROSS_FIELD_REFUSALS: readonly CrossFieldRefusalCase[] = [
name: 'a multi-valued (JSON) column is refused as the referent',
filter: { amount: { $gt: { $field: 'tags' } } },
diagnosticIncludes: ['no scalar stored'],
note: 'A JSON column holds a serialized array; SQL comparison operators have no element-wise reading of it, and #7398 already refuses the scalar operators on such a column for a value comparand.',
note: 'A JSON column holds a serialized array; SQL comparison operators have no element-wise reading of it, and the scalar operators are already refused on such a column for a value comparand, because they compare the whole serialized text.',
},
{
name: 'a formula (virtual) column is refused as the referent',
Expand Down Expand Up @@ -479,7 +479,7 @@ export const CROSS_FIELD_REFUSALS: readonly CrossFieldRefusalCase[] = [
name: 'a $field member of an $in list is refused',
filter: { amount: { $in: [{ $field: 'budget' }, 1] } },
diagnosticIncludes: ['index 0'],
note: 'Before #5041 this did not even crash: it compiled, ran, and returned ZERO ROWS. The index is named because it is the only thing distinguishing the bad member from its legitimate neighbours.',
note: 'Before this driver compiled `$field` comparands and refused the positions it cannot compile, this did not even crash: it compiled, ran, and returned ZERO ROWS. The index is named because it is the only thing distinguishing the bad member from its legitimate neighbours.',
},
{
name: 'a $field member of a $nin list is refused',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ export function currentLiveSchema(): string {
const testPath = expect.getState().testPath;
if (!testPath) {
throw new Error(
'live-dialect isolation (#9350): vitest reported no testPath, so this live connection ' +
'live-dialect isolation: vitest reported no testPath, so this live connection ' +
'cannot be given a per-file schema and would fall back to sharing one database with ' +
'every other live file — the contention this removed. Build live connections from a ' +
'test file, through DIALECT_CELLS[].config().',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ describe('diffManagedTable — multi-value field over a stale textual column (#1
expect(entry.message).toContain('proj_task.tags');
expect(entry.message).toContain('json');
expect(entry.message).toContain('character varying');
expect(entry.message).toContain('#11535');
expect(entry.message).toContain('receives one opaque id instead of a list');
// The remedy is the real statement, not a gesture at one.
expect(entry.message).toContain(manualJsonConversionSql('postgres', 'proj_task', 'tags'));
// The orphaned single-value index is part of the same picture: a json
Expand Down
10 changes: 5 additions & 5 deletions packages/drivers/driver-sql/src/schema-drift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -995,7 +995,7 @@ export function diffManagedTable(args: {
message:
`${table}.${fieldName}: the column carries DEFAULT '${field.defaultValue}', but ` +
`'${field.defaultValue}' is a runtime token the engine resolves per write — the database ` +
`has been stamping the literal token into every insert that omitted the field (#4560). ` +
`has been stamping the literal token into every insert that omitted the field. ` +
`Dropping the default is non-destructive: run "os migrate apply". Rows already holding ` +
`'${field.defaultValue}' are NOT rewritten — the dangling-reference audit reports them.`,
});
Expand Down Expand Up @@ -1142,7 +1142,7 @@ export function diffManagedTable(args: {
`column is \`${col.type}\` — the database was created while the field was single-value and the ` +
`additive sync never migrates a column's type. Arrays are being written as the STRINGIFIED ` +
`literal (e.g. '["a","b"]') and read back as a string, so anything consuming the value ` +
`receives one opaque id instead of a list (#11535). REMEDY: run ` +
`receives one opaque id instead of a list. REMEDY: run ` +
`"${MULTI_VALUE_COLUMN_REMEDY_COMMAND}" — it is a DRY RUN by default that executes nothing ` +
`and prints the statements; take a backup, then re-run it with --apply. ObjectStack never ` +
`migrates this column on its own: the boot path only reports it and "os migrate apply" ` +
Expand Down Expand Up @@ -1347,8 +1347,8 @@ export function diffManagedTable(args: {
`\`varchar(${col.maxLength})\` and the additive sync never changes a column's type. The ` +
`column still caps at ${col.maxLength} characters, so the server refuses longer values the ` +
`declaration ALLOWS (Postgres 22001, MySQL ER_DATA_TOO_LONG) — a data URI in a ` +
`\`signature\`/\`qrcode\` field, or an ordinary rich-text body, is routinely past it ` +
`(#12121). ObjectStack does NOT migrate this column: "os migrate apply" reports this entry ` +
`\`signature\`/\`qrcode\` field, or an ordinary rich-text body, is routinely past it. ` +
`ObjectStack does NOT migrate this column: "os migrate apply" reports this entry ` +
`as skipped. Two operator routes — declare a \`maxLength\` this dialect can express, which ` +
`turns this into the widen op "os migrate apply" performs; or convert the column to TEXT by ` +
`hand, with a backup taken first, restating the FULL column definition on MySQL (MODIFY ` +
Expand Down Expand Up @@ -2391,7 +2391,7 @@ export function diffManagedIndexes(args: {
message:
`${table}.${l.legacyColumns.join('+')}: a legacy platform-wide UNIQUE index (${present.join(', ')}) still enforces ` +
`uniqueness across ALL tenants, but metadata scopes it per '${l.replacement.columns[0]}' — a second ` +
`tenant reusing the value is rejected on insert (#3696). Replacing it with ${indexSignature(l.replacement.columns, true, l.replacement.nullSafeColumns)} ` +
`tenant reusing the value is rejected on insert. Replacing it with ${indexSignature(l.replacement.columns, true, l.replacement.nullSafeColumns)} ` +
`is a pure relaxation: run "os migrate apply".`,
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,10 +190,10 @@ describe('unkeyable TEXT column: the upgrade path names the real remedy (#12999)

expect(message).toMatch(/Column\(s\) "token" are stored as TEXT because the field declares no `maxLength`/);
expect(message).toMatch(/Declare `maxLength` on the field\(s\) so the column is emitted as varchar\(n\)/);
expect(message).toContain('#11374');
expect(message).toContain('so the column is emitted as varchar(n) and can be keyed.');
// ⛔ The new branch must not reach this deployment: nothing here is stale.
expect(message).not.toMatch(/ALREADY declares/);
expect(message).not.toContain('#12999');
expect(message).not.toContain('re-declaring `maxLength` changes nothing here');
});

it('COUNTER-PIN: a bound past the 768-character key ceiling is NOT a stale column', async () => {
Expand All @@ -207,7 +207,7 @@ describe('unkeyable TEXT column: the upgrade path names the real remedy (#12999)

expect(message).toMatch(/wider than 768 characters/);
expect(message).not.toMatch(/ALREADY declares/);
expect(message).not.toContain('#12999');
expect(message).not.toContain('re-declaring `maxLength` changes nothing here');
});

it('COUNTER-PIN: a table this driver holds no declaration for keeps the CREATE message', async () => {
Expand All @@ -221,7 +221,7 @@ describe('unkeyable TEXT column: the upgrade path names the real remedy (#12999)
const message = (await explain(driver, 'os12999_unregistered', ['token'])) ?? '';

expect(message).toMatch(/Declare `maxLength` on the field\(s\)/);
expect(message).not.toContain('#12999');
expect(message).not.toContain('re-declaring `maxLength` changes nothing here');
});

it('names BOTH dispositions when one key column is stale and another is unbounded', async () => {
Expand All @@ -240,7 +240,7 @@ describe('unkeyable TEXT column: the upgrade path names the real remedy (#12999)
// …and the genuinely unbounded half is still told to declare a bound, so a
// composite key does not send the operator down one route for both columns.
expect(message).toMatch(/Column\(s\) "note" in the same key declare no usable bound and DO need `maxLength`/);
expect(message).toContain('#11374');
expect(message).toContain('DO need `maxLength` declared on the field.');
});

it('still declines to explain a failure that is not the TEXT-key refusal', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ describe('#14902 plain unique index over duplicate rows', () => {
expect(entry!.message).toContain('os migrate plan');
// ⛔ And it must NOT borrow path A's story: no prior index admitted these
// rows, so #5030 is not what happened here.
expect(entry!.message).not.toContain('#5030');
expect(entry!.message).not.toContain('NULL-distinct');
expect(entry!.message).not.toContain('NULL-safe');
});

Expand Down Expand Up @@ -298,14 +298,14 @@ describe('#14902 plain unique index over duplicate rows', () => {
const durability = logs.filter((l) => l.level === 'error');
expect(durability).toHaveLength(1);
expect(durability[0].msg).toContain('NULL-safe unique index');
expect(durability[0].msg).toContain('#5030');
expect(durability[0].msg).toContain('NULL-distinct');
expect(durability[0].msg).toContain('ADR-0120 D4');
expect(durability[0].msg).toContain("'organization_id, quote_number'");

const entry = (await driver.detectManagedDrift()).find((d) => d.op.type === 'create_index')!;
expect(entry.category).toBe('destructive');
expect(entry.severity).toBe('error');
expect(entry.message).toContain('#5030');
expect(entry.message).toContain('NULL-distinct');
expect(entry.message).toContain('__global__');
// Both key groups, with counts — the NULL-organization bucket and a real
// organization, which is what makes the COALESCE key self-describing.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ declareDialectCell(MYSQL_CELL, 'hash-shadow plain unique over duplicates (#15479

// ⛔ And it must NOT carry the NULL-safe arm's framing, which is false
// here: nothing admitted these rows and there is no NULL-safe key.
expect(diagnosis).not.toContain('#5030');
expect(diagnosis).not.toContain('NULL-distinct');
expect(diagnosis).not.toContain('NULL-safe');
expect(diagnosis).not.toContain('COALESCE');

Expand All @@ -196,7 +196,7 @@ declareDialectCell(MYSQL_CELL, 'hash-shadow plain unique over duplicates (#15479
const diagnosis = logs.find((l) => l.includes("cannot create hash-shadow unique index 'uniq_os15479_global_v'"));
expect(diagnosis, 'the degradation must reach the durability channel').toBeTruthy();
expect(diagnosis).toMatch(/Conflicting group\(s\):/);
expect(diagnosis).not.toContain('#5030');
expect(diagnosis).not.toContain('NULL-distinct');
expect(diagnosis).not.toContain('COALESCE');

const { idx } = await catalog('os15479_global');
Expand All @@ -219,7 +219,7 @@ declareDialectCell(MYSQL_CELL, 'hash-shadow plain unique over duplicates (#15479
expect(err, 'the NULL-safe arm already survived the boot').toBeNull();
const diagnosis = logs.find((l) => l.includes("cannot create hash-shadow unique index 'uniq_os15479_org_v'"));
expect(diagnosis, 'the NULL-safe degradation must still be logged').toBeTruthy();
expect(diagnosis).toContain('#5030');
expect(diagnosis).toContain('NULL-distinct');
expect(diagnosis).toContain('NULL-safe');
expect(diagnosis).toContain("COALESCE(organization_id, '__global__')");
}, 60_000);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -691,7 +691,7 @@ describe('SqlDriver unique × tenancy (#3696)', () => {
expect(entry!.message).toMatch(/BLOCKED/);
expect(entry!.message).toMatch(/__global__/);
expect(entry!.message).toMatch(/"DUP"/);
expect(entry!.message).toMatch(/#5030/);
expect(entry!.message).toMatch(/NULL-distinct/);

// Even `--allow-destructive` cannot force it: apply re-probes and
// refuses — at no point is a constraint dropped without its replacement
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ describe('syncDeclaredIndexes unique-violation discriminator (#6543)', () => {
expect(errors[0]).toMatch(/cannot create NULL-safe unique index/);
expect(errors[0]).toMatch(/uniq_product_organization_id_code/);
expect(errors[0]).toMatch(/NOT enforced/);
expect(errors[0]).toMatch(/#5030/);
expect(errors[0]).toMatch(/NULL-distinct/);
expect(errors[0]).toMatch(/ADR-0120 D4/);
});

Expand All @@ -205,7 +205,7 @@ describe('syncDeclaredIndexes unique-violation discriminator (#6543)', () => {

await expect(sync([NULL_SAFE_INDEX])).resolves.toBeUndefined();
expect(errors).toHaveLength(1);
expect(errors[0]).toMatch(/#5030/);
expect(errors[0]).toMatch(/NULL-distinct/);
});

it('reads the violation through a driver `cause` wrapper', async () => {
Expand All @@ -215,7 +215,7 @@ describe('syncDeclaredIndexes unique-violation discriminator (#6543)', () => {

await expect(sync([NULL_SAFE_INDEX])).resolves.toBeUndefined();
expect(errors).toHaveLength(1);
expect(errors[0]).toMatch(/#5030/);
expect(errors[0]).toMatch(/NULL-distinct/);
});

// ── Nothing the old regex caught may be narrowed ──────────────────────────
Expand All @@ -229,7 +229,7 @@ describe('syncDeclaredIndexes unique-violation discriminator (#6543)', () => {

await expect(sync([NULL_SAFE_INDEX])).resolves.toBeUndefined();
expect(errors).toHaveLength(1);
expect(errors[0]).toMatch(/#5030/);
expect(errors[0]).toMatch(/NULL-distinct/);
});

// ── The site's own business logic, untouched by the migration ─────────────
Expand Down Expand Up @@ -286,7 +286,7 @@ describe('syncDeclaredIndexes unique-violation discriminator (#6543)', () => {
expect(errors[0]).toMatch(/× 2 rows/);
// ⛔ And it does not borrow the NULL-safe arm's story: no earlier index
// admitted these rows, so #5030 is not what happened here.
expect(errors[0]).not.toMatch(/#5030/);
expect(errors[0]).not.toMatch(/NULL-distinct/);
expect(errors[0]).not.toMatch(/NULL-safe/);
});

Expand Down
Loading
Loading