diff --git a/package.json b/package.json index da64461d512..1a3a00b14e5 100644 --- a/package.json +++ b/package.json @@ -225,6 +225,7 @@ "github-slugger": "2.0.0", "semver": "^7.8.5", "svelte": "^5.57.3", + "ts-morph": "^28.0.0", "tsup": "^8.5.1", "tsx": "^4.23.15", "turbo": "^2.11.10", diff --git a/packages/drivers/driver-sql/src/logger-receiver-detach.test.ts b/packages/drivers/driver-sql/src/logger-receiver-detach.test.ts index d5b98c38313..82355619676 100644 --- a/packages/drivers/driver-sql/src/logger-receiver-detach.test.ts +++ b/packages/drivers/driver-sql/src/logger-receiver-detach.test.ts @@ -48,13 +48,15 @@ * ⓪ the doubles (and the platform's real logger) are receiver-sensitive. * ① the `info` site, driven through the real dev auto-reconcile path. * ② the durability sites, driven through the real declared-index sync. - * ③ a structural pin over `sql-driver.ts` itself: no detach-then-call shape - * survives anywhere in the file, in ANY of the four spellings the sweep - * measured — including the two a single-line regex cannot see. + * ③ a structural pin over `sql-driver.ts` itself, and every non-test + * `sql-driver*.ts` module beside it (#29520: the codemod split moves bodies + * out of the host): no detach-then-call shape survives anywhere in them, in + * ANY of the four spellings the sweep measured — including the two a + * single-line regex cannot see. */ import { describe, it, expect, afterEach } from 'vitest'; -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; import ts from 'typescript'; import { createLogger } from '@objectstack/core'; import { SqlDriver } from './sql-driver.js'; @@ -493,11 +495,29 @@ describe('[#12792] ③ no detach-then-call shape survives in sql-driver.ts', () expect(found.filter((f) => f.line >= 13)).toEqual([]); }); - it('sql-driver.ts contains none of them', () => { - const source = readFileSync(new URL('./sql-driver.ts', import.meta.url), 'utf8'); - expect( - scan(source, 'sql-driver.ts').map((f) => `${f.shape} @${f.line}: ${f.text.trim()}`), - 'every log call must go through a property access so the receiver stays bound', - ).toEqual([]); + // [#29520 codemod] The split moves `SqlDriver` method bodies (log calls + // included) out of `sql-driver.ts` into generated `sql-driver-*.ts` modules, + // and which modules exist depends on what the codemod moves on the `main` it + // is re-run on. So the pin reads the host and EVERY non-test `sql-driver*.ts` + // beside it by listing the directory, not by a named list that would rot at + // the next regeneration. The pin asserts an absence, so a file it stops + // reading would keep it green while it checked less; the floor below makes a + // listing that finds nothing past the host fail instead. + const SCANNED = readdirSync(new URL('.', import.meta.url)) + .filter((name) => /^sql-driver.*\.ts$/.test(name) && !name.endsWith('.test.ts')) + .sort(); + + it('reads the host and every module split out of it', () => { + expect(SCANNED).toContain('sql-driver.ts'); + expect(SCANNED.length).toBeGreaterThan(1); + }); + + it('sql-driver.ts and the modules split out of it contain none of them', () => { + const found = SCANNED.flatMap((name) => + scan(readFileSync(new URL(`./${name}`, import.meta.url), 'utf8'), name).map( + (f) => `${name} ${f.shape} @${f.line}: ${f.text.trim()}`, + ), + ); + expect(found, 'every log call must go through a property access so the receiver stays bound').toEqual([]); }); }); diff --git a/packages/drivers/driver-sql/src/sql-driver-aggregation.ts b/packages/drivers/driver-sql/src/sql-driver-aggregation.ts new file mode 100644 index 00000000000..a37f2a8ae1a --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-aggregation.ts @@ -0,0 +1,344 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The "Aggregation" section of `SqlDriver`: the bodies of + * `aggregate`, `foldEmptyAggregateAnswers` moved out of `sql-driver.ts` whole + * by `scripts/codemod-split-sql-driver.mjs`. + * + * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * method of the same name exactly as it stood: not re-indented, not + * formatted, not edited. The method stays on the class with its docblock and + * signature, so the published `dist/*.d.ts`, an instance spy and a subclass + * override are what they were; the docblock there is the contract. + * + * The bodies reach the class through {@link AggregationHost}: exactly the + * `SqlDriver` members they read. Each method hands its body one literal of the + * members that body reads, plus a `.call`: methods go as closures that call + * them on the driver at call time (the builder factory as a bound reference), + * so a subclass override or an instance spy is still the one reached; fields + * and accessors go as GETTERS, read when the body reads them, as they were on + * the class. + * + * The module imports `sql-driver.ts` for types only, so the two modules form + * no evaluation cycle. `index.ts` re-exports nothing from this module. + */ + +import { emptyGroupValueFor } from '@objectstack/spec/data'; +import type { DriverOptions } from '@objectstack/spec/data'; +import type { DriverQuery } from '@objectstack/spec/contracts'; +import { AGGREGATE_ANSWER_KIND, aggregandOperandSql } from '@objectstack/core'; +import { + refuseDateBucketedGroupBy, refuseDistinctAggregateWithoutField, unsupportedAggregationFilterError, +} from './sql-driver-top-level.js'; +import type { ReadPresentationKind } from './sql-driver-top-level.js'; +import type { SqlDriver } from './sql-driver.js'; + +/** + * The `SqlDriver` members the functions below read — every one of them, and + * no other. Each is typed AS the class member (an indexed access on the + * class), so a change to a member's signature on the class is a change here + * too. `readonly` members are the getters. + */ +export interface AggregationHost { + aggregandColumnClassOf: SqlDriver['aggregandColumnClassOf']; + aggregateBackendFault: SqlDriver['aggregateBackendFault']; + aliasIdentifierSql: SqlDriver['aliasIdentifierSql']; + applyFilters: SqlDriver['applyFilters']; + applyTenantScope: SqlDriver['applyTenantScope']; + buildDateBucketExpr: SqlDriver['buildDateBucketExpr']; + coercionKey: SqlDriver['coercionKey']; + readonly config: SqlDriver['config']; + readonly dateGranularityCapabilities: SqlDriver['dateGranularityCapabilities']; + readonly dialectName: SqlDriver['dialectName']; + foldEmptyAggregateAnswers: SqlDriver['foldEmptyAggregateAnswers']; + getBuilder: SqlDriver['getBuilder']; + readonly knex: SqlDriver['knex']; + mapAggregateFunc: SqlDriver['mapAggregateFunc']; + presentReadColumns: SqlDriver['presentReadColumns']; + readPresentationKind: SqlDriver['readPresentationKind']; +} + +/** The body of `SqlDriver#aggregate`; the method's docblock there is its contract. */ +export async function aggregate(this: Pick, object: string, query: DriverQuery, options?: DriverOptions): Promise[]> { + const builder = this.getBuilder(object, options); + this.applyTenantScope(builder, object, options); + + if (query.where) { + this.applyFilters(builder, query.where, options); + } + + // The same coercion key `applyFilters` just used, so every part of this + // statement agrees on how each column is stored — the WHERE window, the + // GROUP BY bucket expression (#3773) and the result presentation (#3797). + const table = this.coercionKey(builder); + + // Result columns and the presentation each takes, keyed by the column name + // the caller will read: a raw column VALUE (a group key, `min`/`max`) takes + // its column's presentation, and [#20335] a count or total derived from one + // takes the `'number'` presentation (see `AGGREGATE_ANSWER_KIND`). + // Collected while the statement is built because that is the only point + // where a column name and its meaning are both known: a `min()` lands under + // its alias (never under the field name), and a date-BUCKETED column lands + // under the field name while holding a label (`'2026-01'`), not a value. + // Matching on names after the fact gets both of those backwards. + // See {@link presentReadColumns}. + const presentedOutput = new Map(); + + // [#15546] Result columns whose NULL answer folds to the identity the + // platform declares for that aggregate over NOTHING (`emptyGroupValueFor`, + // spec `data/aggregation-policy.ts`), keyed like `presentedOutput` by the + // column name the caller will read. See {@link foldEmptyAggregateAnswers}. + const foldedOutput = new Map(); + + if (query.groupBy) { + // groupBy items may be plain strings ('region') or structured objects + // ({ field: 'closed_at', dateGranularity: 'quarter' }). For structured + // items we emit a dialect-specific bucket expression aliased as the + // projected column name so the resulting row keys match in-memory + // bucketDateValue — see the `outKey` note below for what that name is. + // [#6212] The element type is `GroupByNode` — the spec's own union — so + // the local `Array` restatement is + // gone. It had drifted from the declaration it was restating: `alias` was + // missing from it and `dateGranularity` was widened to `string`, which is + // what forced the `as any` on the `buildDateBucketExpr` call below. + for (const g of query.groupBy) { + if (typeof g === 'string') { + builder.groupBy(g); + builder.select(g); + const kind = this.readPresentationKind(table, g); + if (kind) presentedOutput.set(g, kind); + } else if (g && typeof g === 'object' && g.field) { + // [#6401] The projected column is named `alias ?? field` — the rule + // `AggregationNodeSchema.alias` already gets a few dozen lines below, + // and the one `in-memory-aggregation.ts` has always applied + // (`g.alias ?? g.field`). This face was the half that PARSED the key + // and ignored it, so one aggregate came back keyed by `closed_at` + // under pushdown and by `qtr` under the in-memory fallback — decided + // by a driver capability bit and a timezone the caller cannot see + // (`engine.ts`'s `allStructuredSupported && !tzRequiresInMemory` + // fork). GROUP BY still keys on the FIELD; only the projection is + // renamed, so the buckets are identical and only their column name + // moves. + const outKey = g.alias ?? g.field; + if (g.dateGranularity) { + const bucket = this.buildDateBucketExpr(g.field, g.dateGranularity, table); + if (!bucket) { + // [#6212] Was a bare `throw new Error(...)`; see + // {@link refuseDateBucketedGroupBy} for why it now carries the + // ADR-0112 envelope and what the remote face answers. + refuseDateBucketedGroupBy( + g.dateGranularity, + Object.entries(this.dateGranularityCapabilities).filter(([, on]) => on).map(([k]) => k), + `dialect '${(this.config as any).client}'`, + ); + } + builder.groupByRaw(bucket.sql, bucket.bindings); + builder.select(this.knex.raw(`${bucket.sql} as ${this.aliasIdentifierSql(outKey)}`, [...bucket.bindings])); + } else { + builder.groupBy(g.field); + // Aliased only when the name actually moves: an alias equal to + // the field would otherwise rewrite `select "region"` into + // `select "region" as "region"` on every dialect for no gain. + // [#13714] The alias half is {@link aliasIdentifierSql}, never a + // `??` binding — the FIELD stays a `??` reference (it may be + // qualified), the alias is one identifier by definition. + builder.select(outKey === g.field ? g.field : this.knex.raw(`?? as ${this.aliasIdentifierSql(outKey)}`, [g.field])); + // Keyed by the OUTPUT column, like the aggregation branch below — + // `presentReadColumns` matches on the name the row actually + // carries, so an aliased group value went unpresented before. + const kind = this.readPresentationKind(table, g.field); + if (kind) presentedOutput.set(outKey, kind); + } + } + } + } + + // [#6321] Was `query.aggregations || query.aggregate` / `agg.function || + // agg.func`. Neither `aggregate` nor `func` is declared anywhere in the + // Query Protocol — `QueryASTSchema` declares `aggregations` and + // `AggregationNodeSchema` declares `function` — so those two limbs were a + // private dialect this consumer tolerated, which is what PD#12 rejects. The + // only writers were this package's own fixtures and driver-sqlite-wasm's + // (#4984's family: a fixture spelling the alias keeps the lenient limb green + // forever, and nothing ever measures that deleting it costs nothing). Both + // fixture sets now spell the declared keys, the non-test writer count was + // zero when measured, and ADR-0049 says an unenforced tolerance goes. + const aggregates = query.aggregations; + if (aggregates) { + for (const agg of aggregates) { + // [#10576] A per-aggregation `filter` this compiler cannot express is + // refused before any statement is built — silently emitting the + // UNFILTERED aggregate is the #10413 defect. Unreachable through + // `engine.aggregate` (the engine lowers filtered aggregations in + // memory); this is the direct-caller guard. `{}` is the vacuous filter, + // same convention as `where` / `having`. + if (agg.filter && Object.keys(agg.filter).length > 0) { + throw unsupportedAggregationFilterError(agg.alias ?? agg.field ?? '(unaliased)', 'driver-sql'); + } + const funcName = agg.function; + const lowering = this.mapAggregateFunc(funcName); + // Spec: `field` is optional for COUNT (means COUNT(*)). + const fieldExpr = agg.field ?? '*'; + // [#6409] `count_distinct` lowers to `count(distinct ??)`. The keyword + // goes in the SQL FRAGMENT, never in a binding: `??` still binds the + // column as a knex identifier exactly as it did for the other five, so + // the caller's field name has no path into the statement text. And + // `COUNT(DISTINCT *)` is not valid SQL anywhere — a distinct aggregate + // with no field is refused rather than emitted, so the caller reads + // their own mistake instead of a dialect's syntax error wrapped in a + // 500 (see {@link refuseDistinctAggregateWithoutField}). + if (lowering.distinct && fieldExpr === '*') refuseDistinctAggregateWithoutField(funcName); + // [#11635] A boolean aggregand is CAST for the arithmetic/order + // aggregates on Postgres — the one dialect that stores `Field.boolean` + // as a real `boolean` column and defines no `sum`/`avg`/`min`/`max` + // over it (SQLSTATE `42883`, measured on PG 16.13; SQLite stores 0/1 + // INTEGER and MySQL `tinyint(1)`, so both compute natively). The + // answers are ruled: this face must ANSWER — refusing cannot satisfy + // the contract — and [#11152] (maintainer 2026-08-28, superseding + // #11249's `false`/`true` for the order statistics) pins ALL FOUR as + // numbers: `sum`/`avg` arithmetic over 1/0, `min`/`max` the `0`/`1` + // the cast computes, presented as-is (see the presentation note + // below). `count`/`count_distinct` are deliberately NOT cast (both + // lower to `count`, defined over boolean everywhere — their answers + // were correct before this and must not move). + // [#20387] `sum` / `avg` accumulate in double on PostgreSQL and MySQL, + // the arithmetic SQLite and the engine's rows path already use, so one + // query answers one number on every face (`AGGREGATE_ACCUMULATION`). + // [#21042] Both policies, and the order they compose in, are + // `aggregandOperandSql` (`@objectstack/core`), read with this column's + // class from the registries below — the rule the analytics native-SQL + // face applies too. Still one `??` binding: the cast and the wrap are + // SQL text around it, so the column stays a knex identifier binding. + const aggregandClass = fieldExpr === '*' ? undefined : this.aggregandColumnClassOf(table, fieldExpr); + const argExpr = aggregandOperandSql(funcName, aggregandClass, this.dialectName, '??'); + const rawFunc = lowering.distinct + ? `${lowering.sql}(distinct ${argExpr})` + : `${lowering.sql}(${argExpr})`; + if (agg.alias) { + if (fieldExpr === '*') { + builder.select(this.knex.raw(`${lowering.sql}(*) as ${this.aliasIdentifierSql(agg.alias)}`)); + } else { + builder.select(this.knex.raw(`${rawFunc} as ${this.aliasIdentifierSql(agg.alias)}`, [fieldExpr])); + } + // [#15546] What this aggregate answers over NOTHING, read from the + // policy rather than restated: `sum` (and the two counts, which never + // arrive as NULL) fold to `0`; `avg`/`min`/`max` have no identity and + // their NULL passes through. See {@link foldEmptyAggregateAnswers}. + const identity = emptyGroupValueFor(funcName); + if (identity !== undefined) foldedOutput.set(agg.alias, identity); + // [#20335] A count or a total is presented as the number it is, on + // every dialect: node-postgres hands `bigint` / `numeric` back as a + // string and mysql2 `DECIMAL`, so without this the native path + // answered `"2"` where the rows path and SQLite answer `2`. Keyed on + // the function asked for; the precision policy (one JS double, the + // loss above a double's precision declared) is stated on + // `AGGREGATE_ANSWER_KIND`. The fold above runs first, so a folded + // `0` is already a number and passes through. + if (AGGREGATE_ANSWER_KIND[funcName] === 'number') { + presentedOutput.set(agg.alias, 'number'); + } + // `min`/`max` are the only supported functions that hand back a value + // OF the column rather than a count/total derived from it, so they are + // the only ones whose result still needs the column's presentation. + // `alias` is required by `AggregationNodeSchema`; the unaliased branch + // below lands under a dialect-dependent column name + // (`max("closed_at")` on SQLite, `max` on Postgres) and is defensive + // only, so it is deliberately not tracked. + if (AGGREGATE_ANSWER_KIND[funcName] === 'column' && agg.field) { + // [#11152] A BOOLEAN aggregand is the ruled exception to "the + // result still needs the column's presentation": the maintainer's + // 2026-08-28 ruling (superseding #11249's `false`/`true`, which + // #11635 implemented here) pins that booleans aggregate as NUMBERS + // on every face, with no per-aggregate exception — `min`/`max` + // answer `0`/`1`, the same numeric domain `sum`/`avg` answer in. + // Every dialect's backend already computes exactly that number + // (SQLite 0/1 INTEGER storage, MySQL `tinyint(1)`, Postgres via + // the `cast(?? as int)` above), so the ruled answer is the value + // with NO boolean presentation — the `'boolean'` kind is skipped + // rather than mapped. ROW reads are untouched: `find()` still + // presents a declared boolean as a JSON boolean; it is the + // AGGREGATION context that is numeric by rule. Temporal kinds + // still present (`sql-driver-aggregate-temporal-output.test.ts`), + // and the SQLite-only numeric repair still applies. + const kind = this.readPresentationKind(table, agg.field); + if (kind && kind !== 'boolean') presentedOutput.set(agg.alias, kind); + } + } else { + if (fieldExpr === '*') { + builder.select(this.knex.raw(`${lowering.sql}(*)`)); + } else { + builder.select(this.knex.raw(rawFunc, [fieldExpr])); + } + } + } + } + + // [#11455] The third read exit joins the other two. `find()` and `count()` + // have carried the terminal envelope since #8931; this door executed BARE, + // so any dialect error the statement raised left the driver as the + // backend's own object — a raw SQLSTATE in `code`, `status` UNDEFINED, and + // the compiled statement as the message. Measured on live PostgreSQL 16.13: + // + // sum(flag) => THREW code=42883 status=undefined + // msg=select sum("flag") as "n" from "…" - + // function sum(boolean) does not exist + // + // A raw `42883` is on no list `@objectstack/rest` reads, so an ordinary + // analytics shape — a rate measure over a flag column — was logged as an + // UNHANDLED server fault. `mapDataError` then had nothing declared to + // forward, which is the #1116/#1117 gap {@link mapAggregateFunc} closed for + // the FUNCTION NAME while leaving the STATEMENT half open. + // + // When #11455 landed, whether a boolean aggregate should ANSWER or REFUSE + // was deliberately left to #11152 / #11249 — the envelope was the half + // that could land first. #11249 has since RULED (maintainer 2026-08-23): + // the face answers, and [#11635] delivered the Postgres cast in the + // statement builder above, so a boolean aggregand no longer reaches this + // exit at all. The envelope itself is unchanged — it never recognised + // `42883`, so removing one of its inputs removes nothing from it. + // + // ⛔ And no boolean-specific recognizer: the envelope comes from the EXIT, + // not from matching `42883` or the words `does not exist`. That is the + // #8926 lesson applied in advance — a predicate arm that matches a WORDING + // is an arm that silently fails to fire on the dialect nobody measured. + // + // [#11541] The unresolvable-column arm that {@link SqlDriver.count} runs + // first is here too now — but ⛔ NOT as the blanket arm #11455 refused to + // add. That refusal's words are "Filter on 'x' names a column that object + // 'o' has no column for", and THIS door names columns in three clauses — + // the WHERE, the `groupBy` fields and the aggregation `field`s. A blanket + // arm would tell the author of `avg('nosuchcol')` that their FILTER was + // wrong, which is the unsupportable claim the #8931 ruling refuses to + // make. So the dialect-named column is attributed to the clause the + // CALLER'S OWN QUERY names it in — see + // {@link SqlDriver.aggregateBackendFault}, which holds the three arms and + // the reason the no-name case keeps this terminal unchanged. + // + // Only the EXECUTION is guarded. Every refusal this method composes — + // {@link refuseAggregateFunction}, {@link refuseDistinctAggregateWithoutField}, + // {@link unsupportedAggregationFilterError}, {@link refuseDateBucketedGroupBy} + // — is raised while the statement is BUILT, upstream of this line, so the + // catch-all cannot bury a precise refusal under a generic 500. + let rows: unknown[]; + try { + rows = await builder; + } catch (error) { + // [#8931, #11541] Attribute what the caller's own query supports, then + // fall to the terminal catch-all — see + // {@link SqlDriver.aggregateBackendFault}. + throw this.aggregateBackendFault(object, query, error); + } + return this.presentReadColumns(this.foldEmptyAggregateAnswers(rows, foldedOutput), presentedOutput); + } + +/** The body of `SqlDriver#foldEmptyAggregateAnswers`; the method's docblock there is its contract. */ +export function foldEmptyAggregateAnswers(rows: any, identities: Map): any { + if (identities.size === 0 || !Array.isArray(rows)) return rows; + for (const row of rows) { + if (!row || typeof row !== 'object') continue; + for (const [column, identity] of identities) { + if (row[column] === null) row[column] = identity; + } + } + return rows; + } diff --git a/packages/drivers/driver-sql/src/sql-driver-bulk-operations.ts b/packages/drivers/driver-sql/src/sql-driver-bulk-operations.ts new file mode 100644 index 00000000000..6a0b4abb13e --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-bulk-operations.ts @@ -0,0 +1,354 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The "Bulk & Batch Operations" section of `SqlDriver`: the + * bodies of `bulkCreate`, `bulkUpdate`, `updateMany`, `rotatedUpdateById`, + * `count` moved out of `sql-driver.ts` whole by + * `scripts/codemod-split-sql-driver.mjs`. + * + * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * method of the same name exactly as it stood: not re-indented, not + * formatted, not edited. The method stays on the class with its docblock and + * signature, so the published `dist/*.d.ts`, an instance spy and a subclass + * override are what they were; the docblock there is the contract. + * + * The bodies reach the class through {@link BulkOperationsHost}: exactly the + * `SqlDriver` members they read. Each method hands its body one literal of the + * members that body reads, plus a `.call`: methods go as closures that call + * them on the driver at call time (the builder factory as a bound reference), + * so a subclass override or an instance spy is still the one reached; fields + * and accessors go as GETTERS, read when the body reads them, as they were on + * the class. + * + * The module imports `sql-driver.ts` for types only, so the two modules form + * no evaluation cycle. `index.ts` re-exports nothing from this module. + */ + +import type { DriverOptions } from '@objectstack/spec/data'; +import type { DriverQuery } from '@objectstack/spec/contracts'; +import type { Knex } from 'knex'; +import { nanoid } from 'nanoid'; +import { AUTONUMBER_COLLISION_RETRIES, DEFAULT_ID_LENGTH, isUnresolvableColumnError } from './sql-driver-top-level.js'; +import type { AutoNumberReservation } from './sql-driver-top-level.js'; +import type { SqlDriver } from './sql-driver.js'; + +/** + * The `SqlDriver` members the functions below read — every one of them, and + * no other. Each is typed AS the class member (an indexed access on the + * class), so a change to a member's signature on the class is a change here + * too. `readonly` members are the getters. + */ +export interface BulkOperationsHost { + applyFilters: SqlDriver['applyFilters']; + applyTenantScope: SqlDriver['applyTenantScope']; + applyWriteColumnMap: SqlDriver['applyWriteColumnMap']; + auditMissingTenant: SqlDriver['auditMissingTenant']; + autoNumberCounterKey: SqlDriver['autoNumberCounterKey']; + backendStatementFault: SqlDriver['backendStatementFault']; + collidingAutoNumberReservations: SqlDriver['collidingAutoNumberReservations']; + fillAutoNumberFields: SqlDriver['fillAutoNumberFields']; + formatInput: SqlDriver['formatInput']; + formatOutput: SqlDriver['formatOutput']; + getBuilder: SqlDriver['getBuilder']; + injectTenantOnInsert: SqlDriver['injectTenantOnInsert']; + readonly insertReturnsStoredRows: SqlDriver['insertReturnsStoredRows']; + keepSuppliedUpdatedAt: SqlDriver['keepSuppliedUpdatedAt']; + readonly knex: SqlDriver['knex']; + readBackInsertedRows: SqlDriver['readBackInsertedRows']; + resyncSequenceToDataMax: SqlDriver['resyncSequenceToDataMax']; + rotationShardsOf: SqlDriver['rotationShardsOf']; + rotationWriteTarget: SqlDriver['rotationWriteTarget']; + stampInsertTimestamps: SqlDriver['stampInsertTimestamps']; + stampsUpdatedAt: SqlDriver['stampsUpdatedAt']; + unresolvableFilterColumnRefusal: SqlDriver['unresolvableFilterColumnRefusal']; + update: SqlDriver['update']; + updateWithPresumedTimestamp: SqlDriver['updateWithPresumedTimestamp']; + updatedAtStamp: SqlDriver['updatedAtStamp']; + updatedAtStampIsPresumed: SqlDriver['updatedAtStampIsPresumed']; +} + +/** The body of `SqlDriver#bulkCreate`; the method's docblock there is its contract. */ +export async function bulkCreate(this: Pick, object: string, data: any[], options?: DriverOptions): Promise[]> { + // Same client-side id assignment as create() (id/_id normalization, + // nanoid fallback when neither is supplied) — a row missing an id must + // not be silently inserted with a NULL primary key just because the + // engine batched it into an array. This path used to be rarely + // exercised; framework#2678 made it the common case for seed/import. + const rows = data.map((row) => { + if (!row || typeof row !== 'object') return row; + const { _id, ...rest } = row; + const toInsert: Record = { ...rest }; + if (_id !== undefined && toInsert.id === undefined) { + toInsert.id = _id; + } else if (toInsert.id === undefined) { + toInsert.id = nanoid(DEFAULT_ID_LENGTH); + } + return toInsert; + }); + this.auditMissingTenant(object, 'bulkCreate', options, rows); + for (const row of rows) { + if (row && typeof row === 'object') this.injectTenantOnInsert(object, row, options); + } + + // #6943. A stale counter costs this path more than it costs `create()`. + // Each row reserves its own number in its own committed transaction, and + // then the whole batch goes in as ONE insert — so a single colliding row + // does not burn one number, it burns every number the batch reserved and + // fails the whole request. Measured on `main` @ `c8ff269` (counter at 10, + // rows 11–39 landed by a bypass path): a 3-row `bulkCreate` threw, wrote + // ZERO rows and moved `last_value` 10 → 13; the next call moved it 13 → 16 + // and threw again. And the exposure is the worst available, because + // framework#2678 made this the common path for seed/import — which is + // precisely what *creates* the staleness (`isSystem` replay and + // `preserveAudit` import keep their explicit numbers and never enter + // `fillAutoNumberFields`, #5495/#5503). + // + // ## Batch semantics are deliberately NOT changed + // + // The card anticipated a decision about partial success and transaction + // boundaries. Measurement dissolves it: `insert(rows[])` is a SINGLE + // statement (`… select … union all select …` on SQLite, multi-row VALUES + // elsewhere), so the batch is ALREADY all-or-nothing — the failed batch + // above left the table exactly as it found it. Re-issuing and retrying the + // WHOLE batch therefore preserves the existing contract byte for byte: no + // partial success is introduced, no transaction is opened, no sibling- + // rollback question arises because siblings already fail together. + // Rejected instead: + // - **Per-row retry inside the batch** (the shape the card sketched). + // It requires splitting the one statement into N to learn which row + // failed, which INVENTS partial success where none exists, costs N + // round-trips on the hot seed/import path, and is what the engine's + // own #6806 note warns about ("re-writing the batch could DUPLICATE + // the rows that did land"). Nothing lands here, so nothing can. + // - **Wrapping the batch in a driver-opened transaction.** It would move + // the boundary the card was worried about, for no gain (the statement + // is already atomic), and it defeats the reservation model: each + // `getNextSequenceValue` commits on purpose, which is what makes a + // forward-only re-seed meaningful. On SQLite (pool max 1) it is also + // the deadlock `ensureSequencesTable` documents. + // - **Re-seed but do not re-issue** (what the engine does at #6806). + // Correct there, because the engine cannot know whether an arbitrary + // driver applied the batch partially. Inside THIS driver that is + // measurably false, so the caller can be handed a successful batch + // instead of an error it has to retry itself. + // - **Blanket retry on any unique violation.** Rejected for #5495's + // reason: it silently eats the caller's own 409. + const mayRetry = options?.transaction === undefined; + // What each row currently holds, ACROSS attempts. A retry regenerates only + // the fields it cleared, so `fillAutoNumberFields` reports only those; a + // value kept from the previous pass is still a live reservation and has to + // stay described here, or a second collision on a counter that did not go + // stale the first time would find nothing to route and rethrow blind. + const reservationsPerRow: AutoNumberReservation[][] = rows.map(() => []); + for (let attempt = 0; ; attempt++) { + // Reserve a persistent sequence value for each row's autonumber + // field(s) — the engine no longer pre-fills these (see #1603). + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if (!row || typeof row !== 'object') continue; + const reissued = await this.fillAutoNumberFields(object, row, options); + if (reissued.length === 0) continue; + const replaced = new Set(reissued.map((r) => r.field)); + reservationsPerRow[i] = [...reservationsPerRow[i].filter((r) => !replaced.has(r.field)), ...reissued]; + } + + // Same write-side marshaling as create() (#2735): JSON-typed and + // object-valued fields must be serialized per row before they reach the + // knex binder — the raw batch used to hand `{lat, lng}` objects straight + // to SQLite ("Wrong API use: tried to bind a value of an unknown type"), + // silently failing the whole seed batch. Timestamp stamping runs on the + // FORMATTED copy, mirroring create(). + const formattedRows = rows.map((row) => { + if (!row || typeof row !== 'object') return row; + const formatted = this.applyWriteColumnMap(object, this.formatInput(object, row)); + this.stampInsertTimestamps(object, formatted); + return formatted; + }); + const builder = this.getBuilder(this.rotationWriteTarget(object) ?? object, options); + + try { + if (this.insertReturnsStoredRows) { + const result = await builder.insert(formattedRows).returning('*'); + // Read-back parity with create(): JSON columns come back as their stored + // strings from `returning('*')` — decode them so batch callers see the + // same shapes single-insert callers do. + return Array.isArray(result) + ? result.map((r) => this.formatOutput(object, r)) + : result; + } + await builder.insert(formattedRows); + } catch (error) { + if (!mayRetry || attempt >= AUTONUMBER_COLLISION_RETRIES) throw error; + const colliding = await this.collidingAutoNumberReservations(error, reservationsPerRow.flat(), options); + if (colliding.length === 0) throw error; + + // Re-seed each stale counter ONCE, not once per row that drew from it: + // the scan is identical for every row sharing a counter and the update + // is forward-only, so the repeats are pure cost. + const stale = new Set(); + for (const reservation of colliding) { + const key = this.autoNumberCounterKey(reservation); + if (stale.has(key)) continue; + stale.add(key); + await this.resyncSequenceToDataMax(reservation); + } + + // Re-issue EVERY row drawn from a stale counter — including rows whose + // own value did not collide. This is where the batch genuinely differs + // from `create()`, which keeps a non-colliding value to avoid burning a + // second number. Keeping them here is unsound: a batch that straddles + // the seeded range (say the counter is at 10, rows 11–39 exist, and the + // batch reserved 11–70) has its low rows collide and its high rows not, + // and regenerating only the low ones hands them numbers ABOVE the kept + // ones — an intra-batch duplicate this driver would have created + // itself. Counters that did NOT go stale keep their values, so another + // tenant's rows in the same batch are undisturbed. + for (let i = 0; i < rows.length; i++) { + for (const reservation of reservationsPerRow[i]) { + if (stale.has(this.autoNumberCounterKey(reservation))) delete rows[i][reservation.field]; + } + } + continue; + } + + // [#21227] Reached only when the INSERT landed and answered no rows (the + // MySQL family answered `[insertId]`: one element for the whole batch, + // which the engine's one-result-per-row guard then refused after every + // row had been stored). ONE read for the batch, by the ids written, in + // the written order. Outside the try for the reason `create` gives. + return this.readBackInsertedRows( + object, + this.rotationWriteTarget(object) ?? object, + rows, + options, + ); + } + } + +/** The body of `SqlDriver#bulkUpdate`; the method's docblock there is its contract. */ +export async function bulkUpdate(this: Pick, object: string, updates: Array<{ id: string | number; data: Record }>, options?: DriverOptions): Promise[]> { + // An empty batch issues no statement, exactly as before: a BEGIN/COMMIT + // pair — and the pool checkout behind it — buys nothing for zero rows. + if (updates.length === 0) return []; + + const applyAll = async (scoped: DriverOptions): Promise[]> => { + const results: Record[] = []; + for (const { id, data } of updates) { + const updated = await this.update(object, id, data, scoped); + if (updated) results.push(updated); + } + return results; + }; + + // The `(caller's transaction) ?? this.knex` runner idiom this file already + // uses (see {@link collidingAutoNumberReservations}); here the choice of + // runner is also the choice between a transaction and a savepoint. + const runner: Knex | Knex.Transaction = (options?.transaction as Knex.Transaction | undefined) ?? this.knex; + return runner.transaction(async (trx) => applyAll({ ...options, transaction: trx })); + } + +/** The body of `SqlDriver#updateMany`; the method's docblock there is its contract. */ +export async function updateMany(this: Pick, object: string, query: DriverQuery, data: any, options?: DriverOptions): Promise { + this.auditMissingTenant(object, 'updateMany', options); + + // `data ?? {}` keeps the nullish tolerance this door already had on the + // stamping line below: a null payload normalises to "no keys", which emits + // the same stamp-only statement `main` emitted for it, instead of newly + // throwing inside `formatInput`. + const formatted = this.applyWriteColumnMap(object, this.formatInput(object, data ?? {})); + + // Read from `formatted` rather than the caller's `data`, exactly as + // {@link update} and {@link rotatedUpdateById} do. On an external object + // whose columnMap remaps `updated_at` the two spellings disagree, and this + // door must reach the SAME decision those two reach rather than a private one. + const stamping = this.stampsUpdatedAt(object) && !this.keepSuppliedUpdatedAt(formatted, options); + // Untouched when nothing is stamped, so a timestamp-less object's statement + // is byte-identical to the one `main` emits. + const payload = stamping ? { ...formatted, updated_at: this.updatedAtStamp() } : formatted; + // A rotation-managed object is recorded in `tablesWithTimestamps` by + // `initObjects` before `ensureRotation` runs, so it is never in the + // speculative state — the same property `rotatedUpdateById` relies on. The + // speculative path below therefore only ever sees the single-target loop. + const presumed = stamping && this.updatedAtStampIsPresumed(object); + + let total = 0; + for (const target of this.rotationShardsOf(object) ?? [object]) { + // One definition of the statement, so the speculative attempt, the fenced + // retry and the plain path cannot drift in WHERE or tenant scope. + const issue = (candidate: Record, issueOptions?: DriverOptions): Promise => { + const builder = this.getBuilder(target, issueOptions); + this.applyTenantScope(builder, object, issueOptions); + if (query.where) this.applyFilters(builder, query.where, issueOptions); + return builder.update(candidate) as unknown as Promise; + }; + total += + (presumed + ? await this.updateWithPresumedTimestamp(object, payload, options, issue) + : await issue(payload, options)) || 0; + } + return total; + } + +/** The body of `SqlDriver#rotatedUpdateById`; the method's docblock there is its contract. */ +export async function rotatedUpdateById(this: Pick, object: string, shards: string[], id: string | number, data: Record, options?: DriverOptions): Promise | null> { + const formatted = this.applyWriteColumnMap(object, this.formatInput(object, data)); + // [commit 479fba50d] One definition of the decision, shared with {@link update}. No + // fallback is threaded here, and that is a property of the path rather than + // an omission: `rotationShardsOf` returns shards only once `ensureRotation` + // has run, and `initObjects` records the stronger `tablesWithTimestamps` + // fact on the line immediately before that call — so on this path the + // answer can never come from the declared-shape presumption, and there is + // nothing speculative to recover from. + // + // [#24835] Stamped into a FRESH object, for the reason {@link update} gives: + // `formatted` may be the caller's own `data`. + const payload = this.stampsUpdatedAt(object) && !this.keepSuppliedUpdatedAt(formatted, options) + ? { ...formatted, updated_at: this.updatedAtStamp() } + : formatted; + for (const shard of shards) { + const builder = this.getBuilder(shard, options).where('id', id); + this.applyTenantScope(builder, object, options); + const count = await builder.update(payload); + if (count > 0) { + const readback = this.getBuilder(shard, options).where('id', id); + this.applyTenantScope(readback, object, options); + const updated = await readback.first(); + return this.formatOutput(object, updated) || null; + } + } + return null; + } + +/** The body of `SqlDriver#count`; the method's docblock there is its contract. */ +export async function count(this: Pick, object: string, query?: DriverQuery, options?: DriverOptions): Promise { + const builder = this.getBuilder(object, options); + this.applyTenantScope(builder, object, options); + + if (query?.where) { + this.applyFilters(builder, query.where, options); + } + + // [#8790] `count()` has no recovery ladder and needs none — it carries no + // projection and no ORDER BY, so the only clause an unresolvable column can + // be in is the WHERE, which is the one clause {@link SqlDriver.findRows}' + // ladder may not drop either. What it needs is the ladder's *terminal*: the + // same `INVALID_FILTER` / 400, naming the same column, so the two halves of + // one list view stop answering one predicate two different ways. Before + // this, the dialect error travelled out untouched — `SQLITE_ERROR`, no + // `status`, bound literals inlined in the message. + let result: { count: number }[]; + try { + result = await builder.count<{ count: number }[]>('* as count'); + } catch (error) { + if (isUnresolvableColumnError(error)) { + throw this.unresolvableFilterColumnRefusal(object, error, query?.where); + } + // [#8931] The terminal catch-all — see {@link SqlDriver.backendStatementFault}. + throw this.backendStatementFault(object, error); + } + if (result && result.length > 0) { + const row: any = result[0]; + return Number(row.count ?? row['count(*)'] ?? 0); + } + return 0; + } diff --git a/packages/drivers/driver-sql/src/sql-driver-column-creation.ts b/packages/drivers/driver-sql/src/sql-driver-column-creation.ts new file mode 100644 index 00000000000..f6ef00a3e13 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-column-creation.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The "Column creation helper" section of `SqlDriver`: the + * bodies of `decorateHashShadowDuplicate`, `schemaBytesPerChar`, + * `rethrowWithRowSizeExplanation` moved out of `sql-driver.ts` whole by + * `scripts/codemod-split-sql-driver.mjs`. + * + * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * method of the same name exactly as it stood: not re-indented, not + * formatted, not edited. The method stays on the class with its docblock and + * signature, so the published `dist/*.d.ts`, an instance spy and a subclass + * override are what they were; the docblock there is the contract. + * + * The bodies reach the class through {@link ColumnCreationHost}: exactly the + * `SqlDriver` members they read. Each method hands its body one literal of the + * members that body reads, plus a `.call`: methods go as closures that call + * them on the driver at call time (the builder factory as a bound reference), + * so a subclass override or an instance spy is still the one reached; fields + * and accessors go as GETTERS, read when the body reads them, as they were on + * the class. + * + * The module imports `sql-driver.ts` for types only, so the two modules form + * no evaluation cycle. `index.ts` re-exports nothing from this module. + */ + +import type { SqlDriver } from './sql-driver.js'; + +/** + * The `SqlDriver` members the functions below read — every one of them, and + * no other. Each is typed AS the class member (an indexed access on the + * class), so a change to a member's signature on the class is a change here + * too. `readonly` members are the getters. + */ +export interface ColumnCreationHost { + explainHashShadowDuplicate: SqlDriver['explainHashShadowDuplicate']; + explainRowSizeOverflow: SqlDriver['explainRowSizeOverflow']; + readonly knex: SqlDriver['knex']; + logDurabilityFailure: SqlDriver['logDurabilityFailure']; +} + +/** The body of `SqlDriver#decorateHashShadowDuplicate`; the method's docblock there is its contract. */ +export async function decorateHashShadowDuplicate(this: Pick, tableName: string, values: Record, error: unknown): Promise { + let explanation: string | null = null; + try { + explanation = await this.explainHashShadowDuplicate(tableName, values, error); + } catch { + // The disambiguation is a diagnostic. If it cannot be made, the original + // failure is still the failure — never mask it with this one. + return error; + } + if (!explanation) return error; + this.logDurabilityFailure(explanation); + return Object.assign(new Error(`${explanation} (server said: ${String((error as { message?: string })?.message ?? error)})`), { + code: (error as { code?: string })?.code, + cause: error, + }); + } + +/** The body of `SqlDriver#schemaBytesPerChar`; the method's docblock there is its contract. */ +export async function schemaBytesPerChar(this: Pick): Promise<{ bytesPerChar: number; charset: string } | null> { + try { + const res: any = await this.knex.raw( + 'select cs.CHARACTER_SET_NAME as charset, cs.MAXLEN as maxlen ' + + 'from information_schema.CHARACTER_SETS cs ' + + 'where cs.CHARACTER_SET_NAME = @@character_set_database', + ); + const row = (Array.isArray(res) ? res[0]?.[0] : (res?.rows?.[0] ?? res?.[0])) ?? null; + const maxlen = Number(row?.maxlen ?? row?.MAXLEN); + const charset = String(row?.charset ?? row?.CHARSET ?? ''); + if (!Number.isInteger(maxlen) || maxlen <= 0 || !charset) return null; + return { bytesPerChar: maxlen, charset }; + } catch { + // Introspection is a nicety here; the advice below holds without it. + return null; + } + } + +/** The body of `SqlDriver#rethrowWithRowSizeExplanation`; the method's docblock there is its contract. */ +export async function rethrowWithRowSizeExplanation(this: Pick, tableName: string, fields: Record | undefined, keyedColumns: ReadonlyMap, addedColumns: string[] | null, e: any): Promise { + const overflow = await this.explainRowSizeOverflow(tableName, fields, keyedColumns, addedColumns, e); + if (!overflow) throw e; + const said = String(e?.sqlMessage ?? e?.message ?? e); + this.logDurabilityFailure(overflow, said); + throw Object.assign(new Error(`${overflow} (server said: ${said})`), { + code: (e as { code?: string }).code, + cause: e, + }); + } diff --git a/packages/drivers/driver-sql/src/sql-driver-compile-refusal-seam.test.ts b/packages/drivers/driver-sql/src/sql-driver-compile-refusal-seam.test.ts index 3733345f312..67f0f1e2f1e 100644 --- a/packages/drivers/driver-sql/src/sql-driver-compile-refusal-seam.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-compile-refusal-seam.test.ts @@ -30,7 +30,8 @@ * 1. **The enumeration** reads `sql-driver.ts` and its cross-field seam module * `sql-driver-cross-field-comparison.ts` (#25521 cut 13), plus the read-exit * fault module `sql-driver-read-exit-fault.ts` (#25521 cut 15) and the - * predicate-emitter module `sql-driver-predicate-emitters.ts` (#25521 cut 16), + * predicate-emitter module `sql-driver-predicate-emitters.ts` (#25521 cut 16) + * and the generated top-level leaf `sql-driver-top-level.ts` (#29520 codemod), * and lists every function that builds an `INVALID_FILTER` error. Only three may call * `unsupportedFilterError` directly (the seam itself, the `'author'` * re-issue, and the column refusal raised from a dialect error AFTER the @@ -290,11 +291,14 @@ function enumerateRefusalBuilders(): { // `unresolvableFilterColumnError`; the scan reads it too. // [#25521 cut 16] The leaf predicate emitters moved to a fourth, with the two // `$empty` refusals; the scan reads it too. + // [#29520 codemod] The top-level declarations ahead of the class moved to a + // generated leaf, with the refusal builders among them; the scan reads it too. const sources = [ ['sql-driver.ts', readFileSync(new URL('./sql-driver.ts', import.meta.url), 'utf8')], ['sql-driver-cross-field-comparison.ts', readFileSync(new URL('./sql-driver-cross-field-comparison.ts', import.meta.url), 'utf8')], ['sql-driver-read-exit-fault.ts', readFileSync(new URL('./sql-driver-read-exit-fault.ts', import.meta.url), 'utf8')], ['sql-driver-predicate-emitters.ts', readFileSync(new URL('./sql-driver-predicate-emitters.ts', import.meta.url), 'utf8')], + ['sql-driver-top-level.ts', readFileSync(new URL('./sql-driver-top-level.ts', import.meta.url), 'utf8')], ] as const; const directCallers = new Set(); const seamCallers = new Set(); diff --git a/packages/drivers/driver-sql/src/sql-driver-crud.ts b/packages/drivers/driver-sql/src/sql-driver-crud.ts new file mode 100644 index 00000000000..579e45b4317 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-crud.ts @@ -0,0 +1,185 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The "CRUD — IDataDriver core" section of `SqlDriver`: the + * bodies of `create`, `readBackInsertedRows`, `sequenceKeyHash` moved out of + * `sql-driver.ts` whole by `scripts/codemod-split-sql-driver.mjs`. + * + * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * method of the same name exactly as it stood: not re-indented, not + * formatted, not edited. The method stays on the class with its docblock and + * signature, so the published `dist/*.d.ts`, an instance spy and a subclass + * override are what they were; the docblock there is the contract. + * + * The bodies reach the class through {@link CrudHost}: exactly the + * `SqlDriver` members they read. Each method hands its body one literal of the + * members that body reads, plus a `.call`: methods go as closures that call + * them on the driver at call time (the builder factory as a bound reference), + * so a subclass override or an instance spy is still the one reached; fields + * and accessors go as GETTERS, read when the body reads them, as they were on + * the class. + * + * The module imports `sql-driver.ts` for types only, so the two modules form + * no evaluation cycle. `index.ts` re-exports nothing from this module. + */ + +import type { DriverOptions } from '@objectstack/spec/data'; +import { nanoid } from 'nanoid'; +import { createHash } from 'node:crypto'; +import { + AUTONUMBER_COLLISION_RETRIES, DEFAULT_ID_LENGTH, insertedRowsNotReadBackError, +} from './sql-driver-top-level.js'; +import type { SqlDriver } from './sql-driver.js'; + +/** + * The `SqlDriver` members the functions below read — every one of them, and + * no other. Each is typed AS the class member (an indexed access on the + * class), so a change to a member's signature on the class is a change here + * too. `readonly` members are the getters. + */ +export interface CrudHost { + applyTenantScope: SqlDriver['applyTenantScope']; + applyWriteColumnMap: SqlDriver['applyWriteColumnMap']; + auditMissingTenant: SqlDriver['auditMissingTenant']; + collidingAutoNumberReservations: SqlDriver['collidingAutoNumberReservations']; + decorateHashShadowDuplicate: SqlDriver['decorateHashShadowDuplicate']; + fillAutoNumberFields: SqlDriver['fillAutoNumberFields']; + formatInput: SqlDriver['formatInput']; + formatOutput: SqlDriver['formatOutput']; + getBuilder: SqlDriver['getBuilder']; + injectTenantOnInsert: SqlDriver['injectTenantOnInsert']; + readonly insertReturnsStoredRows: SqlDriver['insertReturnsStoredRows']; + readBackInsertedRows: SqlDriver['readBackInsertedRows']; + remoteColumn: SqlDriver['remoteColumn']; + resolveTenantField: SqlDriver['resolveTenantField']; + resyncSequenceToDataMax: SqlDriver['resyncSequenceToDataMax']; + rotationWriteTarget: SqlDriver['rotationWriteTarget']; + stampInsertTimestamps: SqlDriver['stampInsertTimestamps']; +} + +/** The body of `SqlDriver#create`; the method's docblock there is its contract. */ +export async function create(this: Pick, object: string, data: Record, options?: DriverOptions): Promise> { + const { _id, ...rest } = data; + const toInsert = { ...rest }; + + if (_id !== undefined && toInsert.id === undefined) { + toInsert.id = _id; + } else if (toInsert.id === undefined) { + toInsert.id = nanoid(DEFAULT_ID_LENGTH); + } + + this.auditMissingTenant(object, 'create', options, [toInsert]); + this.injectTenantOnInsert(object, toInsert, options); + + // #5495. The autonumber counter is bootstrapped from the data table exactly + // once, so any row that lands by a path bypassing `fillAutoNumberFields` + // leaves it permanently behind `MAX` — and every create then collided, + // burned a number and failed the request until it had ground past the + // seeded range one 409 at a time. Re-seed and retry instead, but ONLY when + // the collision is provably this counter's; see + // {@link collidingAutoNumberReservations} for how that is decided and why + // it cannot be decided from the error text alone. + // + // Retrying is confined to the no-caller-transaction case on purpose. Inside + // a caller's transaction there is nothing to repair here — the sequence + // UPDATE shares that transaction and rolls back with the refused INSERT, so + // no number is burned (measured) — and there is something to break: on + // Postgres a constraint failure aborts the whole transaction, so a retry + // issued on it could not succeed anyway. The caller owns that retry. + const mayRetry = options?.transaction === undefined; + for (let attempt = 0; ; attempt++) { + const reservations = await this.fillAutoNumberFields(object, toInsert, options); + + // Rotation (ADR-0057 P2): the base name is a read-only view — new rows + // land in the current shard. + const builder = this.getBuilder(this.rotationWriteTarget(object) ?? object, options); + const formatted = this.applyWriteColumnMap(object, this.formatInput(object, toInsert)); + this.stampInsertTimestamps(object, formatted); + + try { + if (this.insertReturnsStoredRows) { + const result = await builder.insert(formatted).returning('*'); + return this.formatOutput(object, result[0]); + } + await builder.insert(formatted); + } catch (error) { + // #11627: on a table whose UNIQUE index is carried by a hash shadow, + // `ER_DUP_ENTRY` quotes a binary digest and names the shadow index, so + // neither the operator nor this driver can read WHICH value conflicted + // — and a genuine duplicate is indistinguishable from a (near- + // impossible) digest collision. Resolve it by reading the source + // columns back, on the failure path only, and re-throw the SAME error + // carrying whichever answer is true. Returns the error untouched when + // this is not a shadow conflict. + const shadowed = await this.decorateHashShadowDuplicate( + this.rotationWriteTarget(object) ?? object, + formatted, + error, + ); + if (!mayRetry || attempt >= AUTONUMBER_COLLISION_RETRIES) throw shadowed; + const colliding = await this.collidingAutoNumberReservations(error, reservations, options); + if (colliding.length === 0) throw shadowed; + for (const reservation of colliding) { + await this.resyncSequenceToDataMax(reservation); + // Clear only what collided, so the next pass regenerates exactly + // those fields. A reservation that did NOT collide keeps its value + // rather than burning a second number for nothing. + delete toInsert[reservation.field]; + } + continue; + } + + // [#21227] Reached only when the INSERT landed and answered no rows. The + // read sits OUTSIDE the try on purpose: a fault in it is not an insert + // failure, so it must never reach the collision re-seed above, whose + // retry would re-issue a write that already landed. + const [stored] = await this.readBackInsertedRows( + object, + this.rotationWriteTarget(object) ?? object, + [toInsert], + options, + ); + return stored; + } + } + +/** The body of `SqlDriver#readBackInsertedRows`; the method's docblock there is its contract. */ +export async function readBackInsertedRows(this: Pick, object: string, writeTable: string, written: Record[], options?: DriverOptions): Promise[]> { + if (written.length === 0) return []; + const idColumn = this.remoteColumn(object, 'id', 'id'); + const tenantField = this.resolveTenantField(object); + const writtenTenants = tenantField + ? [ + ...new Set( + written + .map((row) => row[tenantField]) + .filter((value) => value !== undefined && value !== null && value !== '') + .map(String), + ), + ] + : []; + const everyRowCarriesTenant = + tenantField !== null + && written.every((row) => row[tenantField] !== undefined && row[tenantField] !== null && row[tenantField] !== ''); + const scopeOptions: DriverOptions = everyRowCarriesTenant + ? { ...options, tenantId: writtenTenants[0], tenantIds: writtenTenants } + : { ...options }; + const builder = this.getBuilder(writeTable, options); + this.applyTenantScope(builder, object, scopeOptions); + const stored: Record[] = await builder.whereIn( + idColumn, + written.map((row) => row.id), + ); + const byId = new Map>(); + for (const row of stored) byId.set(String(row[idColumn]), row); + const missing = written.filter((row) => !byId.has(String(row.id))).map((row) => row.id); + if (missing.length > 0) throw insertedRowsNotReadBackError(object, missing, written.length); + return written.map((row) => this.formatOutput(object, byId.get(String(row.id)))); + } + +/** The body of `SqlDriver#sequenceKeyHash`; the method's docblock there is its contract. */ +export function sequenceKeyHash(object: string, tenantId: string, field: string, scope: string): string { + return createHash('sha256') + .update(`${object}\u001f${tenantId}\u001f${field}\u001f${scope}`) + .digest('hex'); + } diff --git a/packages/drivers/driver-sql/src/sql-driver-database-helpers.ts b/packages/drivers/driver-sql/src/sql-driver-database-helpers.ts new file mode 100644 index 00000000000..45180df3dcd --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-database-helpers.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The "Database helpers" section of `SqlDriver`: the bodies of + * `sqliteFilename` moved out of `sql-driver.ts` whole by + * `scripts/codemod-split-sql-driver.mjs`. + * + * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * method of the same name exactly as it stood: not re-indented, not + * formatted, not edited. The method stays on the class with its docblock and + * signature, so the published `dist/*.d.ts`, an instance spy and a subclass + * override are what they were; the docblock there is the contract. + * + * The bodies reach the class through {@link DatabaseHelpersHost}: exactly the + * `SqlDriver` members they read. Each method hands its body one literal of the + * members that body reads, plus a `.call`: methods go as closures that call + * them on the driver at call time (the builder factory as a bound reference), + * so a subclass override or an instance spy is still the one reached; fields + * and accessors go as GETTERS, read when the body reads them, as they were on + * the class. + * + * The module imports `sql-driver.ts` for types only, so the two modules form + * no evaluation cycle. `index.ts` re-exports nothing from this module. + */ + +import type { SqlDriver } from './sql-driver.js'; + +/** + * The `SqlDriver` members the functions below read — every one of them, and + * no other. Each is typed AS the class member (an indexed access on the + * class), so a change to a member's signature on the class is a change here + * too. `readonly` members are the getters. + */ +export interface DatabaseHelpersHost { + readonly config: SqlDriver['config']; + readonly isSqlite: SqlDriver['isSqlite']; + readonly openedEmptyInMemory: SqlDriver['openedEmptyInMemory']; +} + +/** The body of `SqlDriver#sqliteFilename`; the method's docblock there is its contract. */ +export function sqliteFilename(this: Pick): string | null { + if (!this.isSqlite) return null; + // Redirected to `:memory:` because the declared file did not exist and the + // host asked not to create one (#6743). `this.config` still names the + // declared target — on purpose, so the CLI can print it — but NO on-disk + // file backs this connection, which is precisely what this method reports. + // Both callers depend on that reading: `ensureDatabaseExists` would + // otherwise `mkdir` the state directory this mode exists to avoid, and + // `applySqliteJournalMode` would try to put an in-memory database in WAL. + if (this.openedEmptyInMemory) return null; + const conn = (this.config as any).connection; + const filename = typeof conn === 'string' ? conn : conn?.filename; + if (typeof filename !== 'string' || filename === '') return null; + return filename.startsWith(':') ? null : filename; + } diff --git a/packages/drivers/driver-sql/src/sql-driver-distinct.ts b/packages/drivers/driver-sql/src/sql-driver-distinct.ts new file mode 100644 index 00000000000..598b83e21cb --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-distinct.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The "Distinct" section of `SqlDriver`: the bodies of + * `distinct` moved out of `sql-driver.ts` whole by + * `scripts/codemod-split-sql-driver.mjs`. + * + * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * method of the same name exactly as it stood: not re-indented, not + * formatted, not edited. The method stays on the class with its docblock and + * signature, so the published `dist/*.d.ts`, an instance spy and a subclass + * override are what they were; the docblock there is the contract. + * + * The bodies reach the class through {@link DistinctHost}: exactly the + * `SqlDriver` members they read. Each method hands its body one literal of the + * members that body reads, plus a `.call`: methods go as closures that call + * them on the driver at call time (the builder factory as a bound reference), + * so a subclass override or an instance spy is still the one reached; fields + * and accessors go as GETTERS, read when the body reads them, as they were on + * the class. + * + * The module imports `sql-driver.ts` for types only, so the two modules form + * no evaluation cycle. `index.ts` re-exports nothing from this module. + */ + +import type { DriverOptions, FilterCondition } from '@objectstack/spec/data'; +import type { SqlDriver } from './sql-driver.js'; + +/** + * The `SqlDriver` members the functions below read — every one of them, and + * no other. Each is typed AS the class member (an indexed access on the + * class), so a change to a member's signature on the class is a change here + * too. `readonly` members are the getters. + */ +export interface DistinctHost { + applyFilters: SqlDriver['applyFilters']; + applyTenantScope: SqlDriver['applyTenantScope']; + coercionKey: SqlDriver['coercionKey']; + distinctBackendFault: SqlDriver['distinctBackendFault']; + getBuilder: SqlDriver['getBuilder']; + presentReadValue: SqlDriver['presentReadValue']; + readPresentationKind: SqlDriver['readPresentationKind']; +} + +/** The body of `SqlDriver#distinct`; the method's docblock there is its contract. */ +export async function distinct(this: Pick, object: string, field: string, filters?: FilterCondition, options?: DriverOptions): Promise { + const builder = this.getBuilder(object, options); + // The third read door that skipped the chokepoint, and the one #6792 does + // NOT name — the card asserts the opposite ("called at 13 sites — …, + // `count`, `distinct`, …"). It is not among them; the 13th read site is + // `aggregate()`. Found by the gate this change ships, not by the card. + // + // VALUES rather than rows, which lowers the volume and not the class: + // measured on `main` at `6595262`, `distinct(account, 'name', undefined, + // { tenantId: 'org_a' })` returned `[A1, A2, B1, B2, P1]` — every other + // tenant's values for the named column. Documented with a runnable example + // (`content/docs/protocol/objectql/query-syntax.mdx`), so it is exposed the + // same way the window door is. + this.applyTenantScope(builder, object, options); + + if (filters) { + this.applyFilters(builder, filters, options); + } + + builder.distinct(field); + // [commit 7c2c5aedd] The THIRD read door joins the other two. `find()` and `count()` + // have carried the terminal envelope since #8931 and `aggregate()` since + // #11455; this one still executed BARE, so any dialect refusal the + // statement raised left the driver as the backend's own object — a raw + // SQLSTATE in `code`, `status` UNDEFINED, and the compiled statement as the + // message. Measured on live PostgreSQL 16.13: this driver stores every + // `multiple: true` column as `json`, and `json` defines no equality + // operator, so `SELECT DISTINCT` over one is refused by the backend: + // + // distinct(t, 'toggles') => THREW code=42883 status=undefined + // msg=select distinct "toggles" from "…" - + // could not identify an equality operator + // for type json + // + // Class-wide across every JSON column — `toggle`/`boolean`/`number` with + // `multiple: true` and `tags` all measured, with a scalar `boolean` column + // in the same table answering normally as the lit control. A raw `42883` is + // on no list `@objectstack/rest` reads, so `mapDataError` had nothing + // declared to forward and an ordinary caller shape — list the distinct + // values of this column — was logged as an UNHANDLED server fault. + // + // ⛔ NOT a decision about whether a JSON column should ANSWER a distinct + // read; that question belonged to the card commit e04a0aff2 closed, on the same columns. Whatever this door + // ends up doing, it must not leak the backend's own object — so the + // envelope is the half that lands here, exactly as #11455 landed it for + // `aggregate()` while its own answer question was still open. + // + // ⛔ And no `42883` recognizer: the envelope comes from the EXIT, not from + // matching a SQLSTATE or the words `equality operator` — the #8926 lesson, + // applied in advance. The all-dialect sweep in + // `sql-driver-17639-distinct-fault-envelope.test.ts` asserts that on a + // route (a table that was never provisioned) that has no JSON column in it. + // + // ⛔ And still NO BLANKET `isUnresolvableColumnError` arm — commit 9ccc4179e closed + // the gap commit 7c2c5aedd left FILED, and it closed it the way #11541 closed the + // `aggregate()` one: by ATTRIBUTING the column to a clause of the caller's + // own request first. A blanket arm remains forbidden for the reason stated + // here since commit 7c2c5aedd — it would tell the author of `distinct(o, 'nosuchcol')`, + // who passed no filter at all, that their FILTER was wrong — and + // {@link SqlDriver.distinctBackendFault} is what makes that claim + // unnecessary rather than what makes it safe. + // + // Only the EXECUTION is guarded. Every refusal this method composes upstream + // — `applyFilters`' `INVALID_FILTER` out of {@link assertCompilableComparand} + // among them — is raised while the statement is BUILT, so the classifier + // cannot bury a precise refusal under a generic 500. + let results: unknown[]; + try { + results = await builder; + } catch (error) { + throw this.distinctBackendFault(object, field, error, filters); + } + const values = results.map((row: any) => row[field]); + + // Same presentation `find()` gives the column (#3797, #3849) — a caller + // listing a datetime's values should not get epoch integers here and ISO + // strings there, nor `0`/`1` for a boolean. Re-deduplicate afterwards: SQL + // `DISTINCT` compares STORED values, and one SQLite `Field.datetime` column + // holds both INTEGER epoch ms and ISO TEXT, so two rows recording the same + // instant survive as two rows and then collapse to the same presented value. + const kind = this.readPresentationKind(this.coercionKey(builder), field); + if (!kind) return values; + return [...new Set(values.map((v: any) => this.presentReadValue(kind, v)))]; + } diff --git a/packages/drivers/driver-sql/src/sql-driver-field-mapping.ts b/packages/drivers/driver-sql/src/sql-driver-field-mapping.ts new file mode 100644 index 00000000000..b07a604840a --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-field-mapping.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The "Field mapping" section of `SqlDriver`: the bodies of + * `mapSortField`, `orderKeysFor`, `mapAggregateFunc` moved out of + * `sql-driver.ts` whole by `scripts/codemod-split-sql-driver.mjs`. + * + * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * method of the same name exactly as it stood: not re-indented, not + * formatted, not edited. The method stays on the class with its docblock and + * signature, so the published `dist/*.d.ts`, an instance spy and a subclass + * override are what they were; the docblock there is the contract. + * + * The bodies reach the class through {@link FieldMappingHost}: exactly the + * `SqlDriver` members they read. Each method hands its body one literal of the + * members that body reads, plus a `.call`: methods go as closures that call + * them on the driver at call time (the builder factory as a bound reference), + * so a subclass override or an instance spy is still the one reached; fields + * and accessors go as GETTERS, read when the body reads them, as they were on + * the class. + * + * The module imports `sql-driver.ts` for types only, so the two modules form + * no evaluation cycle. `index.ts` re-exports nothing from this module. + */ + +import type { DriverQuery } from '@objectstack/spec/contracts'; +import { SQL_AGGREGATE_FUNCTIONS, refuseAggregateFunction } from './sql-driver-top-level.js'; +import type { SqlAggregateLowering } from './sql-driver-top-level.js'; +import type { SqlDriver } from './sql-driver.js'; + +/** + * The `SqlDriver` members the functions below read — every one of them, and + * no other. Each is typed AS the class member (an indexed access on the + * class), so a change to a member's signature on the class is a change here + * too. `readonly` members are the getters. + */ +export interface FieldMappingHost { + readonly logger: SqlDriver['logger']; + readonly nondeterministicPagingWarned: SqlDriver['nondeterministicPagingWarned']; + paginationTieBreaker: SqlDriver['paginationTieBreaker']; +} + +/** The body of `SqlDriver#mapSortField`; the method's docblock there is its contract. */ +export function mapSortField(field: string): string { + if (field === 'createdAt') return 'created_at'; + if (field === 'updatedAt') return 'updated_at'; + return field; + } + +/** The body of `SqlDriver#orderKeysFor`; the method's docblock there is its contract. */ +export function orderKeysFor(this: Pick, object: string, query: DriverQuery, opts?: { singleRowLookup?: boolean }): Array<{ field: string; direction: 'asc' | 'desc' }> { + const keys: Array<{ field: string; direction: 'asc' | 'desc' }> = []; + if (Array.isArray(query.orderBy)) { + for (const item of query.orderBy) { + if (item.field) { + keys.push({ field: item.field, direction: item.order === 'desc' ? 'desc' : 'asc' }); + } + } + } + + const paged = + !opts?.singleRowLookup && (query.limit !== undefined || query.offset !== undefined); + if (keys.length === 0 && !paged) return keys; + + const tieBreaker = this.paginationTieBreaker(object); + if (tieBreaker && !keys.some((k) => k.field === tieBreaker)) { + keys.push({ field: tieBreaker, direction: keys[keys.length - 1]?.direction ?? 'asc' }); + } else if (!tieBreaker && keys.length === 0) { + // Paged, unsorted, and no column this driver can trust to order by: the + // read goes out exactly as before, and its pages are not a partition. + // Say so once per object rather than leave it to be discovered by a user + // counting records — a guarantee the contract states as MUST, quietly + // unavailable, is the same invisible failure it was written against. + // (Sorted reads on such a table keep the caller's own ORDER BY, so only + // the ties within it reshuffle; this case has nothing at all.) + if (!this.nondeterministicPagingWarned.has(object)) { + this.nondeterministicPagingWarned.add(object); + this.logger.warn( + `Paged read of '${object}' is NOT deterministic: this driver did not create the table, ` + + 'so it cannot name a unique column to order by, and the query asked for no sort of ' + + 'its own. Walking the pages may serve one row twice and never serve another. ' + + 'Give the query an `orderBy` on a unique column, or declare the ' + + 'object so this driver manages its table.', + ); + } + } + return keys; + } + +/** The body of `SqlDriver#mapAggregateFunc`; the method's docblock there is its contract. */ +export function mapAggregateFunc(func: string): SqlAggregateLowering { + const lowering = SQL_AGGREGATE_FUNCTIONS.get(func); + if (lowering !== undefined) return lowering; + refuseAggregateFunction(func); + } diff --git a/packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts b/packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts new file mode 100644 index 00000000000..7ce8c07b717 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts @@ -0,0 +1,326 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The "Filter helpers" section of `SqlDriver`: the bodies of + * `tableNameForBuilder`, `toDateOnly`, `coerceFilterValue`, + * `sqliteCanonicalDatetimeSql`, `sqliteNonTemporalTextSql`, + * `sqliteCanonicalTimeSql`, `readPresentationKind`, `presentReadValue`, + * `presentReadColumns`, `filterColumnExpr`, `logWithheldFilterDiagnostic`, + * `compileFilters` moved out of `sql-driver.ts` whole by + * `scripts/codemod-split-sql-driver.mjs`. + * + * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * method of the same name exactly as it stood: not re-indented, not + * formatted, not edited. The method stays on the class with its docblock and + * signature, so the published `dist/*.d.ts`, an instance spy and a subclass + * override are what they were; the docblock there is the contract. + * + * The bodies reach the class through {@link FilterHelpersHost}: exactly the + * `SqlDriver` members they read. Each method hands its body one literal of the + * members that body reads, plus a `.call`: methods go as closures that call + * them on the driver at call time (the builder factory as a bound reference), + * so a subclass override or an instance spy is still the one reached; fields + * and accessors go as GETTERS, read when the body reads them, as they were on + * the class. + * + * The module imports `sql-driver.ts` for types only, so the two modules form + * no evaluation cycle. `index.ts` re-exports nothing from this module. + */ + +import type { DriverOptions } from '@objectstack/spec/data'; +import { presentAsNumber, temporalStorageForm } from '@objectstack/core'; +import type { Knex } from 'knex'; +import { + AUDIT_TIMESTAMP_COLUMNS, SQLITE_TIME_EXPR_REFS, WITHHELD_FILTER_LOGGED, assertCompilableComparand, + assertDefinedComparands, canonicalTimeOfDay, emptyFieldConstraintError, filterArrayReachedDriverError, + isEmptyFieldConstraint, normalizeSqliteDatetimeOutput, presentAuditTimestampOutput, refusalSubtree, + withheldFilterDiagnosticOf, +} from './sql-driver-top-level.js'; +import type { ReadPresentationKind } from './sql-driver-top-level.js'; +import type { SqlDriver } from './sql-driver.js'; + +/** + * The `SqlDriver` members the functions below read — every one of them, and + * no other. Each is typed AS the class member (an indexed access on the + * class), so a change to a member's signature on the class is a change here + * too. `readonly` members are the getters. + */ +export interface FilterHelpersHost { + applyFilterCondition: SqlDriver['applyFilterCondition']; + applyNormalizedComparison: SqlDriver['applyNormalizedComparison']; + assertOperatorAppliesToColumn: SqlDriver['assertOperatorAppliesToColumn']; + readonly booleanFields: SqlDriver['booleanFields']; + coerceFilterValue: SqlDriver['coerceFilterValue']; + coercionKey: SqlDriver['coercionKey']; + filterColumnExpr: SqlDriver['filterColumnExpr']; + readonly isMysql: SqlDriver['isMysql']; + readonly isSqlite: SqlDriver['isSqlite']; + readonly logger: SqlDriver['logger']; + needsLegacyDatetimeRepair: SqlDriver['needsLegacyDatetimeRepair']; + needsLegacyTimeRepair: SqlDriver['needsLegacyTimeRepair']; + readonly numericFields: SqlDriver['numericFields']; + presentReadValue: SqlDriver['presentReadValue']; + remoteColumn: SqlDriver['remoteColumn']; + sqliteCanonicalDatetimeSql: SqlDriver['sqliteCanonicalDatetimeSql']; + sqliteCanonicalTimeSql: SqlDriver['sqliteCanonicalTimeSql']; + storageDatetimeValue: SqlDriver['storageDatetimeValue']; + temporalFieldKind: SqlDriver['temporalFieldKind']; + toDateOnly: SqlDriver['toDateOnly']; + toTimeOnly: SqlDriver['toTimeOnly']; +} + +/** The body of `SqlDriver#tableNameForBuilder`; the method's docblock there is its contract. */ +export function tableNameForBuilder(builder: any): string | null { + const t = builder?._single?.table; + if (typeof t === 'string') return t; + return null; + } + +/** The body of `SqlDriver#toDateOnly`; the method's docblock there is its contract. */ +export function toDateOnly(value: any): any { + // [#20176] The rule — UTC getters included — is `@objectstack/core`'s + // `temporalStorageForm`, the function `driver-memory` and the engine's + // per-aggregation `filter` / `having` read too; this body was a copy of it. + return temporalStorageForm(value, 'date'); + } + +/** The body of `SqlDriver#coerceFilterValue`; the method's docblock there is its contract. */ +export function coerceFilterValue(this: Pick, table: string | null, field: string, value: any): any { + if (value == null || !table) return value; + if (Array.isArray(value)) return value.map((v) => this.coerceFilterValue(table, field, v)); + + const kind = this.temporalFieldKind(table, field); + if (!kind) return value; + if (kind === 'datetime') return this.storageDatetimeValue(value); + // `Field.time` (#3994) takes the same treatment for the same reason: the + // comparand must be the canonical time-of-day text the column stores. + if (kind === 'time') return canonicalTimeOfDay(value); + return this.toDateOnly(value); + } + +/** The body of `SqlDriver#sqliteCanonicalDatetimeSql`; the method's docblock there is its contract. */ +export function sqliteCanonicalDatetimeSql(columnSql: string): string { + return ( + `(case when typeof(${columnSql}) in ('integer','real') ` + + `then strftime('%Y-%m-%dT%H:%M:%fZ', ${columnSql}/1000.0, 'unixepoch') ` + + `else coalesce(strftime('%Y-%m-%dT%H:%M:%fZ', ${columnSql}), ${columnSql}) end)` + ); + } + +/** The body of `SqlDriver#sqliteNonTemporalTextSql`; the method's docblock there is its contract. */ +export function sqliteNonTemporalTextSql(columnSql: string): string { + return ( + `(typeof(${columnSql}) not in ('integer','real') ` + + `and instr(${columnSql}, '-') = 0 and instr(${columnSql}, ':') = 0 ` + + `and julianday(${columnSql}) is not null)` + ); + } + +/** The body of `SqlDriver#sqliteCanonicalTimeSql`; the method's docblock there is its contract. */ +export function sqliteCanonicalTimeSql(columnSql: string): string { + const canonText = (args: string) => + `case when strftime('%H:%M:%f', ${args}) like '%.000' ` + + `then strftime('%H:%M:%S', ${args}) ` + + `else strftime('%H:%M:%f', ${args}) end`; + return ( + `(case when typeof(${columnSql}) in ('integer','real') ` + + `then ${canonText(`${columnSql}/1000.0, 'unixepoch'`)} ` + + `else coalesce(${canonText(columnSql)}, ${columnSql}) end)` + ); + } + +/** The body of `SqlDriver#readPresentationKind`; the method's docblock there is its contract. */ +export function readPresentationKind(this: Pick, table: string | null | undefined, field: string): ReadPresentationKind | null { + if (!table) return null; + const temporal = this.temporalFieldKind(table, field); + if (temporal) return temporal; + if ((AUDIT_TIMESTAMP_COLUMNS as readonly string[]).includes(field)) return 'audit_timestamp'; + if ((this.isSqlite || this.isMysql) && this.booleanFields[table]?.includes(field)) { + return 'boolean'; + } + // [#16318] Every dialect, for the reason `formatOutput`'s own numeric pass + // records: an exact-decimal column is handed back as a STRING by + // node-postgres and by mysql2, so `aggregate()` / `distinct()` would present + // a declared numeric field as a string on exactly the dialects `find()` now + // presents it as a number. One class, one answer, on every door. + if (this.numericFields[table]?.includes(field)) return 'number'; + return null; + } + +/** The body of `SqlDriver#presentReadValue`; the method's docblock there is its contract. */ +export function presentReadValue(this: Pick, kind: ReadPresentationKind, value: any): any { + if (value == null) return value; + switch (kind) { + case 'date': + return this.toDateOnly(value); + case 'time': + // Every dialect, like `date`: canonicalising also re-pads the fraction + // Postgres trims (`.5` → `.500`), so `distinct()`/`aggregate()` present + // exactly what `find()` presents (#3994, the F6 gap of the #3849 fix). + return this.toTimeOnly(value); + case 'datetime': + // Every dialect ([ADR-0053 D-F1]): the same fold `formatOutput` applies + // to a `find()` row, so `min`/`max`/`distinct` over a declared + // `Field.datetime` (the audit columns too, when the engine declares + // them so) present the canonical text and never the client's `Date`. + return normalizeSqliteDatetimeOutput(value); + case 'audit_timestamp': + // Every dialect ([ADR-0053 D-F1]): the ONE presenter `formatOutput` + // applies to `created_at` / `updated_at` — a `Date` folds to the + // canonical text, a zone-naive legacy string is repaired (ADR-0074), + // and a number passes through as it does on a `find()` row (ADR-0074 + // §3) — so `max(created_at)` and `distinct('updated_at')` answer the + // value `find()` answers, type for type. `normalizeSqliteDatetimeOutput` + // would fold the number too, and did between #13973's first cut and + // its contract review: an author-declared `created_at: number` read + // `1700000000000` off `find()` and `"2023-11-14T22:13:20.000Z"` off + // `distinct()` — the divergence the conformance file's §D pins closed. + return presentAuditTimestampOutput(value); + case 'boolean': + return Boolean(value); + case 'number': + // [#20889] The presenter lives in `@objectstack/core`, beside + // `AGGREGATE_ANSWER_KIND`, so the analytics native-SQL face presents + // a count or a total with this same function. + return presentAsNumber(value); + } + } + +/** The body of `SqlDriver#presentReadColumns`; the method's docblock there is its contract. */ +export function presentReadColumns(this: Pick, rows: any, columns: Map): any { + if (columns.size === 0 || !Array.isArray(rows)) return rows; + for (const row of rows) { + if (!row || typeof row !== 'object') continue; + for (const [column, kind] of columns) { + if (row[column] !== undefined) row[column] = this.presentReadValue(kind, row[column]); + } + } + return rows; + } + +/** The body of `SqlDriver#filterColumnExpr`; the method's docblock there is its contract. */ +export function filterColumnExpr(this: Pick, table: string | null | undefined, field: string, column: string): { sql: string; bindings: any[] } | null { + if (this.needsLegacyDatetimeRepair(table, field)) { + return { + sql: this.sqliteCanonicalDatetimeSql('??'), + bindings: [column, column, column, column], + }; + } + if (this.needsLegacyTimeRepair(table, field)) { + return { + sql: this.sqliteCanonicalTimeSql('??'), + bindings: Array(SQLITE_TIME_EXPR_REFS).fill(column), + }; + } + return null; + } + +/** The body of `SqlDriver#logWithheldFilterDiagnostic`; the method's docblock there is its contract. */ +export function logWithheldFilterDiagnostic(this: Pick, err: unknown): void { + const diagnostic = withheldFilterDiagnosticOf(err); + if (diagnostic === null) return; + const marked = err as Record; + if (marked[WITHHELD_FILTER_LOGGED] === true) return; + Object.defineProperty(err as object, WITHHELD_FILTER_LOGGED, { value: true, enumerable: false }); + // [#8197] The wording no longer says "cross-field reference refused": this + // seam now carries the target-field family too, and a log line that names + // the wrong refusal class sends the operator reading it to the wrong part + // of the filter. What it states instead is the fact that is true of every + // line it writes — the predicate was not positively marked author-written, + // so the naming half went here rather than to the caller. + this.logger.warn( + `[sql-driver] INVALID_FILTER — refusal detail withheld from the response because the ` + + `predicate was not positively marked as author-written. ` + + `Full diagnostic: ${diagnostic}`, + ); + } + +/** The body of `SqlDriver#compileFilters`; the method's docblock there is its contract. */ +export function compileFilters(this: Pick, builder: Knex.QueryBuilder, filters: any, options?: DriverOptions) { + if (!filters) return; + + // [#5158] `where` is a `FilterCondition` OBJECT. It always was — the spec + // declares `QueryASTSchema.where: FilterConditionSchema` — but this method + // used to carry a SECOND compiler for the array spelling, including an + // INFIX dialect (`[condA, 'or', condB]`) that no schema ever declared and + // that `parseFilterAST` cannot express. `FilterArray` is now declared as + // INPUT-ONLY authoring sugar (`spec/data/filter.zod.ts`, #5285) and BOTH + // doors into the runtime lower it before a driver is reached: the protocol + // face (`metadata-protocol`) and the engine (`ObjectQL.find`/`findOne`/ + // `count`/`aggregate`/`update`/`delete`). So an array here is a bug in the + // caller, not a dialect to compile — and refusing it is what converges this + // driver with cloud's `RemoteTransport.buildWhereSQL`, which has refused + // the same input since cloud#1075. That fork had zero tests on either side. + if (Array.isArray(filters)) { + // `[]` keeps its meaning — "no filter", not a failed filter. Unchanged + // from every previous version of this method, and the same reading + // `parseFilterAST([])` gives it. + if (filters.length === 0) return; + // [#20039] The array IS the `where` root, so its own mark is its verdict. + throw filterArrayReachedDriverError(filters); + } + + const table = this.coercionKey(builder); + + if (typeof filters === 'object') { + const hasMongoOperators = Object.keys(filters).some( + (k) => + k.startsWith('$') || + (typeof filters[k] === 'object' && + filters[k] !== null && + Object.keys(filters[k]).some((op) => op.startsWith('$'))), + ); + + if (hasMongoOperators) { + this.applyFilterCondition(builder, filters, 'and', table, filters, options); + return; + } + + for (const [key, value] of Object.entries(filters)) { + if (['limit', 'offset', 'fields', 'orderBy'].includes(key)) continue; + const column = this.remoteColumn(table, key, key); + // #5240 — `{ field: {} }` reaches this loop when NO key of the filter + // carries an operator; the combinator path refuses it on the reduction + // walk. Refused here with the SAME message so one condition has one + // wording wherever the author wrote it — this position used to answer + // with #5041's generic "cannot be bound as a SQL parameter", which + // describes a comparand and not a constraint with no operator at all. + if (isEmptyFieldConstraint(value)) { + throw emptyFieldConstraintError(key, `filter.${key}`, value); + } + // #6050 — the same position as the `undefined` gate on the reduction + // walk, reached the same way `{ field: {} }` above reaches its own: this + // loop runs when NO key of the filter carries an operator, so the walk + // never sees the node. `{ d: undefined }` IS that shape — `typeof + // undefined` is not `'object'`, so it cannot make `hasMongoOperators` + // true — and it is the single most likely spelling of the defect + // (`{ owner_id: ctx.user?.id }`). ONE function answers both call sites + // so the two positions cannot drift into two verdicts, which is the + // #5240 lesson this driver already paid for once. + // [#20039] `filters` is the node carrying `key`, as for the gate below. + assertDefinedComparands(key, value, `filter.${key}`, filters); + // #5041 — the plain `{ field: value }` map compiles to an implicit `=`, + // so it is a comparison emitter too and gets the same gate. + // [#8197] `filters` is both this loop's node and the query's `where` + // root — this branch runs only when NO key carries an operator, so + // there is no nearer enclosing node to hand over. + assertCompilableComparand(column, '=', value, filters); + // #7398 — and the column-type question the comparand gate cannot ask. + // This loop IS the bare `{ field: value }` spelling, one of the four + // the issue measured as silently answering zero rows on an array column. + this.assertOperatorAppliesToColumn(table, key, column, '=', true, refusalSubtree(value, filters)); + const coerced = this.coerceFilterValue(table, key, value); + const expr = this.filterColumnExpr(table, key, column); + if (expr && this.applyNormalizedComparison(builder, 'and', expr, '=', coerced)) continue; + builder.where(column, coerced as any); + } + return; + } + + // A truthy non-object, non-array `where` (`'active'`, `42`) emits no + // predicate. Pre-existing behaviour on a shape only a cast can produce — + // the protocol face rejects it (`unusableFilterError`) and `FilterCondition` + // does not describe it. Untouched here on purpose: #5158 is about the ARRAY + // dialect, and widening the refusal is a separate change with its own + // blast radius. + } diff --git a/packages/drivers/driver-sql/src/sql-driver-internal-helpers.ts b/packages/drivers/driver-sql/src/sql-driver-internal-helpers.ts new file mode 100644 index 00000000000..1cdd15a3b4b --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-internal-helpers.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The "Internal helpers" section of `SqlDriver`: the bodies of + * `applyTenantScope`, `injectTenantOnInsert`, `auditMissingTenant` moved out of + * `sql-driver.ts` whole by `scripts/codemod-split-sql-driver.mjs`. + * + * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * method of the same name exactly as it stood: not re-indented, not + * formatted, not edited. The method stays on the class with its docblock and + * signature, so the published `dist/*.d.ts`, an instance spy and a subclass + * override are what they were; the docblock there is the contract. + * + * The bodies reach the class through {@link InternalHelpersHost}: exactly the + * `SqlDriver` members they read. Each method hands its body one literal of the + * members that body reads, plus a `.call`: methods go as closures that call + * them on the driver at call time (the builder factory as a bound reference), + * so a subclass override or an instance spy is still the one reached; fields + * and accessors go as GETTERS, read when the body reads them, as they were on + * the class. + * + * The module imports `sql-driver.ts` for types only, so the two modules form + * no evaluation cycle. `index.ts` re-exports nothing from this module. + */ + +import type { DriverOptions } from '@objectstack/spec/data'; +import type { Knex } from 'knex'; +import type { SqlDriver } from './sql-driver.js'; + +/** + * The `SqlDriver` members the functions below read — every one of them, and + * no other. Each is typed AS the class member (an indexed access on the + * class), so a change to a member's signature on the class is a change here + * too. `readonly` members are the getters. + */ +export interface InternalHelpersHost { + isMultiTenantMode: SqlDriver['isMultiTenantMode']; + readonly logger: SqlDriver['logger']; + resolveTenantField: SqlDriver['resolveTenantField']; + readonly tenantAuditWarned: SqlDriver['tenantAuditWarned']; +} + +/** The body of `SqlDriver#applyTenantScope`; the method's docblock there is its contract. */ +export function applyTenantScope(this: Pick, builder: Knex.QueryBuilder, object: string, options?: DriverOptions): Knex.QueryBuilder { + const tenantId = options?.tenantId; + if (tenantId === undefined || tenantId === null || tenantId === '') return builder; + const field = this.resolveTenantField(object); + if (!field) return builder; + // [ADR-0105 D2 / #3623] Union tenant scope: under the `group` posture the + // engine threads the caller's whole membership set as `tenantIds`, and the + // native wall widens to `IN (...)` — the SAME union the Layer 0 + // authorization wall enforces above. Equality here would AND under that + // union and collapse group reads to active-org reach. A malformed or + // empty set falls through to the equality path: fail toward isolation, + // never toward exposure. Insert-side injection (injectTenantOnInsert) + // deliberately keeps `tenantId` — the active org is the write target (D5). + const rawIds = options?.tenantIds; + const tenantIds = Array.isArray(rawIds) + ? rawIds.filter((v: unknown) => typeof v === 'string' && v !== '') + : []; + if (tenantIds.length > 0) { + return builder.whereIn(field, tenantIds.map(String)); + } + // `field = :tenantId`, and nothing else. [ADR-0131 D1, D14] NULL is not an + // owner: an organization-less row is no tenant's, so no tenant scope reaches + // it. The v17 reading (#2734) added an `OR field IS NULL` arm here, under + // which such a row stood for platform state; ADR-0131 replaced that reading + // (D2, D6, D7) and the 18.x upgrade ceremony retired the rows it served: a + // certified database holds no NULL organization, a fresh install never + // writes one, and a v17 database is refused at boot until it is certified. + // A composition that registers no organization object can still write + // organization-less rows, and it never reaches this line with a tenant: no + // session, guest or system write there carries an organization. + return builder.where(field, String(tenantId)); + } + +/** The body of `SqlDriver#injectTenantOnInsert`; the method's docblock there is its contract. */ +export function injectTenantOnInsert(this: Pick, object: string, row: Record, options?: DriverOptions): void { + const tenantId = options?.tenantId; + if (tenantId === undefined || tenantId === null || tenantId === '') return; + const field = this.resolveTenantField(object); + if (!field) return; + if (row[field] === undefined || row[field] === null || row[field] === '') { + row[field] = String(tenantId); + } + } + +/** The body of `SqlDriver#auditMissingTenant`; the method's docblock there is its contract. */ +export function auditMissingTenant(this: Pick, object: string, op: 'create' | 'update' | 'delete' | 'bulkCreate' | 'bulkDelete' | 'updateMany' | 'deleteMany' | 'upsert', options?: DriverOptions, insertRows?: readonly unknown[]): void { + if (process.env.OS_TENANT_AUDIT === '0') return; + if (options?.bypassTenantAudit === true) return; + // A write that DID carry its tenant is the case this audit has nothing to + // say about, and it is the overwhelmingly common one — so it exits here, + // before the posture read below. Ordering only (both guards are pure + // predicates over independent facts); it is what makes `isMultiTenantMode()` + // affordable as a live read now that it no longer memoises (#5262). + const tenantId = options?.tenantId; + if (tenantId !== undefined && tenantId !== null && tenantId !== '') return; + // Only meaningful in multi-tenant deployments. Single-tenant stacks have no + // tenant isolation, yet the kernel now ALWAYS provisions an `organization_id` + // column (its existence is decoupled from the tenant flag). Column presence + // alone therefore no longer implies "tenant-scoped" — without this gate every + // system/sudo write (e.g. the notification/http delivery dispatchers' claim + // updates) would spam a meaningless warning on single-tenant boots. + if (!this.isMultiTenantMode()) return; + const field = this.resolveTenantField(object); + if (!field) return; + // Empty is spelled as `injectTenantOnInsert` spells it: a row it would + // stamp is a row that does not carry its organization. + const carriesTenant = (row: unknown): boolean => { + const value = row && typeof row === 'object' ? (row as Record)[field] : undefined; + return value !== undefined && value !== null && value !== ''; + }; + if (insertRows !== undefined && insertRows.length > 0 && insertRows.every(carriesTenant)) return; + const key = `${object}:${op}`; + if (this.tenantAuditWarned.has(key)) return; + this.tenantAuditWarned.add(key); + this.logger.warn( + `[tenant-audit] ${op} on tenant-scoped object "${object}" without options.tenantId — the statement is not confined to one organization. ` + + 'If the write belongs to one organization, run it in that organization\'s context (an ExecutionContext carrying its organization); ' + + 'the row\'s own organization column does not scope an update or a delete. ' + + 'If it crosses organizations on purpose (a platform admin the organization wall exempts, or a system job over every organization), ' + + 'nothing needs fixing: the caller declares it with bypassTenantAudit:true, which also silences this line.', + { object, op, tenantField: field }, + ); + } diff --git a/packages/drivers/driver-sql/src/sql-driver-lifecycle.ts b/packages/drivers/driver-sql/src/sql-driver-lifecycle.ts new file mode 100644 index 00000000000..e1382b28058 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-lifecycle.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The "Lifecycle" section of `SqlDriver`: the bodies of + * `connect` moved out of `sql-driver.ts` whole by + * `scripts/codemod-split-sql-driver.mjs`. + * + * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * method of the same name exactly as it stood: not re-indented, not + * formatted, not edited. The method stays on the class with its docblock and + * signature, so the published `dist/*.d.ts`, an instance spy and a subclass + * override are what they were; the docblock there is the contract. + * + * The bodies reach the class through {@link LifecycleHost}: exactly the + * `SqlDriver` members they read. Each method hands its body one literal of the + * members that body reads, plus a `.call`: methods go as closures that call + * them on the driver at call time (the builder factory as a bound reference), + * so a subclass override or an instance spy is still the one reached; fields + * and accessors go as GETTERS, read when the body reads them, as they were on + * the class. + * + * The module imports `sql-driver.ts` for types only, so the two modules form + * no evaluation cycle. `index.ts` re-exports nothing from this module. + */ + +import type { SqlDriver } from './sql-driver.js'; + +/** + * The `SqlDriver` members the functions below read — every one of them, and + * no other. Each is typed AS the class member (an indexed access on the + * class), so a change to a member's signature on the class is a change here + * too. `readonly` members are the getters. + */ +export interface LifecycleHost { + applyAutoVacuumIncremental: SqlDriver['applyAutoVacuumIncremental']; + applySqliteJournalMode: SqlDriver['applySqliteJournalMode']; + ensureDatabaseExists: SqlDriver['ensureDatabaseExists']; + readonly isSqlite: SqlDriver['isSqlite']; + releaseRefusedSqliteConnection: SqlDriver['releaseRefusedSqliteConnection']; +} + +/** The body of `SqlDriver#connect`; the method's docblock there is its contract. */ +export async function connect(this: Pick): Promise { + try { + // Ensure the database directory exists before any query can trigger + // better-sqlite3 to open the file (e.g. loadMetaFromDb on startup). + await this.ensureDatabaseExists(); + + if (this.isSqlite) { + // Both pragmas below are persistent properties of the FILE, so one + // connection setting them is enough for every process that follows. + // Each is READ first and set only when the file answers differently, so + // a connect to a file an earlier boot already configured writes nothing + // to it (#21734) — one rule for every connect, read-only boots included. + // A `false` means the very first statement on this connection failed — + // in practice a native addon that cannot load — so asking it for another + // pragma would only repeat the same warning. + if (await this.applyAutoVacuumIncremental()) { + await this.applySqliteJournalMode(); + } + } + } catch (error) { + if (this.isSqlite) await this.releaseRefusedSqliteConnection(); + throw error; + } + } diff --git a/packages/drivers/driver-sql/src/sql-driver-managed-drift.ts b/packages/drivers/driver-sql/src/sql-driver-managed-drift.ts new file mode 100644 index 00000000000..56b17736222 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-managed-drift.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The "Managed-schema drift & reconcile (#2186)" section of + * `SqlDriver`: the bodies of `isProductionEnv`, + * `probeNullSafeUniqueDuplicates`, `buildRebuiltColumn`, `dropIndexIfExists`, + * `syncTableIndexes` moved out of `sql-driver.ts` whole by + * `scripts/codemod-split-sql-driver.mjs`. + * + * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * method of the same name exactly as it stood: not re-indented, not + * formatted, not edited. The method stays on the class with its docblock and + * signature, so the published `dist/*.d.ts`, an instance spy and a subclass + * override are what they were; the docblock there is the contract. + * + * The bodies reach the class through {@link ManagedDriftHost}: exactly the + * `SqlDriver` members they read. Each method hands its body one literal of the + * members that body reads, plus a `.call`: methods go as closures that call + * them on the driver at call time (the builder factory as a bound reference), + * so a subclass override or an instance spy is still the one reached; fields + * and accessors go as GETTERS, read when the body reads them, as they were on + * the class. + * + * The module imports `sql-driver.ts` for types only, so the two modules form + * no evaluation cycle. `index.ts` re-exports nothing from this module. + */ + +import { GLOBAL_TENANT } from './schema-drift.js'; +import type { Knex } from 'knex'; +import { uniqueProbeNotNullClause } from './sql-driver-top-level.js'; +import type { IntrospectedColumn } from './sql-driver-top-level.js'; +import type { SqlDriver } from './sql-driver.js'; + +/** + * The `SqlDriver` members the functions below read — every one of them, and + * no other. Each is typed AS the class member (an indexed access on the + * class), so a change to a member's signature on the class is a change here + * too. `readonly` members are the getters. + */ +export interface ManagedDriftHost { + readonly isMysql: SqlDriver['isMysql']; + readonly isPostgres: SqlDriver['isPostgres']; + readonly knex: SqlDriver['knex']; + syncDeclaredIndexes: SqlDriver['syncDeclaredIndexes']; + uniqueIndexesFromFields: SqlDriver['uniqueIndexesFromFields']; +} + +/** The body of `SqlDriver#isProductionEnv`; the method's docblock there is its contract. */ +export function isProductionEnv(): boolean { + try { + return (process.env.NODE_ENV ?? '').toLowerCase() === 'production'; + } catch { + return false; + } + } + +/** The body of `SqlDriver#probeNullSafeUniqueDuplicates`; the method's docblock there is its contract. */ +export async function probeNullSafeUniqueDuplicates(this: Pick, table: string, columns: string[], nullSafeColumns: string[]): Promise> { + const ns = new Set(nullSafeColumns); + const q = (id: string) => this.knex.ref(id).toQuery(); + const parts = columns.map((c) => + ns.has(c) ? `COALESCE(${q(c)}, '${GLOBAL_TENANT}')` : q(c), + ); + const selectList = parts.map((p, i) => `${p} AS k${i}`).join(', '); + const groupList = parts.join(', '); + const sql = + `SELECT ${selectList}, COUNT(*) AS n FROM ${q(table)}` + + `${uniqueProbeNotNullClause(columns, ns, q)} ` + + `GROUP BY ${groupList} HAVING COUNT(*) > 1 ORDER BY n DESC LIMIT 20`; + const res: any = await this.knex.raw(sql); + const rows: any[] = Array.isArray(res) ? (Array.isArray(res[0]) ? res[0] : res) : (res?.rows ?? []); + return rows.map((r: any) => ({ + key: columns.map((c, i) => `${c}=${JSON.stringify(r[`k${i}`])}`).join(', '), + rows: Number(r.n ?? r.N ?? 0), + })); + } + +/** The body of `SqlDriver#buildRebuiltColumn`; the method's docblock there is its contract. */ +export function buildRebuiltColumn(t: Knex.CreateTableBuilder, c: IntrospectedColumn): any { + if (c.name === 'id') return t.string('id').primary(); + const ty = (c.type || 'text').toLowerCase(); + if (ty.includes('int')) return t.integer(c.name); + if (/(real|floa|doub|num|dec)/.test(ty)) return t.float(c.name); + if (ty.includes('bool')) return t.boolean(c.name); + if (ty.includes('datetime') || ty.includes('timestamp')) return t.timestamp(c.name); + if (ty === 'date') return t.date(c.name); + if (ty === 'time') return t.time(c.name); + if (ty.includes('json')) return t.json(c.name); + if (ty.includes('blob') || ty.includes('binary')) return t.binary(c.name); + if (ty.includes('text')) return t.text(c.name); + return t.string(c.name); + } + +/** The body of `SqlDriver#dropIndexIfExists`; the method's docblock there is its contract. */ +export async function dropIndexIfExists(this: Pick, tableName: string, indexName: string): Promise { + const attempts: string[] = this.isPostgres + ? [ + `ALTER TABLE ?? DROP CONSTRAINT IF EXISTS ??`, + `DROP INDEX IF EXISTS ??`, + ] + : this.isMysql + ? [`ALTER TABLE ?? DROP INDEX ??`] + : [`DROP INDEX IF EXISTS ??`]; + + let dropped = false; + for (const sql of attempts) { + try { + const bindings = sql.startsWith('ALTER TABLE') ? [tableName, indexName] : [indexName]; + await this.knex.raw(sql, bindings); + dropped = true; + } catch (e: any) { + const msg = String(e?.message ?? e); + // "does not exist" / "check that column/key exists" — nothing to drop. + if (/does not exist|doesn't exist|no such index|check that column/i.test(msg)) continue; + throw e; + } + } + return dropped; + } + +/** The body of `SqlDriver#syncTableIndexes`; the method's docblock there is its contract. */ +export async function syncTableIndexes(this: Pick, tableName: string, fields: Record, declaredIndexes: any[] | undefined, tenantField: string | null, physicalColumns: Set): Promise { + const fromFields = this.uniqueIndexesFromFields(tableName, fields, tenantField); + const declared = Array.isArray(declaredIndexes) ? declaredIndexes : []; + if (fromFields.length === 0 && declared.length === 0) return; + // Pass the tenant column through rather than letting `syncDeclaredIndexes` + // re-resolve it: every caller of this method already holds the value the + // registration recorded, and a declared `unique: 'organization'` index + // (ADR-0120 D1) must scope against exactly that column. + await this.syncDeclaredIndexes(tableName, [...fromFields, ...declared], physicalColumns, tenantField, fields); + } diff --git a/packages/drivers/driver-sql/src/sql-driver-query-plan.ts b/packages/drivers/driver-sql/src/sql-driver-query-plan.ts new file mode 100644 index 00000000000..fd40bfddfb2 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-query-plan.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The "Query Plan Analysis" section of `SqlDriver`: the bodies + * of `analyzeQuery` moved out of `sql-driver.ts` whole by + * `scripts/codemod-split-sql-driver.mjs`. + * + * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * method of the same name exactly as it stood: not re-indented, not + * formatted, not edited. The method stays on the class with its docblock and + * signature, so the published `dist/*.d.ts`, an instance spy and a subclass + * override are what they were; the docblock there is the contract. + * + * The bodies reach the class through {@link QueryPlanHost}: exactly the + * `SqlDriver` members they read. Each method hands its body one literal of the + * members that body reads, plus a `.call`: methods go as closures that call + * them on the driver at call time (the builder factory as a bound reference), + * so a subclass override or an instance spy is still the one reached; fields + * and accessors go as GETTERS, read when the body reads them, as they were on + * the class. + * + * The module imports `sql-driver.ts` for types only, so the two modules form + * no evaluation cycle. `index.ts` re-exports nothing from this module. + */ + +import type { DriverOptions } from '@objectstack/spec/data'; +import type { DriverQuery } from '@objectstack/spec/contracts'; +import type { SqlDriver } from './sql-driver.js'; + +/** + * The `SqlDriver` members the functions below read — every one of them, and + * no other. Each is typed AS the class member (an indexed access on the + * class), so a change to a member's signature on the class is a change here + * too. `readonly` members are the getters. + */ +export interface QueryPlanHost { + applyFilters: SqlDriver['applyFilters']; + applyTenantScope: SqlDriver['applyTenantScope']; + readonly config: SqlDriver['config']; + getBuilder: SqlDriver['getBuilder']; + readonly isMysql: SqlDriver['isMysql']; + readonly isPostgres: SqlDriver['isPostgres']; + readonly isSqlite: SqlDriver['isSqlite']; + readonly knex: SqlDriver['knex']; + mapSortField: SqlDriver['mapSortField']; +} + +/** The body of `SqlDriver#analyzeQuery`; the method's docblock there is its contract. */ +export async function analyzeQuery(this: Pick, object: string, query: DriverQuery, options?: DriverOptions): Promise { + const builder = this.getBuilder(object, options); + // A PLAN, not rows — so this is a smaller fix than the one above, and it is + // made on its own merits rather than riding in on that one (#6792). It is + // the SAME defect #6577 fixed on this method one builder line lower: a plan + // is only worth reading if it explains the statement `find()` would + // actually run, and a missing tenant predicate is not a cosmetic + // difference — it changes selectivity and therefore which index the planner + // picks, so the EXPLAIN answers for a query nobody will execute. + // Measured on `main` at `6595262`, `tenantId: 'org_a'`: + // analyze -> select * from `os6792_account` + // find -> select * from `os6792_account` + // where (`organization_id` = ? or `organization_id` is null) + // order by `id` asc + this.applyTenantScope(builder, object, options); + + if (query.fields) { + builder.select(query.fields); + } else { + builder.select('*'); + } + + if (query.where) { + this.applyFilters(builder, query.where, options); + } + + if (query.orderBy && Array.isArray(query.orderBy)) { + for (const sort of query.orderBy) { + builder.orderBy(this.mapSortField(sort.field), sort.order || 'asc'); + } + } + + // PRESENCE, not truthiness — see `findWithWindowFunctions()` above (#6577). + // The stake is different here and no smaller: this door returns a PLAN, and + // a plan is only worth reading if it explains the statement `find()` would + // actually run. Measured on `main` before this line changed: `{ limit: 0 }` + // compiled to `select * from `orders`` while `find()` sent + // `select * from `orders` order by `id` asc limit ?` — an EXPLAIN for a + // different query, which is the one thing an EXPLAIN must never be. + if (query.limit !== undefined) builder.limit(query.limit); + if (query.offset !== undefined) builder.offset(query.offset); + + const sql = builder.toSQL(); + const client = (this.config as any).client; + let explainResults: any; + + try { + if (this.isPostgres) { + explainResults = await this.knex.raw(`EXPLAIN (FORMAT JSON, ANALYZE) ${sql.sql}`, sql.bindings); + } else if (this.isMysql) { + explainResults = await this.knex.raw(`EXPLAIN FORMAT=JSON ${sql.sql}`, sql.bindings); + } else if (this.isSqlite) { + explainResults = await this.knex.raw(`EXPLAIN QUERY PLAN ${sql.sql}`, sql.bindings); + } else { + return { + sql: sql.sql, + bindings: sql.bindings, + client, + note: 'EXPLAIN not supported for this database client', + }; + } + + return { sql: sql.sql, bindings: sql.bindings, client, plan: explainResults }; + } catch (error: any) { + return { + sql: sql.sql, + bindings: sql.bindings, + client, + error: error.message, + note: 'Failed to execute EXPLAIN.', + }; + } + } diff --git a/packages/drivers/driver-sql/src/sql-driver-rotation.ts b/packages/drivers/driver-sql/src/sql-driver-rotation.ts new file mode 100644 index 00000000000..c56204c7d09 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-rotation.ts @@ -0,0 +1,194 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The "Data-lifecycle rotation (ADR-0057 P2)" section of + * `SqlDriver`: the bodies of `computeTenantField`, + * `registerManagedObjectMetadata` moved out of `sql-driver.ts` whole by + * `scripts/codemod-split-sql-driver.mjs`. + * + * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * method of the same name exactly as it stood: not re-indented, not + * formatted, not edited. The method stays on the class with its docblock and + * signature, so the published `dist/*.d.ts`, an instance spy and a subclass + * override are what they were; the docblock there is the contract. + * + * The bodies reach the class through {@link RotationHost}: exactly the + * `SqlDriver` members they read. Each method hands its body one literal of the + * members that body reads, plus a `.call`: methods go as closures that call + * them on the driver at call time (the builder factory as a bound reference), + * so a subclass override or an instance spy is still the one reached; fields + * and accessors go as GETTERS, read when the body reads them, as they were on + * the class. + * + * The module imports `sql-driver.ts` for types only, so the two modules form + * no evaluation cycle. `index.ts` re-exports nothing from this module. + */ + +import { FILE_REFERENCE_TYPES, NUMERIC_VALUE_TYPES, isTenancyDisabled, parseAutonumberFormat, resolveAutonumberFormat } from '@objectstack/spec/data'; +import type { AutonumberToken } from '@objectstack/spec/data'; +import { aggregandColumnClass } from '@objectstack/core'; +import { StorageNameMapping } from '@objectstack/spec/system'; +import { + AUDIT_TIMESTAMP_COLUMNS, NUMERIC_SCALAR_TYPES, declaredValueShapes, isMultiValuedColumn, +} from './sql-driver-top-level.js'; +import type { SqlDriver } from './sql-driver.js'; + +/** + * The `SqlDriver` members the functions below read — every one of them, and + * no other. Each is typed AS the class member (an indexed access on the + * class), so a change to a member's signature on the class is a change here + * too. `readonly` members are the getters. + */ +export interface RotationHost { + readonly autoNumberFields: SqlDriver['autoNumberFields']; + readonly booleanFields: SqlDriver['booleanFields']; + computeAndRecordTenantField: SqlDriver['computeAndRecordTenantField']; + readonly dateFields: SqlDriver['dateFields']; + readonly datetimeFields: SqlDriver['datetimeFields']; + readonly fractionalNumericFields: SqlDriver['fractionalNumericFields']; + isJsonField: SqlDriver['isJsonField']; + readonly jsonFields: SqlDriver['jsonFields']; + readonly managedObjectFields: SqlDriver['managedObjectFields']; + readonly managedObjectIndexes: SqlDriver['managedObjectIndexes']; + readonly mediaFields: SqlDriver['mediaFields']; + readonly numericFields: SqlDriver['numericFields']; + readonly numericValueFields: SqlDriver['numericValueFields']; + readonly physicalKeyIndexes: SqlDriver['physicalKeyIndexes']; + readonly tenantFieldByTable: SqlDriver['tenantFieldByTable']; + readonly timeFields: SqlDriver['timeFields']; + readonly undeclaredAuditTimestampFields: SqlDriver['undeclaredAuditTimestampFields']; + readonly updatedAtColumnState: SqlDriver['updatedAtColumnState']; + readonly valueShapeFields: SqlDriver['valueShapeFields']; +} + +/** The body of `SqlDriver#computeTenantField`; the method's docblock there is its contract. */ +export function computeTenantField(schema: { fields?: Record; tenancy?: any }): string | null { + const tenancyDecl = (schema as any)?.tenancy; + // Explicit opt-out wins over any column-presence heuristic. + if (isTenancyDisabled(schema)) return null; + const fields = schema?.fields; + if (tenancyDecl?.tenantField) { + const declared = String(tenancyDecl.tenantField); + if (fields && Object.prototype.hasOwnProperty.call(fields, declared)) return declared; + } + if (fields && Object.prototype.hasOwnProperty.call(fields, 'organization_id')) return 'organization_id'; + return null; + } + +/** The body of `SqlDriver#registerManagedObjectMetadata`; the method's docblock there is its contract. */ +export function registerManagedObjectMetadata(this: Pick, obj: { name: string; fields?: Record; tenancy?: any; indexes?: any[] }): { tableName: string; tenantField: string | null } { + const tableName = StorageNameMapping.resolveTableName(obj); + // #2186: remember the authoritative metadata field set for this table so + // drift detection / `os migrate` can diff the physical schema against it. + this.managedObjectFields.set(tableName, obj.fields ?? {}); + // [#21259] Recomputed on every registration, so a re-registration that now + // declares a column stops treating it as undeclared. + this.undeclaredAuditTimestampFields[tableName] = AUDIT_TIMESTAMP_COLUMNS.filter( + (col) => !Object.prototype.hasOwnProperty.call(obj.fields ?? {}, col), + ); + // Always overwrite — a metadata change that REMOVES `indexes` must clear + // the previous entry, or drift detection keeps expecting an index nobody + // declares any more (and never reports it as orphaned). + if (Array.isArray(obj.indexes)) { + this.managedObjectIndexes.set(tableName, obj.indexes); + } else { + this.managedObjectIndexes.delete(tableName); + } + // [#8621] This call may create the table, or add a unique index to one + // that already exists, so whatever the upsert pre-flight introspected + // before it is stale. Dropping the entry is enough — the entry is + // re-read lazily, and a refusal re-reads unconditionally. + this.physicalKeyIndexes.delete(tableName); + + const jsonCols: string[] = []; + const mediaCols: string[] = []; + const booleanCols: string[] = []; + const numericCols: string[] = []; + const numericValueCols: string[] = []; + const fractionalCols: string[] = []; + const autoNumberCols: Array<{ name: string; format: string; tokens: AutonumberToken[]; tenantField: string | null }> = []; + // Tenant-isolation column: explicit tenancy opt-out → declared field → + // implicit `organization_id`. See {@link computeAndRecordTenantField} + // (shared with registerExternalObject so the two paths can't drift). + const tenantField = this.computeAndRecordTenantField(tableName, obj); + if (obj.fields) { + for (const [name, field] of Object.entries(obj.fields)) { + const type = field.type || 'string'; + if (this.isJsonField(type, field)) { + jsonCols.push(name); + } + // Unconditional, on BOTH arms — see {@link mediaFields}. The read-side + // legacy-encoding repair runs on a deployment that has not moved too. + if (!isMultiValuedColumn(type, field) && FILE_REFERENCE_TYPES.has(type)) { + mediaCols.push(name); + } + // `toggle` shares boolean storage/affinity, so it needs the same + // read coercion (stored 1/0 → JS true/false) or it leaks back as a + // number/string instead of a boolean (#field-zoo). + // [commit d46deba19] SCALAR only, like the three neighbours below: a + // `multiple: true` boolean/toggle is a JSON column, and the read + // coercion this registry exists for presents ONE JS boolean — which + // collapses the parsed array to `true` whatever it holds. See + // {@link booleanFields}. + if ((type === 'boolean' || type === 'toggle') && !isMultiValuedColumn(type, field)) { + booleanCols.push(name); + } + // Numeric scalars are coerced back to JS numbers on read so legacy + // TEXT-affinity columns (created before they were mapped to a numeric + // column) still return numbers, not strings — see NUMERIC_SCALAR_TYPES. + if (NUMERIC_SCALAR_TYPES.has(type) && !isMultiValuedColumn(type, field)) { + numericCols.push(name); + } + // [#16318] The authorable half only — see {@link numericValueFields}. + if (NUMERIC_VALUE_TYPES.has(type) && !isMultiValuedColumn(type, field)) { + numericValueCols.push(name); + } + // [#20387, #21042] See {@link fractionalNumericFields}: the predicate's + // `'fractional'` class, scalar only by the predicate's own reading. + if (aggregandColumnClass({ type, multiple: field?.multiple }) === 'fractional') { + fractionalCols.push(name); + } + if (type === 'date') { + (this.dateFields[tableName] ??= new Set()).add(name); + } + if (type === 'datetime') { + (this.datetimeFields[tableName] ??= new Set()).add(name); + } + if (type === 'time') { + (this.timeFields[tableName] ??= new Set()).add(name); + } + if (type === 'auto_number' || type === 'autonumber') { + const fmt = resolveAutonumberFormat(field); + // Tokenize once: the renderer resolves date tokens (`{YYYYMMDD}`), + // field interpolation (`{island_zone}`) and the sequence slot at + // fill time. The counter scopes to whatever renders before the slot. + const tokens = parseAutonumberFormat(fmt); + autoNumberCols.push({ name, format: fmt, tokens, tenantField }); + } + } + } + this.jsonFields[tableName] = jsonCols; + // [#20444] The declared value shapes `$empty` expands — see {@link valueShapeFields}. + this.valueShapeFields[tableName] = declaredValueShapes(obj.fields); + this.mediaFields[tableName] = mediaCols; + this.booleanFields[tableName] = booleanCols; + this.numericFields[tableName] = numericCols; + this.numericValueFields[tableName] = numericValueCols; + this.fractionalNumericFields[tableName] = fractionalCols; + this.autoNumberFields[tableName] = autoNumberCols; + this.tenantFieldByTable[tableName] = tenantField; + // [commit 479fba50d] The declared shape's answer to "does this table carry + // `updated_at`?", installed here because here is the one place a managed + // object reaches the driver on EVERY boot posture — `initObjects` calls + // this first, and a `skipSchemaSync` boot calls it and stops. Presumed + // rather than asserted (see {@link updatedAtColumnState}); DDL that + // OBSERVES the column still records the stronger fact in + // `tablesWithTimestamps`, so a table this driver built is never in the + // speculative state at all. Never downgrades an answer already resolved + // from the database — re-registration is idempotent metadata assignment + // and must not throw away a `columnInfo()` result. + if (!this.updatedAtColumnState.has(tableName)) { + this.updatedAtColumnState.set(tableName, 'presumed'); + } + return { tableName, tenantField }; + } diff --git a/packages/drivers/driver-sql/src/sql-driver-schema-sync-section.ts b/packages/drivers/driver-sql/src/sql-driver-schema-sync-section.ts new file mode 100644 index 00000000000..a0914c6477c --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-schema-sync-section.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The "Schema Sync (syncSchema / init)" section of + * `SqlDriver`: the bodies of `reclaimSpace` moved out of `sql-driver.ts` whole + * by `scripts/codemod-split-sql-driver.mjs`. + * + * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * method of the same name exactly as it stood: not re-indented, not + * formatted, not edited. The method stays on the class with its docblock and + * signature, so the published `dist/*.d.ts`, an instance spy and a subclass + * override are what they were; the docblock there is the contract. + * + * The bodies reach the class through {@link SchemaSyncSectionHost}: exactly the + * `SqlDriver` members they read. Each method hands its body one literal of the + * members that body reads, plus a `.call`: methods go as closures that call + * them on the driver at call time (the builder factory as a bound reference), + * so a subclass override or an instance spy is still the one reached; fields + * and accessors go as GETTERS, read when the body reads them, as they were on + * the class. + * + * The module imports `sql-driver.ts` for types only, so the two modules form + * no evaluation cycle. `index.ts` re-exports nothing from this module. + */ + +import type { DriverOptions } from '@objectstack/spec/data'; +import { reclaimBetterSqlite3 } from './sql-driver-top-level.js'; +import type { SqlDriver } from './sql-driver.js'; + +/** + * The `SqlDriver` members the functions below read — every one of them, and + * no other. Each is typed AS the class member (an indexed access on the + * class), so a change to a member's signature on the class is a change here + * too. `readonly` members are the getters. + */ +export interface SchemaSyncSectionHost { + readonly isSqlite: SqlDriver['isSqlite']; + readonly knex: SqlDriver['knex']; +} + +/** The body of `SqlDriver#reclaimSpace`; the method's docblock there is its contract. */ +export async function reclaimSpace(this: Pick, _options?: DriverOptions): Promise { + if (!this.isSqlite) return; + const client = this.knex.client; + if (client.driverName === 'better-sqlite3') { + const connection = await client.acquireConnection(); + try { + reclaimBetterSqlite3(connection); + } finally { + await client.releaseConnection(connection); + } + return; + } + await this.knex.raw('PRAGMA incremental_vacuum'); + } diff --git a/packages/drivers/driver-sql/src/sql-driver-sequence-semantics.ts b/packages/drivers/driver-sql/src/sql-driver-sequence-semantics.ts new file mode 100644 index 00000000000..0b343fbfa43 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-sequence-semantics.ts @@ -0,0 +1,586 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The + * "The sequence semantics a second face shares, stated once" section of + * `SqlDriver`: the bodies of `defineSequencesTable`, `autoNumberValueExists`, + * `autoNumberCounterKey`, `update`, `assertMergeLandedOnSuppliedIdentity`, + * `assertMergeLandedInWrittenOrganization`, `upsert` moved out of + * `sql-driver.ts` whole by `scripts/codemod-split-sql-driver.mjs`. + * + * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * method of the same name exactly as it stood: not re-indented, not + * formatted, not edited. The method stays on the class with its docblock and + * signature, so the published `dist/*.d.ts`, an instance spy and a subclass + * override are what they were; the docblock there is the contract. + * + * The bodies reach the class through {@link SequenceSemanticsHost}: exactly the + * `SqlDriver` members they read. Each method hands its body one literal of the + * members that body reads, plus a `.call`: methods go as closures that call + * them on the driver at call time (the builder factory as a bound reference), + * so a subclass override or an instance spy is still the one reached; fields + * and accessors go as GETTERS, read when the body reads them, as they were on + * the class. + * + * The module imports `sql-driver.ts` for types only, so the two modules form + * no evaluation cycle. `index.ts` re-exports nothing from this module. + */ + +import type { DriverOptions } from '@objectstack/spec/data'; +import { isUnbackedConflictTargetError } from '@objectstack/types'; +import type { PhysicalIndex } from './schema-drift.js'; +import { withUpsertConflictClause } from './upsert-conflict-clause.js'; +import type { Knex } from 'knex'; +import { nanoid } from 'nanoid'; +import { + AUTONUMBER_COLLISION_RETRIES, DEFAULT_ID_LENGTH, isUpsertFenceRefusal, refuseCrossRowIdentityMerge, + refuseUnbackedConflictTarget, refuseUpsertConflictOutsideWrittenOrganization, +} from './sql-driver-top-level.js'; +import type { AutoNumberReservation } from './sql-driver-top-level.js'; +import type { SqlDriver } from './sql-driver.js'; + +/** + * The `SqlDriver` members the functions below read — every one of them, and + * no other. Each is typed AS the class member (an indexed access on the + * class), so a change to a member's signature on the class is a change here + * too. `readonly` members are the getters. + */ +export interface SequenceSemanticsHost { + applyTenantScope: SqlDriver['applyTenantScope']; + applyWriteColumnMap: SqlDriver['applyWriteColumnMap']; + assertConflictTargetBackedWithoutClause: SqlDriver['assertConflictTargetBackedWithoutClause']; + assertConflictTargetHonoured: SqlDriver['assertConflictTargetHonoured']; + assertMergeLandedInWrittenOrganization: SqlDriver['assertMergeLandedInWrittenOrganization']; + assertMergeLandedOnSuppliedIdentity: SqlDriver['assertMergeLandedOnSuppliedIdentity']; + auditMissingTenant: SqlDriver['auditMissingTenant']; + collidingAutoNumberReservations: SqlDriver['collidingAutoNumberReservations']; + fillAutoNumberFields: SqlDriver['fillAutoNumberFields']; + formatInput: SqlDriver['formatInput']; + formatOutput: SqlDriver['formatOutput']; + getBuilder: SqlDriver['getBuilder']; + injectTenantOnInsert: SqlDriver['injectTenantOnInsert']; + insertOnlyUpsertColumns: SqlDriver['insertOnlyUpsertColumns']; + readonly isMysql: SqlDriver['isMysql']; + keepSuppliedUpdatedAt: SqlDriver['keepSuppliedUpdatedAt']; + readonly knex: SqlDriver['knex']; + mergeLandsInWrittenOrganization: SqlDriver['mergeLandsInWrittenOrganization']; + readonly physicalTableByObject: SqlDriver['physicalTableByObject']; + resolveSequenceTenantId: SqlDriver['resolveSequenceTenantId']; + resolveTenantField: SqlDriver['resolveTenantField']; + resyncSequenceToDataMax: SqlDriver['resyncSequenceToDataMax']; + rotatedUpdateById: SqlDriver['rotatedUpdateById']; + rotationShardsOf: SqlDriver['rotationShardsOf']; + rotationWriteTarget: SqlDriver['rotationWriteTarget']; + sequenceKeyHash: SqlDriver['sequenceKeyHash']; + stampInsertTimestamps: SqlDriver['stampInsertTimestamps']; + stampUpsertUpdatedAt: SqlDriver['stampUpsertUpdatedAt']; + stampsUpdatedAt: SqlDriver['stampsUpdatedAt']; + updateWithPresumedTimestamp: SqlDriver['updateWithPresumedTimestamp']; + updatedAtStamp: SqlDriver['updatedAtStamp']; + updatedAtStampIsPresumed: SqlDriver['updatedAtStampIsPresumed']; + upsertTenantGuard: SqlDriver['upsertTenantGuard']; +} + +/** The body of `SqlDriver#defineSequencesTable`; the method's docblock there is its contract. */ +export function defineSequencesTable(this: Pick, t: Knex.CreateTableBuilder): void { + t.string('key_hash', 64).notNullable().primary(); + t.string('object').notNullable(); + t.string('tenant_id').notNullable(); + t.string('field').notNullable(); + // Non-indexed, so it is free of the PK length limit — a long `{plan_no}` + // composite scope fits. 1024 is far above any realistic rendered prefix. + t.string('scope', 1024).notNullable().defaultTo(''); + t.bigInteger('last_value').notNullable().defaultTo(0); + t.timestamp('updated_at').defaultTo(this.knex.fn.now()); + } + +/** The body of `SqlDriver#autoNumberValueExists`; the method's docblock there is its contract. */ +export async function autoNumberValueExists(queryRunner: Knex | Knex.Transaction, reservation: AutoNumberReservation): Promise { + let builder = queryRunner(reservation.tableName).select(reservation.field).where(reservation.field, reservation.value); + if (reservation.tenantField && reservation.tenantId !== null) { + builder = builder.where(reservation.tenantField, reservation.tenantId); + } + const hit = await builder.first(); + return hit !== undefined && hit !== null; + } + +/** The body of `SqlDriver#autoNumberCounterKey`; the method's docblock there is its contract. */ +export function autoNumberCounterKey(this: Pick, reservation: AutoNumberReservation): string { + const resolvedTenantId = this.resolveSequenceTenantId( + reservation.tenantField, + reservation.tenantId, + reservation.tableName, + reservation.field, + ); + return this.sequenceKeyHash(reservation.tableName, resolvedTenantId, reservation.field, reservation.scope); + } + +/** The body of `SqlDriver#update`; the method's docblock there is its contract. */ +export async function update(this: Pick, object: string, id: string | number, data: Record, options?: DriverOptions): Promise | null> { + this.auditMissingTenant(object, 'update', options); + const rotationShards = this.rotationShardsOf(object); + if (rotationShards) return this.rotatedUpdateById(object, rotationShards, id, data, options); + const formatted = this.applyWriteColumnMap(object, this.formatInput(object, data)); + + // One definition of the statement, so the speculative attempt, the fenced + // retry and the plain path cannot drift in WHERE or tenant scope. + const issue = (payload: Record, issueOptions?: DriverOptions): Promise => { + const builder = this.getBuilder(object, issueOptions).where('id', id); + this.applyTenantScope(builder, object, issueOptions); + return builder.update(payload) as unknown as Promise; + }; + + if (this.stampsUpdatedAt(object) && !this.keepSuppliedUpdatedAt(formatted, options)) { + // [#24835] A FRESH object, never `formatted`: `formatInput` and + // `applyWriteColumnMap` are copy-on-write, so for a patch that needs no + // coercion `formatted` IS the caller's `data`. Assigning the stamp onto it + // put `updated_at` into the caller's patch — which ObjectQL publishes as + // `DataEvent.changes` — and on Postgres/MySQL that value is a knex `Raw` + // holding the client, its pool and a timer, so every reader that + // serialises the event (the webhook outbox first) threw. `updateMany`'s + // shape, below. + const stamped = { ...formatted, updated_at: this.updatedAtStamp() }; + if (this.updatedAtStampIsPresumed(object)) { + await this.updateWithPresumedTimestamp(object, stamped, options, issue); + } else { + await issue(stamped, options); + } + } else { + await issue(formatted, options); + } + + const readback = this.getBuilder(object, options).where('id', id); + this.applyTenantScope(readback, object, options); + const updated = await readback.first(); + return this.formatOutput(object, updated) || null; + } + +/** The body of `SqlDriver#assertMergeLandedOnSuppliedIdentity`; the method's docblock there is its contract. */ +export async function assertMergeLandedOnSuppliedIdentity(this: Pick, object: string, writeTable: string, id: string | number, rivals: PhysicalIndex[], writtenTenant: unknown, options?: DriverOptions): Promise { + const builder = this.getBuilder(writeTable, options); + // Scoped to the tenant this row was written under (see the docblock): the + // caller's org on an ordinary call, the explicitly named one on an admin + // cross-tenant write, and a no-op when neither exists. `tenantIds` is + // dropped deliberately — the group-union posture widens a READ to a + // membership set, and this is an identity probe for ONE row, not a read. + const tenantContext = options?.tenantId !== undefined && options?.tenantId !== null && options?.tenantId !== ''; + const scopeOptions: DriverOptions = { + ...options, + tenantId: + tenantContext && typeof writtenTenant === 'string' && writtenTenant !== '' ? writtenTenant : options?.tenantId, + tenantIds: undefined, + }; + this.applyTenantScope(builder, object, scopeOptions); + const landed = await builder.where('id', id).first('id'); + if (landed) return; + const tableName = this.physicalTableByObject[writeTable] ?? writeTable; + throw refuseCrossRowIdentityMerge(object, tableName, id, rivals); + } + +/** The body of `SqlDriver#assertMergeLandedInWrittenOrganization`; the method's docblock there is its contract. */ +export async function assertMergeLandedInWrittenOrganization(this: Pick, object: string, writeTable: string, mergeKeys: string[], matchOn: Record, sent: Record, guard: { column: string; value: unknown }, identity: { id: string | number; rivals: PhysicalIndex[] } | null, options?: DriverOptions): Promise { + // The tenant scope of a ONE-row probe: the written tenant, with + // `tenantIds` dropped for #8807's reason (the group-union posture widens a + // READ to a membership set; this asks about one organization). + const scopeOptions: DriverOptions = { ...options, tenantId: String(guard.value), tenantIds: undefined }; + + const landedProbe = this.getBuilder(writeTable, options); + this.applyTenantScope(landedProbe, object, scopeOptions); + landedProbe.where(guard.column, guard.value as any); + for (const [column, value] of Object.entries(matchOn)) landedProbe.where(column, value as any); + if (await landedProbe.first('id')) return; + + if (identity) { + for (const rival of identity.rivals) { + const probeable = rival.columns.length > 0 && rival.columns.every((c) => sent[c] !== undefined && sent[c] !== null); + if (!probeable) continue; + const rivalProbe = this.getBuilder(writeTable, options); + this.applyTenantScope(rivalProbe, object, scopeOptions); + rivalProbe.where(guard.column, guard.value as any); + for (const column of rival.columns) rivalProbe.where(column, sent[column] as any); + if (await rivalProbe.first('id')) { + const tableName = this.physicalTableByObject[writeTable] ?? writeTable; + throw refuseCrossRowIdentityMerge(object, tableName, identity.id, identity.rivals); + } + } + } + throw refuseUpsertConflictOutsideWrittenOrganization(object, mergeKeys); + } + +/** The body of `SqlDriver#upsert`; the method's docblock there is its contract. */ +export async function upsert(this: Pick, object: string, data: Record, conflictKeys?: string[], options?: DriverOptions): Promise> { + const { _id, ...rest } = data; + const toUpsert = { ...rest }; + + if (_id !== undefined && toUpsert.id === undefined) { + toUpsert.id = _id; + } else if (toUpsert.id === undefined) { + toUpsert.id = nanoid(DEFAULT_ID_LENGTH); + } + + this.auditMissingTenant(object, 'upsert', options); + this.injectTenantOnInsert(object, toUpsert, options); + + const mergeKeys = conflictKeys && conflictKeys.length > 0 ? conflictKeys : ['id']; + + // [#21185] The organization fence (ADR-0131 D8: the tenant predicate + // reaches every driver door, and the merge leg of an upsert was the one it + // did not). The conflict target is resolved against the WHOLE table — the + // primary key and a `unique: 'global'` column are installation-wide — so + // the row a tenant-scoped call collides with can belong to an organization + // the caller cannot read, and the merge leg used to write onto it, tenant + // column included. With a fence the merge may only land on a row whose + // stored tenant column equals the written one; a conflict anywhere else is + // refused with `UNIQUE_VIOLATION` and writes nothing. `null` for a call with + // no tenant context, which keeps the merge and relies on the tenant column + // being insert-only ({@link insertOnlyUpsertColumns}). See + // {@link upsertTenantGuard}. + const tenantGuard = this.upsertTenantGuard(object, toUpsert, options); + + // [#8621, #8755, #8807] Pre-flight the conflict target — see + // {@link assertConflictTargetHonoured} for the mechanism and why it is + // MySQL-only. Two placement facts, both load-bearing: + // + // - ⚠️ It now runs whether or not the CALLER named a target, and the + // reasoning that used to stop at the caller-named case is superseded + // rather than merely widened. That reasoning was *"a probe on the default + // path could only ever confirm what the driver just built"*, and it was + // right about the question #8621/#8755 were asking — "is `['id']` backed?" + // has one possible answer on a table this driver created. #8807 asks a + // different question of the same introspection — "does anything ELSE on + // this table absorb a collision?" — and that one is genuinely unknown + // until read. The read is cached per table + // ({@link physicalKeyIndexes}), so the hot path pays it once, and the + // verdict it returns is not a refusal: see below. + // - It runs BEFORE the retry loop, therefore before + // `fillAutoNumberFields`. Refusing after it would burn an autonumber + // reservation for a statement that never executes — a visible gap in an + // externally meaningful sequence, handed out for a rejected call. + const callerNamed = !!(conflictKeys && conflictKeys.length > 0); + const preflight = this.isMysql + ? await this.assertConflictTargetHonoured(object, mergeKeys, callerNamed) + : { verifyIdentity: false }; + + // [#8807] The primary-key-targeted merge on a table that carries a rival + // UNIQUE key: MySQL may land it on a row this call never identified, and + // nothing before the statement can tell whether it will. So the statement + // and the identity check run as ONE unit of work, and the check's failure + // is what rolls the wrong write back. + // + // The accept set moves for exactly the calls that corrupt data: a merge + // that lands on the supplied identity — every insert, and every legitimate + // re-upsert of the same row — passes the check untouched, because `id` is + // insert-only (#8622) and therefore a row merged on the primary key always + // still carries it. + const verifyIdentity = preflight.verifyIdentity === true; + + // [#21185] MySQL's fence is a check after the statement, inside the same + // unit of work — `ON DUPLICATE KEY UPDATE` takes no `WHERE` — so it is owed + // whenever the call is fenced, not only when a rival UNIQUE key exists. See + // {@link assertMergeLandedInWrittenOrganization}. + const mysqlFence = this.isMysql && tenantGuard !== null; + const checkAfterStatement = verifyIdentity || mysqlFence; + + // #6943. Measured: `upsert` does NOT share `bulkCreate`'s shape. It is + // single-row, so a stale counter costs it exactly one burned number per + // call — the same shape `create()` had before #5495, for the same reason + // (no re-seed, no retry). `ON CONFLICT (mergeKeys) DO UPDATE` absorbs a + // conflict on the merge key only; the tenanted autonumber lives under a + // DIFFERENT unique index, so its violation is still raised and still + // reaches here. The remedy is therefore `create()`'s, verbatim: re-seed and + // re-issue, but only when the collision is provably this counter's + // ({@link collidingAutoNumberReservations}), and only outside a caller + // transaction (inside one the sequence UPDATE rolls back with the refused + // INSERT, so nothing is burned and there is nothing to repair — measured). + const mayRetry = options?.transaction === undefined; + // The row the last attempt SENT, in storage form: the read-back below + // looks the landed row up by its conflict-key values. + let sent: Record = {}; + for (let attempt = 0; ; attempt++) { + const reservations = await this.fillAutoNumberFields(object, toUpsert, options); + + const formatted = this.applyWriteColumnMap(object, this.formatInput(object, toUpsert)); + sent = formatted; + this.stampInsertTimestamps(object, formatted); + // [#11176] …and the same slot filled on Postgres/MySQL, where the line + // above returns early. Without it `updated_at` is not in `formatted`, so + // it is not in the merge set built below, so the merge branch leaves the + // row reading its INSERT-time default forever. See + // {@link stampUpsertUpdatedAt} — no-op on SQLite (already filled) and on + // any object whose `updated_at` column this driver has not OBSERVED. + this.stampUpsertUpdatedAt(object, formatted); + + // Rotation: conflict-merge is scoped to the CURRENT shard (telemetry is + // effectively append-only; a cross-shard upsert would need a probe-first + // strategy nothing on the platform requires today). + const writeTable = this.rotationWriteTarget(object) ?? object; + // `created_at` is insert-only — never overwrite it when an existing row is + // merged on conflict (the stamped/seeded value belongs to the original + // insert). [#7011] `auto_number` columns are insert-only for the same + // reason, and a stronger one: the number is an externally visible + // business identifier once assigned (`CASE-00001`), and + // `fillAutoNumberFields` above reserved a FRESH value before the + // statement could know it would merge — leaving these columns in the + // merge set rewrote the existing row's number on every merge-path upsert + // (measured on a healthy counter: create → CASE-00001, two upserts of + // the same id → CASE-00002 then CASE-00003, one row throughout). The + // exclusion is unconditional — an explicit payload value does not + // renumber on merge either; `update()` is the deliberate renumbering + // path. The reservation itself still happens on the merge path (a gap, + // not a rewrite) — that pre-burn half is #6943's reseed family, not + // this exclusion's. + // + // [#8622] `id` is insert-only for the SAME argument at its strongest. + // `upsert` mints a nanoid whenever the caller supplies none (just above), + // so the merge set carried `id = excluded.id` — the freshly minted id of + // the insert that LOST. On the default `['id']` conflict target that + // clause is a no-op (both sides are the same value), which is precisely + // why it stayed invisible; the moment `conflictKeys` names a business key + // the merged row's identity is silently replaced. Measured here on SQLite + // and live Postgres 16.13, and on live MySQL 8.0.46 in #8592 — a + // dialect-independent defect, because the mechanism is the merge set this + // driver builds, not anything a server decides: + // + // upsert({email:'x@b.com', title:'first'}, ['email']) -> id=rSxhabt0… + // upsert({email:'x@b.com', title:'second'}, ['email']) -> id=hXqcSC45… + // ONE row, NEW primary key. + // + // `id` is what relationships, audit records, external id mappings and any + // client-held reference point at, so this dangles every one of them with + // no error on any dialect. The exclusion is unconditional for #7011's + // reason: an explicit payload `id` (or its `_id` alias, folded into `id` + // before `formatted` is built) does not re-key on merge either. `update()` + // is the deliberate path — it writes exactly the columns it is handed, + // `id` included. + // + // Everything else (incl. `updated_at`) merges as before, so an upsert + // that updates a row still advances `updated_at`. [#11176] That last + // clause used to hold on SQLite ONLY: it needs `updated_at` to BE in + // `formatted`, which before this card nothing put there on Postgres or + // MySQL. `stampUpsertUpdatedAt` above is what now makes the sentence true + // on all three. + const insertOnlyColumns = this.insertOnlyUpsertColumns(object); + const mergeColumns = Object.keys(formatted).filter((c) => !insertOnlyColumns.has(c)); + + // [#8622] Excluding `id` made a branch REACHABLE that could not fire + // before, and the bare `merge()` below is merge-ALL: it re-admits every + // insert-only column. Measured on live Postgres with an object whose only + // non-`id` column is an `auto_number` — `upsert({ id: seeded }, ['id'])` + // left `mergeColumns` empty and the merge-ALL fallback rewrote the row's + // `case_no` from `0003` to `0004`, defeating #7011's exclusion. (SQLite + // never reached it: `stampInsertTimestamps` puts a mergeable `updated_at` + // in the payload there. [#11176] `stampUpsertUpdatedAt` now does the same + // on Postgres and MySQL, so a TIMESTAMPED object no longer reaches this + // fallback on any dialect either — it is still reachable for an object + // with no observed `updated_at` column.) + // + // The remedy keeps the emitted SQL byte-identical to what `main` emits + // for this shape. Falling back to the CONFLICT-TARGET columns present in + // the payload is a provable no-op — a conflict means those columns matched, + // so `excluded.` IS the stored value — and it reproduces exactly + // the `merge(['id'])` `main` produces here, because `id` was `main`'s only + // mergeable column in this case. So this PR's net behavioural delta on the + // degenerate path is nil rather than a #7011 regression. + // + // [#8740] When the payload carries no conflict-target column either, there + // is nothing the merge leg may write, and the statement is a plain INSERT + // with no conflict clause: a collision surfaces as the driver's own + // unique-violation error and the stored row is left as it was. This used + // to fall back to knex's bare `merge()`, which is merge-ALL on every + // dialect: it re-admitted every insert-only column the lines above + // exclude. ⛔ Not `ignore()` either: knex lowers it on MySQL to + // `insert ignore`, which swallows unrelated errors and drops the conflict + // target. See {@link withUpsertConflictClause}. + const noopMergeColumns = Object.keys(formatted).filter((c) => mergeKeys.includes(c)); + const columnsToMerge = mergeColumns.length > 0 ? mergeColumns : noopMergeColumns; + // …and that plain INSERT still refuses a conflict target nothing backs, + // as every dialect does when there is something to merge. + if (columnsToMerge.length === 0) await this.assertConflictTargetBackedWithoutClause(object, writeTable, mergeKeys); + + // [#21185] The fence for THIS attempt: the tenant column, and the value + // the row is written under in storage form (what the statement sends). + const fence = tenantGuard && { + column: tenantGuard.column, + value: formatted[tenantGuard.column] ?? tenantGuard.value, + }; + + // The statement, plus [#8807]'s identity check when one is owed. The + // builder is built HERE rather than above the comment block so it can be + // bound to whichever transaction this write runs on — the caller's, the + // one opened just below, or none at all. The emitted INSERT is otherwise + // unchanged. + const runStatement = async (writeOptions?: DriverOptions): Promise => { + // Bound in two steps, not chained: this is the INSERT door, and + // `check:tenant-chokepoint` classifies a builder as a write by seeing + // `.insert` used on the BINDING. Collapsing these into one expression + // hides that from the gate and the door reads as an unscoped read — + // measured when this closure was first extracted. Insert-side tenancy + // is `injectTenantOnInsert`'s job (called above), which is exactly why + // this one is deliberately not scoped. + const builder = this.getBuilder(writeTable, writeOptions); + const insertion = builder.insert(formatted); + const { statement, merges } = withUpsertConflictClause(insertion, mergeKeys, columnsToMerge); + // [#21185] SQLite and PostgreSQL: the fence is a predicate INSIDE the + // merge statement — `… DO UPDATE SET … WHERE` the stored tenant column + // equals the written one — so a conflict on a row of another + // organization (or of none) leaves that row untouched, atomically, with + // no added round trip. The read-back below then finds no row under the + // written organization, and that is where the call refuses. NULL-safe + // on both (`IS` on SQLite, `IS NOT DISTINCT FROM` on PostgreSQL); the + // written value is never NULL here, so a stored NULL is "distinct", and + // a platform row with no organization is fenced off too. The target + // table is named by its bare physical name, which both dialects accept + // for the INSERT target in this clause, schema-qualified or not. + // MySQL takes no `WHERE` here; its fence runs after the statement. A + // plain INSERT ([#8740]) has no merge leg to fence. + await (merges && fence && !this.isMysql + ? statement.where(this.mergeLandsInWrittenOrganization(writeTable, fence.column)) + : statement); + if (mysqlFence) { + // [#21185] One read, under the written tenant exactly; #8807's + // identity verdict rides on it when a rival key made one owed. + const matchOn: Record = mergeKeys.every((k) => formatted[k] !== undefined && formatted[k] !== null) + ? Object.fromEntries(mergeKeys.map((k) => [k, formatted[k]])) + : { id: toUpsert.id }; + await this.assertMergeLandedInWrittenOrganization( + object, + writeTable, + mergeKeys, + matchOn, + formatted, + fence!, + verifyIdentity ? { id: toUpsert.id, rivals: preflight.rivals ?? [] } : null, + writeOptions, + ); + } else if (verifyIdentity) { + // The tenant the row was WRITTEN under, read off the payload after + // `injectTenantOnInsert` has run — the caller's org on an ordinary + // call, an explicitly supplied one on an admin cross-tenant write. + const tenantField = this.resolveTenantField(object); + await this.assertMergeLandedOnSuppliedIdentity( + object, + writeTable, + toUpsert.id, + preflight.rivals ?? [], + tenantField ? toUpsert[tenantField] : undefined, + writeOptions, + ); + } + }; + + try { + if (checkAfterStatement && options?.transaction === undefined) { + // [#8807] No caller transaction, so the driver opens one: the check + // is only worth making if its failure can UNDO the write it judged, + // and an autocommitted statement is already permanent by the time the + // row can be read back. Scoped to the calls a check is owed on — + // `verifyIdentity`, and since #21185 every fenced MySQL call — so the + // ordinary upsert (SQLite, PostgreSQL, and a MySQL call with neither) + // keeps its single autocommitted round trip. + // + // Inside a caller transaction #8807's wrapper is deliberately NOT + // added: the statement is already transactional, and throwing hands + // the rollback decision to the owner of that transaction, exactly as + // the autonumber path above reasons about the same boundary. + await this.knex.transaction(async (trx) => { + await runStatement({ ...options, transaction: trx }); + }); + } else if (mysqlFence) { + // [#21185] …but the organization fence does not hand that decision + // over. A cross-organization merge is never the caller's to keep, so + // inside a caller transaction the statement and its check run in a + // nested transaction — a SAVEPOINT on MySQL — and the check's throw + // rolls back to it: the other organization's row is restored before + // the refusal reaches the caller, whatever the caller then does with + // its own transaction. + await (options!.transaction as Knex.Transaction).transaction(async (savepoint) => { + await runStatement({ ...options, transaction: savepoint }); + }); + } else { + await runStatement(options); + } + break; + } catch (error) { + // [#21185] The fence's refusal is final: it is not the server's error, + // so neither the unbacked-target recogniser nor the autonumber re-seed + // below has anything to say about it, and a retry would be refused the + // same way. The pair is this driver's own envelope; no dialect error + // carries it. + if (isUpsertFenceRefusal(error)) throw error; + // [#8445] Classified BEFORE the autonumber retry logic, for three + // reasons that all point the same way. It is not an autonumber + // collision — `collidingAutoNumberReservations` gates on + // `isUniqueViolationError`, and this error is not a unique violation: + // no index existed for anything to violate. + // + // ⚠️ [#8567] That gate does NOT agree, and this comment used to claim it + // did ("which is false for this error"). Measured on the real SQLite + // error: `isUniqueViolationError` returns TRUE, because SQLite's + // missing-index sentence ends `…PRIMARY KEY or UNIQUE constraint` and + // the shared vocabulary matches the word pair `unique constraint` + // wherever it appears. Filed as #8590. So the ORDERING here is + // load-bearing rather than merely tidy: move this line below the branch + // and the reservation probe really does run — querying the sequences + // table for a collision that cannot exist, then rethrowing the raw + // error. Recognising first is what makes that unreachable. + // + // It is also not transient — the same statement fails identically on + // every attempt, so a retry is a wasted round trip by construction. And + // it is the only placement that envelopes BOTH exits below: a refusal + // recognised after the branch would still escape raw whenever + // `mayRetry` is false. + if (isUnbackedConflictTargetError(error)) throw refuseUnbackedConflictTarget(object, mergeKeys, error); + if (!mayRetry || attempt >= AUTONUMBER_COLLISION_RETRIES) throw error; + const colliding = await this.collidingAutoNumberReservations(error, reservations, options); + if (colliding.length === 0) throw error; + for (const reservation of colliding) { + await this.resyncSequenceToDataMax(reservation); + // Clear only what collided, so the next pass regenerates exactly + // those fields — a reservation that did NOT collide keeps its value + // rather than burning a second number for nothing. (Safe here in a + // way it is NOT for a batch: one row cannot collide with itself. + // See {@link bulkCreate}.) + delete toUpsert[reservation.field]; + } + } + } + + // [#21166] Read back the row the statement landed on, by the identity it + // MATCHED on: the conflict-key values. Reading it back by `toUpsert.id` + // answers the wrong row on exactly the call #8622 protects: a merge on a + // business key keeps the stored row's `id`, so the payload's `id` (or the + // nanoid minted above) names no row, the read finds nothing, and the + // fallback below answered the PAYLOAD. Measured on SQLite and live + // Postgres 16: the row stored as `row-a` answered `id: 'row-NEW'`, an id + // no stored row has. The conflict-key values name the landed row on both + // legs: the inserted row carries them, and the merged row is the one that + // matched them. A conflict key the row leaves empty cannot have matched + // (NULL never conflicts), so the statement inserted and the row carries + // `toUpsert.id`. On the default `['id']` target the two readings are the + // same query. The tenant scope is applied to either, as before. + const matchedOn = mergeKeys.every((k) => sent[k] !== undefined && sent[k] !== null); + const readback = this.getBuilder(object, options); + if (matchedOn) for (const k of mergeKeys) readback.where(k, sent[k]); + else readback.where('id', toUpsert.id); + if (tenantGuard) { + // [#21185] A fenced call reads back under the WRITTEN organization + // EXACTLY: the chokepoint scope for that tenant (`tenantIds` dropped, as + // for #8807's one-row probe), plus equality on the tenant column, which + // closes the scope's NULL-organization arm. A row the fence left alone — + // another organization's, or a platform row with none — is therefore not + // found, and that absence IS the refusal: the statement wrote nothing + // (SQLite / PostgreSQL), or rolled back (MySQL, where the same read ran + // inside the transaction first). Scoping to the caller's ACTIVE org + // instead would miss a row an admin really wrote under another + // organization and refuse a correct write. + const writtenTenant = sent[tenantGuard.column] ?? tenantGuard.value; + this.applyTenantScope(readback, object, { ...options, tenantId: String(writtenTenant), tenantIds: undefined }); + readback.where(tenantGuard.column, writtenTenant); + const landed = await readback.first(); + if (!landed) throw refuseUpsertConflictOutsideWrittenOrganization(object, mergeKeys); + return this.formatOutput(object, landed) || landed; + } + this.applyTenantScope(readback, object, options); + const result = await readback.first(); + return this.formatOutput(object, result) || toUpsert; + } diff --git a/packages/drivers/driver-sql/src/sql-driver-sqlite-serialisation.ts b/packages/drivers/driver-sql/src/sql-driver-sqlite-serialisation.ts new file mode 100644 index 00000000000..f72f424e304 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-sqlite-serialisation.ts @@ -0,0 +1,531 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The "SQLite serialisation" section of `SqlDriver`: the + * bodies of `formatInput`, `formatOutput` moved out of `sql-driver.ts` whole by + * `scripts/codemod-split-sql-driver.mjs`. + * + * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * method of the same name exactly as it stood: not re-indented, not + * formatted, not edited. The method stays on the class with its docblock and + * signature, so the published `dist/*.d.ts`, an instance spy and a subclass + * override are what they were; the docblock there is the contract. + * + * The bodies reach the class through {@link SqliteSerialisationHost}: exactly the + * `SqlDriver` members they read. Each method hands its body one literal of the + * members that body reads, plus a `.call`: methods go as closures that call + * them on the driver at call time (the builder factory as a bound reference), + * so a subclass override or an instance spy is still the one reached; fields + * and accessors go as GETTERS, read when the body reads them, as they were on + * the class. + * + * The module imports `sql-driver.ts` for types only, so the two modules form + * no evaluation cycle. `index.ts` re-exports nothing from this module. + */ + +import { + AUDIT_TIMESTAMP_COLUMNS, canonicalTimeOfDay, normalizeSqliteDatetimeOutput, presentAuditTimestampOutput, +} from './sql-driver-top-level.js'; +import type { SqlDriver } from './sql-driver.js'; + +/** + * The `SqlDriver` members the functions below read — every one of them, and + * no other. Each is typed AS the class member (an indexed access on the + * class), so a change to a member's signature on the class is a change here + * too. `readonly` members are the getters. + */ +export interface SqliteSerialisationHost { + readonly booleanFields: SqlDriver['booleanFields']; + readonly columnFieldByObject: SqlDriver['columnFieldByObject']; + readonly dateFields: SqlDriver['dateFields']; + readonly datetimeFields: SqlDriver['datetimeFields']; + readonly fileColumnsMoved: SqlDriver['fileColumnsMoved']; + readonly isMysql: SqlDriver['isMysql']; + readonly isSqlite: SqlDriver['isSqlite']; + readonly jsonFields: SqlDriver['jsonFields']; + readonly mediaFields: SqlDriver['mediaFields']; + readonly numericFields: SqlDriver['numericFields']; + readonly numericValueFields: SqlDriver['numericValueFields']; + storageDatetimeValue: SqlDriver['storageDatetimeValue']; + readonly timeFields: SqlDriver['timeFields']; + toDateOnly: SqlDriver['toDateOnly']; + toTimeOnly: SqlDriver['toTimeOnly']; + readonly undeclaredAuditTimestampFields: SqlDriver['undeclaredAuditTimestampFields']; +} + +/** The body of `SqlDriver#formatInput`; the method's docblock there is its contract. */ +export function formatInput(this: Pick, object: string, data: any): any { + let copy: any = data; + let copied = false; + + // Insert/update-time safety net: any caller that passes the literal + // string 'NOW()' (often because a field defaultValue leaked unresolved) + // gets it replaced with a real ISO timestamp here, before it hits the + // wire. Applies to every driver, not just SQLite. + if (data && typeof data === 'object') { + const now = new Date().toISOString(); + for (const key of Object.keys(data)) { + const v = (data as any)[key]; + if (typeof v === 'string' && /^now\(\)$/i.test(v.trim())) { + if (!copied) { copy = { ...data }; copied = true; } + copy[key] = now; + } + } + } + + // ADR-0053: a `Field.datetime` is an instant stored as UTC. Make the STORAGE + // say so — collapse every accepted input shape (JS `Date`, epoch number, ISO + // string, zone-naive wall clock, bare calendar day) to one canonical + // `YYYY-MM-DDTHH:MM:SS.sssZ` before it hits the wire (#3912). + // + // This is the write half of the fix; `coerceFilterValue` applies the SAME + // function to comparands, so the two sides of a comparison can no longer + // disagree about shape. It runs on EVERY dialect, for two different reasons: + // - SQLite has no temporal type, so whatever is bound is what is stored. A + // `Date` landed as INTEGER epoch and a REST/JSON write as ISO TEXT, in + // the same column — the mixed storage that made every window filter + // return the wrong rows, and that still makes ORDER BY sort all INTEGER + // rows before all TEXT ones (#3928). + // - Postgres/MySQL do have one, but a zone-NAIVE string bound into it is + // interpreted in the SERVER's timezone, not UTC — measured at 8 hours off + // on an `Asia/Shanghai` server. Sending an explicit `Z` removes the + // server's timezone from the write path entirely. + const datetimeFields = this.datetimeFields[object]; + if (datetimeFields && datetimeFields.size > 0 && copy && typeof copy === 'object') { + for (const field of datetimeFields) { + const v = copy[field]; + if (v == null) continue; + // `NOW()` was already replaced with an ISO instant above; anything else + // that is not interpretable as a time passes through untouched. + const normalized = this.storageDatetimeValue(v); + if (normalized !== v) { + if (!copied) { copy = { ...copy }; copied = true; } + copy[field] = normalized; + } + } + } + + // [#21259] The same rule for a builtin audit timestamp the object does NOT + // declare (see {@link undeclaredAuditTimestampFields}). Its column is the + // `DATETIME(3)` / `timestamptz` / canonical-text column this driver created, + // and the engine's audit hook stamps it as `toISOString()` text, which + // MySQL refuses as a datetime literal. Measured on live MySQL 8.0.46: every + // insert into `sys_jwks` (declares `created_at` only) and `sys_member` was + // refused on `updated_at`, so no JWT signing key was ever stored. On SQLite + // and Postgres the hook's text is already the canonical form and is bound + // unchanged; a `Date` lands as that same text instead of an epoch INTEGER. + const undeclaredAudit = this.undeclaredAuditTimestampFields[object]; + if (undeclaredAudit && undeclaredAudit.length > 0 && copy && typeof copy === 'object') { + for (const field of undeclaredAudit) { + const v = copy[field]; + if (v == null) continue; + const normalized = this.storageDatetimeValue(v); + if (normalized !== v) { + if (!copied) { copy = { ...copy }; copied = true; } + copy[field] = normalized; + } + } + } + + // ADR-0053 Phase 1: a `Field.date` is a timezone-naive calendar day, not + // an instant. Collapse any `Date` or full-ISO value to `YYYY-MM-DD` before + // it hits the wire so storage matches the date-only contract the filter + // layer (`coerceFilterValue`) already enforces — the write/filter + // asymmetry was the root cause of the silent date-equality miss. + const dateFields = this.dateFields[object]; + if (dateFields && dateFields.size > 0 && copy && typeof copy === 'object') { + for (const field of dateFields) { + const v = copy[field]; + if (v == null) continue; + const normalized = this.toDateOnly(v); + if (normalized !== v) { + if (!copied) { copy = { ...copy }; copied = true; } + copy[field] = normalized; + } + } + } + + // #3994: a `Field.time` is a wall-clock time-of-day. Collapse every accepted + // input shape (bare `HH:MM[:SS[.fff]]`, JS `Date`, epoch number, full ISO or + // zone-naive timestamp) to the canonical `HH:MM:SS[.fff]` before it hits the + // wire — the write half of the same fix `coerceFilterValue` applies to + // comparands. On SQLite this ends the mixed TEXT/INTEGER storage that broke + // window filters and ORDER BY; on Postgres/MySQL it turns shapes the native + // TIME type rejects outright (full ISO — measured failing on both) or + // resolves against the process's local timezone (a bound `Date` on pg) into + // the one literal every dialect parses the same way. + const timeFields = this.timeFields[object]; + if (timeFields && timeFields.size > 0 && copy && typeof copy === 'object') { + for (const field of timeFields) { + const v = copy[field]; + if (v == null) continue; + const normalized = canonicalTimeOfDay(v); + if (normalized !== v) { + if (!copied) { copy = { ...copy }; copied = true; } + copy[field] = normalized; + } + } + } + + // ── JSON field serialisation: ONE encoding, every dialect (commit 4045b954d) ────── + // + // `JSON.stringify` unconditionally, which is what Postgres and MySQL have + // always done here. This DELETES the SQLite branch rather than adding one, + // and the reason is the declared contract, not strictness: `json`'s stored + // contract is `z.unknown()` (`spec/src/data/field-value.zod.ts`, "openness + // is now an explicit decision, not an accident of nobody checking"), so + // `123` the number and `'123'` the string are BOTH legal values of one + // field and no driver has license to collapse them onto one representation. + // + // What the deleted branch did, measured on live SQLite/PG 16.13/MySQL + // 8.0.46 before this change (PG and MySQL: 17/17 faithful; SQLite: 13/17 + // type-changed). It stored a non-object AS-IS, which broke the round trip + // through two INDEPENDENT mechanisms: + // + // 1. READ-side. `formatOutput` `JSON.parse`s every string in a json + // column, so a stored string whose CONTENT is valid JSON came back + // type-changed: `'true'` → boolean, `'null'` → null, `'[]'` → array, + // `'{"a":1}'` → object. Reversible — the bytes were still on disk. + // 2. WRITE-side, and NOT reversible. The column is declared type `json`, + // which contains none of `INT`/`CHAR`/`CLOB`/`TEXT`/`BLOB`/`REAL`/ + // `FLOA`/`DOUB`, so SQLite's affinity rules fall through to **NUMERIC** + // — and a bound number-like string is converted to INTEGER/REAL BEFORE + // storage. `'0123'`, `' 123 '`, `'1e5'`, `'1.0'` and `'-0'` were + // destroyed on disk, indistinguishable from the numbers they became. + // + // Stringifying closes BOTH without touching the DDL, because the encoded + // form of a string carries its quotes (`'123'` → `"123"`) and `"123"` is + // not a well-formed numeric literal, so NUMERIC affinity leaves it TEXT. + // Measured, not reasoned — see `sql-driver-12380-json-roundtrip.test.ts`. + // + // The on-disk delta for NEW writes is therefore exactly two classes: + // strings (now quoted) and booleans (now TEXT `true`/`false` instead of + // INTEGER 1/0, which is mechanism (3) the read path could never repair — + // `formatOutput`'s `booleanFields` pass is keyed to declared + // `Field.boolean` COLUMNS, not to booleans living inside a json payload). + // Objects, arrays and null are byte-identical to before, and so are + // numbers: `123` bound as a number and `"123"` bound as text both land as + // INTEGER 123 under NUMERIC affinity. Existing rows are converged by + // {@link backfillCanonicalJsonEncoding}. + const jsonFields = this.jsonFields[object]; + if (jsonFields && jsonFields.length > 0) { + for (const field of jsonFields) { + if (copy[field] === undefined || copy[field] === null) continue; + if (!copied) { copy = { ...copy }; copied = true; } + copy[field] = JSON.stringify(copy[field]); + } + } + + // ── ADR-0104: the MOVED media arm ─────────────────────────────────────── + // + // On a deployment whose media columns have moved, a single-value media + // field is NOT in `jsonFields` (see {@link isJsonField}), so the loop above + // did not touch it and the id reaches the wire BARE — which is the whole + // point of the ruling: the column holds the actual `sys_file` id. + // + // What is left to do is the one value shape the moved column cannot hold. + // A legacy INLINE metadata object can still be handed to a write on a + // deployment that admits lax media values (`OS_ALLOW_LAX_MEDIA_VALUES`), + // and binding a plain object to a `varchar` column is a raw driver-level + // TypeError on SQLite and an unusable `[object Object]` on the server + // dialects. Serialising it degrades that write to a stored string — the + // SAME safety-net posture the SQLite arm below has always taken, applied + // on every dialect because after the move every dialect holds this family + // in a character column. `formatOutput`'s media pass reads it straight back + // as the object it was, so the round trip is unchanged for that population. + if (this.fileColumnsMoved) { + const mediaFields = this.mediaFields[object]; + if (mediaFields && mediaFields.length > 0) { + for (const field of mediaFields) { + const v = copy[field]; + if (v === undefined || v === null || typeof v === 'string') continue; + if (!copied) { copy = { ...copy }; copied = true; } + copy[field] = JSON.stringify(v); + } + } + } + + if (!this.isSqlite) return copy; + + // Safety net: better-sqlite3 can only bind numbers/strings/bigints/buffers/ + // null. Any value still an array or plain object here (a field type not + // classified as JSON, a `Field.multiple` we didn't catch, or an ad-hoc + // payload) would otherwise throw a raw TypeError mid-insert. Serialize it + // to JSON so the write degrades to a stored string instead of a 500. + for (const key of Object.keys(copy)) { + const v = copy[key]; + if (v !== null && typeof v === 'object' && !(v instanceof Date) && !Buffer.isBuffer(v)) { + if (!copied) { copy = { ...copy }; copied = true; } + copy[key] = JSON.stringify(v); + } + } + return copy; + } + +/** The body of `SqlDriver#formatOutput`; the method's docblock there is its contract. */ +export function formatOutput(this: Pick, object: string, data: any): any { + if (!data) return data; + + // External columnMap (ADR-0015): rename physical remote-column keys to local + // field names BEFORE coercion (which is keyed by local field). No-op for + // managed objects and external objects without a columnMap. + const colToField = this.columnFieldByObject[object]; + if (colToField && typeof data === 'object') { + for (const [remoteCol, localField] of Object.entries(colToField)) { + if (remoteCol !== localField && Object.prototype.hasOwnProperty.call(data, remoteCol)) { + // Explicit columnMap wins: the remote column is the source of truth for + // this local field, even if a same-named native column also exists. + data[localField] = data[remoteCol]; + delete data[remoteCol]; + } + } + } + + if (this.isSqlite) { + // The exact inverse of `formatInput`'s `JSON.stringify` (commit 4045b954d). Postgres + // and MySQL need no arm here because their clients already parse a native + // `json`/`jsonb` column; SQLite hands back the stored TEXT, so the driver + // parses it. One codec, three dialects, same answer. + // + // Only strings are parsed, and that is load-bearing rather than + // incidental: a json NUMBER is stored under NUMERIC affinity as INTEGER/ + // REAL and comes back as a JS number, which is already the value that was + // written — parsing is neither possible nor needed for it. + // + // ── Why the catch survives, and what it now means ─────────────────────── + // + // Nothing this driver writes can reach it: every new value on disk is the + // output of `JSON.stringify`, so it parses by construction. It is the + // READ-SIDE REPAIR for rows written before commit 4045b954d — a pre-fix plain string + // was stored raw (`America/New_York`), and re-quoting it is exactly what + // {@link backfillCanonicalJsonEncoding} does on the next `syncSchema`. + // Keeping it here is the same posture `backfillCanonicalDatetimes` takes: + // correctness must NEVER be contingent on a migration having run, so an + // un-migrated (or un-migratable, e.g. `skipSchemaSync`) deployment reads a + // legacy plain string back as that same string — byte-identical to what it + // read before this change — instead of throwing mid-row. + // + // ⚠️ It cannot repair the two classes the pre-fix encoding made ambiguous, + // and must not pretend to: a legacy TEXT cell that DOES parse (a stored + // object, or a stored string whose content was valid JSON) and a legacy + // INTEGER/REAL cell (a number, a boolean, or a number-like string eaten by + // NUMERIC affinity) are collisions already resolved on disk. Those rows + // read exactly as they read before this change — the class stops growing; + // it is not retroactively repaired. See the decision recorded in commit 4045b954d's message. + const jsonFields = this.jsonFields[object]; + if (jsonFields && jsonFields.length > 0) { + for (const field of jsonFields) { + if (data[field] !== undefined && typeof data[field] === 'string') { + try { + data[field] = JSON.parse(data[field]); + } catch { + // Row from before commit 4045b954d: keep the raw string, which IS its value. + } + } + } + } + + } + + // ── ADR-0104: the media family reads BOTH encodings, on every dialect ──── + // + // The addendum's dual-encoding window requires the driver to read a bare id + // and a legacy JSON-quoted id throughout, on every dialect. SQLite already + // did — its `jsonFields` pass above parses a quoted cell and its `catch` + // keeps a bare one, which is why the gap was never a three-dialect one — + // but Postgres and MySQL had no arm at all: MEASURED on live PG 16.13, a + // JSON-quoted id sitting in a `varchar` column reads back WITH ITS QUOTES. + // That column is not hypothetical: `os generate migration --format sql` + // emits `VARCHAR(2048)` for this family, so a generator-built deployment on + // a server dialect has been storing `"file_01HXYZ"` and handing it back + // verbatim (#15771's silent corruption, reproduced on a live cell). + // + // ⚠️ Runs on BOTH arms, and it is a REPAIR rather than an arm: + // + // - moved deployment — the cell is bare, no leading delimiter, no-op; + // a cell step 3 has not converted yet still reads correctly. + // - unmoved, json column — the client (or the pass above) already parsed + // it, so the value here is not a string, no-op. + // - unmoved, character column — the corrupt population above. THIS is + // the only place the pass changes an answer, and the answer it changes + // it to is the id that was written. + // + // ⛔ The delimiter test is load-bearing and must not be relaxed to "try + // parsing everything": a `sys_file` id is word characters and `-` + // (`isFileIdToken`), so an all-digit id would `JSON.parse` to a NUMBER and + // `null` / `true` / `false` to non-strings. Only a leading `"`, `{` or `[` + // can be a JSON encoding of a media value, and none of the three can begin + // an id, a resolver URL (`https:` / `/api/` / `data:` / `blob:`) or a + // `data:` URI. A cell that starts with one and does NOT parse keeps its raw + // string, exactly as the SQLite arm above does. + const mediaFields = this.mediaFields[object]; + if (mediaFields && mediaFields.length > 0) { + for (const field of mediaFields) { + const v = data[field]; + if (typeof v !== 'string' || v.length === 0) continue; + const head = v[0]; + if (head !== '"' && head !== '{' && head !== '[') continue; + try { + data[field] = JSON.parse(v); + } catch { + // Not an encoding after all — the raw string IS its value. + } + } + } + + // Numeric scalars handed back as STRINGS are coerced to numbers, on EVERY + // dialect, so the declared type wins regardless of which dialect stored the + // value or when the column was created. Only strings are touched — a + // REAL/INTEGER column already yields a number — and a genuinely non-numeric + // value (junk legacy data) is left intact rather than turned into NaN. + // See NUMERIC_SCALAR_TYPES. + // + // ⚠️ [#16318] This pass was SQLite-only until the numeric family moved to an + // exact-decimal column, on the stated premise that string-valued numerics + // "exist for legacy TEXT-affinity columns, which no other dialect has". + // That premise is now false and it is THIS change that falsified it, so it + // is corrected here rather than left as a fossil: measured on live + // PostgreSQL 16.13, node-postgres parses `real` to a JS `number` and + // `numeric` to a STRING (`1234567.89` arrives as + // `'1234567.890000000000000000000000000000'`), and mysql2 does the same for + // `DECIMAL`. Without this line every `number` / `currency` / `percent` / + // `slider` / `progress` / `summary` field would start reading back as a + // string on the two server dialects — a wire-contract break, since + // `valueSchemaFor` gives the whole class `z.number().finite()`. + // + // Two SQLite readings stay exactly as they were: the legacy TEXT-affinity + // repair this pass was written for, and a fresh column, which knex declares + // `float` for both the old float arm and the new decimal one. + // + // ⚠️ [#16318] The registry is chosen PER DIALECT, and the narrowing is the + // "new tables only" bound, not a taste. `numericFields` carries the + // driver-internal aliases `integer` / `int` / `float`, which is how an + // EXTERNAL, introspected table's columns reach this driver. A PostgreSQL + // `bigint` is handed back by node-postgres as a STRING precisely because it + // does not fit a JS double, so running it through `Number()` would silently + // round it above 2^53 — on a table this change never created. SQLite is the + // one dialect where the wider set is right, because there the pass exists + // for legacy TEXT-affinity columns of exactly those alias types. + // + // ⚠️ The repair is bounded by the wire contract it restores, and that bound + // is binary64: `valueSchemaFor` gives this whole class `z.number().finite()` + // (ADR-0104 D1), so a `find()` result is a JS double however exact the + // COLUMN is. Measured: a value the driver itself wrote from a JS number + // round-trips exactly ('1234567.890000000000000000000000000000' → 1234567.89), + // because the shortest representation is what was stored; a value that was + // never a double does not ('1234567890123456.123' → 1234567890123456, + // 2^53+1 → 2^53). ⇒ the exactness this change buys is exact-column-through- + // a-double: SQL-side writers, `summary` roll-ups computed in SQL and any + // magnitude at or above 2^53 are bounded by the read seam, not by the + // column. Widening that is a wire-contract change and is NOT in #16318. + const numericFields = this.isSqlite + ? this.numericFields[object] + : this.numericValueFields[object]; + if (numericFields && numericFields.length > 0) { + for (const field of numericFields) { + const v = data[field]; + if (typeof v === 'string' && v.trim() !== '') { + const n = Number(v); + if (!Number.isNaN(n)) data[field] = n; + } + } + } + + // [ADR-0053 D-F1] (#13973) — the two instant classes present as ONE shape + // on EVERY dialect: the canonical `YYYY-MM-DDTHH:MM:SS.sssZ` text. Both + // loops below sat inside the `if (this.isSqlite)` arm above until #13973, + // and that gate WAS the divergence the card's census measured: on + // Postgres and MySQL the client library's `Date` left this door as-is, + // while SQLite handed out text — one value, two runtime types, through the + // same `any`-shaped record, invisible to the type system. Eight consumers + // were wrong under it (#13382 in production, #13993–#13999 by reading), + // all in the direction "expected the text, received a `Date`", none the + // reverse. The client parsers are NOT touched ([ADR-0053 D-F2]); the + // driver canonicalises at its own read boundary, here and in + // `presentReadValue` for the `aggregate()`/`distinct()` doors. + + // Builtin audit timestamps. On SQLite: repair any legacy/raw row stored as + // a zone-naive, space-separated string (CURRENT_TIMESTAMP or the pre-fix + // UPDATE stamp) to canonical ISO-8601 with `Z`, idempotent on + // already-canonical values (ADR-0074). On Postgres/MySQL: fold the + // client's `Date`. See `presentAuditTimestampOutput`. + for (const col of AUDIT_TIMESTAMP_COLUMNS) { + if (data[col] !== undefined) data[col] = presentAuditTimestampOutput(data[col]); + } + + // Present every `Field.datetime` value as one canonical instant — + // ISO-8601 with an explicit `Z` — regardless of its on-disk storage form. + // A SQLite `datetime` column mixes forms: an explicit value bound as a JS + // `Date` is stored as INTEGER epoch ms, while a `defaultValue: 'NOW()'` + // slot is TEXT (canonical ISO-`Z` post-fix, or a legacy timezone-naive + // `CURRENT_TIMESTAMP` string). Without this, reads leak the raw integer or + // a zone-naive string that `Date.parse` mis-reads as LOCAL time. Folds all + // shapes to UTC ISO-`Z` and transparently repairs legacy rows with no data + // migration — mirroring the `Field.date`/numeric read-repairs above and + // the audit-column repair just above. A Postgres/MySQL `Date` takes the + // same fold. See `normalizeSqliteDatetimeOutput`. + const datetimeFields = this.datetimeFields[object]; + if (datetimeFields && datetimeFields.size > 0) { + for (const field of datetimeFields) { + if (data[field] !== undefined) { + data[field] = normalizeSqliteDatetimeOutput(data[field]); + } + } + } + + // [#11782] Present a declared `Field.boolean` as a JSON boolean on the + // dialects whose STORAGE form is a number: SQLite (INTEGER 0/1) and MySQL + // (`tinyint(1)`, which mysql2 hands back as a JS number). Postgres stores a + // real `boolean` and node-pg already parses it, so its stored form IS the + // presented form and it deliberately stays outside the gate — the same + // per-dialect posture {@link readPresentationKind} takes for the read doors + // that return raw builder output (`distinct`; `aggregate` tracks its own + // result columns per #11635). Before this, the gate was SQLite-only and a + // declared boolean answered `1`/`0` on MySQL's row-read door while + // answering `true`/`false` on the other two dialects — and, once #11635 + // presented `min`/`max` everywhere, `find()` and `aggregate()` gave + // OPPOSITE answers for the same column on the same MySQL connection. + if (this.isSqlite || this.isMysql) { + const booleanFields = this.booleanFields[object]; + if (booleanFields && booleanFields.length > 0) { + for (const field of booleanFields) { + if (data[field] !== undefined && data[field] !== null) { + data[field] = Boolean(data[field]); + } + } + } + } + + // ADR-0053 Phase 1: present `Field.date` as a timezone-naive `YYYY-MM-DD` + // string, slicing any stored time component. This transparently repairs + // legacy rows written as a full timestamp before this normalization, so + // date-equality works without a data migration. Runs for every dialect. + const dateFields = this.dateFields[object]; + if (dateFields && dateFields.size > 0) { + for (const field of dateFields) { + const v = data[field]; + if (v == null) continue; + const normalized = this.toDateOnly(v); + if (normalized !== v) data[field] = normalized; + } + } + + // Present `Field.time` as the canonical wall-clock time-of-day (#2004, + // #3994) — the same `canonicalTimeOfDay` the write and filter paths apply, + // so storage, comparand and presentation are one shape. On read this + // transparently repairs legacy rows (full-timestamp text from the old + // `CURRENT_TIMESTAMP` default, epoch ms from a bound `Date`) with no data + // migration, and re-pads the fraction Postgres trims (`.5` → `.500`). Runs + // for every dialect. See `toTimeOnly`. + const timeFields = this.timeFields[object]; + if (timeFields && timeFields.size > 0) { + for (const field of timeFields) { + const v = data[field]; + if (v == null) continue; + const normalized = this.toTimeOnly(v); + if (normalized !== v) data[field] = normalized; + } + } + + return data; + } diff --git a/packages/drivers/driver-sql/src/sql-driver-top-level.ts b/packages/drivers/driver-sql/src/sql-driver-top-level.ts new file mode 100644 index 00000000000..5d0b4f62e39 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-top-level.ts @@ -0,0 +1,3999 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [#29520 codemod] The top-level declarations ahead of `SqlDriver` in +// `sql-driver.ts` that moved out whole by +// `scripts/codemod-split-sql-driver.mjs` (lever (b) of ruling 6591753301). +// +// A MOVE, not a redesign: each declaration below is its `sql-driver.ts` text +// exactly, with the comments above it, in the order it stood there. A LEAF: +// it imports neither `sql-driver.ts` nor any `sql-driver-*` module, so it is +// evaluated before the class and forms no cycle with it. `sql-driver.ts` +// re-exports, by name, every declaration here it exported before, so every +// importer of `./sql-driver.js` reads what it read. + +import { ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, AggregationFunction, NUMERIC_VALUE_TYPES, RETIRED_FILTER_OPERATORS, hasDanglingLikeEscape, hasNulInLikePattern, isAcceptedFilterComparand, isListComparandOperator, isMultiValueField, isNowDefaultToken, reduceFilterKeyVerdict, reduceFilterVerdict } from '@objectstack/spec/data'; +import type { FilterVerdictHooks, SchemaMode, FilterVerdict as SharedFilterVerdict, ValueShapeFieldDef } from '@objectstack/spec/data'; +import type { DriverQuery, IntrospectedColumn as SpecIntrospectedColumn, IntrospectedSchema as SpecIntrospectedSchema, IntrospectedTable as SpecIntrospectedTable } from '@objectstack/spec/contracts'; +import { jsonColumnOperatorRefusalText, temporalStorageForm } from '@objectstack/core'; +import type { JsonColumnFieldClass } from '@objectstack/core'; +import { StandardErrorCode } from '@objectstack/spec/api'; +import type { PhysicalIndex } from './schema-drift.js'; +import { probeAbsentSqliteDatabaseFile } from './sqlite-database-file.js'; +import type { Knex } from 'knex'; +import { AsyncLocalStorage } from 'node:async_hooks'; + +/** + * [#20768] The async scope of a driver's own PRE-DDL question: the ADR-0104 + * media-arm resolver that {@link SqlDriver.resolveFileColumnsMoved} asks at the + * start of the first `initObjects`, before this driver has created any table. + * + * The resolver the engine supplies answers by reading `sys_migration`. On the + * first boot of a new database that table does not exist yet, because this + * very schema pass is what creates it. The backend refuses the read, and the + * resolver answers "not moved", which is the correct answer for a store with + * no columns. So the refusal is the ordinary state of a new database, and not + * a `DATABASE_ERROR` to put in front of an operator. + * + * Two questions read through this scope — that one, and the ADR-0131 D1 + * certification {@link SqlDriver.isTenantKeyCertified} asks of the same + * ledger — and only {@link SqlDriver.backendStatementFault} consults it. ⛔ It demotes one class, + * a missing table, recognised by the shared `isMissingTableError`. Any other + * refusal inside the scope still warns, and so does a missing table outside it. + * + * Module-level rather than per instance: the read the question issues goes to + * whichever driver serves the ledger. On a multi-datasource composition that + * can be another instance of this class, and it is in the same async chain. + */ +export const PRE_DDL_QUESTION_SCOPE = new AsyncLocalStorage(); + +/** + * [ADR-0131 D1] The `WHERE` of a unique key's duplicate probe: every plain key + * part NOT NULL, so the probe groups only the rows the index constrains (SQL + * UNIQUE is NULL-distinct). Empty when every part is NULL-safe. The Turso + * remote face spells the same clause with its own identifier quoting. + */ +function uniqueProbeNotNullClause( + columns: readonly string[], + nullSafe: ReadonlySet, + quote: (identifier: string) => string, +): string { + const plain = columns.filter((c) => !nullSafe.has(c)); + return plain.length === 0 ? '' : ` WHERE ${plain.map((c) => `${quote(c)} IS NOT NULL`).join(' AND ')}`; +} + +/** + * Default ID length for auto-generated IDs. + */ +const DEFAULT_ID_LENGTH = 16; + +/** + * [#21385] What every refusal line in this file knows about the dialect text it + * logs: a statement THIS driver sent raised it, so a statement may lead that + * text whatever word it opens with. The shared cut then runs without asking the + * leak predicate, exactly as it does at the engine's raw-statement door. + */ +export const OWN_STATEMENT_FAULT = { statementSent: true } as const; + +// ── Raw index DDL the driver executes on the framework's behalf (#4884) ────── +/** An SQL identifier in any dialect's quoting, or bare. */ +const SQL_IDENTIFIER = '(?:`[^`]+`|"[^"]+"|\\[[^\\]]+\\]|[A-Za-z_][A-Za-z0-9_$]*)'; +/** Cheap prefilter so ordinary `execute()` traffic never touches the parsers below. */ +export const INDEX_DDL_PREFIX = /^\s*(?:create|drop)\s+(?:unique\s+)?index\b/i; +export const CREATE_INDEX_DDL = new RegExp( + `^\\s*create\\s+(?:unique\\s+)?index\\s+(?:concurrently\\s+)?(?:if\\s+not\\s+exists\\s+)?` + + `(${SQL_IDENTIFIER})\\s+on\\s+(${SQL_IDENTIFIER}(?:\\.${SQL_IDENTIFIER})?)`, + 'i', +); +export const DROP_INDEX_DDL = new RegExp( + `^\\s*drop\\s+index\\s+(?:concurrently\\s+)?(?:if\\s+exists\\s+)?` + + `(${SQL_IDENTIFIER}(?:\\.${SQL_IDENTIFIER})?)`, + 'i', +); + +/** Peel one layer of `"…"` / `` `…` `` / `[…]` quoting off an identifier. */ +export function unquoteSqlIdentifier(raw: string): string { + const s = raw.trim(); + if (s.length >= 2) { + const first = s[0]; + const last = s[s.length - 1]; + if ((first === '"' && last === '"') || (first === '`' && last === '`') || (first === '[' && last === ']')) { + return s.slice(1, -1); + } + } + return s; +} + +/** + * The unquoted final segment of a possibly schema-qualified reference — + * `"public"."sys_metadata"` → `sys_metadata`. Tokenised rather than split on + * `.`, so a dot INSIDE a quoted identifier does not become a separator. + */ +export function lastIdentifierSegment(raw: string): string { + const parts = raw.match(new RegExp(SQL_IDENTIFIER, 'g')); + return unquoteSqlIdentifier(parts && parts.length > 0 ? parts[parts.length - 1] : raw); +} + +/** + * Internal table that persists per-(object, tenant, field) auto-number + * counters so sequences are monotonic, tenant-isolated, and resilient to + * concurrent writers. Lazily created on first autonumber-bearing insert. + */ +export const SEQUENCES_TABLE = '_objectstack_sequences'; + +/** + * How many times `create()` will re-seed a stale autonumber counter and retry + * a refused INSERT before giving the unique violation back to the caller + * (#5495). + * + * One is enough for the defect this bounds: a re-seed jumps the counter to the + * observed MAX in a single step, so the whole "one-time storm per database" + * collapses to one retry no matter how far behind the counter had fallen. The + * remaining budget is for concurrent writers taking the re-seeded number + * between our scan and our INSERT, which is a per-attempt race rather than a + * standing condition. The bound is what keeps a genuinely un-satisfiable insert + * from looping: at exhaustion the original error is rethrown unchanged. + */ +const AUTONUMBER_COLLISION_RETRIES = 3; + +/** + * One autonumber value this driver generated for a row it is about to insert + * (#5495) — the field, the value, and the coordinates under which that value's + * counter would be re-seeded. + * + * Its reason for existing is the distinction between "a number this driver + * handed out" and "a value the caller typed": only the former may be + * regenerated after a unique violation, and only the latter's 409 belongs to + * the client. + */ +export interface AutoNumberReservation { + /** The `auto_number` field on the object. */ + field: string; + /** Physical table the value lives in (external objects map away from the object name). */ + tableName: string; + /** Full rendered prefix — the LIKE anchor the re-seed scan uses. */ + prefix: string; + /** Rendered counter scope (date/`{field}` tokens); `''` for a fixed-prefix format. */ + scope: string; + /** Rendered text after the counter slot (#6468). */ + suffix: string; + /** Tenant column for this object, or `null` when it has none. */ + tenantField: string | null; + /** Tenant this row belongs to, or `null` for the global counter. */ + tenantId: string | null; + /** The rendered value written onto the row. */ + value: string; +} + +/** + * Field types whose value is a numeric scalar. SINGLE SOURCE for the DDL + * column-type switch (these map to INTEGER/REAL columns) and the read-side + * coercion registry (`numericFields`). + * + * ⚠️ [#16318] The read coercion no longer reads this whole set on every + * dialect. The three ALIASES below are how an external, introspected column + * reaches the driver, and on PostgreSQL a `bigint` arrives as a STRING; the + * server-dialect arm therefore reads {@link SqlDriver.numericValueFields}, the + * authorable `NUMERIC_VALUE_TYPES` half, and SQLite keeps this full set for the + * legacy TEXT-affinity repair described below. The DDL switch is unchanged. + * + * The read coercion exists so the fix is robust on SQLite even when the column + * predates it: a `rating`/`slider`/`progress` column created before #2025 has + * TEXT affinity and returns '4' not 4, and SQLite never alters a column's type + * in-place (the reconciler only ADDS columns). Coercing numeric-looking strings + * back to numbers on read transparently repairs those legacy rows — mirroring + * how `dateFields` repairs legacy timestamp-typed `Field.date` rows — so the + * type fidelity no longer depends on column affinity alone. `toggle`/`record` + * already self-heal this way via `booleanFields`/`jsonFields`; this closes the + * gap for the numeric scalars. + */ +export const NUMERIC_SCALAR_TYPES = new Set([ + // Spec numeric value class (ADR-0104 D1) + driver-internal SQL aliases. + ...NUMERIC_VALUE_TYPES, + 'integer', 'int', 'float', +]); + +// [#20387, #21042] Whether a column holds FRACTIONS — the exact-decimal members +// of `NUMERIC_COLUMN_REPRESENTATION` and this driver's `float` alias — is the +// `'fractional'` class of `aggregandColumnClass` (`@objectstack/core`), the one +// predicate the analytics native-SQL face asks too. Read into +// {@link SqlDriver.fractionalNumericFields}. + +/** + * The builtin audit-timestamp columns every managed object carries. They are + * stamped to a single canonical instant format on SQLite (see + * `stampInsertTimestamps`/`update`) and presented on read — on EVERY dialect — + * as that same canonical instant text by {@link presentAuditTimestampOutput} + * ([ADR-0053 D-F1], #13973). + */ +export const AUDIT_TIMESTAMP_COLUMNS = ['created_at', 'updated_at'] as const; + +/** + * [#21241] The fractional-seconds precision of every MySQL `DATETIME` column + * this driver creates, and therefore of every server-clock expression that + * defaults or stamps one. `DATETIME(3)` keeps the canonical instant's + * milliseconds (#3942). + * + * ONE source, read by every site that spells it: the declared `Field.datetime` + * column ({@link SqlDriver.createColumn}), the builtin audit columns + * ({@link SqlDriver.createAuditTimestampColumn}), the `NOW()` column default + * ({@link SqlDriver.nowColumnDefault}), the UPDATE stamp + * ({@link SqlDriver.updatedAtStamp}) and the legacy `TIMESTAMP` widening + * ({@link SqlDriver.migrateMysqlDatetimeColumns}). + * + * A column and its default cannot disagree, and that is not a style point: + * MySQL refuses a `CURRENT_TIMESTAMP` default whose precision differs from its + * `DATETIME` column's (`ER_INVALID_DEFAULT`, "Invalid default value for …"), + * so the whole `CREATE TABLE` fails. The audit columns carried `now(3)` beside + * their `DATETIME(3)` as a second literal, and the declared-field default fell + * through to a bare `knex.fn.now()` beside the same `DATETIME(3)` column. + * Measured on MySQL 8.0.46: every table declaring a `defaultValue: 'NOW()'` + * datetime field (`sys_activity.timestamp`, `sys_presence.last_seen`) was + * never created, and its data door answered `500`. + */ +export const MYSQL_DATETIME_PRECISION = 3; + +/** + * Read-side repair for the builtin audit timestamps on SQLite. + * + * SQLite has no native timestamp type. Rows written before the canonical-format + * fix — or by a raw insert that fell back to the `CURRENT_TIMESTAMP` column + * default — hold a timezone-NAIVE, space-separated string + * (`'YYYY-MM-DD HH:MM:SS[.fff]'`). `Date.parse` reads such a zone-less string as + * LOCAL time, so a stored UTC wall-clock silently shifts by the host offset on + * any non-UTC runtime — the bug that made the objectos freshness probe never + * evict. Re-emit those values as canonical ISO-8601 with an explicit `Z`, + * interpreting the stored wall-clock as UTC (exactly what `CURRENT_TIMESTAMP` + * and the legacy UPDATE stamp both wrote). + * + * Idempotent and total: a value that already carries an explicit zone (`…Z` or + * `±HH:MM`) is returned unchanged, so re-reading a normalised row is a no-op + * (this keeps optimistic-lock `updated_at` tokens stable — see + * objectql `assertVersionMatch`). Non-strings (e.g. a `Field.datetime`-typed + * audit column stored as epoch-ms INTEGER) and unrecognised shapes pass + * through untouched. + */ +function repairNaiveUtcAuditTimestamp(value: unknown): unknown { + if (typeof value !== 'string') return value; + const s = value.trim(); + if (s === '') return value; + // Already zone-explicit (`…Z` or `±HH:MM`) — leave as-is (idempotent). + if (/[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return value; + // Zone-naive `YYYY-MM-DD[ T]HH:MM:SS[.fff]` → interpret the wall-clock as UTC. + const m = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/.exec(s); + if (!m) return value; + const d = new Date(`${m[1]}T${m[2]}Z`); + return Number.isNaN(d.getTime()) ? value : d.toISOString(); +} + +/** + * Fold a client library's `Date` — what node-pg materialises a `timestamptz` + * as and what mysql2 materialises a `DATETIME(3)` as — into the canonical + * instant text, `YYYY-MM-DDTHH:MM:SS.sssZ`. + * + * Total in the sense #14078 ruled for the shared consumer spelling: the ONE + * `Date` shape `toISOString()` refuses — an Invalid `Date`, whose time value + * is `NaN` — is handed back UNCHANGED rather than thrown on. Both live + * dialects are measured to produce it from rows that are already on disk (a + * MySQL zero `DATETIME`; a Postgres year past 275760, inside the server's + * range and outside JS's — `sql-driver-14078-invalid-date-materialisation.test.ts`), + * and it has no canonical text to fold to. It is neither nulled (that erases a + * stored value silently) nor spelled as the text `Invalid Date` (that would + * change the wire, where `JSON.stringify` already serialises it as `null`); it + * leaves as the client's `Date`, which is exactly the shape the ruled-B + * consumer arms (`canonicalIsoInstant` and kin) already absorb. + * [ADR-0053 D-F3]. + */ +function isoFromValidDate(value: Date): unknown { + return Number.isNaN(value.getTime()) ? value : value.toISOString(); +} + +/** + * What a record read door hands out for a builtin audit timestamp, on every + * dialect ([ADR-0053 D-F1], #13973): the canonical instant text. + * + * Two arms, one per shape the dialects actually produce. A `Date` — the + * client-level materialisation on Postgres (`timestamptz`) and MySQL + * (`DATETIME(3)`), which the driver deliberately does NOT alter at the client + * parser ([ADR-0053 D-F2]; see {@link SqlDriver.withPostgresCalendarDayAsText}) + * — is folded by {@link isoFromValidDate}. A string — SQLite's TEXT, canonical + * post-ADR-0074 or a legacy zone-naive `CURRENT_TIMESTAMP` — takes ADR-0074's + * {@link repairNaiveUtcAuditTimestamp}, which is idempotent on the canonical + * form. Every other shape passes through untouched, exactly as before. + * + * Before #13973 the string arm ran inside `if (this.isSqlite)` and the `Date` + * arm did not exist, so the two live dialects handed the client's `Date` + * through the read door — the divergence the #13973 census measured eight + * consumers to be wrong under (#13382 in production, #13993–#13999 by reading), + * every one in the direction "expected the text, received a `Date`". + */ +function presentAuditTimestampOutput(value: unknown): unknown { + if (value instanceof Date) return isoFromValidDate(value); + return repairNaiveUtcAuditTimestamp(value); +} + +/** + * Whether a field's `defaultValue` is the framework's `'NOW()'` convention + * ("use the database clock at insert time"). Case-insensitive, whitespace + * tolerant. + * + * Thin alias over the spec's {@link isNowDefaultToken} — the token vocabulary + * itself lives in `@objectstack/spec/data` so the engine's insert-time + * resolution and this driver's DDL read one set (#4560). + */ +export const isNowDefaultValue = isNowDefaultToken; + +/** + * Read-side normalization for user-declared `Field.datetime` columns on SQLite. + * + * SQLite has no native timestamp type, so one `datetime` column can hold MIXED + * storage: + * - an explicitly-written value bound through better-sqlite3 as a JS `Date` + * lands as INTEGER epoch milliseconds; + * - a value left to a `defaultValue: 'NOW()'` column default lands as TEXT — + * canonical ISO-8601-`Z` for columns created after this fix + * (`SqlDriver.nowColumnDefault`), or a legacy timezone-NAIVE + * `'YYYY-MM-DD HH:MM:SS'` (`CURRENT_TIMESTAMP`) for columns created before it. + * + * Present every shape as one canonical instant — full ISO-8601 with an explicit + * `Z` (`new Date(...).toISOString()`) — so reads are uniform and unambiguous + * regardless of how/when the row was written. A NAIVE string's wall-clock is + * interpreted as UTC, exactly what `CURRENT_TIMESTAMP` wrote; without this a + * zone-less string is read back by `Date.parse` as LOCAL time and the stored + * instant shifts by the host offset on a non-UTC runtime (the same class of bug + * ADR-0074 fixed for the builtin `created_at`/`updated_at` audit columns, and + * ADR-0053's "`datetime` is an instant stored as UTC" applied to user fields). + * + * Idempotent (an already zone-explicit `…Z`/`±HH:MM` string is preserved) and + * total (`null`/`undefined`/unparseable shapes pass through untouched). Reuses + * ADR-0074's `repairNaiveUtcAuditTimestamp` for the string shapes (the single + * source of the zone-naive→UTC rules) and adds the INTEGER epoch-ms / `Date` + * folding, mirroring the read-repair the `Field.date`/numeric-scalar paths do. + * + * Runs on EVERY dialect since #13973 ([ADR-0053 D-F1]): the name records the + * storage forms it was written to fold (SQLite's INTEGER epoch and naive TEXT), + * not where it runs. Postgres and MySQL store a real `timestamptz` / + * `DATETIME(3)` and never carry the mixed-storage ambiguity, but their client + * libraries hand the column back as a JS `Date`, and that `Date` used to leave + * the read door as-is — the SQLite-gated fold was the whole of the dialect + * divergence the #13973 census measured. The `Date` arm below is the one those + * two dialects take, and the client parser stays untouched ([ADR-0053 D-F2]). + */ +function normalizeSqliteDatetimeOutput(value: unknown): unknown { + if (value == null) return value; + // INTEGER/REAL epoch milliseconds — what better-sqlite3 binds a JS `Date` to. + if (typeof value === 'number') { + if (!Number.isFinite(value)) return value; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? value : d.toISOString(); + } + // The client-level materialisation on Postgres and MySQL (never what + // better-sqlite3 returns): fold it to the canonical text, an Invalid `Date` + // passing through — see `isoFromValidDate` for why that is the total arm. + if (value instanceof Date) return isoFromValidDate(value); + if (typeof value !== 'string') return value; + const s = value.trim(); + if (s === '') return value; + // A bare integer rendered as TEXT (defensive) — treat as epoch milliseconds. + if (/^-?\d+$/.test(s)) { + const d = new Date(Number(s)); + return Number.isNaN(d.getTime()) ? value : d.toISOString(); + } + // Any other string — zone-explicit or zone-naive `YYYY-MM-DD HH:MM:SS` — takes + // the same shape rules as an audit timestamp; reuse that repair as the single + // source for the string-handling logic (idempotent on zone-explicit values). + return repairNaiveUtcAuditTimestamp(s); +} + +/** + * The CANONICAL on-disk form of a `Field.datetime` value: a fixed-width, + * zone-explicit UTC instant — `YYYY-MM-DDTHH:MM:SS.sssZ` (`Date#toISOString`). + * + * ADR-0053 already declares `datetime` to be "an instant stored as UTC"; this is + * the function that makes the STORAGE match the declaration instead of leaving + * it to whatever the caller happened to pass. It is applied on write + * ({@link SqlDriver.formatInput}) and to filter comparands + * ({@link SqlDriver.coerceFilterValue}) so both sides of every comparison are + * the same shape, on every dialect. + * + * Why THIS form (#3912): + * - Fixed width + UTC means lexicographic order IS chronological order, so a + * SQLite TEXT column sorts and range-compares correctly *through an index* — + * no expression wrapper, which is what an epoch-integer convention forces. + * - `strftime`/`julianday` parse it directly, so the date-bucket expression + * needs no epoch↔text CASE (#3773). + * - It is what `formatOutput`/`normalizeSqliteDatetimeOutput` ALREADY present + * on read, so storage and presentation stop disagreeing. + * - It matches the `Field.date` convention (ISO TEXT), so the platform has one + * temporal storage story rather than one per field type. + * - Postgres parses it into `timestamptz` unambiguously, which is precisely + * what a zone-naive string does NOT do (it is read in the SERVER's + * timezone — an 8-hour shift on an Asia/Shanghai server). + * + * Distinct from {@link repairNaiveUtcAuditTimestamp}, which is deliberately + * idempotent on any zone-EXPLICIT string and so preserves a `+08:00` offset. + * That is right for a read repair and wrong for a storage canon: `'…T12:00+08:00'` + * and `'…T04:00Z'` are the same instant but sort differently as text. Everything + * lands in `Z` here. + * + * Total: `null`/`undefined`, empty strings and unparseable junk pass through + * untouched rather than becoming `Invalid Date` — a value the driver cannot + * interpret is never silently rewritten. + */ +function canonicalUtcDatetime(value: unknown): unknown { + // [#20176] The rule is `@objectstack/core`'s — the same function + // `driver-memory` and the engine's per-aggregation `filter` / `having` read. + // This body was a word-for-word copy of it until the rule was lifted there. + return temporalStorageForm(value, 'datetime'); +} + +/** + * The canonical instant rendered as a MySQL datetime literal — the same UTC wall + * clock, spelled the only way MySQL will parse it (#3942). + * + * MySQL/MariaDB reject the ISO-8601 the rest of the platform speaks: neither the + * `T` separator nor the `Z` suffix is accepted in a datetime literal, so + * `'2026-03-20T12:34:56.789Z'` fails the INSERT outright with *Incorrect datetime + * value* (measured on MariaDB 10.11). MySQL 8.0.19+ added `±HH:MM` offsets, but + * still not `Z`, and the platform supports older servers — so the offset is + * dropped and the value is stored as the UTC wall clock in a `DATETIME(3)` + * column, which does no timezone conversion of its own. + * + * This is a PHYSICAL spelling, not a semantic change: the column still holds the + * same instant, and every layer above the bind — API payloads, filter authoring, + * CEL — keeps the canonical `…Z` form. Reads convert back (the connection is + * pinned to UTC, so mysql2 reconstructs the instant correctly). + * + * Total, and deliberately strict: only an exactly-canonical string is rewritten. + * Anything else — an unparseable value `canonicalUtcDatetime` passed through, or + * a year outside MySQL's 1000..9999 range, which `toISOString` renders in + * expanded `+0YYYYY` form — is handed to MySQL untouched, so it fails loudly + * rather than being silently reinterpreted. + */ +export function mysqlDatetimeLiteral(canonical: unknown): unknown { + if (typeof canonical !== 'string') return canonical; + const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2}\.\d{3})Z$/.exec(canonical); + return m ? `${m[1]} ${m[2]}` : canonical; +} + +/** + * The CANONICAL form of a `Field.time` value: a timezone-naive wall-clock + * time-of-day — `HH:MM:SS`, with a `.fff` millisecond suffix only when the + * milliseconds are non-zero (#3994). + * + * `Field.time` is a time-of-day, not an instant (#2004), so unlike + * {@link canonicalUtcDatetime} there is no zone marker — but the same + * write-unnormalised / repair-on-read drift produced the same broken window + * filters as #3912: a bound `Date` stored INTEGER epoch ms on SQLite (sorts + * before every TEXT row), a full ISO string stored as text beginning `'2026-…'` + * (sorts after every bare time-of-day), and `09:00 <= t <= 18:00` silently + * dropped both. This function is applied on write + * ({@link SqlDriver.formatInput}), to filter comparands + * ({@link SqlDriver.coerceFilterValue}) and on read + * ({@link SqlDriver.toTimeOnly}), so both sides of every comparison — and the + * presented value — are one shape. + * + * Why THIS form: + * - `.` sorts below every digit, so lexicographic order is chronological + * order even with the variable-width suffix (`'14:30:00.100' < + * '14:30:01'`), and a SQLite TEXT column range-compares through an index. + * - Deterministic per time-of-day: `'14:30'` and `'14:30:00'` are the same + * wall clock and canonicalise identically, so equality filters and + * `distinct()` cannot split one time into several values. + * - The zero-millisecond spelling is `HH:MM:SS` — the shape every dialect's + * native TIME emits and the field-zoo round-trip (#2022) already asserts — + * so converged common-case data never changes presentation. + * - Every dialect parses it: SQLite stores the text verbatim, Postgres + * `time` and MySQL `TIME(3)` both accept `HH:MM:SS[.fff]` literals — which + * the full-ISO spelling is precisely NOT (measured: `invalid input syntax + * for type time` on PG 16, `Incorrect time value` on MariaDB 10.11). + * + * A `Date` / epoch-ms / full-timestamp string folds to its **UTC** time-of-day + * (ADR-0053): the platform's instants are UTC everywhere else, and it matches + * what the SQLite read repair and `nowColumnDefault` already produced — + * crucially it does NOT depend on the Node process's local timezone, which is + * exactly what binding a raw `Date` to a Postgres TIME column did (pg + * serialised `14:30Z` as `09:30-05:00` on an America/New_York host). Fractions + * beyond milliseconds are truncated, matching `Date` resolution. + * + * Total: `null`/`undefined`, empty strings, out-of-range wall clocks (`'25:00'`) + * and unparseable junk pass through untouched — a value the driver cannot + * interpret is never silently rewritten. + */ +function canonicalTimeOfDay(value: unknown): unknown { + // [#20176] The rule is `@objectstack/core`'s — see {@link canonicalUtcDatetime}. + // A value that is not a bare time-of-day is read as an instant by the + // `datetime` rule there and keeps its UTC time-of-day, as it did here. + return temporalStorageForm(value, 'time'); +} + +/** + * How many times {@link SqlDriver.sqliteCanonicalTimeSql} spells its column + * reference — the binding count a caller must supply per use of the expression. + * (1 `typeof` + 3 per `strftime` CASE branch pair × 2 + 1 `coalesce` fallback.) + */ +export const SQLITE_TIME_EXPR_REFS = 8; + +/** + * How many times {@link SqlDriver.sqliteNonTemporalTextSql} spells its column + * reference — the binding count a caller must supply per use of the predicate. + * (1 `typeof` + 2 `instr` + 1 `julianday`.) + */ +export const SQLITE_NON_TEMPORAL_TEXT_REFS = 4; + +/** + * [#4436] A filter this driver cannot COMPILE — the caller sent an operator (or + * an operand shape) outside what the backend can express. + * + * This is a refusal the request caused, and #4209/#4029 already made it a + * refusal rather than a silent match-everything. What was missing is the wire + * IDENTITY of that refusal: the thrown `Error` carried no `code`, so + * `mapDataError`'s default branch served `{ "error": "" }` with no + * `code` at all — breaking the ADR-0112 contract that `error.code` is the + * schema-enforced SCREAMING_SNAKE vocabulary every sibling rejection on this + * route already speaks (`INVALID_FIELD`, `INVALID_FILTER`, `RECORD_NOT_FOUND`). + * + * `INVALID_FILTER` is the catalogued code for the condition, and the SAME one + * `metadata-protocol` emits for a filter that fails to parse upstream + * (`malformedFilterArrayError` / `unusableFilterError`): one condition — "this + * filter cannot run" — has one wire code however the caller reached it. + * + * `status: 400` puts the rejection on `@objectstack/rest`'s + * `isExpectedQueryRejection` list, so a client mistake stops being logged as an + * unhandled server error. + * + * It does NOT decide whether the message text survives, and the claim that it + * "makes `sendError` pass the message through instead of routing it to the + * SQL-leak heuristic" was backwards (#5423): WITHOUT a status these messages + * already reached the client verbatim through `mapDataError`'s final + * `{ status: 400, body: { error: raw } }` — the leak heuristics do not match + * this wording. WITH the status they entered the explicit-status passthrough, + * whose 500-character bound used to swap the whole body text for + * `'Request failed'` — so in that band adding the status made the message LESS + * readable, the opposite of what this comment promised. That bound now + * truncates instead of replacing, so the main clause survives either way; the + * tail (attribution, issue numbers) may be cut. Keep the actionable part — + * operator, field, path, what arrived, what the spec declares — at the FRONT. + * + * [#5489] The "without a status it reached the client verbatim" half is now + * history: that terminal branch answers a sanitised 500 (`INTERNAL_ERROR`). + * Declaring `status` + `code` at the throw site is therefore the ONLY way a + * refusal's words reach the caller at all — which is the contract-first + * arrangement #4436 wanted, no longer relying on a fallback that leaked. + * + * The `[sql-driver]` prefix these messages used to carry is GONE from the text: + * it is driver-internal wording, and shipping it to clients is exactly what the + * #3867 sanitiser exists to stop. The operator/field/vocabulary detail — the + * part a caller can act on — stays, EXCEPT where the caller may not be its + * author: see {@link withheldFilterError}. + * + * [#7929, maintainer ruling 2026-08-12] That exception is the cross-field + * `{ $field }` family. A read scope — an administrator's CEL sharing/permission + * rule, compiled by `compileCelToFilter` and ANDed into the query by the + * security middleware (`plugin-security`) or by `ObjectQLStrategy.withReadScope` + * (`service-analytics`) — reaches this compiler as a bare `FilterCondition` with + * NOTHING marking it as policy-authored. When it is refused, the "detail a + * caller can act on" is detail the caller never wrote: policy column names, and + * the identity of the tenant-isolation column. Those refusals therefore keep + * `code` and `status` and lose their operands; the full text goes to the server + * log. + * + * [#8197, maintainer ruling 2026-08-15] The exception is no longer only the + * cross-field family. The same argument reaches one step out: on a read-scope + * predicate the refused constraint's TARGET column is as administrator-authored + * as its referent, so the five refusals named in {@link refusalSubtree} join the + * same seam. The paragraph above still describes every refusal in this file that + * neither family covers. + * + * [#20039, the #8220 contract] On the FILTER-COMPILE path — everything + * {@link SqlDriver.applyFilters} runs — there is no such refusal any more: the + * class is closed. Every builder that path can throw from goes through + * {@link withheldFilterError} with the node it was raised from, so the provenance + * mark decides what the wire may name, and a builder added later that calls this + * function directly instead discloses by omission. That is what + * `sql-driver-compile-refusal-seam.test.ts` pins: it enumerates this file's + * direct callers of this function and of {@link withheldFilterError} and holds + * the first set to three named exceptions — the seam itself, the `'author'` + * re-issue in {@link SqlDriver.resolveWithheldFilterRefusal}, and + * {@link unresolvableFilterColumnError}, which is raised from a dialect error + * AFTER the statement ran and resolves its own provenance by name + * ({@link unresolvableColumnProvenance}) — and the second to the set its + * behavioural table drives under all three marks. + */ +export function unsupportedFilterError(message: string): Error { + const err = new Error(message) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.INVALID_FILTER; + err.status = 400; + return err; +} + +/** + * [#8790] Does this dialect error say the statement named a column the backend + * could not resolve? + * + * ONE spelling of the question, read by both halves that have to answer it — + * {@link SqlDriver.findRows}' recovery ladder and {@link SqlDriver.count}. It + * used to be an inline predicate inside `findRows` alone, which is exactly how + * one condition ended up with two behaviours: `find()` swallowed it into `[]` + * and `count()`, having no copy of the test, threw the dialect's own error. + * + * REACH — all three dialects this driver speaks: SQLite (`no such column: x`), + * Postgres (`column "x" does not exist`) and, since #8926, MySQL + * (`Unknown column 'x' in 'where clause'` / `'field list'` / `'order clause'` + * — `ER_BAD_FIELD_ERROR` spells every clause position with one sentence). + * + * [#8926, maintainer ruling 2026-08-16] The MySQL arm is the late one, and it + * was ruled rather than assumed, because this predicate serves both consumers + * at once and widening it therefore moves the accept set in BOTH directions in + * one line: MySQL gains the #8790 refusal envelope (`INVALID_FILTER` / 400 + * naming the column, the dialect message with its inlined bound literals kept + * to the server log — the narrowing), AND it gains the #3821 projection and + * ORDER-BY recoveries it never had, so a query that used to throw the raw + * dialect error now returns rows (the widening). Both halves are intended — + * full dialect parity, one shared predicate; a split predicate (envelope + * without recoveries) was considered and refused as a second spelling of one + * fact. The widening is safe for the same structural reason #8790 could turn + * the ladder's terminal into a refusal: every rung is rebuilt from + * `buildBase()`, which unconditionally re-applies `query.where`, so a wider + * predicate hands MySQL a recovered projection or sort but can never drop a + * WHERE. + */ +export function isUnresolvableColumnError(error: unknown): boolean { + const message = (error as { message?: unknown } | null | undefined)?.message; + if (typeof message !== 'string') return false; + return ( + message.includes('no such column') || + (message.includes('column') && message.includes('does not exist')) || + message.includes("Unknown column '") + ); +} + +/** + * [#16019] The raw-execution twin of {@link backendStatementFaultError}: the + * envelope {@link SqlDriver.execute} raises when the backend refuses a + * statement it was handed verbatim. `no such function: translate` on a SQLite + * datasource whose analytics compiler emitted a function the dialect lacks + * (#16028) is the measured case; every other dialect refusal on this path is + * the same class. + * + * # Why this exists beside the read-exit terminal + * + * Every typed read exit (`find`, `count`, `aggregate`) already terminates in + * {@link SqlDriver.backendStatementFault}, so a dialect refusal there leaves + * the driver DECLARED — `code: DATABASE_ERROR`, `status: 500` — and the HTTP + * doors withhold its prose by that declaration (`declaresServerFault`). The + * raw path had no terminal at all: `execute()` awaited `knex.raw()` bare, so + * the dialect's own error object left the driver with no `status`, a `code` + * from the backend's vocabulary (`SQLITE_ERROR`) and knex's ` - + * ` message. Undeclared, it fell to `looksLikeInternalErrorLeak` + * at the doors — a phrasing heuristic that recognises `no such column:` and + * not `no such function:` — so whether a caller saw the engine's text was a + * property of which limb the message happened to match, and one transport + * over, of whether the message carried a statement prefix at all. + * + * Maintainer ruling 2026-09-06 (decision batch #57, option 3): the substring + * list is not grown; the driver declares its own fault and the doors classify + * on the declaration. This is that declaration for the raw path — the idiom + * this file already spells at its other terminals, not a second mechanism. + * + * # What the envelope carries, and what it does not + * + * The message is COMPOSED, for the reason `backendStatementFaultError` gives: + * there is no cut of a dialect's text that keeps its words and reliably drops + * a caller's inlined value. No statement, no diagnostic and no function name + * reaches the message. The dialect error travels whole under `cause`, + * NON-ENUMERABLE — readable by cause-following predicates + * (`isMissingTableError` still classifies a missing table on this path + * through it), invisible to `JSON.stringify` and `{ ...err }` — and the driver + * writes its diagnostic to the server log before composing, with the statement + * and its bound values cut (#21385). ⛔ No `DRIVER_TARGETED_TABLE` + * is declared here: a raw statement may reference any number of tables, and + * naming one would make a missing JOINED table read as the caller's own — the + * misclassification #13438 exists to prevent. + * + * @param cause - the dialect error, kept whole for the log and for + * cause-following predicates. + */ +export function rawStatementFaultError(cause: unknown): Error { + const err = new Error( + 'The database refused to run a raw statement. The driver could not attribute the failure ' + + 'to any part of the request, so no verdict about the statement is claimed here. The ' + + "backend's own diagnostic was written to the server log for an operator to read, with " + + 'the statement and its bound values cut.', + ) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.DATABASE_ERROR; + err.status = 500; + Object.defineProperty(err, 'cause', { + value: cause, + enumerable: false, + writable: true, + configurable: true, + }); + return err; +} + +/** + * [#21227] The refusal {@link SqlDriver.readBackInsertedRows} raises when an + * INSERT was accepted but a row it wrote is not there to be read back. + * + * `create` and `bulkCreate` answer the stored record (`IDataDriver.create`). + * On a dialect whose INSERT returns no rows the driver reads what it wrote + * back by the id it wrote, and only a row removed between the two statements + * (a concurrent delete, a trigger) can be missing. There is then no stored + * record to answer. Answering the caller's payload in its place would look + * like a success while answering a row that is not stored, and would hide a + * read-back keyed on the wrong column for good, so the door refuses instead. + * + * `DATABASE_ERROR` / 500, the pair this file's other terminals declare for a + * fault the request did not cause. The message is composed and names only the + * object the caller passed. The ids, which are the driver's own values (the + * caller's `id` / `_id` or a minted nanoid, never a business column), travel + * under a non-enumerable `cause` for the server log, as in + * {@link backendStatementFaultError}. + */ +function insertedRowsNotReadBackError(object: string, missingIds: unknown[], writtenCount: number): Error { + const err = new Error( + `The database accepted the insert into object '${object}', but ${missingIds.length} of its ` + + `${writtenCount} row(s) could not be read back by the id this driver wrote, so the stored ` + + 'record cannot be answered. This database returns no rows from an INSERT, so the driver ' + + 'reads each written row back by its id, and a row removed between the two statements (a ' + + 'concurrent delete or a trigger) leaves nothing to read. The write was not retried, because ' + + 're-issuing it could duplicate a row that did land.', + ) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.DATABASE_ERROR; + err.status = 500; + Object.defineProperty(err, 'cause', { + value: new Error(`no row carries the written id(s) ${JSON.stringify(missingIds)} after the insert`), + enumerable: false, + writable: true, + configurable: true, + }); + return err; +} + +/** [#9354] Marks the enveloped refusal below, so the flush's own catch can tell + * it from every other failure without re-running the recognizer on a wrapper. */ +export const LOCK_WAIT_REFUSAL = Symbol.for('objectstack.driver-sql.deferredDdlLockWaitRefusal'); + +/** [#9354] True only for the refusal {@link deferredDdlLockWaitError} built. */ +function isDeferredDdlLockWaitRefusal(err: unknown): boolean { + return Boolean(err && typeof err === 'object' && (err as Record)[LOCK_WAIT_REFUSAL]); +} + +/** + * [#7929] The full, operand-naming text of a refusal whose caller-visible + * message was redacted — carried on the Error under a SYMBOL key. + * + * A symbol rather than a string property because the carrier must not travel: + * `JSON.stringify(err)`, `{ ...err }`, `Object.keys`, `for…in` and the + * structured-clone boundary all skip symbol keys, so an error mapper that + * spreads or serialises the error cannot put the text back on the wire — which + * is the one way this redaction could be undone without anyone editing it. + * `Symbol.for` (the global registry) rather than a module-local symbol so a + * duplicated copy of this package still resolves the same key. + */ +const WITHHELD_FILTER_DIAGNOSTIC = Symbol.for('objectstack.driver-sql.withheldFilterDiagnostic'); + +/** + * [#7929] Set on an error once its diagnostic has been written to the log, so + * the seam can be applied at more than one frame without the operator reading + * one refusal twice. Needed because knex invokes a WHERE-group callback lazily, + * which puts nested refusals outside the filter entry point's `catch` — see + * {@link SqlDriver.withWithheldFilterLog}. + */ +const WITHHELD_FILTER_LOGGED = Symbol.for('objectstack.driver-sql.withheldFilterLogged'); + +/** + * [#8220] The filter subtree the refusal was raised FROM — the reference the + * throw site held — so {@link SqlDriver.resolveWithheldFilterRefusal} can ask + * the spec's provenance resolver where that node sits under the query's own + * `where` root. A symbol for the same non-travel reason as the diagnostic: + * nothing that serialises or spreads the error can carry a live tree reference + * to a place that could misread it. + */ +const WITHHELD_FILTER_SUBTREE = Symbol.for('objectstack.driver-sql.withheldFilterSubtree'); + +/** + * [#8220] The full author-facing message of a refusal whose subtree turns out + * to be positively marked `'author'` — the diagnostic plus the repair + * prescription the redacted message states only generically. Kept beside the + * diagnostic rather than derived from it at resolution time so each builder + * owns ONE composition of its own wording, redacted and restored alike. + */ +const WITHHELD_FILTER_AUTHOR_TEXT = Symbol.for('objectstack.driver-sql.withheldFilterAuthorText'); + +/** + * [#7929, maintainer ruling 2026-08-12, verbatim: 「接受你的全部建议。」 adopting + * "B now, A next"] An `INVALID_FILTER` refusal that answers the caller with + * `message` and keeps `diagnostic` — the half naming the operands — server-side. + * + * # Why the redaction is unconditional + * + * The driver cannot tell an author's filter from a policy's. Measured on both + * merge boundaries (#7929's analytics capture, #7988's CRUD capture): the + * predicate arrives as a bare `FilterCondition` in `where`, `DriverQuery` is + * `Omit< QueryAST, 'object' >` and `QueryAST` has no provenance slot, and the + * one thing that does cross (`context`) says who is asking, never which subtree + * they did not write. A driver-side guess at provenance is precisely the shape + * the triage lens rejected, so B withholds for EVERY caller. + * + * # BOTH operands are withheld, not just the `$field` one + * + * On a read-scope refusal the whole predicate is the administrator's: in + * `{ amount: { $gt: { $field: 'secret_policy_column' } } }` the target `amount` + * is as policy-authored as the referent. Echoing the target would leave half + * the disclosure live, so the redaction takes both operands, the operator, the + * list index and the boundary `reason` — everything derived from the predicate. + * What stays is the refusal's IDENTITY (`INVALID_FILTER` / 400), which of the + * three cross-field refusal classes fired, and the capability statement, none + * of which is derived from what the caller or the administrator wrote. + * + * # The accepted cost, named rather than hidden + * + * An author debugging their OWN cross-field filter used to get the redacted + * message too — B's accepted cost. [#8220] (A of the same ruling) is what pays + * it back: the refusal carries the subtree it was raised from, and + * {@link SqlDriver.resolveWithheldFilterRefusal} swaps in the full diagnostic + * when — and ONLY when — that subtree is positively marked `'author'` by a + * read-scope merge boundary (`markFilterSubtreeProvenance`, + * `@objectstack/spec/data`). Unmarked or ambiguous stays exactly here, on the + * withholding branch — the mark is permission to reveal, never a duty to prove + * secrecy. ⛔ Do not "fix" the remaining redaction by re-adding the names + * unconditionally, and ⛔ do not widen the REST boundary's 5xx-only withhold + * to 4xx instead (ruled out: it would delete #5367's tiering and #5667's + * legible-undeclared-5xx decision). + * + * `subtree` is the node the throw site held (the reference comparand, the + * list member, the field-spec map). `authorText` is what an `'author'` verdict + * puts on the wire; it defaults to `diagnostic` so no builder can end up with + * a restored message emptier than its own server-log line. + */ +export function withheldFilterError( + message: string, + diagnostic: string, + subtree?: unknown, + authorText?: string, +): Error { + const err = unsupportedFilterError(message); + Object.defineProperty(err, WITHHELD_FILTER_DIAGNOSTIC, { value: diagnostic, enumerable: false }); + if (subtree !== null && typeof subtree === 'object') { + Object.defineProperty(err, WITHHELD_FILTER_SUBTREE, { value: subtree, enumerable: false }); + } + Object.defineProperty(err, WITHHELD_FILTER_AUTHOR_TEXT, { + value: authorText ?? diagnostic, + enumerable: false, + }); + return err; +} + +/** + * [#7929] The withheld diagnostic carried by `err`, or `null` for any other + * error. The read half of {@link withheldFilterError} — used by + * {@link SqlDriver.logWithheldFilterDiagnostic} to write the text to the + * server log, and exported so a host that maps driver errors itself can do the + * same rather than re-deriving the seam. + */ +export function withheldFilterDiagnosticOf(err: unknown): string | null { + if (err === null || (typeof err !== 'object' && typeof err !== 'function')) return null; + const text = (err as Record)[WITHHELD_FILTER_DIAGNOSTIC]; + return typeof text === 'string' ? text : null; +} + +/** + * [#8197, maintainer ruling 2026-08-15] The node a TARGET-FIELD refusal hands + * {@link SqlDriver.resolveWithheldFilterRefusal} — and the one place this + * family's shape is written down. + * + * # The family, and why it is exactly these five + * + * #7929/B withheld the cross-field operands; this card measured what B did not + * reach. Five refusals in this file name the constraint's TARGET column, and + * every one of them is raised by a predicate an RLS rule can plausibly produce: + * {@link jsonColumnOperatorError} (the #7398 gate — a CEL permission rule over a + * `multiple: true` field lowers to a membership test on a JSON-stored column, + * the most reachable of the five), {@link emptyFieldConstraintError}, + * {@link unbindableComparandError} (which also answers a MALFORMED `{ $field }` + * — one that is not a `FieldReferenceSchema`, so the cross-field arm does not + * recognise it) and {@link betweenArityError}. On such a predicate the target + * column is as administrator-authored as the referent B already withholds, which + * is the argument the ruling accepted. + * + * # What redaction takes, and why it is more than the field name + * + * Everything DERIVED FROM THE PREDICATE: the target field, the operator, the + * comparand's contents, the filter path and any list index. What stays is the + * refusal's IDENTITY (`INVALID_FILTER` / 400), WHICH class fired, and the + * capability statement and prescription — none of which is derived from what the + * caller or the administrator wrote. That is #7929's own line, applied + * unchanged: it withheld both operands because "echoing the target would leave + * half the disclosure live", and a comparand preview (`received an object + * ({"a":1})`) is the administrator's literal just as surely as a column name is. + * ⛔ Do not soften this into "field name only" — one file must not carry two + * redaction disciplines. + * + * # Which node, and why the fallback is safe + * + * The DEEPEST resolvable node wins, because + * `resolveFilterSubtreeProvenance` reads the innermost mark on the ancestor + * chain: handing it an ancestor when a nearer node exists would drop a mark set + * closer to the leaf. `comparand` is that node when it is an object — the + * reference the throw site holds, findable under the query's `where` root by + * IDENTITY — and `enclosing` (the field-spec map, or the condition node) takes + * over when it is not. + * + * The fallback cannot widen disclosure, and the reason is structural rather than + * empirical: `markFilterSubtreeProvenance` silently no-ops on a non-object, so a + * primitive comparand can never carry a mark of its own, and its effective + * provenance IS its enclosing node's. The two candidates therefore differ only + * where the deeper one has nothing to say. + */ +function refusalSubtree(comparand: unknown, enclosing: unknown): unknown { + return comparand !== null && typeof comparand === 'object' ? comparand : enclosing; +} + +/** + * [#6409] How one declared aggregate function lowers into SQL. + * + * `sql` is the function NAME; `distinct` decides whether the argument list + * carries the `DISTINCT` keyword. Two fields rather than one string because + * `count_distinct` is the first entry in the vocabulary whose lowering is not a + * function name at all — `COUNT(DISTINCT x)` puts a keyword INSIDE the argument + * list, so a table of bare names has nowhere to say it. Encoding it as a + * template (`'count(distinct %s)'`) was the alternative and was rejected: the + * emitter binds the column as a knex identifier (`??`), and a template would + * have moved that binding into data. + * + * `distinct` also decides whether a FIELD is mandatory, which is why there is no + * third flag saying so: `COUNT(*)` is the spelling `AggregationNodeSchema` + * explicitly allows by making `field` optional, and `COUNT(DISTINCT *)` is a + * syntax error in every dialect this driver targets — there is nothing to + * deduplicate without a column. See {@link refuseDistinctAggregateWithoutField}. + */ +interface SqlAggregateLowering { + readonly sql: string; + readonly distinct: boolean; +} + +/** + * [#5907] The aggregate functions this driver LOWERS into SQL, and what each + * becomes. + * + * The refusals below read their "compiled here" list off THIS table instead of + * repeating it. A hand-written copy agrees with the compiler on the day it is + * typed and never again — the note already sitting over `driver-memory`'s + * `SUPPORTED_FIELD_OPERATORS` (#5345), applied to the aggregate vocabulary. + * + * A `Map` rather than a plain object on purpose: a caller-supplied name is + * looked up here, and `{}['constructor']` answers with a function. + * + * [#6409] `count_distinct` joined the table, and with it the table gained the + * `distinct` column. It is the ENFORCE leg of #6188's split ruling + * (2026-08-07): `array_agg`/`string_agg` left `AggregationFunction` because no + * SQL backend compiled them and none had one portable shape to compile TO, + * while `count_distinct` has exactly one — `COUNT(DISTINCT x)`, already the + * lowering `service-analytics`'s `AGGREGATE_SQL` uses for the Cube face — and + * stayed declared on the strength of it. With this entry the declared + * vocabulary and this driver's compiled vocabulary are the SAME SET; the values + * are pinned against the remote face by `AGGREGATION_CASES`. + */ +const SQL_AGGREGATE_FUNCTIONS: ReadonlyMap = new Map([ + ['count', { sql: 'count', distinct: false }], + ['sum', { sql: 'sum', distinct: false }], + ['avg', { sql: 'avg', distinct: false }], + ['min', { sql: 'min', distinct: false }], + ['max', { sql: 'max', distinct: false }], + ['count_distinct', { sql: 'count', distinct: true }], +]); + +// [#20335, #20889] `AGGREGATE_ANSWER_KIND` — what each declared aggregate +// function ANSWERS, and the one-double precision policy for that answer — lives +// in `@objectstack/core` (`utils/aggregate-answer.ts`), imported above with its +// `'number'` presenter, so the analytics native-SQL face presents a count or a +// total with this driver's own rule. + +// [#20387, #21042] `AGGREGATE_ACCUMULATION` — what each declared aggregate +// function ACCUMULATES IN on PostgreSQL and MySQL, the arithmetic half of the +// one-double policy — lives in `@objectstack/core` (`utils/aggregate-answer.ts`) +// with its docblock, beside the boolean-aggregand cast and the column-class +// predicate both read. {@link SqlDriver.aggregate} asks them through +// `aggregandOperandSql`, so the analytics native-SQL face accumulates the same +// operand with this driver's own rule. + +/** + * [#5907] The aggregate vocabulary the Query Protocol DECLARES, read from the + * spec rather than restated — `AggregationNodeSchema.function` is this enum, so + * "declared" has exactly one definition and this driver cannot drift from it. + */ +const DECLARED_AGGREGATE_FUNCTIONS: readonly string[] = AggregationFunction.options; + +/** + * [#5907] Class 1 — a function name the Query Protocol does not declare. + * + * The caller wrote something no backend can run (`median`), so this is a + * request-shaped mistake: `INVALID_QUERY` / 400, the catalogued + * `StandardErrorCode` for "malformed query syntax" and a member of + * `@objectstack/rest`'s `isExpectedQueryRejection` list, so a client mistake + * stops being logged as an unhandled server fault. + * + * `INVALID_QUERY` is not a new spelling for this condition — it is the one the + * PROTOCOL DOOR already gives it. `metadata-protocol`'s `invalidQueryError` + * refuses "a function outside the spec enum" on the aggregations axis with + * exactly `400 INVALID_QUERY` (#4254), so a caller who reaches this driver + * in-process gets the same wire identity as one who came through REST: one + * condition, one code, however the caller arrived — the argument + * {@link unsupportedFilterError} makes for `INVALID_FILTER`. + * + * The FIRST SENTENCE is shared verbatim with the twin in `driver-turso`'s + * `remote-transport.ts` (#5240 — one condition, one wording): a caller must not + * be able to tell which transport answered from the words it used. The parity is + * pinned by a test that compares the two RUNTIME messages, not two copies of a + * literal (`remote-transport-aggregate-function-refusal.test.ts`). + * + * Judged against the declared enum CASE-SENSITIVELY, which is what the enum is: + * `COUNT_DISTINCT` is not `count_distinct`, and answering "declared but not + * implemented" for it would be false. When this was written the remote transport + * still lowercased the name before its own lookup while this driver read it raw, + * so classifying on each face's post-normalisation name would have handed + * `COUNT_DISTINCT` a 400 here and a 501 there for one query. #6203 has since + * removed that lowercasing, so the two faces now normalise alike — but the + * case-sensitive judgement here is not merely a survivor of that fork: the enum + * IS case-sensitive (`AggregationFunction.parse('COUNT')` throws), so this is + * what "declared" means, whatever any transport does before asking. + */ +function undeclaredAggregateFunctionError(func: string): Error { + const err = new Error( + `Aggregate function "${func}" is not a declared aggregate function. ` + + `Declared functions: ${DECLARED_AGGREGATE_FUNCTIONS.join(', ')} ` + + `(@objectstack/spec AggregationFunction). Fix the "function" key of the aggregations[] ` + + `entry — the Query Protocol has no such function, so this is a query no backend can run, ` + + `not a gap in this one.`, + ) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.INVALID_QUERY; + err.status = 400; + return err; +} + +/** + * [#5907] Class 2 — a DECLARED function this backend cannot compile. + * + * Distinct from {@link undeclaredAggregateFunctionError} on purpose, and this is + * the half that must not be collapsed into it: `count_distinct` is declared by + * `AggregationFunction` and implemented by other backends (`driver-mongodb`, + * and `driver-memory`'s analytics face), so telling a dashboard author their + * `count_distinct` is a typo would be false — the same line #5345 drew in + * `driver-memory`'s `filter-refusal.ts` between `unknownFieldOperatorError` and + * `uncompilableFieldOperatorError`. + * + * `array_agg` and `string_agg` used to belong to this class too. #6188 retired + * both from `AggregationFunction` (ADR-0049 — declared by the spec, compiled by + * no SQL backend), so they now fall to class 1 and answer 400: the protocol no + * longer has those names, which is a different fact from "this backend cannot + * lower them" and deserves the different answer. `count_distinct` was + * deliberately kept and took the enforce leg instead. + * + * ⚠️ [#6409] **This class is now EMPTY, and the producer is kept deliberately.** + * `count_distinct` was its last inhabitant; with its lowering landed, + * `SQL_AGGREGATE_FUNCTIONS` covers every member of `AggregationFunction` and + * nothing reaches this branch — pinned as a positive assertion by + * `sql-driver-out-of-contract-aggregate-function.test.ts` ("the + * declared-but-uncompiled set is empty"), not left to be rediscovered. + * + * Deleting it as dead code was considered and rejected. The branch is not an + * unenforced DECLARATION — the ADR-0049 shape — it is the classifier that + * decides which of two truths a future name gets told. Removing it does not + * remove the condition; it makes {@link refuseAggregateFunction} answer 400 for + * the FIRST function a later spec bump adds, telling the author of a + * correctly-spelled `median` that the protocol has no such function. That is + * precisely the misreport #5907 exists to prevent, and it would land in the + * window between a spec change and a driver change — the window this repo + * opens on purpose whenever it takes ADR-0049's enforce leg, as #6188 just did + * for this very name. The cost of keeping it is one unreachable branch; the + * cost of dropping it is a wrong answer at exactly the moment the vocabulary + * grows. + * + * `NOT_IMPLEMENTED` / 501 is the answer, from the ADR-0112 STANDARD catalog + * ("Feature not yet implemented"), whose own `HttpStatusErrorCodeMap` pairs it + * with 501 — so code and status are each other's mirror by construction rather + * than by this function's choice. It is the spelling the repo already uses for + * every "not supported by this protocol/runtime" answer, and the ledger's rule + * for a generic condition is the standard catalog over a registered synonym. + * The registered alternatives were measured and rejected: `UNSUPPORTED` is a + * 400 in both places that emit it (a share link that does not expose messages), + * `UNSUPPORTED_QUERY_PARAM` is a 400 on the client-mistake list, and + * `UNSUPPORTED_TRANSFORM` belongs to `@objectstack/rest`'s import mapper. + * + * Measured consequence, recorded so it is not rediscovered as a bug: on the + * `/data` routes `mapDataError`'s generic status passthrough spans 400-599, so + * this declared 501 DOES survive to the wire as `501` / `NOT_IMPLEMENTED` — + * pinned end-to-end by `rest-5xx-status-passthrough.test.ts`. This paragraph + * read the opposite until #7402 closed #5582: the passthrough was 4xx-ONLY, + * this was its first live producer, and the status fell to + * `UNCLASSIFIED_FAULT`'s `500 INTERNAL_ERROR`. What that band does drop is the + * PROSE — a 5xx body is the generic `Internal server error` — so the driver's + * job at the throw site is unchanged: state the condition truthfully + * (ADR-0112), which is what reaches every in-process caller and the operator + * log. + */ +function uncompilableAggregateFunctionError(func: string): Error { + const err = new Error( + `Aggregate function "${func}" is declared but not implemented by this backend. ` + + `Compiled here: ${[...SQL_AGGREGATE_FUNCTIONS.keys()].join(', ')}. The name is spelled ` + + `correctly and @objectstack/spec AggregationFunction declares it — this is a capability gap ` + + `in the backend, not a mistake in the query, which is why it answers NOT_IMPLEMENTED/501 ` + + `rather than a 400. Aggregate with a function this backend compiles; whether the declaration ` + + `itself should stand is ADR-0049's enforce-or-remove question.`, + ) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.NOT_IMPLEMENTED; + err.status = 501; + return err; +} + +/** + * [#10576] An aggregation entry carries a per-aggregation `filter` + * (`AggregationNodeSchema.filter`, the contract half of #10413) and this + * backend compiles no conditional-aggregate expression (SQL `FILTER (WHERE …)` + * / `CASE WHEN`) for it — so it is refused rather than silently aggregating + * the UNFILTERED rows, which is exactly the wrong-numbers defect #10413 + * measured ("won deals" counting every row). Same class and envelope as + * {@link uncompilableAggregateFunctionError}: the query is spelled correctly + * and the spec declares the key, so this is a capability gap in the backend + * (NOT_IMPLEMENTED/501), not a mistake in the query (400). + * + * Unreachable through `engine.aggregate`, deliberately: the engine routes any + * call whose aggregations carry a filter through its in-memory lowering + * (`applyInMemoryAggregation`), which honours the predicate on every driver. + * This guard is the direct-caller half of "no silent drop, anywhere" — the + * refusal names the remedy so a direct caller knows the engine path works. + */ +function unsupportedAggregationFilterError(alias: string, backend: string): Error { + const err = new Error( + `Per-aggregation \`filter\` on "${alias}" is not supported by this backend (${backend}). ` + + `The query is spelled correctly and @objectstack/spec AggregationNodeSchema declares the key — ` + + `this backend compiles no conditional-aggregate (SQL FILTER (WHERE …) / CASE WHEN) expression ` + + `for it, so it is refused rather than silently aggregating the UNFILTERED rows, which ` + + `is why it answers NOT_IMPLEMENTED/501 rather than a 400. \`engine.aggregate\` lowers filtered ` + + `aggregations in memory for every driver without native support — route the query through the ` + + `engine, or drop the \`filter\` key.`, + ) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.NOT_IMPLEMENTED; + err.status = 501; + return err; +} + +/** + * [#6409] `count_distinct` written with no `field` — nothing to deduplicate. + * + * `AggregationNodeSchema` makes `field` optional because `COUNT(*)` is a real + * spelling, and the schema has no way to say "optional for this function, + * required for that one". So the aggregation parses, reaches this driver, and + * asks for `COUNT(DISTINCT *)` — which no dialect this driver targets accepts. + * + * Refused HERE rather than emitted and left to the database, for the reason + * #5907 gives at length one function up: a syntax error raised by SQLite or + * Postgres arrives with the driver's SQL in it and no `code`/`status`, so + * `mapDataError` serves an opaque 500 for what is a completely legible mistake + * in the request. The class is 1, not 2 — `INVALID_QUERY` / 400: the FUNCTION + * is compiled here, it is this aggregation node that is malformed, and the + * remedy is a key the caller can add. + * + * The twin lives in `driver-turso`'s `remote-transport.ts`, first sentence for + * first sentence (#5240 — one condition, one wording), and the two are compared + * as runtime messages by `remote-transport-aggregate-function-refusal.test.ts`. + */ +function refuseDistinctAggregateWithoutField(func: string): never { + const err = new Error( + `Aggregate function "${func}" needs a "field" — there is nothing to deduplicate. ` + + `COUNT(*) counts rows and is the spelling that takes no field; a distinct count has to name ` + + `the column whose values are deduplicated. Add "field" to the aggregations[] entry.`, + ) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.INVALID_QUERY; + err.status = 400; + throw err; +} + +/** + * [#5907] Which refusal a name that this face cannot compile deserves. + * + * The classification is written ONCE per face and shared by both faces' + * throw sites, so "is this the caller's mistake or ours?" cannot be answered two + * ways for one query. `func` is the name the CALLER wrote — not a normalised + * form — because that is what the enum is judged against and what the message + * has to quote back. + */ +function refuseAggregateFunction(func: string): never { + throw DECLARED_AGGREGATE_FUNCTIONS.includes(func) + ? uncompilableAggregateFunctionError(func) + : undeclaredAggregateFunctionError(func); +} + +/** + * The window functions {@link SqlDriver.findWithWindowFunctions} compiles — + * a CLOSED set, keyed by the lowercased name a caller may spell in any case. + * + * The door emits a function ARGUMENT-LESS (`FUNC() OVER (…)`, see + * {@link SqlWindowFunctionSpec}), so the set is exactly the standard ranking + * functions that take no argument, each one available on every dialect this + * driver targets (PostgreSQL, MySQL 8, SQLite 3.25+). The name used to be + * spliced into the statement verbatim; it is now looked up here and only the + * value from this table reaches SQL, so the statement's text is never the + * caller's. + */ +const SQL_WINDOW_FUNCTIONS: ReadonlyMap = new Map([ + ['row_number', 'ROW_NUMBER'], + ['rank', 'RANK'], + ['dense_rank', 'DENSE_RANK'], + ['percent_rank', 'PERCENT_RANK'], + ['cume_dist', 'CUME_DIST'], +]); + +/** The two `OVER (ORDER BY …)` directions, keyed by the lowercased spelling. */ +const SQL_WINDOW_SORT_DIRECTIONS: ReadonlyMap = new Map([ + ['asc', 'ASC'], + ['desc', 'DESC'], +]); + +/** + * A `windowFunctions[]` entry this door cannot compile: an unknown function + * name or sort direction. A malformed query (`INVALID_QUERY`, 400), the class + * {@link undeclaredAggregateFunctionError} answers at the aggregate door. + */ +function invalidWindowFunctionQueryError(message: string): Error { + const err = new Error(message) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.INVALID_QUERY; + err.status = 400; + return err; +} + +/** + * [#6212] A `groupBy` entry asks for a date BUCKET this face cannot emit. + * + * `GroupByNodeSchema` declares `{ field, dateGranularity }` and `DateGranularity` + * declares all five names, so a granularity this dialect has no expression for is + * a CAPABILITY GAP in the backend, not a mistake in the query — the same 501 + * class {@link uncompilableAggregateFunctionError} answers for a + * declared-but-uncompiled aggregate function, and for the same reason (#5907, + * ADR-0112). It used to be a bare `throw new Error(...)`: `code`/`status` both + * `undefined`, so `mapDataError` served an opaque 500 for a named condition. + * + * WHICH granularities a backend buckets natively is PUBLISHED, per driver, as + * `supports.queryDateGranularity`, and the engine reads that record and falls + * back to in-memory bucketing for anything absent from it (objectql `engine.ts`, + * aggregate dispatch). Reaching this throw therefore means a caller went around + * that bit, so the message names the bit rather than the SQL. + * + * Written once per FACE — `RemoteTransport` carries the twin, first sentence for + * first sentence — because `TursoDriver` picks its face from `url` and one + * condition may not have two wire identities (#5240 / #5907). The two faces + * refuse different populations (this one refuses what its DIALECT cannot bucket, + * the remote transport refuses every granularity because it buckets none), but + * both refuse exactly `supports.queryDateGranularity[g] !== true`, which is one + * condition stated per face. + */ +function refuseDateBucketedGroupBy(granularity: string, bucketedHere: string[], face: string): never { + const err = new Error( + `Date bucketing by '${granularity}' is not supported by this backend. ` + + `Bucketed here: ${bucketedHere.length > 0 ? bucketedHere.join(', ') : 'none'} (${face}). ` + + `The query is spelled correctly and @objectstack/spec DateGranularity declares it — this is ` + + `a capability gap in the backend, not a mistake in the query, which is why it answers ` + + `NOT_IMPLEMENTED/501 rather than a 400. A driver publishes the granularities it buckets ` + + `natively as \`supports.queryDateGranularity\`; the engine reads that record and buckets ` + + `in memory for every granularity absent from it, which is always correct.`, + ) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.NOT_IMPLEMENTED; + err.status = 501; + throw err; +} + +/** + * [#11567] A field reached DDL carrying `reference_to` — a key `FieldSchema` + * REFUSES — so this face refuses it too, in the spec's own words. + * + * ## Why the DDL seam needs a door the schema already has + * + * `reference` is the only relationship spelling the spec declares. ON THE + * AUTHORING FACE `reference_to` is a REJECTED ALIAS, not a normalised one. + * Measured on `origin/main`: + * + * ``` + * FieldSchema.safeParse({ name:'parent', type:'lookup', reference_to:'p' }) + * => success:false, issue.code = `unrecognized_keys` + * "Unrecognized key(s) on this field: `reference_to`. + * Did you mean `reference_to` → `reference`?" + * ``` + * + * ⚠️ That verdict is the authoring face and only the authoring face — do NOT + * read this door as "a stored `reference_to` stays verbatim forever". #13700 + * landed the `field-reference-to-alias` conversion (`toMajor: 18`, + * `retiredFromLoadPath: true`, in `packages/spec/src/conversions/registry.ts`), + * and its docblock already states the split this comment has to be read + * against: the entry "covers the two paths that serve or rewrite EXISTING data + * — stored rehydration and `os migrate meta`", where the key is NORMALISED to + * `reference` rather than refused, while "the DDL doors above keep guarding + * the third path (metadata handed straight to a driver, around both the gate + * and the stored pass); they are downstream of this entry, not replaced by + * it." This door is one of those DDL doors. Refused when authored, rewritten + * when already at rest, refused again here — three paths, and this comment + * speaks for the third. + * + * Until #11567 this driver read `reference_to` and ONLY `reference_to`, as the + * gate on a `table.foreign(name).references('id')`. So one key had TWO doors + * with opposite answers: the authoring door refused it, while the DDL door + * silently honoured it and changed the PHYSICAL SCHEMA — and the silent one + * was the one that touched the database. It was reachable only by metadata + * that went around Zod (`registerObject` deliberately skips it, #3896), which + * is why no authored deployment ever had these constraints: measured across + * all 44 exported platform objects on live Postgres 16.13 and MySQL 8.0.46, + * **zero** FOREIGN KEYs existed. The emission is retired; this is the door + * that stays, so the driver stops disagreeing with the spec in silence. + * + * ## Why refuse rather than ignore + * + * Ignoring the key would be a THIRD answer to one question. An author who + * wrote `reference_to` meant to point the field somewhere; a driver that drops + * it on the floor creates a column pointing nowhere and says nothing — the + * "dropped silently" shape #4001 closed at the schema, re-opened one layer + * down. The refusal is stated at the DDL seam because that is the last place + * the mistake is still cheap: before the column exists, not after rows are in + * it. + * + * `VALIDATION_ERROR`/400 rather than a 500 (ADR-0112): the metadata is the + * caller's, the condition is decided entirely by what the caller wrote, and + * the fix is a one-word rename. A bare `throw new Error` would leave + * `code`/`status` undefined and serve an opaque 500 through `mapDataError` for + * a named, caller-fixable condition — the trade {@link refuseDateBucketedGroupBy} + * records above (#6212). + */ +function refuseRejectedReferenceAlias(column: string): never { + const err = new Error( + `[sql-driver] field '${column}' declares \`reference_to\`, a rejected alias of \`reference\`. ` + + `Did you mean \`reference_to\` → \`reference\`? \`reference\` is the only relationship spelling ` + + `@objectstack/spec declares, and \`FieldSchema\` refuses this key with that same verdict ` + + `(\`unrecognized_keys\`) — so a field still carrying it at DDL time went around the schema ` + + `(raw \`registerObject\` skips Zod). Rename the key. The column is built from ` + + `\`reference\`, and referential integrity is enforced by the ENGINE via \`deleteBehavior\` ` + + `(the 409 DELETE_RESTRICTED), not by a database FOREIGN KEY: the FK DDL this ` + + `key used to gate is retired, because it could never fire for a spec-conformant lookup in the first place.`, + ) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.VALIDATION_ERROR; + err.status = 400; + throw err; +} + +/** + * [#16319] Has this field declared a `type` at all? + * + * The whole predicate the DDL emitter needs, and deliberately no more: it asks + * PRESENCE, not membership. Membership is decided one layer up and for the whole + * object — see {@link refuseUndeclaredFieldType}. + */ +function isDeclaredFieldType(field: { type?: unknown }): boolean { + return typeof field.type === 'string' && field.type !== ''; +} + +/** + * [#17469] Is this column MULTI-VALUED — the one question, asked of the one + * predicate `@objectstack/spec` publishes. + * + * Maintainer ruling 2026-09-13 (decision batch #128 item 5, option 1′): there is + * ONE definition of "multi-valued", `isMultiValueField`, and storage follows it. + * Every site below used to read `field.multiple` raw, which answered `true` on + * types the spec predicate calls single-valued — so the DDL built a JSON array + * column, the read-side deserializer agreed with it, and a consumer shaping a + * query from the SPEC predicate composed `=` against that column and was + * answered 400. `FieldSchema` now refuses the flag on those types at the + * authoring entrance, and this is the storage half of the same ruling. + * + * Takes the resolved `type` rather than reading `field.type`, because the + * callers below have already applied their own resolution (`field.type || + * 'string'`, the driver-internal alias default) and two spellings of that + * default is how the drift this fixes started. + */ +export function isMultiValuedColumn(type: string, field: { multiple?: unknown } | null | undefined): boolean { + return isMultiValueField({ type, multiple: field?.multiple === true }); +} + +/** + * [#20444] The declared value shape of every field that declares a `type` — + * the {@link SqlDriver.valueShapeFields} entry for one object. Reads the RAW + * `type`, deliberately not the `field.type || 'string'` default the column + * registries apply: a field with no type has no row of the ruled 「is empty」 + * table, and inventing `'string'` for it here would answer `$empty` with a row + * nobody declared. + */ +export function declaredValueShapes(fields: Record | undefined): Record { + const shapes: Record = {}; + for (const [name, field] of Object.entries(fields ?? {})) { + const type = (field as { type?: unknown } | null | undefined)?.type; + if (typeof type !== 'string' || type === '') continue; + shapes[name] = { type, multiple: (field as { multiple?: unknown }).multiple === true }; + } + return shapes; +} + +/** + * [#17231] ADR-0113's physical column constraint, asked once for every column + * {@link SqlDriver.createColumn} builds. + * + * It exists because `createColumn` has TWO exits: the multi-value short-circuit + * above its per-type switch, and the shared tail below it. The constraint used + * to be read only at the tail, so `storage: { notNull: true }` on a multi-valued + * field was silently inert on the platform's own table while BOTH + * `os generate migration` formats emitted it — one declaration, two databases, + * an INSERT omitting the field accepted by the platform's table and refused by + * every generated one. One exit acquiring the constraint and the other not is + * exactly how that happened, so the predicate is stated once here instead of + * being spelled at each exit. + * + * Truthiness rather than `=== true` deliberately: that is the test the tail has + * always applied, and this move is about WHERE the question is asked, never + * about which values answer it. + */ +function declaresColumnNotNull(field: unknown): boolean { + return Boolean((field as { storage?: { notNull?: boolean } } | null | undefined)?.storage?.notNull); +} + +/** + * [#16319] DDL-time defence: a field declaration with NO `type` gets no column + * — it gets a refusal. + * + * MAINTAINER RULING, 2026-09-10 (director seat batch #111 item 2): 「一个没写 + * type(或拼错)的字段 应该禁止加载」, and 「下游默认值全部改拒绝:`createColumn` + * 的 `|| 'string'` … 一律改为响亮拒绝 … ⛔ 不再猜族;按构造它们应当不可达,拒绝是 + * 防御」. + * + * What it replaces is `createColumn`'s `const type = field.type || 'string'`, + * which sized the column from `declaredVarcharLength(field)` — the declared + * `maxLength` verbatim, knex's 255 without one — while BOTH `os generate + * migration` formats defaulted the SAME declaration to `TEXT`. Two families from + * one declaration: measured on live PostgreSQL 16.13, `{ maxLength: 100 }` with + * no `type` produced `character varying(100)` here and `TEXT` there, so the + * platform refused a 101-character value both generated tables accepted (#16319). + * + * ⭐ By construction this is now unreachable: `SchemaRegistry.registerObject` + * refuses the whole object declaration, and every route into `syncSchema` — + * boot rehydration, package and plugin manifests, programmatic registration — + * is fronted by it. It stays as defence, and it stays LOUD, because the + * alternative is a silent guess and reaching this line means the object was + * handed to the driver by a path the registry does not front. + * + * ⚠️ PRESENCE only, and that boundary is deliberate — ⛔ do not widen it to + * `FieldType` membership here without ruling on the cost. A NON-MEMBER `type` + * still falls to this switch's own catch-all arm, unchanged: membership is + * refused at the registration door for the whole object, which is where the + * ruling put the single point of closure and what its acceptance list means by + * 「驱动永远到不了」. Measured on this tree: 388 sites across ~100 of this + * package's own test files declare a non-member spelling (`'string'` 361, + * `'integer'` 17, `'auto_number'` 5, `'varchar'` 4, `'object'` 1) and drive + * `initObjects` directly, never through the registry — and `'string'` is a + * declared `case` arm of this very switch whose column shape differs from every + * member's, so re-typing them is a corpus migration with column consequences, + * not a spelling fix. + * + * `VALIDATION_ERROR` + 400, exactly as {@link refuseRejectedReferenceAlias} one + * function up: a standard-catalog member, so no new code is minted and this + * package's ledger entry is unchanged. + */ +function refuseUndeclaredFieldType(column: string): never { + const err = new Error( + `[sql-driver] field '${column}' declares no \`type\`, so no column is created for it. ` + + `⛔ The driver no longer guesses a family here: it used to build \`varchar(255)\` (or the ` + + `declared \`maxLength\`) while both \`os generate migration\` formats built \`TEXT\` from ` + + `the same declaration, so the platform refused values the generated tables accepted, in ` + + `both directions and silently. \`FieldSchema\` requires \`type\`, and ` + + `\`SchemaRegistry.registerObject\` refuses the whole object declaration before any DDL runs ` + + `— so reaching this line means the object was handed to \`syncSchema\` by a path that does ` + + `not go through the registry. Give the field a \`FieldType\` member, or remove the field.`, + ) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.VALIDATION_ERROR; + err.status = 400; + throw err; +} + +/* + * [#8445 → #8567] `isUnbackedConflictTargetError` — "is this the conflict + * target is not a key failure?" — is imported from `@objectstack/types` + * (`unbacked-conflict-target.ts`), not written here. + * + * It began as a private regex on SQLite's sentence, because that was the only + * dialect #8445's container could raise the condition on and transcribing the + * other two from memory was ruled out as evidence. #8567 raised it on a real + * Postgres 16.13 through this same knex path and found a second, unrelated + * sentence — so the vocabulary is now per-dialect, which is the shape that + * belongs beside `isUniqueViolationError` rather than inside a driver. The + * measurements, the reason the `code` channel is unread on BOTH dialects + * (SQLite's is generic; Postgres' `42P10` also fires for an out-of-range + * `ORDER BY` position), and the reason MySQL has no limb at all are all + * recorded there. + * + * ⚠️ Deliberately NOT `isUniqueViolationError`, which is imported one line + * away: that predicate answers the OPPOSITE condition (a unique index exists + * and the row violated it). Confusing the two would report a working + * constraint as a missing one. + */ + +/** + * [#8445] A `conflictKeys` upsert whose target no unique index backs. + * + * # What the raw throw did + * + * `mapDataError` builds the response envelope from `error.code` / `error.status`. + * With neither set it falls through to its default branch and ships the thrown + * message as the whole body — and the message above is the STATEMENT, values + * included: `{ "error": "insert into `plain` (...) values ('a@b.com', ...) ..." }`. + * Unbranchable by any client, and an information leak of the statement text. + * That fall-through is not a guess about `mapDataError`; it is the shape + * `sql-driver-date-bucket.test.ts` already records for a `code`/`status`-less + * driver throw. + * + * # Why `VALIDATION_ERROR` / 400 + * + * Reused, deliberately, from the remote twin — the rationale is written out in + * full at `driver-turso`'s `refuseUnbackedConflictTarget` (#8413) and is not + * re-derived here. In one line: a client branching on why its call failed needs + * two facts — **do not retry** (the next attempt fails identically; nothing is + * transient, so a 5xx would invite a retry storm) and **nothing collided** (the + * merge could not be attempted at all, which is why it is not + * `RESOURCE_CONFLICT`/409 and not `DUPLICATE_VALUE`). `conflictKeys` is a + * request argument that did not validate against the target, which is exactly + * what the catalogued generic covers. `status: 400` puts the rejection on + * `@objectstack/rest`'s expected-rejection list, so a caller's un-satisfiable + * upsert stops being logged once per request as an unhandled SERVER fault. + * + * # The wording is the remote refusal's, first sentence for first sentence + * + * #5240 — one condition, one wording. `TursoDriver` picks its face from `url`, + * so this condition can be answered by either compiler in one deployment, and a + * second wording would make the answer a property of the connection string. + * The text below is #8413's, verbatim, including the "table created before its + * `unique` declaration was emitted as DDL" clause: that leg is not remote-only + * — this face creates declared indexes, but it cannot create one over a table + * that already holds duplicates (`syncDeclaredIndexes` degrades rather than + * rewriting rows), so the same table arrives here with the same missing index. + * + * [#8567] One clause moved, on BOTH faces in the same commit: "and SQLite + * refuses the statement" is now "and **the database** refuses the statement". + * This face serves three dialects, and once recognition covers Postgres a + * Postgres operator was being told SQLite had refused their statement — a + * sentence naming the wrong engine, which is worse than a vague one because it + * sends the reader to the wrong manual. #5240 still holds: one condition, one + * wording, so `driver-turso`'s copy moved with it and #8568's cross-face parity + * pin is what proves they moved together rather than drifting apart. + * + * ⚠️ The original error is kept as `cause` rather than discarded — the server's + * own text is the ground truth an operator debugging the table wants (SQLite's + * `ON CONFLICT clause does not match…`, Postgres' `there is no unique or + * exclusion constraint…`), and nothing above this layer can recover it once + * replaced. It is ASSIGNED rather than passed to the `Error` constructor, + * matching the twin: the two-argument form needs the ES2022 `ErrorOptions` + * overload. + */ +export function refuseUnbackedConflictTarget(object: string, mergeKeys: string[], cause: unknown): Error { + const keys = mergeKeys.map((k) => `"${k}"`).join(', '); + const err = new Error( + `Cannot upsert into "${object}" on conflict keys (${keys}): no PRIMARY KEY or UNIQUE index ` + + `backs them, so the merge target does not exist and the database refuses the statement. This is ` + + `usually a table created before its "unique" declaration was emitted as DDL, or conflict ` + + `keys naming columns that were never declared unique. Fix by declaring the column(s) ` + + `"unique: true" and re-running schema sync so the unique index is created — if the table ` + + `already holds duplicate values the index cannot be created until they are resolved, and ` + + `this driver does not rewrite stored rows to force it. Alternatively upsert on the primary ` + + `key by supplying "id" and omitting conflictKeys.`, + ) as Error & { code?: string; status?: number; cause?: unknown }; + err.code = StandardErrorCode.enum.VALIDATION_ERROR; + err.status = 400; + err.cause = cause; + return err; +} + +/** + * What the upsert pre-flight tells {@link SqlDriver.upsert} once it has judged + * the table's physical keys — a value rather than the bare `void` it returned + * while every verdict it could reach was either "proceed" or a throw. + * + * `verifyIdentity` is [#8807]'s third answer: the statement may run, but the row + * it lands on has to be checked afterwards. `rivals` and `tableName` are carried + * so the refusal can name the keys the introspection actually read instead of + * introspecting a second time to say the same thing. + */ +export interface ConflictTargetVerdict { + verifyIdentity: boolean; + rivals?: PhysicalIndex[]; + tableName?: string; +} + +/** + * [#8807] The merge landed on a row the caller never identified. + * + * # Why this is a different condition from both refusals above, per #5240 + * + * {@link refuseUnbackedConflictTarget} answers *no index backs your target* and + * {@link refuseAmbiguousConflictTarget} answers *your target is backed but + * cannot be honoured* — both to a caller who NAMED a target, both before the + * statement is compiled. This one answers a caller who named nothing at all + * (or named `id`, the identity the driver itself supplies), and it can only be + * answered AFTER the statement has run, because the question is which row the + * server picked. Reusing either sentence would instruct an author to change a + * `conflictKeys` argument they never wrote. + * + * The ruled principle it enforces (maintainer, 2026-08-15): *an `upsert` must + * never modify a row whose identity the caller did not supply and whose conflict + * key it did not name.* + * + * # Why the write is rolled back rather than reported + * + * "Never modify" is not satisfied by noticing afterwards. The check therefore + * runs inside a transaction with the statement, and throwing here is what undoes + * the wrong write — see {@link SqlDriver.upsert}. Reporting a completed + * cross-row overwrite would leave the corruption and merely narrate it. + * + * # Why `VALIDATION_ERROR` / 400 + * + * Same classifier, same answer as the two refusals above, and for the same + * reason: this is conditional on the TABLE, not on the dialect. The identical + * statement against the identical MySQL server is honoured the moment the table + * stops carrying a rival UNIQUE key, so the remedy is a schema change and not + * "wait for the backend". 400 also keeps the sentence on the wire — `rest` + * withholds the body of any 5xx, and this message is the deliverable. + * + * Measured on live MySQL 8.0.46 through the same knex + `mysql2` path, `email` + * and `tax_id` both `unique: true`, NO `conflictKeys` at all: + * + * ``` + * seed upsert({email:'d@b.com', tax_id:'T-9', title:'first'}) -> id=iVvD35rMk4BIayYc + * B upsert({email:'e@b.com', tax_id:'T-9', title:'second'}) -> RESOLVED, id=F-Fp1OGCQB-l5XRu + * ONE row, and it is the SEEDED one: iVvD35rMk4BIayYc, its `email` rewritten + * d@b.com -> e@b.com. The id the caller was handed back does not exist in the + * table at all. + * ``` + */ +function refuseCrossRowIdentityMerge( + object: string, + tableName: string, + id: unknown, + rivals: PhysicalIndex[], +): Error { + const named = rivals.map((i) => `${i.name}(${i.columns.join(', ')})`).join(', '); + const err = new Error( + `Cannot upsert into "${object}": the merge landed on a row this call never identified. No ` + + `conflict target was named, so the merge target is the primary key — but this backend is ` + + `MySQL, whose only merge statement is ON DUPLICATE KEY UPDATE, and that statement carries ` + + `no conflict target. The row did not collide on its primary key; it collided on another ` + + `UNIQUE key on "${tableName}" — ${named} — and MySQL merged there instead, overwriting a ` + + `DIFFERENT row than the one this call supplied. The write has been rolled back; nothing was ` + + `changed. Fix by naming the business key you meant to merge on (pass it as conflictKeys, so ` + + `the intent is checkable), by dropping or renaming the extra UNIQUE key(s) so the primary ` + + `key is the only one the row can collide on, or by running this object on a dialect that ` + + `honours the target — SQLite and PostgreSQL compile ON CONFLICT (...), which raises a ` + + `unique violation here instead of merging.`, + ) as Error & { code?: string; status?: number; cause?: unknown }; + err.code = StandardErrorCode.enum.VALIDATION_ERROR; + err.status = 400; + // Schema identifiers plus the id this call supplied — the same payload + // contract the two refusals above keep. The id is the driver's OWN value + // (minted here, or the caller's `id`/`_id`), never a business column value. + err.cause = new Error( + `no row on "${tableName}" carries id ${JSON.stringify(id)} after the merge; UNIQUE keys that ` + + `can absorb a primary-key-targeted merge: ${named}`, + ); + return err; +} + +/** + * [#21185] The wire identity of an upsert refused because its conflict landed + * on a row outside the organization the row is written under: the registered + * `UNIQUE_VIOLATION` / 409, the answer a colliding insert gets. + * + * ⛔ No new code, by the ruling on #21185 (record 5934879010). From the + * caller's organization the conflicting row does not exist — the caller cannot + * read it — so the call IS an insert, and that insert collides on an + * installation-wide key (the primary key, or a `unique: 'global'` column under + * ADR-0120 D1). A dedicated answer would tell the caller something `create()` + * does not; this one tells it exactly what `create()` already does, which is the + * oracle ADR-0120 records as the accepted cost of a `'global'` key. + */ +const UPSERT_UNIQUE_VIOLATION_CODE = 'UNIQUE_VIOLATION'; +/** @see {@link UPSERT_UNIQUE_VIOLATION_CODE} */ +const UPSERT_UNIQUE_VIOLATION_STATUS = 409; + +/** + * [#21185] Build the refusal above. One constructor for both faces — + * {@link SqlDriver.upsert} and the remote face `driver-turso` builds over it — + * so the two cannot answer one condition with two sentences (#5240). + * + * The sentence is the engine's duplicate-record sentence (`DuplicateRecordError` + * in `@objectstack/objectql`), in both of its forms: a single conflict key is + * named, several are not. ⛔ It names no organization, no tenant column and no + * value of the row the conflict landed on, and it carries no `cause` — there is + * no server error to attach, and anything read off that row would be the + * cross-organization detail the refusal exists to withhold. It does not begin + * with a SQL verb (the importer's sanitiser replaces such messages wholesale). + */ +export function refuseUpsertConflictOutsideWrittenOrganization(object: string, mergeKeys: string[]): Error { + const err = new Error( + `Duplicate record refused on '${object}': ` + + (mergeKeys.length === 1 + ? `a unique constraint on '${mergeKeys[0]}' already holds this value. ` + : 'a unique constraint already holds these values. ') + + 'No record was written.', + ) as Error & { code?: string; status?: number }; + err.code = UPSERT_UNIQUE_VIOLATION_CODE; + err.status = UPSERT_UNIQUE_VIOLATION_STATUS; + return err; +} + +/** [#21185] Is this the refusal above? Read off the envelope pair, which no dialect error carries. */ +function isUpsertFenceRefusal(error: unknown): boolean { + const e = error as { code?: unknown; status?: unknown } | null | undefined; + return e?.code === UPSERT_UNIQUE_VIOLATION_CODE && e?.status === UPSERT_UNIQUE_VIOLATION_STATUS; +} + +/** + * [#5158] A `FilterArray` reached the driver unlowered. + * + * `where` is a `FilterCondition` — `QueryASTSchema.where: FilterConditionSchema` + * — and `FilterArray` is INPUT-ONLY authoring sugar the spec declares separately + * (`spec/data/filter.zod.ts`, #5285). Both doors into the runtime lower it + * through `parseFilterAST` before any driver is reached: the protocol face + * (`metadata-protocol`, since #4121) and the engine (`ObjectQL`, ruling C). + * + * Until ruling C this driver carried a SECOND filter compiler for the array + * spelling — one that also accepted an INFIX join form (`[condA, 'or', condB]`) + * no schema ever declared and `parseFilterAST` cannot express. Two compilers + * for one query is the ADR-0053 D-A1 divergence, and it had already produced a + * live product fork: cloud's `RemoteTransport.buildWhereSQL` refuses the exact + * input this method used to compile (cloud#1075), with zero tests on either + * side of the split. Deleting the dialect converges them. + * + * The message names the lowering, not the SQL builder, because the fix is + * always at the caller: lower the value (or go through the engine, which does). + */ +function filterArrayReachedDriverError(filters: unknown[]): Error { + // [#20039, the #8220 contract] The serialized array is the WHOLE predicate — + // every field, operator and literal in it. A read scope handed to this + // driver as the `where` root in array form would be relayed entire, so the + // array itself is the node the seam resolves (it is the root, so its own + // mark is its verdict), and only an `'author'`-marked one gets it back. + return withheldFilterError( + `A filter ARRAY reached the driver. 'where' is a FilterCondition object; the array form ` + + `('FilterArray') is input-only authoring sugar and is lowered by @objectstack/spec ` + + `parseFilterAST() at the engine and protocol doors before any driver sees it. This driver ` + + `no longer carries a second compiler for it — call through ObjectQL, or lower the value ` + + `yourself with parseFilterAST(). Note the INFIX join form ([condA, "or", condB]) has no ` + + `lowering at all: write the prefix form ["or", condA, condB]. The array's contents are ` + + `withheld from the message; the full diagnostic is in the server log.`, + `A filter ARRAY reached the driver: ${JSON.stringify(filters)}. ` + + `'where' is a FilterCondition object; the array form ('FilterArray') is input-only ` + + `authoring sugar and is lowered by @objectstack/spec parseFilterAST() at the engine ` + + `and protocol doors before any driver sees it. This driver no longer carries a ` + + `second compiler for it — call through ObjectQL, or lower the value yourself with ` + + `parseFilterAST(). Note the INFIX join form ([condA, "or", condB]) has no lowering at ` + + `all: write the prefix form ["or", condA, condB].`, + filters, + ); +} + +/** + * [#5702] A RETIRED filter operator reached the compiler. + * + * Separate from {@link unknownFieldOperatorMessage}'s "this name is not in the + * vocabulary" on purpose: `$regex` and `$options` were not typos, they were + * spellings this driver ANSWERED until #4706 retired them, and the author who + * wrote one needs the replacement rather than a list to search. The + * prescription is `RETIRED_FILTER_OPERATORS[op].why`, printed verbatim — the + * spec table exists so that the five refusal sites stop each composing their + * own sentence about the same retirement. + * + * `siblings` are the other keys of the SAME field constraint, and every retired + * one among them is named too. `{ $regex: '^acme', $options: 'i' }` is ONE + * mistake with ONE fix (write `$icontains`), so a message naming only the key + * the loop happened to reach first would send its author back for a second + * round-trip on the other one. + * + * Returns `null` when `op` is not retired, so the caller can fall through to + * the ordinary unknown-operator refusal with one expression. + * + * [#20020, the #8220 contract] The operator, the field, the replacement and + * the retirement note all derive from the predicate, so a refusal raised from + * a read-scope subtree the caller never wrote keeps them in the server log; + * `subtree` is the field's operator map, and only a positively + * `'author'`-marked one gets the full text back. What stays on the wire is the + * class — a RETIRED operator, not an unknown one. + */ +function retiredFilterOperatorError( + op: string, + field: string, + siblings: readonly string[] = [], + subtree?: unknown, +): Error | null { + const guidance = RETIRED_FILTER_OPERATORS[op]; + if (!guidance) return null; + const replacement = guidance.to ? ` Write "${guidance.to}" instead.` : ''; + const alsoRetired = siblings.filter((key) => key !== op && RETIRED_FILTER_OPERATORS[key]); + const also = alsoRetired.length + ? ` The same field constraint also carries the retired ` + + `${alsoRetired.map((key) => `"${key}"`).join(', ')} — one "${guidance.to}" replaces the whole ` + + `shape, so this is ONE mistake with ONE fix, not one per key.` + : ''; + const full = + `Filter operator "${op}" on field "${field}" is RETIRED and is no longer evaluated by this ` + + `driver.${replacement} ${guidance.why}${also}`; + return withheldFilterError( + 'A filter operator in this filter is RETIRED and is no longer evaluated by this driver. The ' + + 'operator, the field it was aimed at and the operator that replaces it are withheld from ' + + 'the message; the full diagnostic is in the server log.', + full, + subtree, + ); +} + +/** + * The operator vocabulary {@link SqlDriver.applyFilterCondition}'s emitter + * compiles, as the refusal below states it. A capability statement, not a + * detail of the refused predicate, so it survives the redaction. + */ +const SUPPORTED_FILTER_OPERATORS_SENTENCE = + 'Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $between, $contains, ' + + '$notContains, $startsWith, $endsWith, $icontains, $like, $ilike, $null, $exists, $empty.'; + +/** + * An operator outside the emitter's vocabulary and outside the retired table. + * + * [#20020, the #8220 contract] The operator name and the field are the + * predicate's, so they go to the server log unless `subtree` — the field's + * operator map — is positively marked `'author'`. The supported vocabulary is + * the capability statement and stays on the wire either way. + */ +function unsupportedFilterOperatorError(op: string, field: string, subtree?: unknown): Error { + return withheldFilterError( + `A filter operator in this filter is not one this driver evaluates. ` + + `${SUPPORTED_FILTER_OPERATORS_SENTENCE} The operator and the field it was aimed at are ` + + `withheld from the message; the full diagnostic is in the server log.`, + `Unsupported filter operator "${op}" on field "${field}". ${SUPPORTED_FILTER_OPERATORS_SENTENCE}`, + subtree, + ); +} + +/** + * [#5702] `$icontains` received a comparand that is not a non-empty string. + * + * Two rejections, one constructor, because they are one mistake at the + * comparand position and the repair is the same sentence: + * + * - **non-string** — `StringOperatorSchema` declares `$icontains: z.string()`. + * Coercing `42` to `"42"` answers a query nobody wrote (the reading + * `applyLike`'s `String(value)` would otherwise give it). + * - **empty string** — every row contains the empty substring, so the predicate + * constrains nothing. A dropped predicate WIDENS a result set, and on an RLS + * read scope that is a permission bypass rather than a degraded filter + * (#3948) — the same reason #5240 refused `{ field: {} }` one level up. + */ +function icontainsComparandError(field: string, value: unknown, path: string, subtree?: unknown): Error { + const shown = typeof value === 'string' ? `""` : JSON.stringify(value) ?? String(value); + // [#20039, the #8220 contract] The field, the path and the comparand are the + // predicate's; the operator names the refusal's CLASS (one builder, one + // operator), so it stays on the wire with the capability statement. + return withheldFilterError( + `Operator "$icontains" in this filter requires a NON-EMPTY string comparand. "$icontains" is a ` + + `case-insensitive LITERAL substring search, so its comparand is the text to look for — an ` + + `empty one matches every row (a predicate that constrains nothing), and a non-string one ` + + `would have to be coerced into text this query never asked for. The field it was aimed at ` + + `and the value it received are withheld from the message; the full diagnostic is in the ` + + `server log.`, + `Operator "$icontains" on field "${field}" at ${path} requires a NON-EMPTY string comparand, ` + + `received ${shown}. "$icontains" is a case-insensitive LITERAL substring search, so its ` + + `comparand is the text to look for — an empty one matches every row (a predicate that ` + + `constrains nothing), and a non-string one would have to be coerced into text this query ` + + `never asked for.`, + subtree, + ); +} + +/** + * [#7536] `$like` / `$ilike` received a comparand that is not a string. + * + * The declared comparand is `z.string()`, and the coercion the emitter would + * otherwise apply is worse here than at `$icontains`: a `$like` comparand is a + * PATTERN, so `String(value)` does not merely invent text, it invents WILDCARDS + * — `String({})` is `[object Object]`, whose `[` opens a GLOB character class + * on the SQLite dialects. An empty string is deliberately NOT refused (see the + * gate's own note): `LIKE ''` matches only the empty string, which constrains + * plenty. + */ +function likePatternComparandError( + field: string, + op: string, + value: unknown, + path: string, + subtree?: unknown, +): Error { + // [#20039, the #8220 contract] `$like` and `$ilike` share this builder, so + // WHICH of the two fired is the predicate's, not the class's — withheld with + // the field, the path and the comparand, as the combinator door withholds + // which of `$and` / `$or` it was. + return withheldFilterError( + `A pattern operator ("$like" / "$ilike") in this filter requires a string comparand. It takes ` + + `a PATTERN — "%" matches any sequence, "_" matches one character, and a backslash escapes ` + + `either — so a non-string comparand cannot be coerced without inventing wildcards the caller ` + + `never wrote. For a literal substring search write "$contains", whose comparand IS text. ` + + `Which operator it was, the field it was aimed at and the value it received are withheld ` + + `from the message; the full diagnostic is in the server log.`, + `Operator "${op}" on field "${field}" at ${path} requires a string comparand, received ` + + `${describeFilterOperand(value)} (${safeShapePreview(value)}). "${op}" takes a PATTERN — ` + + `"%" matches any sequence, "_" matches one character, and a backslash escapes either — so ` + + `a non-string comparand cannot be coerced without inventing wildcards the caller never ` + + `wrote. For a literal substring search write "$contains", whose comparand IS text.`, + subtree, + ); +} + +/** + * [#7536] A `$like` / `$ilike` pattern ending in a lone unpaired backslash. + * + * Refused rather than given a meaning because no meaning survives all five + * backends: Postgres raises `LIKE pattern must not end with escape character` + * at query time, SQLite's GLOB has no escape character at all and would read + * the backslash as an ordinary one, and the JS faces would have to invent a + * third answer. A shape that cannot mean one thing everywhere is refused at the + * door — the direction #5041 / #5234 already settled for comparands this driver + * cannot compile faithfully. `hasDanglingLikeEscape` is the spec's shared test, + * so every face refuses the SAME patterns. + */ +function danglingLikeEscapeError( + field: string, + op: string, + pattern: string, + path: string, + subtree?: unknown, +): Error { + // [#20039, the #8220 contract] The pattern is the predicate's literal, and so + // are the field, the path and which of the two pattern operators it was. + return withheldFilterError( + `A pattern operator ("$like" / "$ilike") in this filter has a pattern ending in a lone unpaired ` + + `backslash. A backslash escapes the character after it, so a trailing one escapes nothing and ` + + `the backends disagree about what it means — Postgres rejects the pattern outright, SQLite's ` + + `GLOB has no escape character to reject. Write "\\\\\\\\" to match a literal backslash, or drop ` + + `the trailing one. Which operator it was, the field it was aimed at and the pattern are ` + + `withheld from the message; the full diagnostic is in the server log.`, + `Operator "${op}" on field "${field}" at ${path} has a pattern ending in a lone unpaired ` + + `backslash (${JSON.stringify(pattern)}). A backslash escapes the character after it, so a ` + + `trailing one escapes nothing and the backends disagree about what it means — Postgres ` + + `rejects the pattern outright, SQLite's GLOB has no escape character to reject. Write ` + + `"\\\\\\\\" to match a literal backslash, or drop the trailing one.`, + subtree, + ); +} + +/** + * [#20041] A `$like` / `$ilike` pattern holding U+0000 (NUL). + * + * Refused for the reason {@link danglingLikeEscapeError} is: no meaning survives + * every backend. This driver's SQLite arm compiles the pattern to `GLOB`, and + * SQLite reads a pattern only up to its first U+0000, so the pattern was CUT + * there and answered a different question than the JS faces, with nothing + * raised. Measured on better-sqlite3, sql.js and libSQL alike: `'%'` + U+0000 + * returned every non-NULL row, where `@objectstack/formula` returns only the + * values ending in U+0000. There is no NUL-safe SQLite pattern primitive to + * compile to instead. `hasNulInLikePattern` is the spec's shared test, asked on + * the same walk as the dangling escape, so every face refuses the SAME patterns + * — on every dialect of this driver, not only SQLite. + * + * Asked AFTER the dangling escape, so a pattern that has both keeps the refusal + * it already had. + */ +function nulLikePatternError( + field: string, + op: string, + pattern: string, + path: string, + subtree?: unknown, +): Error { + // [#20041, the #8220 contract] The pattern is the predicate's literal, and so + // are the field, the path and which of the two pattern operators it was. + return withheldFilterError( + `A pattern operator ("$like" / "$ilike") in this filter has a pattern holding the NUL character ` + + `U+0000. SQLite reads a pattern only up to its first NUL, so such a pattern would match a ` + + `different set of rows there than on the other backends, and no escape makes the character ` + + `portable. Remove it from the pattern. Which operator it was, the field it was aimed at and ` + + `the pattern are withheld from the message; the full diagnostic is in the server log.`, + `Operator "${op}" on field "${field}" at ${path} has a pattern holding the NUL character ` + + `U+0000 (${JSON.stringify(pattern)}). SQLite reads a pattern only up to its first NUL, so ` + + `such a pattern would match a different set of rows there than on the other backends, and ` + + `no escape makes the character portable. Remove it from the pattern.`, + subtree, + ); +} + +/** + * [#5041] The referenced field name when `value` is a Filter Protocol FIELD + * REFERENCE (`{ $field: 'other_column' }` — spec `FieldReferenceSchema` in + * `data/filter.zod.ts`), else `null`. + * + * The predicate deliberately mirrors `@objectstack/formula`'s `resolveValue` + * (`matches-filter.ts`): an object, not an array, carrying a `$field` key. The + * two execution paths must agree on **what a field reference is**; they differ + * only in what they DO with one — the in-memory evaluator resolves it against + * the record, this driver refuses it (below). A driver that recognised a + * narrower shape than the evaluator would silently bind the remainder as + * literal values again, which is precisely the defect being closed. + */ +export function fieldReferenceOf(value: unknown): string | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const ref = (value as Record).$field; + return typeof ref === 'string' ? ref : null; +} + +/** + * [#25808] A `{ $now, addDays }` now-anchored comparand — the current instant + * shifted by whole days, compared against a temporal column of the row + * (`NowAnchorSchema`, `data/filter.zod.ts`; maintainer ruling 2026-10-26, + * letter A). Recognised by SHAPE — an object carrying a `$now` key — the way + * {@link fieldReferenceOf} recognises a reference, and for the same reason: the + * evaluator (`@objectstack/formula`'s `resolveValue`) reads the same shape, so + * the two paths agree on what an anchor IS and a malformed one is refused here + * rather than bound as a literal object. + * + * `$now` arrives in two spellings. `true` is the AUTHORED form; the engine's + * token resolver (`resolveFilterTokens`, `@objectstack/core`) pins it to the + * tree's instant — the ISO string of the same `now` every `{N_days_ago}` + * token in the tree resolved from — before any driver runs. A caller that + * reaches this driver without that seam (a direct `find`, a permission filter + * assembled in code) still carries `true`, and the driver reads its own clock + * for it, as the evaluator does. Anything else is `invalid`. + */ +export type NowAnchor = { kind: 'instant'; at: Date } | { kind: 'invalid'; reason: string }; + +function nowAnchorOf(value: unknown): NowAnchor | null { + if (!value || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) return null; + if (!('$now' in value)) return null; + const raw = (value as Record).$now; + if (raw === true) return { kind: 'instant', at: new Date() }; + if (typeof raw === 'string') { + const ms = Date.parse(raw); + if (Number.isFinite(ms)) return { kind: 'instant', at: new Date(ms) }; + return { kind: 'invalid', reason: `$now ${JSON.stringify(raw)} is not an instant the resolver could have pinned` }; + } + return { + kind: 'invalid', + reason: `$now must be the literal true (the resolver pins it to the current instant), not ${JSON.stringify(raw)}`, + }; +} + +/** + * [#25808] A `{ $now }` anchor reached a position this driver does not compile: + * a list member, a text pattern, the bare `{ field: { $now: true } }` spelling + * (which reaches the operator loop as an operator named `$now`). The same + * boundary as {@link crossFieldComparisonError}, for the same reason, with the + * same withholding (#7929): the anchor compiles only as the WHOLE comparand of + * one of the six scalar comparisons, where {@link SqlDriver.applyNowAnchorComparison} + * answers it. + */ +export function nowAnchorPositionError(field: string, op: string, subtree?: unknown, index?: number): Error { + const position = index === undefined ? '' : ` at index ${index} of its value list`; + const diagnostic = + `Operator "${op}" on field "${field}" compares against a now-anchored comparand ` + + `({ "$now": true, "addDays": … })${position}, a position SQL push-down does not compile.`; + return withheldFilterError( + `A now-anchored comparand ({ "$now": true, "addDays": … }) in this filter sits in a position SQL ` + + `push-down does not compile. It compiles only as the whole comparand of a scalar comparison ` + + `operator ($eq/$ne/$gt/$gte/$lt/$lte) on a date or datetime column. Compare against a literal ` + + `value here, or write the test as one of those comparisons. The field and the operator this ` + + `filter used are withheld from the message; the full diagnostic is in the server log.`, + diagnostic, + subtree, + `${diagnostic} It compiles only as the whole comparand of a scalar comparison operator ` + + `($eq/$ne/$gt/$gte/$lt/$lte) on a date or datetime column.`, + ); +} + +/** + * [#5041→#5222] `{ $field }` reached a position this driver does NOT compile to + * a column-to-column comparison. + * + * `FieldReferenceSchema` is declared in the spec and really is PRODUCED — + * `compileCelToFilter` emits `{ $field: path }` for a field-to-field comparison + * in a CEL permission/RLS rule. #5041 refused the shape everywhere (before it, + * the reference object was handed to Knex as a BIND VALUE and sqlite answered + * with a bare `TypeError`); #5222 narrowed that gate: the reference now + * COMPILES when it is the whole comparand of one of the six scalar comparison + * operators ({@link CROSS_FIELD_COMPARISON_OPERATORS} — see + * {@link SqlDriver.applyCrossFieldComparison}). This error answers every + * position still outside that boundary, deliberately: + * + * - **`$in` / `$nin` / `$between` list MEMBERS.** The memory evaluator resolves + * a member per record and `looseEq`s it, which SQL's `IN (…)` bind list has + * no slot for — compiling it would need per-member OR-expansion whose NULL + * and type-affinity behaviour was not proven conformance-equivalent, and an + * unproven push-down of a permission shape is the #3948 class. Before #5041 + * this position did not even crash: it compiled and returned ZERO ROWS. + * - **The string operators (`$contains` / `$startsWith` / … / `like`).** In + * memory the referenced value is matched as a LITERAL substring; in SQL it + * would become the TEXT of a LIKE pattern, so any `%` / `_` / `\` stored in + * the referenced column would act as WILDCARDS — the filter-bypass class + * `applyLike` escapes literal comparands to prevent. Escaping a column-side + * value portably needs a dialect REPLACE chain nobody has proven; refused. + * - **Any other operator** (`$null`, a retired spelling, …): no cross-field + * reading exists to compile. + * + * [#7929] The operands, the operator and the list index are withheld from the + * caller-visible half and kept in the server-log half — see + * {@link withheldFilterError} for why, and for what stays. [#8220] `subtree` + * is the offending node itself, so a positively author-marked filter gets the + * naming half back. + */ +export function crossFieldComparisonError( + field: string, + op: string, + ref: string, + subtree?: unknown, + index?: number, +): Error { + const position = index === undefined ? '' : ` at index ${index} of its value list`; + const diagnostic = + `Operator "${op}" on field "${field}" compares against another field ` + + `({ "$field": "${ref}" })${position}, a position SQL push-down does not compile.`; + return withheldFilterError( + `A cross-field comparison ({ "$field": … }) in this filter sits in a position SQL ` + + `push-down does not compile. Cross-field comparison compiles only as the whole comparand ` + + `of a scalar comparison operator ($eq/$ne/$gt/$gte/$lt/$lte) between two same-table ` + + `declared columns. Compare against a literal value here, or evaluate the rule in memory ` + + `(matchesFilter). The columns and the operator this filter used are withheld from the ` + + `message; the full diagnostic is in the server log.`, + diagnostic, + subtree, + `${diagnostic} Cross-field comparison compiles only as the whole comparand of a scalar ` + + `comparison operator ($eq/$ne/$gt/$gte/$lt/$lte) between two same-table declared columns. ` + + `Compare against a literal value here, or evaluate the rule in memory (matchesFilter).`, + ); +} + +/** + * [#5222] `{ field: { $field: 'other' } }` — a field reference used as a BARE + * field spec, i.e. in the implicit-equality position where a literal comparand + * would mean `field = value`. + * + * ## [#7597] It no longer has an AUTHORING route — and is still refused + * + * `parseFilterAST` used to lower the authored triple + * `['amount', '=', { $field: 'budget' }]` (and its `==` / `equals` / `eq` + * spellings) to exactly this shape, while `['amount', '>', …]` kept its + * operator and lowered to `{ $gt: … }` — one authoring dialect producing both + * a supported and an unsupported spelling of the same intent, with the + * unsupported one silent on the path that produced it. #7597 fixed the sink: + * an equality triple whose comparand is a `FieldReferenceSchema` now lowers to + * `{ $eq: ref }`, which this driver compiles. The array sugar therefore cannot + * reach this error any more. + * + * What CAN still reach it is a hand-authored `FilterCondition` carrying the + * bare form, and that keeps being refused rather than compiled to `$eq`, + * deliberately: the in-memory evaluator does NOT read this shape as an + * equality. `matches-filter.ts` `evalField` sees an all-`$` key set and + * dispatches `$field` to `evalOp`, which has no arm for it and answers `false` + * (its fail-closed default). Compiling a column-to-column equality here would + * make SQL answer rows for a filter the memory path answers `false` for — a + * NEW divergence. #7597 ruled that the evaluator's unknown-operator posture + * stays as #6520 left it and moved the LOWERING instead, so this message keeps + * pointing at the `$eq` spelling that does compile. + * + * [#7929] It points at that spelling with PLACEHOLDER names now. The old text + * wrote the corrected filter out with the two real column names — twice — which + * on a read-scope refusal hands the administrator's policy back to the tenant + * as a suggestion. The SHAPE survives the redaction; the names do not. + */ +function bareFieldReferenceError(field: string, ref: string, subtree?: unknown): Error { + const diagnostic = + `Field "${field}" is constrained by a bare field reference ({ "$field": "${ref}" }) with no ` + + `operator. The spelling that compiles is ` + + `{ "${field}": { "$eq": { "$field": "${ref}" } } }.`; + return withheldFilterError( + `A field in this filter is constrained by a bare field reference ({ "$field": … }) with no ` + + `operator. Write the comparison explicitly — { "TARGET_FIELD": { "$eq": { "$field": ` + + `"OTHER_FIELD" } } } — which compiles to a column-to-column comparison. The bare form is ` + + `refused because the in-memory evaluator does not read it as an equality (it matches no ` + + `record), so compiling it here would make the two execution paths answer this filter ` + + `differently. The columns this filter named are withheld from the message; the ` + + `full diagnostic is in the server log.`, + diagnostic, + subtree, + `${diagnostic} The bare form is refused because the in-memory evaluator does not read it as ` + + `an equality (it matches no record), so compiling it here would make the two execution ` + + `paths answer this filter differently.`, + ); +} + +/** + * [#5222] The operators whose `{ $field }` comparand compiles to a + * column-to-column comparison — the six scalar comparisons, in the `$`-form + * spelling the emitters read (array-triple / infix authorings are lowered to + * these before a driver ever runs; see `parseFilterAST` and #5158). + * + * Deliberately NOT the whole of {@link SCALAR_COMPARAND_OPERATORS}: `like` / + * `ilike` are in that set only for the bind gate, and a cross-field LIKE is + * refused (see {@link crossFieldComparisonError}). + */ +const CROSS_FIELD_COMPARISON_OPERATORS: ReadonlySet = new Set([ + '$eq', '$ne', '$gt', '$gte', '$lt', '$lte', +]); + +/** + * [#5041] Operators whose comparand is a single bound VALUE, in the Filter + * Protocol `$`-form read by {@link SqlDriver.applyFilterCondition} — the one + * spelling this driver still compiles. It also listed the canonicalised infix + * form, read by an `applyAstComparison` emitter deleted with the array dialect + * in #5158; the infix spellings stay in the set because + * {@link assertCompilableComparand} is called with an already-canonicalised + * operator on the reduction path. + * + * The list-shaped operators (`$in` / `$nin` / `$between`) are deliberately + * ABSENT: an array is their legitimate comparand, and they compile through + * their own `whereIn` / `whereBetween` arms. Only their MEMBERS are inspected — + * for `$field` (#5041) and, since #5234, for bindability — never their arity: + * the existing descriptive `$between` refusal stays the one that answers a + * malformed range. See {@link isListComparandOperator}. + */ +const SCALAR_COMPARAND_OPERATORS: ReadonlySet = new Set([ + '$eq', '$ne', '$gt', '$gte', '$lt', '$lte', + '=', '==', '!=', '<>', '>', '>=', '<', '<=', 'like', 'ilike', + // [#7536] The `$`-forms of the two infix spellings already here. They are + // SCALAR rather than {@link TEXT_PATTERN_OPERATORS} for the reason that set's + // note gives: their comparand ARRIVES as the pattern, so nothing wraps or + // escapes it and the question to ask of the value is "can this be bound", + // which is this set's question. + '$like', '$ilike', +]); + +/** + * Can this value be handed to a driver as a bound parameter at all? + * + * Same classification the write path applies in `formatInput` — anything that + * is not a primitive, a `Date` or a binary buffer is a shape better-sqlite3 + * refuses outright and the other dialects mangle. (`ArrayBuffer.isView` covers + * `Buffer`, which is a `Uint8Array`.) + * + * [#7872] The type membership is the shared comparand-type door's now + * (`isAcceptedFilterComparand`, `@objectstack/spec/data`) — this list and + * `RemoteTransport.serializeComparand`'s reached the identical six types twice + * independently, which is the measured fact the door was ruled from, so the SET + * is sourced there rather than duplicated here. Two driver-local extras stay, + * each recorded: `undefined` answers TRUE only because the #6050 walk refuses + * every undefined comparand FIRST with its purpose-written message, so this arm + * is unreachable on the filter path and flipping it would only swap which error + * fires for a direct caller; `ArrayBuffer.isView` is this driver's own bindable + * (blob columns) that the engine-level door does not admit — it remains + * reachable from direct driver calls, which is also where it was ever usable. + */ +function isBindableComparand(value: unknown): boolean { + if (value === undefined) return true; + return isAcceptedFilterComparand(value) || ArrayBuffer.isView(value); +} + +/** + * [#5234] Operators whose comparand becomes the TEXT of a `LIKE` pattern, i.e. + * the ones {@link SqlDriver.applyLike} serves. Kept separate from + * {@link SCALAR_COMPARAND_OPERATORS} because the two ask different questions of + * the same value: a scalar operator needs a value a driver can BIND, a pattern + * operator needs one that has a faithful TEXT rendering. Every value in the + * first set except a binary buffer is also in the second, but the reason is not + * the same reason, and the messages a caller needs differ. + * + * `like` / `ilike` (and, since #7536, their `$like` / `$ilike` spellings) are + * absent on purpose: they arrive already carrying a pattern and compile + * through the scalar bind arm, which already refuses an object. + */ +const TEXT_PATTERN_OPERATORS: ReadonlySet = new Set([ + '$contains', '$notContains', '$startsWith', '$endsWith', '$icontains', +]); + +/** + * [#7536] The two operators whose comparand IS a `LIKE` pattern — the caller's + * own wildcards, neither escaped nor wrapped. + * + * Kept as a named set beside {@link TEXT_PATTERN_OPERATORS} rather than folded + * into it because the two families ask OPPOSITE things of the same string: a + * `$contains` comparand is text that must be made literal (every `%` escaped), + * while a `$like` comparand is a pattern that must be left alone (every `%` is + * the caller's wildcard). Running one through the other's rule is precisely the + * defect #7536 closes. + */ +const LIKE_PATTERN_OPERATORS: ReadonlySet = new Set(['$like', '$ilike']); + +/** + * [#14079] Every operator that reads the column as TEXT — the union of + * {@link TEXT_PATTERN_OPERATORS} and {@link LIKE_PATTERN_OPERATORS}. The two + * families ask opposite things of their COMPARAND (the docblocks above), but + * they ask the same thing of the COLUMN: that its stored value be a string. The + * one place that question is answered at compile time is + * {@link SqlDriver.applyTextOperatorOverNonTextColumn}, and this set is what + * routes an operator there. + */ +const TEXT_OPERATORS: ReadonlySet = new Set([ + ...TEXT_PATTERN_OPERATORS, + ...LIKE_PATTERN_OPERATORS, +]); + +/** + * [#5234] Does this value have a faithful rendering as the text of a LIKE + * pattern? + * + * An ALLOW-list, deliberately, and the same one `driver-turso`'s + * `RemoteTransport.serializeComparand` settled on for the identical question in + * remote mode (cloud#1004 / #1058): a deny-list silently re-admits whatever + * value form is invented next, which is exactly how that bug survived its first + * fix. `undefined` is inside the fence only because it is not authorable (JSON + * has no `undefined`) and the analytics door normalises it to `null` rather than + * refusing it (#5526) — refusing it HERE would invent a disagreement rather than + * close one. See {@link unrenderableTextComparandError} for the rest. + */ +function isRenderableTextComparand(value: unknown): boolean { + // [#7872] The six-type membership is the shared door's; `undefined` stays a + // recorded local admission (see the paragraph above — the #6050 walk refuses + // it first, so the arm is unreachable on the filter path). + return value === undefined || isAcceptedFilterComparand(value); +} + +/** + * [#5041] The one gate every comparison comparand passes before it becomes a + * bind parameter, covering both halves of the gap the issue measured: + * + * 1. **`{ $field }`** — declared, produced, and implemented only in memory. + * Refused wherever it appears, including inside an `$in` / `$nin` / `$between` + * list. The list case matters more than it looks: a `$field` member did not + * even crash — the query compiled, ran, and returned ZERO ROWS. A silent + * wrong answer is what #3948 / #4209 settled is strictly worse than an error + * on a permission-scoped read. + * + * 2. **The general arm** — a known operator whose comparand is a shape no + * dialect can bind (a plain object, an array where one value belongs). The + * issue noted this branch had no rejection arm at all; measured, every such + * shape produced the same bare `TypeError`. Scoped to + * {@link SCALAR_COMPARAND_OPERATORS} so the legitimate array binds keep + * working untouched. + * + * 3. **[#5234] The two shapes #5041 deliberately left out** — a non-`$field` + * OBJECT member of an `$in`/`$nin` list, and an object comparand on the + * `LIKE` family. #5041 read them as a lesser class ("a filter applied + * nonsensically, not one that cannot be applied") whose direction was + * fail-closed, and stopped. Both halves of that reading were measured wrong + * on `main` before this change: + * + * - The direction is not uniformly fail-closed. `{status: {$nin: [{…}]}}` + * compiles to `NOT IN ('[object Object]')`, which excludes NOTHING — an + * exclusion the caller wrote that silently does not happen. On a + * read-scope lowering that is over-reach, the same direction #5347 / #5324 + * ruled on. `$notContains` does it too: it EXCLUDED the one fixture row + * whose text happened to be `[object Object]`. + * - The answers were never merely "zero rows". Against a row literally + * named `[object Object]`, `{name: {$contains: {}}}` MATCHED it. That is + * not a narrowed result set, it is a wrong one. + * + * Two more measurements decided the exact fence: + * + * - An ARRAY comparand on the LIKE family already forked INSIDE + * `service-analytics`: `{name: {$contains: ['al','be']}}` binds `%al,be%` + * through `read-scope-sql` (and here, via `String(array)`) but `%al%` + * through the analytics `where` door, which reads `values[0]`. Refusing + * the array closes a live split rather than opening one. + * - `driver-turso`'s `RemoteTransport` has refused BOTH of these shapes + * since cloud#1004 / #1058, with the reasoning written out: refusing an + * uncompilable comparand "in one family while tolerating it in the other + * would leave the failure mode alive at a different spelling." So local + * SQLite and remote SQLite answered the same query differently. This + * change is what converges them, and it copies that fix's ALLOW-list + * shape (see {@link isRenderableTextComparand}) rather than inventing a + * second policy. + * + * What stays accepted is measured, not assumed: `{$contains: 5}` → `%5%` + * and `{$contains: null}` → `%null%` agree across this driver, + * `driver-memory` and both analytics faces today, and #5526 pinned the + * `null` reading deliberately. Primitives are therefore untouched; only + * objects — for which `String()` has no faithful answer — are refused. + */ +function assertCompilableComparand( + field: string, + op: string, + value: unknown, + // [#8197] The node ENCLOSING this comparand — the field-spec map, or the + // filter node for the bare `{ field: value }` spelling. Used only when the + // comparand itself cannot carry a mark; see {@link refusalSubtree}. + enclosing?: unknown, +): void { + const ref = fieldReferenceOf(value); + if (ref !== null) throw crossFieldComparisonError(field, op, ref, value); + // [#25808] …and a now-anchored comparand, outside the six comparisons. + if (nowAnchorOf(value) !== null) throw nowAnchorPositionError(field, op, value); + + // [#5234] The pattern family answers first: an array IS an object here, so + // the member scan below would otherwise report `{$contains: ['a', {}]}` as a + // bad LIST member — a message about a list the operator never takes. + if (TEXT_PATTERN_OPERATORS.has(op) && !isRenderableTextComparand(value)) { + // [#20039] `value` is an object or an array here — markable, and the + // deepest node the throw site holds (see {@link refusalSubtree}). + throw unrenderableTextComparandError(field, op, value, refusalSubtree(value, enclosing)); + } + + if (Array.isArray(value)) { + for (const [index, member] of value.entries()) { + const memberRef = fieldReferenceOf(member); + if (memberRef !== null) throw crossFieldComparisonError(field, op, memberRef, member, index); + if (nowAnchorOf(member) !== null) throw nowAnchorPositionError(field, op, member, index); + // [#5234] Every member of a list operator's array is a comparand in its + // own right and gets the same bind test the whole comparand gets. Scoped + // to the operators for which an array is legitimate (the spec's list + // half, {@link isListComparandOperator}), so a scalar operator handed an + // array keeps answering with its own message ("requires a single + // comparable value") instead of reporting the first bad member of a list + // it should never have been given. + // [#20039] The member when it is an object, else the list that holds it. + if (isListComparandOperator(op) && !isBindableComparand(member)) { + throw unbindableListMemberError(field, op, member, index, refusalSubtree(member, value)); + } + } + // An array IS the comparand for the list operators; only a scalar operator + // is wrong to receive one, and that falls through to the check below. + } + + if (!SCALAR_COMPARAND_OPERATORS.has(op) || isBindableComparand(value)) return; + + throw unbindableComparandError(field, op, value, refusalSubtree(value, enclosing)); +} + +/** + * [#5041] A scalar operator's comparand that cannot become a bind parameter. + * + * [#8197] Extracted from {@link assertCompilableComparand}'s body — where it was + * an inline `unsupportedFilterError` — for one reason: it is a member of + * {@link refusalSubtree}'s family and needed somewhere to compose its redacted + * and restored wordings side by side, which is the arrangement every other + * withheld refusal in this file already uses. + * + * It also answers the MALFORMED cross-field spelling. `{ "$gt": { "$field": 42 } }` + * is not a `FieldReferenceSchema` (the referent must be a string), so + * {@link fieldReferenceOf} does not recognise it and the cross-field arm never + * sees it — it arrives here as an ordinary unbindable object. Its comparand + * preview used to print `{"$field":42}` beside the target column; both halves + * are predicate-derived and both are now redacted. + */ +function unbindableComparandError( + field: string, + op: string, + value: unknown, + subtree?: unknown, +): Error { + return withheldFilterError( + `A comparison in this filter requires a single comparable value, but received a value that ` + + `cannot be bound as a SQL parameter. Use ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} (or a ` + + `binary value); for a list use $in/$nin, and for a range use $between. The field, the ` + + `operator and the value this filter used are withheld from the message; the full ` + + `diagnostic is in the server log.`, + `Operator "${op}" on field "${field}" requires a single comparable value, but received ` + + `${Array.isArray(value) ? 'an array' : `an object (${safeShapePreview(value)})`}, which cannot be ` + + `bound as a SQL parameter. Use ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} (or a binary ` + + `value); for a list use $in/$nin, and for a range use $between.`, + subtree, + ); +} + +/** + * `$between` handed something that is not a two-element range. + * + * [#8197] The operator is NAMED in the redacted half, and it is the one thing in + * this family that is: `$between` here is the refusal's CLASS, not a variable of + * it — the same "which class fired" {@link crossFieldComparisonError} keeps. The + * three refusals whose operator varies within one class (the JSON-column gate, + * the bind gate) withhold theirs, because there the operator says WHICH + * constraint of the predicate failed rather than which rule refused it. + * + * The target field is the whole disclosure, so redacting it leaves a sentence + * that is still the complete repair: a `$between` takes `[min, max]`, and an + * author who wrote one malformed range in one filter has no second candidate to + * confuse it with. + */ +function betweenArityError(field: string, subtree?: unknown): Error { + return withheldFilterError( + `Operator "$between" in this filter requires a [min, max] value array. The field it was ` + + `aimed at is withheld from the message; the full diagnostic is in the server log.`, + `Operator "$between" on field "${field}" requires a [min, max] value array.`, + subtree, + ); +} + +/** + * [#5234] A member of an `$in` / `$nin` / `$between` list that cannot become a + * bind parameter. + * + * The list case is the one that hides. A scalar operator handed an object fails + * to bind and at least says so; a LIST simply loses the member — Knex binds it, + * the statement is valid, and the offending entry can never equal any stored + * value. So `{status: {$in: ['a', {…}]}}` answers exactly as if the author had + * written `{$in: ['a']}`, and `{status: {$nin: [{…}]}}` excludes nothing at all + * while claiming to exclude something. Neither is reported anywhere. + * + * The message names the INDEX because that is the only thing distinguishing the + * bad entry from its legitimate neighbours — the same reason + * {@link crossFieldComparisonError} takes one. + */ +function unbindableListMemberError( + field: string, + op: string, + value: unknown, + index: number, + subtree?: unknown, +): Error { + // [#20039, the #8220 contract] The index is what tells the bad member apart + // from its neighbours — for the AUTHOR. On a read-scope list it, the member's + // preview, the field and which of the three list operators it was are all the + // administrator's, so they go to the server log. + return withheldFilterError( + `A list operator ("$in" / "$nin" / "$between") in this filter has a member that cannot be ` + + `bound as a SQL parameter. Every member of an $in/$nin/$between list is a comparand in its ` + + `own right — use ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} (or a binary value). Refusing ` + + `rather than binding it: the member can equal no stored value, so the list silently loses ` + + `that entry (and a $nin loses the exclusion the caller wrote). Which operator it was, the ` + + `field it was aimed at, the member's position in the list and its value are withheld from ` + + `the message; the full diagnostic is in the server log.`, + `Operator "${op}" on field "${field}" has a value at index ${index} of its list that cannot be ` + + `bound as a SQL parameter: ${safeShapePreview(value)}. Every member of an $in/$nin/$between ` + + `list is a comparand in its own right — use ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} ` + + `(or a binary value). Refusing rather than binding it: the member can equal no stored value, ` + + `so the list silently loses that entry (and a $nin loses the exclusion the caller wrote).`, + subtree, + ); +} + +/** + * [#5234] An object where the `LIKE` family expects the text of a pattern. + * + * `applyLike` reaches the comparand through `String(value)`, and `String({})` is + * the literal `'[object Object]'`. The result is a syntactically perfect, + * parameterised `LIKE` against a string the author never wrote — and it is not + * merely always-false: a stored value that happens to READ `[object Object]` + * matches it, which is how this was measured. `$notContains` inverts that into + * excluding a real row for a reason nothing records. + * + * `filter.zod.ts`'s `StringOperatorSchema` declares every one of these + * comparands `z.string()`, so this refusal enforces a declaration that already + * exists rather than adding a rule (Prime Directive #12 — declared = enforced). + * It stops at OBJECTS on purpose: a number, boolean or `null` renders to text + * the same way on this driver, on `driver-memory` and on both `service-analytics` + * faces, and #5526 pinned `{$contains: null}` → `%null%` deliberately. Refusing + * those would break agreement instead of creating it. + */ +function unrenderableTextComparandError( + field: string, + op: string, + value: unknown, + subtree?: unknown, +): Error { + // [#20039, the #8220 contract] The comparand's preview is the predicate's + // literal, and the field and the operator (five share this builder) name + // which constraint failed, not which rule refused it. + return withheldFilterError( + `A text-matching operator in this filter matches against the TEXT of a pattern, but received a ` + + `comparand that is an object or an array. The spec declares this comparand a string ` + + `(filter.zod.ts StringOperatorSchema); ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} is ` + + `accepted. Refusing rather than stringifying it: String({}) is "[object Object]", so the ` + + `pattern that ran would be one the caller never wrote. The operator, the field it was aimed ` + + `at and the value it received are withheld from the message; the full diagnostic is in the ` + + `server log.`, + `Operator "${op}" on field "${field}" matches against the TEXT of a pattern, but received ` + + `${Array.isArray(value) ? 'an array' : 'an object'} (${safeShapePreview(value)}). The spec ` + + `declares this comparand a string (filter.zod.ts StringOperatorSchema); ` + + `${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} is accepted. Refusing rather than stringifying ` + + `it: String({}) is "[object Object]", so the pattern that ran was one the caller never ` + + `wrote — valid SQL, and a row storing that literal text would have matched it.`, + subtree, + ); +} + +/** + * [#7398] The column-type gate's operator set — `JSON_COLUMN_INCOMPATIBLE_OPERATORS` + * — and the words of its refusal live in `@objectstack/core` + * (`json-column-operator-refusal.ts`) since #21007, imported above under the + * same name, unchanged. They moved there because `@objectstack/objectql` + * evaluates a per-aggregation `filter` itself and now refuses the same operators + * on the same declared fields: one set and one sentence for both faces, rather + * than a copy each. The reasoning behind every member and every sentence is in + * that module's docblocks. + */ + +/** + * [#7398] A scalar-comparison operator met a column this driver stores as JSON + * TEXT, so the comparison can never mean what the caller wrote — the measured + * consequences are on {@link jsonColumnOperatorRefusalText}. + * + * ADR-0112 class 1 — a caller mistake, so `INVALID_FILTER` / 400, the same + * envelope {@link unsupportedFilterError} gives the unknown-operator refusal + * one arm over. + * + * The prescription is `$contains` (and an `$or` of `$contains` for any-of) + * because that is the membership spelling on such a column (the spec's + * `$contains` docblock); a closed-spec-set `$overlaps` / `$containsAny` is the + * issue's ask 2 and is deliberately not answered here. + * + * [#8197] The most reachable member of {@link refusalSubtree}'s family, and the + * one the card led with: a CEL permission rule over a multi-select field lowers + * to exactly this membership test, so the column this gate names is the + * administrator's. The prescription survives redaction with PLACEHOLDER names — + * the SHAPE is the repair, and the shape names nothing. + * + * [#21007] The TEXT is `@objectstack/core`'s; the CONSTRUCTOR stays here, + * because the #8220 provenance seam it goes through is this driver's. + * [#21067] That text no longer names this driver's storage form, since the + * engine and `driver-memory` print it too; the measured SQL consequences above + * live in the builder's docblock. + * + * [#21236] The `$contains` prescription is the multi-value class's. A + * single-value file-class field inside the ADR-0104 window is a JSON column + * here too, but it holds one JSON string, and `$contains` with its exact id + * answered no rows. So `fieldClass`, which the gate reads from this driver's + * registries ({@link SqlDriver.jsonColumnFieldClass}), picks the words, and + * that class is told to finish the media-column move. + */ +function jsonColumnOperatorError( + field: string, + op: string, + bare: boolean, + fieldClass: JsonColumnFieldClass, + subtree?: unknown, +): Error { + const { message, diagnostic } = jsonColumnOperatorRefusalText(field, op, bare, fieldClass); + return withheldFilterError(message, diagnostic, subtree); +} + +/** A short, non-throwing rendering of an offending comparand for the message. */ +function safeShapePreview(value: unknown): string { + try { + const json = JSON.stringify(value); + if (typeof json !== 'string') return typeof value; + return json.length > 80 ? `${json.slice(0, 77)}...` : json; + } catch { + return typeof value; + } +} + +/** + * Where the wildcard sits relative to the comparand. Named exactly as + * `service-analytics`'s `LikeShape` names its own, so the twin implementations + * read alike: `contains` → `%v%`, `starts` → `v%`, `ends` → `%v`. + */ +export type TextMatchShape = 'contains' | 'starts' | 'ends'; + +/** The character bound into every `ESCAPE` clause this driver emits. */ +export const LIKE_ESCAPE_CHARACTER = '\\'; + +/** + * [#5134] What a filter node is worth as a boolean, before any SQL is emitted. + * + * - `'true'` — matches every row; the compiler emits NO clause for it. + * - `'false'` — matches no row; the compiler emits the dialect FALSE constant. + * - `'clause'` — carries at least one real predicate; compile it normally. + * + * [#5659] The vocabulary is `@objectstack/spec`'s now, because the REDUCTION + * that produces it is — see {@link reduceFilterNode}. Kept as a local alias so + * every use site below still reads `FilterVerdict`. + */ +type FilterVerdict = SharedFilterVerdict; + +/** + * [#5134] Is `value` a Filter Protocol NODE — the shape `FilterConditionSchema` + * declares for every element of `$and`/`$or` and for the operand of `$not`? + * + * The prototype check is the load-bearing half, not pedantry. The identity + * reduction below turns "this node has no predicates" into "matches every row", + * so any object whose OWN ENUMERABLE KEYS are empty is read as TRUE. A `Date`, + * a `RegExp`, a `Map` or a class instance all satisfy `typeof x === 'object' && + * !Array.isArray(x)` while enumerating to nothing — accepting them would + * PROMOTE garbage from "silently ignored" (the old bug) to "matches all rows" + * (strictly worse). A filter condition always arrives as JSON or as the output + * of `compileCelToFilter`, i.e. a plain object, so requiring one costs nothing + * real and makes the reduction total. + */ +export function isFilterNode(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** A short type name for an operand the filter compiler refuses. */ +function describeFilterOperand(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + const kind = typeof value; + if (kind !== 'object') return kind; + const ctor = (value as { constructor?: { name?: string } }).constructor; + return ctor?.name && ctor.name !== 'Object' ? ctor.name : 'object'; +} + +/** + * [#5134] The gate that gives "this group compiled to empty" exactly ONE cause. + * + * Identity reduction is only sound once an empty compile can mean "the author + * wrote an empty group" and nothing else. Before this gate, `$or: [null]`, + * `$or: ['x']`, `$or: [[…]]` and `$or: [new Date()]` also produced no clause and + * vanished without a trace; reducing on top of that would have turned each of + * them into "matches every row". Refusing them here — loudly, in the ADR-0112 + * envelope every sibling filter refusal in this driver speaks — is what makes + * the reduction safe. Same discipline as cloud#1073 on Turso's + * `RemoteTransport.buildWhereSQL`. + */ +function assertFilterNode( + value: unknown, + path: string, + enclosing?: unknown, +): asserts value is Record { + if (isFilterNode(value)) return; + // [#20039, the #8220 contract] The position, the kind and the preview are the + // predicate's (a policy's literal, measured: `$or: ['']`). The + // node is the refused value when it is an object — an array, a `Date` — and + // otherwise `enclosing`: the `$and` / `$or` list that holds it, or the node + // carrying `$not`, whose provenance a primitive inherits. + throw withheldFilterError( + `A filter node in this filter is not a filter condition object. Every element of "$and"/"$or" ` + + `and the operand of "$not" must be a plain object of field constraints (e.g. { "status": ` + + `"active" }) or nested combinators — @objectstack/spec FilterConditionSchema declares this ` + + `position as a FilterCondition. It is refused rather than skipped because skipping it would ` + + `silently change which rows match. Where it sits, what it is and its value are withheld from ` + + `the message; the full diagnostic is in the server log.`, + `Filter node at ${path} is a ${describeFilterOperand(value)} (${safeShapePreview(value)}), not a filter ` + + `condition object. Every element of "$and"/"$or" and the operand of "$not" must be a plain object of ` + + `field constraints (e.g. { "status": "active" }) or nested combinators — @objectstack/spec ` + + `FilterConditionSchema declares this position as a FilterCondition. It is refused rather than skipped ` + + `because skipping it would silently change which rows match.`, + refusalSubtree(value, enclosing), + ); +} + +/** + * [#5240] `{ field: {} }` — a field constrained by ZERO operators. + * + * One declared shape, three answers across this repo: THIS driver refused it at + * the top level (the #5041 comparand gate) while DROPPING it inside + * `$and`/`$or`/`$not` — where a predicate that emits nothing means "matches + * every row" — and `driver-memory` / `@objectstack/formula` both answered + * "matches nothing". So `{ $or: [ { a: {} }, { b: 2 } ] }` returned a different + * row set per backend, and this driver contradicted ITSELF depending on whether + * the same constraint sat at the top level or one combinator deep. + * + * Ruled on #5240: refuse it everywhere, in the ADR-0112 envelope every sibling + * filter refusal here speaks. Not TRUE and not FALSE — because the shape is + * almost always an authoring accident (a filter builder that recorded a field + * and no operator, or generated metadata that lost its operator), and both + * silent readings answer it with a row count the author never asked for. The + * same reasoning #5041 applied one position over: a filter that cannot be given + * one meaning is refused at the producer, loudly, at authoring time. + */ +function emptyFieldConstraintError(field: string, path: string, subtree?: unknown): Error { + return withheldFilterError( + `A field constraint in this filter carries zero operators ({ "FIELD": {} }). A field ` + + `constraint must name at least one operator (e.g. { "FIELD": { "$eq": "value" } }) or be a ` + + `direct comparand (e.g. { "FIELD": "value" }). It is refused rather than ignored because ` + + `the backends disagreed on what it means — this driver dropped it inside $and/$or/$not ` + + `(matching EVERY row) while refusing it at the top level, and driver-memory / ` + + `@objectstack/formula answered "matches nothing". The field this filter named and ` + + `its position are withheld from the message; the full diagnostic is in the server ` + + `log.`, + `Field constraint at ${path} carries zero operators ({ "${field}": {} }). A field constraint ` + + `must name at least one operator (e.g. { "${field}": { "$eq": "value" } }) or be a direct ` + + `comparand (e.g. { "${field}": "value" }). It is refused rather than ignored because the ` + + `backends disagreed on what it means — this driver dropped it inside $and/$or/$not (matching ` + + `EVERY row) while refusing it at the top level, and driver-memory / @objectstack/formula ` + + `answered "matches nothing".`, + subtree, + ); +} + +/** [#5240] Is this field spec the zero-operator constraint refused above? */ +function isEmptyFieldConstraint(spec: unknown): boolean { + return isFilterNode(spec) && Object.keys(spec).length === 0; +} + +/** + * [#5348] A `$`-prefixed key in a NODE position that is not a declared + * combinator. + * + * `FilterConditionSchema` declares exactly three (`LOGICAL_OPERATORS`: `$and`, + * `$or`, `$not`); every other key of a node is a FIELD NAME. This driver's + * emitter is written on that assumption and nothing checked it, so `$where`, + * `$nor`, `$expr` and friends fell through to the field arms and were compiled + * as COLUMNS — `remoteColumn(table, '$where', …)`. On SQLite a double-quoted + * name that resolves to no column degrades to a string literal, so the query + * compiled, ran, and returned ZERO ROWS: + * + * ``` + * WHERE {"$where":"return true"} → SELECT … WHERE "$where" = 'return true' → [] + * WHERE {"$nor":[{"stage":"won"}]} → (its array value missed the object arm) → [] + * ``` + * + * Measured on better-sqlite3 in #5348. Other dialects reject the unknown + * identifier instead — a different symptom, the same cause, and neither is an + * answer to the filter that was asked. + * + * The refusal is this driver's FIELD-LEVEL posture (#3948 / #4436) finally + * reaching the node position: `{ stage: { $sounds_like: 'x' } }` has answered + * `INVALID_FILTER` / 400 for two releases while `{ $sounds_like: 'x' }` one + * level up answered "no rows". One driver, two positions, two answers — the + * same internal contradiction #5240 closed for `{ field: {} }`. + * + * The wording is `driver-memory`'s `unknownLogicalOperatorError`, verbatim + * through the vocabulary sentence, because #3948 made the backends AGREE that + * an uncompilable filter is a refusal and #5240 made one condition speak one + * wording. Only the closing clause differs: it names what THIS driver used to + * do with the key. + */ +function unknownLogicalOperatorError(key: string, path: string, subtree?: unknown): Error { + // [#20039, the #8220 contract] The key is the predicate's — `$`-prefixed or + // not, it is a name the filter's author wrote — and so is its position. The + // node is the one CARRYING the key (never its value, which may be marked on + // its own: the key belongs to the node above it). + return withheldFilterError( + `A filter node in this filter carries a $-prefixed key that is not a declared combinator. A ` + + `filter node's $-prefixed keys are the declared logical operators $and, $or and $not ` + + `(@objectstack/spec LOGICAL_OPERATORS); every other key is a field name. It is refused rather ` + + `than compiled as a COLUMN of that name, which would produce a predicate that matched no row ` + + `and reported nothing. The key and where it sits are withheld from the message; the full ` + + `diagnostic is in the server log.`, + `Unsupported filter combinator "${key}" at ${path}. A filter node's $-prefixed keys are the ` + + `declared logical operators $and, $or and $not (@objectstack/spec LOGICAL_OPERATORS); every ` + + `other key is a field name. It is refused rather than compiled as a COLUMN of that name, ` + + `which is what this driver used to do — producing a predicate that matched no row and ` + + `reported nothing, so a caller could not tell "no rows matched" from "the filter never ` + + `compiled".`, + subtree, + ); +} + +/** + * [#5347] `$null` whose comparand is not a boolean. + * + * `FieldOperatorsSchema` declares `$null: z.boolean()`, and nothing between an + * authored `where` and this driver validates against it — so a non-boolean + * really does arrive here. Every backend then read it, and they did NOT agree; + * measured in #5347 against one row with `stage: 'won'` and one with + * `stage: null`, on `{ stage: { $null: 'yes' } }`: + * + * | backend | compiled to | rows | + * |---|---|---| + * | driver-sql / driver-sqlite-wasm / Turso local | `IS NULL` (anything but `false`) | the NULL row | + * | driver-memory live path (mingo), driver-mongodb | `IS NOT NULL` (anything but `true`) | the valued row | + * | driver-memory reference matcher | nothing at all — the constraint vanished | BOTH rows | + * + * Three readings of one declared operator, and two of them are each other's + * exact complement. The cause is a pair of default branches hung on opposite + * sides: this driver's emitter asked `opValue === false`, the JS drivers asked + * `val === true`. Neither is a rule anyone wrote down; both are what a + * two-branch conditional does with a third value. + * + * Ruled on #5347: REFUSED, in every position, on every backend — the same + * disposition #5240 gave `{ field: {} }` and for the same reason. `$null: 0` + * read as IS NULL (this driver's rule) is almost certainly not what the author + * meant, `$null: 0` read as IS NOT NULL (the JS rule) is no better, and the + * string `"false"` — which an AI-authored or JSON-round-tripped scope produces + * readily — is truthy, so it lands on the opposite side from the `false` it was + * written to mean. There is no reading of a non-boolean here that is not a + * guess about the author's intent, so the driver stops guessing. + * + * `$exists` carries the identical `=== false` identity read one arm below and + * is deliberately NOT touched here: it diverges too, but on its own axis (what + * "exists" means for a null-valued key is #5299's open question), and #5347 + * ruled on `$null`. Filed separately rather than settled as a rider. + */ +/** + * [#5369, applying #5347's ruling A] `$exists` whose comparand is not a boolean. + * + * The symmetric twin of {@link nonBooleanNullComparandError}, and deliberately + * a copy of its disposition rather than a fresh judgement: `FieldOperatorsSchema` + * declares `$exists: z.boolean()` exactly as it declares `$null`, nothing between + * an authored `where` and this driver validates against it, and the backends + * split the same way on a third value — this emitter's `opValue === false` + * identity reads anything but `false` as IS NOT NULL, while the `=== true` + * spelling used elsewhere reads anything but `true` as IS NULL. Two complements, + * neither a rule anyone wrote down. + * + * #5347 ruled that shape REFUSED for `$null`; #5369 asked whether the same + * applies here and the 2026-08-06 ruling on #5298 said yes, "照 #5347-A". So the + * gate lands beside `$null`'s in {@link reduceFilterKey}, not in the emitter — + * same evaluation-order reason, same `INVALID_FILTER` envelope. + * + * Note what does NOT change with it: the emitter's `opValue === false` arm and + * the polarity table's `value === false` arm (this driver's + * `nullValueSatisfiesOperator` then; since #20822 the shared lowering's) stay + * exactly as they are. Once the gate holds, `true` and `false` are the only + * comparands that reach either, so both tests are already exhaustive two-way choices — the + * lenient-vs-strict question #5347 had to answer for `$null` does not arise + * here, because both spellings agree on the two surviving values. + */ +function nonBooleanExistsComparandError( + field: string, + value: unknown, + path: string, + subtree?: unknown, +): Error { + return withheldFilterError( + nonBooleanFlagWithheldMessage('$exists'), + `Operator "$exists" on field "${field}" requires a boolean comparand (true or false). ` + + `Received ${describeFilterOperand(value)} (${safeShapePreview(value)}) at ${path}. ` + + `@objectstack/spec FieldOperatorsSchema declares $exists as a boolean. It is refused rather ` + + `than coerced for the same reason $null is: a non-boolean lands on whichever side ` + + `the backend's two-branch conditional happens to default to, and those defaults point in ` + + `OPPOSITE directions — this driver's \`=== false\` test compiles IS NOT NULL for anything ` + + `but false, a \`=== true\` test compiles IS NULL for anything but true. Note "false" the ` + + `STRING is truthy, so it lands on the side opposite the false it was written to mean.`, + subtree, + ); +} + +function nonBooleanNullComparandError( + field: string, + value: unknown, + path: string, + subtree?: unknown, +): Error { + return withheldFilterError( + nonBooleanFlagWithheldMessage('$null'), + `Operator "$null" on field "${field}" requires a boolean comparand (true or false). ` + + `Received ${describeFilterOperand(value)} (${safeShapePreview(value)}) at ${path}. ` + + `@objectstack/spec FieldOperatorsSchema declares $null as a boolean. It is refused rather ` + + `than coerced because the backends read a non-boolean in OPPOSITE directions — this driver ` + + `compiled IS NULL (anything but false), driver-memory's query path and driver-mongodb ` + + `compiled IS NOT NULL (anything but true), and driver-memory's matcher dropped the ` + + `constraint entirely. Note "false" the STRING is truthy, so it landed on the side opposite ` + + `the false it was written to mean.`, + subtree, + ); +} + +/** + * [#20020, the #8220 contract] What the two flag refusals above say when the + * field's operator map is not positively marked `'author'`. The operator names + * the refusal's CLASS (the `$between` precedent in {@link betweenArityError}), + * and the declaration is the capability statement; the field, the value and + * the filter path are the predicate's, and go to the server log. + */ +function nonBooleanFlagWithheldMessage(op: '$null' | '$exists'): string { + return ( + `Operator "${op}" in this filter requires a boolean comparand (true or false). ` + + `@objectstack/spec FieldOperatorsSchema declares ${op} as a boolean, and a non-boolean is ` + + 'refused rather than coerced because the backends read one in OPPOSITE directions. The ' + + 'field it was aimed at and the value it received are withheld from the message; the full ' + + 'diagnostic is in the server log.' + ); +} + +// ── [#20444] The `$empty` operator ─────────────────────────────────────────── + +/** + * [#20444] A non-boolean `$empty` comparand. `FieldOperatorsSchema` declares + * `$empty: z.boolean()`, and the spec's save door already refuses anything else + * (`filter-save-door-refusals.ts` counts it among the boolean flags beside + * `$null` / `$exists`). Refused here on the validating walk for the reason its + * two siblings are: a two-branch emitter has a DEFAULT side, and a third value + * would silently land on it. + */ +function nonBooleanEmptyComparandError( + field: string, + value: unknown, + path: string, + subtree?: unknown, +): Error { + return withheldFilterError( + 'Operator "$empty" in this filter requires a boolean comparand (true or false). ' + + '@objectstack/spec FieldOperatorsSchema declares $empty as a boolean, and a non-boolean is ' + + 'refused rather than coerced: true asks for the empty rows, false for their exact ' + + 'complement, and any other value would land on whichever side a compiler defaults to. The ' + + 'field it was aimed at and the value it received are withheld from the message; the full ' + + 'diagnostic is in the server log.', + `Operator "$empty" on field "${field}" requires a boolean comparand (true or false). ` + + `Received ${describeFilterOperand(value)} (${safeShapePreview(value)}) at ${path}. ` + + `@objectstack/spec FieldOperatorsSchema declares $empty as a boolean: true asks for the ` + + `empty rows, false for their exact complement.`, + subtree, + ); +} + +/** + * [#6050] `undefined` in a COMPARAND position. + * + * Ruled REFUSED on 2026-08-07 (ruling B on #6050), the same disposition #5347-A + * gave a non-boolean `$null`, and for a sharper version of the same reason: + * there is no reading of `undefined` here that is not a guess, and the two + * candidate readings are each other's opposite. + * + * `undefined` cannot survive a JSON round trip, so it only ever arrives from + * IN-PROCESS code — which is exactly what makes it dangerous rather than + * academic. `{ owner_id: ctx.user?.id }` with a missing id is the shape that + * produced this issue: measured on `origin/main` (`cba7454df`, four rows, `d` + * valued on 1-2 and NULL on 3-4), the SAME `TursoDriver` answered it two ways, + * chosen by the `url` it was constructed with: + * + * | filter | LOCAL (this compiler) | REMOTE (`RemoteTransport`) | + * |---|---|---| + * | `{ d: undefined }` | bare knex `Undefined binding(s)` | `['3','4']` | + * | `{ d: { $eq: undefined } }` | bare knex `Undefined binding(s)` | `['3','4']` | + * | `{ $not: { d: undefined } }` | bare knex `Undefined binding(s)` | `['1','2']` | + * | `{ d: { $ne: undefined } }` | `['1','2']` | `['1','2']` | + * | `{ $not: { d: { $ne: undefined } } }` | `[]` | `['3','4']` | + * | `{ d: { $in: [undefined] } }` | bare knex `Undefined binding(s)` | `[]` | + * | `{ d: { $gt: undefined } }` | bare knex `Undefined binding(s)` | `[]` | + * + * Two defects in one gate: + * + * 1. The thrown rows carried NO ADR-0112 envelope — knex's `Undefined + * binding(s) detected when compiling SELECT` has neither `code` nor + * `status`, so `mapDataError` served an opaque 500 for what is a caller + * mistake in a filter (#1116 / #4436 catalogued this exact shape for other + * inputs; this one was missing from the list). + * 2. The `$not: { $ne: undefined }` row is this driver contradicting ITSELF: + * the `$ne` emitter reads `coerced == null` (LOOSE — so `undefined` compiled + * `IS NOT NULL`, a TOTAL predicate), while its polarity tables + * `operatorIsNullTotal` and `nullValueSatisfiesOperator` (deleted by #20822; + * the shared lowering carries the rule now) read `value === null` (STRICT — so they + * judged the same leaf non-total AND satisfied by a NULL row). The guard + * then wrapped a total predicate in `d IS NULL OR d IS NOT NULL`, a + * tautology, whose negation is FALSE — the `[]` above. That is the #5298 + * invariant broken at its own definition: a polarity table pins the spelling + * of ITS OWN emitter. + * + * Refusing the comparand kills both at once and does it BEFORE either can act: + * knex never sees an undefined binding, and guard-vs-emitter disagreement about + * `undefined` becomes unreachable rather than merely repaired. + * + * ⛔ What deliberately does NOT move: `null`. `{ f: null }`, `{ $eq: null }`, + * `{ $ne: null }` and `$null` keep their exact behaviour — `null` IS a declared + * comparand and IS the null predicate. The refusal is about the JS value that + * the language cannot distinguish from an ABSENT key: `{ f: undefined }` and + * `{}` are the same object to every reader, and they mean opposite things (a + * predicate vs no constraint at all). + */ +function undefinedComparandError(field: string, path: string, subtree?: unknown): Error { + // [#20039, the #8220 contract] The field and the position are the + // predicate's; the repair survives redaction with a PLACEHOLDER name, as the + // zero-operator refusal's does — the shape is the repair, and it names + // nothing. `undefined` itself can never carry a mark, so `subtree` is the + // nearest object holding it (the node, the operator map, or the list). + return withheldFilterError( + `A comparand in this filter is undefined. @objectstack/spec FieldOperatorsSchema declares no ` + + `undefined comparand, and in JavaScript { "FIELD": undefined } cannot be told apart from ` + + `omitting the key — yet the two mean OPPOSITE things (a predicate versus no constraint at ` + + `all), so there is no reading of it that is not a guess. Write null if you meant the null ` + + `predicate ({ "FIELD": null } or { "FIELD": { "$null": true } }), or omit the key when the ` + + `value is genuinely absent. The field it was aimed at and where it sits are withheld from ` + + `the message; the full diagnostic is in the server log.`, + `Comparand at ${path} is undefined. @objectstack/spec FieldOperatorsSchema declares no ` + + `undefined comparand, and in JavaScript { "${field}": undefined } cannot be told apart from ` + + `omitting the key — yet the two mean OPPOSITE things (a predicate versus no constraint at ` + + `all), so there is no reading of it that is not a guess. Write null if you meant the null ` + + `predicate ({ "${field}": null } or { "${field}": { "$null": true } }), or omit the key when ` + + `the value is genuinely absent (e.g. \`if (id !== undefined) where.${field} = id\`). It is ` + + `refused rather than compiled because the backends disagreed: driver-sql handed it to knex ` + + `and got a bare "Undefined binding(s)" Error carrying no code, while Turso's remote transport ` + + `compiled it to IS NULL — so \`{ owner_id: ctx.user?.id }\` with a missing id silently ` + + `matched every env-wide row instead of failing.`, + subtree, + ); +} + +/** + * [#6050] Refuse every `undefined` sitting in a comparand position of ONE field + * constraint. + * + * The positions are enumerated rather than swept, because "comparand" is a + * position and not a type: + * + * - the DIRECT comparand — `{ d: undefined }`, the implicit `=`; + * - an OPERATOR's comparand — `{ d: { $eq: undefined } }`, `$ne`, `$gt`, the + * LIKE family, and every other single-value operator; + * - a MEMBER of a list operator's array — `{ d: { $in: [undefined] } }`, + * `$nin`, `$between`. The array itself IS `$in`'s legitimate comparand; each + * element is a comparand in its own right, which is the same split + * {@link assertCompilableComparand} makes for `$field`. + * + * Two positions are deliberately NOT swept: + * + * - `$null` / `$exists`. Their comparand is a declared BOOLEAN — a flag, not a + * value to compare against — and `undefined` there is already refused by + * {@link nonBooleanNullComparandError} / {@link nonBooleanExistsComparandError} + * with a message that names the declared domain. Re-answering it here would + * swap a better message for a worse one and give one condition two wordings, + * which is what #5240 ruled against. + * - a bare ARRAY in DIRECT comparand position (`{ d: [1, undefined] }`). An + * array is not a comparand outside a list operator, and + * {@link assertCompilableComparand} already refuses it as a whole for that + * reason; inspecting its members here would relabel a shape that is refused + * either way. + */ +function assertDefinedComparands( + field: string, + spec: unknown, + path: string, + // [#20039] The filter node carrying `field` — the only object that holds a + // DIRECT `{ field: undefined }`. The two deeper positions hand over the + // nearest object of their own: the operator map, and the list. + enclosing?: unknown, +): void { + if (spec === undefined) throw undefinedComparandError(field, path, enclosing); + if (!isFilterNode(spec)) return; + for (const [op, opValue] of Object.entries(spec)) { + if (op === '$null' || op === '$exists') continue; + const opPath = `${path}.${op}`; + if (opValue === undefined) throw undefinedComparandError(field, opPath, spec); + if (!Array.isArray(opValue)) continue; + opValue.forEach((member, index) => { + if (member === undefined) throw undefinedComparandError(field, `${opPath}[${index}]`, opValue); + }); + } +} + +/** + * [#5134] `$and`/`$or` take a list; anything else is refused, never coerced. + * + * [#20020, the #8220 contract] The operand's preview and the combinator's + * position are the predicate's, so they go to the server log unless the + * refusal's node is positively marked `'author'`. That node is the operand + * itself when it is an object (the deepest markable node), and otherwise + * `enclosing` — the filter node that carries the combinator key, which a + * primitive operand inherits its provenance from. + */ +function assertFilterNodeList( + value: unknown, + key: string, + path: string, + enclosing?: unknown, +): asserts value is unknown[] { + if (Array.isArray(value)) return; + throw withheldFilterError( + 'A filter combinator ("$and" / "$or") in this filter requires an array of filter conditions. ' + + '@objectstack/spec FilterConditionSchema declares both as FilterCondition[]. Which one it ' + + 'was, where it sits and the value it received are withheld from the message; the full ' + + 'diagnostic is in the server log.', + `Filter combinator "${key}" at ${path} requires an array of filter conditions, but received a ` + + `${describeFilterOperand(value)} (${safeShapePreview(value)}). @objectstack/spec FilterConditionSchema ` + + `declares "${key}" as FilterCondition[].`, + refusalSubtree(value, enclosing), + ); +} + +/** + * [#5134] Reduce one filter node to its boolean verdict, validating shapes on + * the way down. + * + * A node is the AND of its entries, so FALSE dominates, and a node with no + * entries at all is TRUE (the empty conjunction) — which is why `{}` is a TRUE + * disjunct inside `$or` and why `{ $not: {} }` is FALSE. + * + * This walks the WHOLE tree without short-circuiting: a `$or: []` sibling must + * not stop the walk from reaching — and refusing — a malformed node further + * along, or the shape gate would be conditional on evaluation order. + * + * Deciding structurally, rather than by compiling and then asking Knex whether + * anything came out, is deliberate. The old defect WAS an observation of + * emptiness ("the group callback added nothing"), and an observation cannot tell + * "empty because the author wrote nothing" from "empty because something failed + * to compile". A structural verdict has no such blind spot, and it lets the + * emitter guarantee that every group it opens receives at least one clause. + * + * ## [#5659] The algebra is `@objectstack/spec`'s; the REFUSALS are this driver's + * + * Everything above describes a ruling (#5322/#5134) that four consumers had to + * agree on and implemented four times — here, in `driver-mongodb`, in + * `driver-memory`'s matcher, and nearly a fifth time inside `@objectstack/lint`, + * which declined to hand-write it and filed #5659 instead. The reduction now + * lives once, in {@link reduceFilterVerdict}, proven against the same + * `FILTER_LOGIC_CASES` table this driver's conformance suite runs. + * + * What stays here is what is genuinely this driver's: WHICH shapes it refuses + * and with which message. They are handed to the shared walk as + * {@link SQL_FILTER_VERDICT_HOOKS} and are invoked from exactly the positions + * they were invoked from before, so no wording, code or status moved — the + * conformance case-set is green on both sides of the change. + */ +function reduceFilterNode(node: Record, path: string): FilterVerdict { + return reduceFilterVerdict(node, { ...sqlFilterVerdictHooks(node, path), path }); +} + +/** + * [#5134] The verdict of ONE key of a filter node. `enclosing` is the node the + * key belongs to — see {@link sqlFilterVerdictHooks} for what reads it. + */ +function reduceFilterKey(key: string, value: unknown, path: string, enclosing?: unknown): FilterVerdict { + return reduceFilterKeyVerdict(key, value, { ...sqlFilterVerdictHooks(enclosing, path), path }); +} + +/** + * [#5659] This driver's half of the reduction: the shape refusals, at the + * positions the shared walk visits them. + * + * `assertFilterNodeList` / `assertFilterNode` are wrapped in arrows rather than + * passed by reference because they are TypeScript assertion functions, whose + * narrowing is meaningless — and whose declaration requirements are a nuisance + * — through a property reference. Nothing else about the call changes. + * + * [#20020] Built per reduction rather than shared, because the non-list + * combinator refusal needs the NODE that carries the combinator key, and the + * shared walk hands `assertNodeList` only the operand, the key and the path. + * The node is recovered from the path without widening that spec-declared hook + * signature: every node the walk reduces is either `root` (at `rootPath`) or + * an element / `$not` operand it announced through `assertNode` at the exact + * path it then reduces it under — so recording those announcements is a + * complete path-to-node index for the positions `assertNodeList` can name. The + * index is keyed only by paths built from combinator keys and list indices, + * never by a field name, so a field key containing a dot cannot collide with it. + * + * [#20039] Every refusal the walk raises now asks the same index, because every + * one of them is withheld through the #8220 seam and needs the node it was + * raised FROM: `classifyKey` gets the node carrying the key (the only object + * holding a direct `{ field: undefined }`, and the owner of an undeclared + * `$`-key), and `assertNode` gets what ENCLOSES a non-node element — the + * `$and` / `$or` list, recorded as `assertNodeList` announces it (the walk + * announces a list before it visits a single element), or the node carrying + * `$not`. Lists are kept in their own map, keyed by the combinator key's + * position, so the node index above stays exactly what it was. + */ +function sqlFilterVerdictHooks(root: unknown, rootPath: string): FilterVerdictHooks { + const nodeAt = new Map([[rootPath, root]]); + const listAt = new Map(); + // The walk names a key's position `${nodePath}.${key}` (bare `key` under an + // empty prefix), so stripping the key recovers the node's own path. + const nodeCarrying = (key: string, path: string): unknown => + nodeAt.get(path === key ? '' : path.slice(0, path.length - key.length - 1)); + return { + assertNodeList: (value, key, path) => { + listAt.set(path, value); + assertFilterNodeList(value, key, path, nodeCarrying(key, path)); + }, + assertNode: (value, path) => { + nodeAt.set(path, value); + // An element's position is `${listPath}[${index}]`; the `$not` operand's + // is the key's own position, so its enclosing node is the one carrying it. + const element = /\[\d+\]$/.exec(path); + assertFilterNode( + value, + path, + element ? listAt.get(path.slice(0, element.index)) : nodeCarrying('$not', path), + ); + }, + classifyKey: (key, value, here) => classifyFilterKey(key, value, here, nodeCarrying(key, here)), + }; +} + +/** + * [#5134] The verdict of ONE **non-combinator** key — and this driver's gate on + * everything a field constraint may not be. + * + * `here` is the already-joined path of the key, exactly as the reduction hands + * it over; the three combinator arms this used to open with are the shared + * walk's now, and the refusals below are unchanged from when they sat under + * them. + */ +function classifyFilterKey( + key: string, + value: unknown, + here: string, + // [#20039] The filter node this key belongs to — see + // {@link sqlFilterVerdictHooks}. Absent means withheld for every refusal + // below that would have used it, the fail-closed direction. + enclosing?: unknown, +): FilterVerdict { + // [#5348] Everything still `$`-prefixed at this point is an UNDECLARED + // combinator — the shared walk resolved the three declared ones before this + // key ever reached the hook (#5659). Refused here and + // not in the emitter for exactly the reason the two lines below are here, and + // the reason #5327 gave for `{ field: {} }`: this walk is exhaustive and does + // not short-circuit, while the emitter is skipped wholesale by a boolean + // identity. `{ $or: [ {}, { $where: '…' } ] }` reduces to TRUE on its first + // disjunct, so an emitter-side gate would refuse the `$where` or ignore it + // depending on its SIBLINGS — "a gate conditional on evaluation order", which + // this function's own doc comment warns against. + // + // It must also come BEFORE the field arms below, because that is precisely + // what those arms did wrong: they accepted `$where` as a field name. + if (key.startsWith('$')) throw unknownLogicalOperatorError(key, here, enclosing); + + // [#5240] `{ field: {} }` is refused HERE — on the validating walk, beside + // `assertFilterNode` / `assertFilterNodeList` — rather than in the emitter + // below, for the same reason those two sit here: the walk is exhaustive and + // does NOT short-circuit, so the refusal cannot be skipped by an identity that + // resolves the enclosing node first. Gating it in the compile branch instead + // would let `{ $or: [ { a: {} }, {} ] }` slip through untouched — the `{}` + // disjunct reduces the whole `$or` to TRUE, the emitter returns before it ever + // reaches `{ a: {} }`, and the shape would be refused or ignored depending on + // its SIBLINGS. That is the "gate conditional on evaluation order" this + // function's own doc comment warns against. + // + // Note what is deliberately NOT changed: the VERDICT. A field key still + // contributes `'clause'`, exactly as #5134 classified it. This adds a refusal, + // it does not reclassify a surviving shape, so every filter that compiled + // before compiles byte-identically now. + // [#8197] `value` IS the `{}` — an object, so it is markable and findable by + // identity under the query's `where` root. The reduction walks the same tree + // the merge boundaries marked, by reference, so nothing is copied between the + // mark and this throw. + if (isEmptyFieldConstraint(value)) throw emptyFieldConstraintError(key, here, value); + + // [#6050] `undefined` in a comparand position, refused on THIS walk for the + // two reasons the walk exists — it is exhaustive and it runs FIRST. + // + // "First" is the whole design here, not a preference. Both defects #6050 + // measured live downstream of this line: the emitter hands an undefined bind + // to knex (a bare `Undefined binding(s)` Error, outside ADR-0112), and the + // polarity tables of the `$not` rewrite this driver carried until #20822 + // (the shared lowering's rule 3 now) read it `=== null`, disagreeing with + // the `$ne` emitter's `== null` about exactly this value. + // `applyFilterCondition` runs the whole reduction before + // it reaches either, so a refusal raised here makes BOTH unreachable rather + // than repaired — and the polarity tables never have to answer a question + // nobody ruled on. See {@link undefinedComparandError} for the measured + // local/remote matrix and why `null` is untouched. + assertDefinedComparands(key, value, here, enclosing); + + // [#7929, maintainer ruling 2026-08-12] A `{ $field }` comparand at any + // operator OUTSIDE the six that compile one is the CROSS-FIELD refusal — + // answered here, ahead of the comparand-SHAPE gates below. + // + // Not a new refusal: every one of these shapes was already refused, and the + // set of accepted filters is byte-identical. What changes is WHICH refusal + // answers, and that is the point. `$icontains`, `$like`/`$ilike`, `$null` and + // `$exists` each carry their own comparand-shape gate on this walk, and each + // gate renders the offending comparand into its message — so a field + // reference came back to the caller as `received {"$field":"…"}`, naming the + // referenced column, while the same reference at `$contains` (no gate here, + // refused at the emitter) was answered by the cross-field refusal instead. + // Measured on `origin/main`: five operators disclosing, four not, decided by + // nothing a caller can see. One condition — "this comparand is a reference to + // another field, in a position SQL push-down does not compile" — now has one + // answer, and that answer is the redacted one. + // + // The six scalar comparison operators are skipped because a reference IS + // their compiled meaning ({@link SqlDriver.applyCrossFieldComparison}); their + // own boundary refusals are raised there with the same withhold. + if (isFilterNode(value)) { + for (const [op, comparand] of Object.entries(value)) { + if (CROSS_FIELD_COMPARISON_OPERATORS.has(op)) continue; + const ref = fieldReferenceOf(comparand); + if (ref !== null) throw crossFieldComparisonError(key, op, ref, comparand); + // [#25808] A now-anchored comparand under any other operator, likewise. + if (nowAnchorOf(comparand) !== null) throw nowAnchorPositionError(key, op, comparand); + } + } + + // [#5347] `$null`'s comparand is a boolean by declaration. Checked on this + // walk rather than in the emitter's `$null` arm for the same + // evaluation-order reason, and checked on the RAW value so the message names + // the shape the caller sent rather than whatever `coerceFilterValue` made of + // it. Only `$null` is inspected: the surrounding operator vocabulary is the + // emitter's `default: throw` to enforce, and widening this walk into a second + // vocabulary gate is how two lists drift apart (#3948). + // `hasOwnProperty` rather than `'$null' in value` so an inherited key can + // never trip the gate, and rather than `Object.hasOwn` because this package + // targets es2020. `{ $null: undefined }` still counts: the key is own and + // enumerable, and `undefined` is exactly one of the comparands the issue + // measured a divergence on. + if ( + isFilterNode(value) && + Object.prototype.hasOwnProperty.call(value, '$null') && + typeof value.$null !== 'boolean' + ) { + throw nonBooleanNullComparandError(key, value.$null, `${here}.$null`, value); + } + + // [#5369] `$exists`'s comparand is a boolean by the same declaration, refused + // on the same walk, for the same evaluation-order reason. Kept as a separate + // `if` rather than folded into a loop over a two-name list: each operator gets + // its own message naming its own emitter's default direction, and a shared + // loop would be the start of the second vocabulary gate the block above warns + // about (#3948). + if ( + isFilterNode(value) && + Object.prototype.hasOwnProperty.call(value, '$exists') && + typeof value.$exists !== 'boolean' + ) { + throw nonBooleanExistsComparandError(key, value.$exists, `${here}.$exists`, value); + } + + // [#20444] `$empty`'s comparand is a boolean by the same declaration, refused + // on the same walk for the same evaluation-order reason — its own `if`, not a + // loop over a flag list, for the reason the `$exists` gate above gives. + if ( + isFilterNode(value) && + Object.prototype.hasOwnProperty.call(value, '$empty') && + typeof value.$empty !== 'boolean' + ) { + throw nonBooleanEmptyComparandError(key, value.$empty, `${here}.$empty`, value); + } + + // [#5702] `$icontains`'s comparand is a NON-EMPTY string by declaration, + // refused on this walk for the same evaluation-order reason as the two gates + // above: an empty comparand makes the predicate match every row, and a gate + // the emitter carries alone is skipped wholesale whenever a boolean identity + // settles the enclosing node — so `{ $or: [ {}, { name: { $icontains: '' } } ] }` + // would be refused or silently widened depending on its SIBLINGS. + if ( + isFilterNode(value) && + Object.prototype.hasOwnProperty.call(value, '$icontains') && + (typeof value.$icontains !== 'string' || value.$icontains === '') + ) { + throw icontainsComparandError( + key, + value.$icontains, + `${here}.$icontains`, + refusalSubtree(value.$icontains, value), + ); + } + + // [#7536] `$like` / `$ilike` carry a PATTERN, gated on this same walk and for + // the same evaluation-order reason as the three above. + // + // Note what is NOT refused: an EMPTY pattern. `$icontains: ''` is refused + // because every row contains the empty substring, so it constrains nothing — + // but `LIKE ''` matches only the empty string, which is a narrow and perfectly + // well-formed predicate. Copying the sibling's rule here would refuse a + // legitimate query. + if (isFilterNode(value)) { + for (const op of LIKE_PATTERN_OPERATORS) { + if (!Object.prototype.hasOwnProperty.call(value, op)) continue; + const pattern = (value as Record)[op]; + // [#20039] The comparand when it is an object, else the operator map. + if (typeof pattern !== 'string') { + throw likePatternComparandError(key, op, pattern, `${here}.${op}`, refusalSubtree(pattern, value)); + } + if (hasDanglingLikeEscape(pattern)) { + throw danglingLikeEscapeError(key, op, pattern, `${here}.${op}`, value); + } + // [#20041] A pattern holding U+0000 — SQLite's GLOB cuts it there. The + // operator map is the node, as for the dangling escape. + if (hasNulInLikePattern(pattern)) { + throw nulLikePatternError(key, op, pattern, `${here}.${op}`, value); + } + } + } + + // A field key always contributes a predicate. + return 'clause'; +} + +/** + * [#24707] PostgreSQL only: the session's `search_path` reaches one table name + * in more than one schema, and {@link SqlDriver.introspectSchema} keys its + * answer by bare name — one key cannot hold two tables. + * + * This used to be silent: the later table was overwritten by the earlier one + * (`sales.customers` vanished behind `public.customers`), and nothing in the + * answer said a table was missing. Keeping the first and logging the second + * is not loud either — the HTTP and AI callers of the listing never see a + * server log, so to them it is the same silent drop. The answer is a refusal + * naming every colliding name with its schemas in `search_path` order, and + * the remedy: one schema per connection (the datasource's `schema` setting is + * what sets its `search_path`), or a rename. A connection that reaches one + * schema — the default `public` deployment — can never collide, so no caller + * that sees one schema is refused. + * + * `collisions` is non-empty; each entry's `schemas` has two or more members. + */ +export function postgresTableNameCollisionError(collisions: ReadonlyArray<{ table: string; schemas: string[] }>): Error { + const list = (names: string[]) => names.map((s) => `'${s}'`).join(', '); + const [first] = collisions; + const err = new Error( + `Cannot introspect this PostgreSQL connection: its search_path reaches ${collisions.length} table name(s) ` + + `in more than one schema, for example '${first.table}' in ${list(first.schemas)}. ` + + `Introspection keys each table by its bare name, so one key cannot hold both, and the bare name ` + + `resolves only to '${first.schemas[0]}.${first.table}'; listing it once would hide the other ` + + `table and its columns. Colliding: ` + + collisions.map((c) => `'${c.table}' (${list(c.schemas)})`).join('; ') + + `. Point the connection at one schema (the datasource's 'schema' setting sets its search_path), ` + + `or rename one of the tables.`, + ) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.RESOURCE_CONFLICT; + err.status = 409; + return err; +} + +// ── Introspection Types ────────────────────────────────────────────────────── + +/** + * These are DERIVED from `packages/spec/src/contracts/schema-diff-service.ts`, + * never re-declared beside it. + * + * They used to be a second, independent declaration that happened to describe + * the same idea in a different vocabulary: this file spelled a column's key + * membership `isPrimary?`, the spec spells it `primaryKey`; the spec also + * declares `dialect` and a REQUIRED `introspectedAt` that this file's schema + * type did not mention and `introspectSchema` therefore never emitted. `plugin.ts` hands + * this driver's result straight to `ExternalDatasourceService`, which is typed + * against the spec — so the consumer read a key no driver ever set and every + * federated object drafted from a remote table silently lost its primary key. + * Nothing was type-unsound; the two contracts simply never met a compiler. + * + * Maintainer ruling, 2026-08-22 (live session, 「同意所有」 item 9 = + * 驱动侧对齐 spec 契约): `packages/spec` is the one contract and the DRIVER + * aligns to it. Deriving rather than copying is what makes that mechanical — + * a key added to the spec contract now fails this file's `tsc` until the + * driver emits it, which is exactly the failure that was missing. + * + * The `Omit` carve-outs that used to mark two remaining divergences + * (`defaultValue`, `indexes`) are RETIRED (#11122, maintainer ruling + * 2026-08-23 option B): the spec now declares `defaultValue` as the raw + * `unknown` this driver actually reports and `indexes` as optional, so both + * keys are inherited straight from the spec contract. What this layer still + * adds is EXTRA facts only — per-column `isUnique` / `maxLength`, per-table + * `foreignKeys` / `primaryKeys` — never a second spelling of a spec key. + */ +export interface IntrospectedColumn extends SpecIntrospectedColumn { + /** + * SQL-introspection extra: the column ALONE carries a single-column unique + * constraint — it is true iff some unique constraint covers this column and + * nothing else. + * + * Membership of a COMPOSITE constraint is deliberately not represented on + * this flag (#11202, maintainer ruling 2026-08-23 option A→B). `UNIQUE + * (a, b)` constrains the pair, and a per-column boolean cannot say that; + * setting it on both members would assert that each column is unique on its + * own, which is a different — and false — claim, and one a consumer acts on + * (`introspectedSchemaToObjects` in `@objectstack/objectql` turns this flag + * into a drafted field's `unique: true`). A real composite surface is + * option B and waits for demand; until it exists, an absent flag on a + * composite member means "not single-column unique", never "no constraint". + * + * A PRIMARY KEY is NOT a unique constraint to this flag (#11654), on any + * dialect and for any key type. `isUnique` means a *declared* single-column + * UNIQUE constraint; key membership has a lossless face of its own + * ({@link IntrospectedTable.primaryKeys} and `primaryKey` below), so + * excluding keys keeps the two flags non-overlapping and drops no fact. Note + * this is a statement about what KIND of constraint the flag reports, never + * a claim that a key column admits duplicates. A key column that separately + * carries its own single-column unique constraint is still flagged — the + * constraint is what is being reported, not the column. + * + * All three dialect arms of {@link SqlDriver.introspectUniqueConstraints} + * produce it through one predicate — see {@link singleColumnUniqueColumns}. + */ + isUnique?: boolean; + /** + * SQL-introspection extra: declared maximum length for string types — raw + * as knex `columnInfo()` reports it, which is a NUMBER on some dialects + * and a STRING on SQLite (measured live, 2026-08-23: `"255"` for a + * `t.string(…)` column). Consumers narrow via `typeof`, as + * `schema-drift.ts`'s varchar differ already does. + */ + maxLength?: number | string; +} + +/** + * No spec counterpart — foreign keys are a SQL-introspection extra. + * + * ## A composite key is ORDERED SIBLING ROWS, and that order is load-bearing + * + * This is deliberately a FLAT PER-COLUMN record with no ordinal field (#11324). + * An N-column foreign key is N records, and the contract every arm of + * {@link SqlDriver.introspectForeignKeys} owes is that those records arrive + * **contiguous, in declared key order, each pairing its own child column with + * its own parent column** — `(x, y) references p (a, b)` is exactly + * `x -> p.a` then `y -> p.b`. That is what makes the ordinal unnecessary rather + * than merely absent: the position in the array carries it. + * + * The reason it is written down here is that it was NOT true and nothing said + * so. The Postgres arm joined child columns to parent columns with no ordinal + * correlation and returned the N x N cartesian product — for the key above, + * four records including the phantom `x -> p.b` and `y -> p.a`, which no + * consumer of this flat shape can tell from the real two. Widening the record + * with an ordinal was considered and rejected: it would have let a wrong ORDER + * BY keep shipping wrong rows that merely *describe* their wrongness, where the + * pairing is a fact the query itself must get right. + */ +export interface IntrospectedForeignKey { + columnName: string; + /** + * The parent's BARE relation name, always — never conditionally qualified + * (#11377). When the parent lives outside the session's resolution scope, + * the qualification arrives as the separate {@link referencedSchema} key + * rather than as a `"schema.table"` spelling here: a key whose spelling + * depends on context is exactly the shape a consumer mis-parses. + */ + referencedTable: string; + referencedColumn: string; + constraintName?: string; + /** + * The parent table's schema, PRESENT WHEN AND ONLY WHEN the parent lives + * outside the session's own resolution scope — i.e. when the bare + * {@link referencedTable} name is NOT the name this session would resolve + * to that table (#11377). For an in-scope parent the key is ABSENT (never + * `undefined`), so an answer that carries it is saying something. + * + * Until #11324 a cross-schema foreign key contributed zero rows on + * Postgres, so this fact was unreachable; repairing that made the + * constraint visible under a bare name the session's `search_path` does + * not resolve — an answer true of the constraint and unusable as an + * address, or worse, one that collides with a same-named table in the + * current schema (the #11201 family). A consumer that turns + * `referencedTable` into an object reference MUST read this key and refuse + * to wire the bare name when it is present + * (`convertIntrospectedSchemaToObjects` in `@objectstack/objectql` is the + * reference consumer). + * + * Per dialect arm: + * - **Postgres**: the parent's schema, filled when that schema is not in + * `current_schemas(false)` — the same `search_path` scoping every other + * statement in the session resolves bare names against (#11201). + * - **MySQL**: filled symmetrically — the parent's database + * (`REFERENCED_TABLE_SCHEMA`) when it differs from the session's default + * database (`DATABASE()`); InnoDB permits cross-database foreign keys, + * and `KEY_COLUMN_USAGE` carries the parent's database on the same row + * the arm already reads. + * - **SQLite**: never present — SQLite has no schemas, and a foreign key + * cannot cross an ATTACHed database at all, so the fact this key carries + * cannot exist there. + */ + referencedSchema?: string; +} + +/** + * `indexes` is inherited from the spec contract, where it is OPTIONAL and + * absence is meaningful (#11122): this driver does not introspect indexes, + * so `introspectSchema` deliberately OMITS the key — `indexes: []` would + * tell a schema differ that a table HAS none when it merely was not asked, + * a worse answer than an absent key. Wiring the real read + * ({@link SqlDriver.introspectIndexes} exists) is explicitly a separate + * decision with its own `onFailure` ruling. + */ +export interface IntrospectedTable extends SpecIntrospectedTable { + columns: IntrospectedColumn[]; + /** SQL-introspection extra: outbound foreign keys. */ + foreignKeys: IntrospectedForeignKey[]; + /** SQL-introspection extra: the table's primary-key columns, in key order. */ + primaryKeys: string[]; +} + +/** + * `dialect` and `introspectedAt` are inherited from the spec contract, where + * `introspectedAt` is REQUIRED — so `tsc` now refuses an `introspectSchema` + * that omits them, which is the check that was missing while this type + * declared `{ tables }` alone. + */ +export interface IntrospectedSchema extends SpecIntrospectedSchema { + tables: Record; +} + +// ── Window Function Types (driver-private, #6212) ──────────────────────────── + +/** + * One entry of {@link SqlWindowFunctionQuery.windowFunctions} — the flat shape + * {@link SqlDriver.findWithWindowFunctions} actually reads. + * + * This type lives HERE, not in `packages/spec`, deliberately. #4286 retired the + * spec's window cluster (`WindowFunctionNodeSchema` and friends) precisely + * because it declared `field` / `over` / `frame` members this door never read — + * a vocabulary describing an input no executor accepts. Re-adding a window + * vocabulary to the spec would undo that judgement; window functions are a + * SQL-driver-private capability (the door is not on `IDataDriver`), so the + * driver declares its own shape at the layer that owns it. The spec's own + * removal note names this shape verbatim — `{ function, alias, partitionBy?, + * orderBy? }`, `packages/spec/src/data/query.zod.ts` — as does the published + * migration prescription (`query-window-functions-retired` in + * `packages/spec/src/migrations/registry.ts`), which points embedders at this + * door. Those two texts and this type must keep saying the same thing. + * + * Every member is what {@link SqlDriver.buildWindowFunction} consumes and + * nothing else: + * - `function` is emitted as `FUNC()` — uppercased, ARGUMENT-LESS. `lag(revenue)` + * renders as `LAG()`; the builder has no argument slot, which is why there is + * no `field` member to declare (the skills' aggregation rules say the same). + * - `orderBy`'s `order` is optional here even though a `DriverQuery`'s top-level + * `SortNode` requires it: the builder reads `s.order || 'asc'`, so absence is + * a spelling this door genuinely accepts. Declaring it required would reject + * input that works. + */ +export interface SqlWindowFunctionSpec { + /** + * Window function name, emitted argument-less and uppercased (`rank` → `RANK()`): + * one of `row_number`, `rank`, `dense_rank`, `percent_rank`, `cume_dist`, in any + * case. Any other name is refused with `INVALID_QUERY` (400). + */ + function: string; + /** Column alias the computed value is projected as. */ + alias: string; + /** `PARTITION BY` targets, mapped through the driver's storage-name mapping. */ + partitionBy?: string[]; + /** `ORDER BY` inside the `OVER (…)` clause; `order` defaults to `asc`, and any value but `asc` / `desc` (any case) is refused. */ + orderBy?: { field: string; order?: 'asc' | 'desc' }[]; +} + +/** + * The query {@link SqlDriver.findWithWindowFunctions} takes: a + * {@link DriverQuery} — the contract shape, minus the redundant `object` the + * first argument already carries (#5181) — carrying this driver's private + * `windowFunctions` array. + * + * `windowFunctions` is `Omit`ed off `DriverQuery` before being re-declared + * because the spec key is a `retiredKey()` TOMBSTONE: `QueryAST['windowFunctions']` + * resolves to `undefined`, so a plain intersection would leave the property + * unwritable and this door's own documented payload would not compile. The + * tombstone is correct — the REQUEST surface really has no window functions — + * and this type is what keeps the driver-level door open without reopening it. + */ +export type SqlWindowFunctionQuery = Omit & { + windowFunctions?: SqlWindowFunctionSpec[]; +}; + +// ── Configuration Types ────────────────────────────────────────────────────── + +/** + * SqlDriver configuration — passed directly to Knex. + * See https://knexjs.org/guide/#configuration-options + * + * `schemaMode` (ADR-0015) is an ObjectStack-level concern, not a Knex + * option: it is stripped before constructing the Knex instance and gates + * all schema-mutating DDL. Defaults to `'managed'` when omitted, preserving + * legacy behaviour. + */ +/** + * How a stored column value must be reshaped to become the value a `find()` row + * presents. One entry per rule `formatOutput` applies to a scalar column; the + * read paths that bypass `formatOutput` (`aggregate`, `distinct`) name the rule + * per column instead. See {@link SqlDriver.readPresentationKind}. + * + * `audit_timestamp` is the builtin `created_at` / `updated_at` rule — + * {@link presentAuditTimestampOutput}, the presenter `formatOutput` applies to + * those two columns — kept apart from `datetime` because the two presenters + * differ on a number: the datetime fold turns an epoch INTEGER into ISO text, + * the audit presenter passes it through (ADR-0074 §3), and a `find()` row + * takes the latter ([ADR-0053 D-F1], #13973). + */ +export type ReadPresentationKind = 'datetime' | 'date' | 'time' | 'boolean' | 'number' | 'audit_timestamp'; + +/** + * Journal modes the driver knows how to ask a file-backed SQLite database for. + * See {@link SqlDriver.applySqliteJournalMode} for why `'wal'` is the default + * and what `'delete'` is for. + */ +export type SqliteJournalMode = 'wal' | 'delete'; + +/** + * What to do when a **file-backed** SQLite datasource points at a path that + * does not exist yet (#6743). + * + * - `'create'` — SQLite's own behaviour, and the default: the file is brought + * into existence by the first statement on the connection. + * - `'empty-in-memory'` — do NOT create it. Open an ephemeral `:memory:` + * database instead, which introspects as a database with zero tables — the + * same thing a freshly-created empty file introspects as. For a read-only + * caller (`os migrate plan`) the report is therefore identical while the + * filesystem is left untouched. Only a file that `'create'` could bring into + * existence counts as absent: one that can be neither opened nor created is + * refused, as `'create'`'s connect refuses it (#24961) — see + * {@link resolveSqliteAbsentFileTarget}. + * + * `'empty-in-memory'` is for callers that only ever READ. A caller that may + * write MUST leave it at `'create'`: writes land in the ephemeral database and + * are lost at disconnect. `os migrate apply` is exactly that caller — it boots + * with `deferSchemaDdl` too, but flushes the deferred DDL after confirmation, + * so it must keep `'create'`. Deferring the DDL and never opening the file are + * different promises; #3917 made the first, this makes the second. + */ +export type SqliteAbsentFileMode = 'create' | 'empty-in-memory'; + +/** + * Resolve the filename a SQLite connection should actually open, given the + * declared target and the {@link SqliteAbsentFileMode} the host asked for. + * + * The ONE place the "does the target exist?" judgement is made (#6743). It is + * exported so the service layer's native→wasm step-down can resolve the same + * answer for its fallback rung rather than open-coding a second `existsSync` + * that is free to drift — the two-places objection that ruled out doing this + * check up in the CLI. + * + * Pseudo-filenames (`:memory:` and anything else `:`-prefixed) and non-file + * dialects are already fileless, so they are returned untouched. + * + * ## Absent is not the same as unopenable (#24961) + * + * Only a file a boot that may write could bring into existence is absent. One + * that can be neither opened nor created — a path under a regular file, a + * directory this process may not write — is refused here with the + * `SqliteDatabaseFileError` naming the file and the operating system's answer, + * the refusal `SqlDriver.connect()` gives the same path on a boot that writes. + * The judgement writes nothing: see `probeAbsentSqliteDatabaseFile` + * (`sqlite-database-file.ts`), which also names the one shape it cannot see + * without a write. It used to be + * `existsSync`, which answers `false` for every path it cannot stat, so a + * read-only boot opened an empty in-memory database over a file that cannot + * exist and reported the datasource connected. + * + * @throws SqliteDatabaseFileError under `'empty-in-memory'`, for a file that + * can be neither opened nor created. Both callers get the same answer: the + * driver's constructor, and the service layer's wasm rung. + */ +export function resolveSqliteAbsentFileTarget( + filename: string, + mode: SqliteAbsentFileMode | undefined, +): { filename: string; openedEmptyInMemory: boolean } { + if (mode !== 'empty-in-memory') return { filename, openedEmptyInMemory: false }; + if (typeof filename !== 'string' || filename === '' || filename.startsWith(':')) { + return { filename, openedEmptyInMemory: false }; + } + if (probeAbsentSqliteDatabaseFile(filename) === 'exists') return { filename, openedEmptyInMemory: false }; + return { filename: ':memory:', openedEmptyInMemory: true }; +} + +export type SqlDriverConfig = Knex.Config & { + schemaMode?: SchemaMode; + /** + * Dev-only schema auto-reconcile (issue #2186). When `'safe'`, `initObjects` + * automatically applies *non-destructive* alters (relax NOT NULL, widen + * varchar) so an existing database self-heals after a metadata change + * loosens a constraint. `'off'` (default) only warns. Never applies + * destructive DDL, and is force-disabled when `NODE_ENV==='production'`. + */ + autoMigrate?: 'off' | 'safe'; + /** + * Journal mode for a **file-backed** SQLite database (#3941). Defaults to + * `'wal'`, which is what lets a dev server and a CLI command share one file + * without serializing against each other. `'delete'` restores SQLite's + * built-in rollback journal — the escape hatch for a database on a network + * filesystem, where WAL cannot work. Ignored for `:memory:` and for + * non-SQLite dialects; overridable per deployment with + * `OS_DATABASE_SQLITE_JOURNAL_MODE`. + * + * @see {@link SqlDriver.applySqliteJournalMode} + */ + sqliteJournalMode?: SqliteJournalMode; + /** + * What to do when the **file-backed** SQLite target does not exist (#6743). + * Defaults to `'create'` — SQLite's own behaviour, and what every existing + * caller gets, since this option changes nothing unless it is passed. + * + * Ignored for `:memory:` and for non-SQLite dialects, neither of which can + * bring a database file into existence. + * + * @see {@link SqliteAbsentFileMode} + * @see {@link SqlDriver.sqliteOpenedEmptyInMemory} + */ + sqliteAbsentFile?: SqliteAbsentFileMode; + /** + * Has THIS deployment completed the ADR-0104 column move for the file family + * — the step that retypes the media columns and rewrites the values they + * hold into the bare-id encoding (`sys_migration.columns_moved_at`)? + * + * ⛔ This is NOT the `adr-0104-file-references` flag and must never be wired + * to it alone. Every creation-attested store since 17.0 — including every + * dogfood boot — and every deployment that ran `os migrate + * files-to-references --apply` before the column step existed carries that + * flag AND JSON-quoted ids in a JSON column. The flag alone therefore cannot + * key the write arm; `columns_moved_at` is the evidence that the columns + * themselves moved, written by the same act that moves them. + * + * ## Absence is the JSON arm, and that is the whole safety property + * + * Omitted, `false`, a resolver that throws, a resolver that never runs — all + * four answer "not moved", so the driver keeps today's JSON encoding. Every + * row that exists in the world today lacks `columns_moved_at`, and a host + * that has not been threaded this option at all is indistinguishable from + * one whose deployment has not moved. That is the required direction: a + * driver that guessed "moved" would write bare ids into a JSON column. + * + * A function is resolved ONCE per driver instance, at {@link + * SqlDriver.initObjects}, and memoized — the flag lives in `sys_migration`, + * which does not exist until schema sync has run, so it cannot be read at + * construction time. A host outside that path (`skipSchemaSync`, + * `registerObjectMetadata`-only) simply never resolves it and stays on the + * JSON arm, which is again the correct fail-toward. + * + * ## Naming it at all — in EITHER polarity — shuts out the engine's supply + * + * The ordinary composition does not set this key: `ObjectQL.registerDriver` + * hands the driver a resolver over `sys_migration.columns_moved_at` instead + * (see {@link SqlDriver.setFileColumnsMovedResolver}). Setting it here says + * the host knows its own storage better than the ledger does, so the engine + * will not contradict it — and that is true of `false` as much as of `true`, + * since a declared `false` overruled to `true` is bare ids in a JSON column. + * + * @see {@link SqlDriver.setFileColumnsMovedResolver} + */ + fileColumnsMoved?: boolean | (() => boolean | Promise); +}; + +// ── SQL Driver ─────────────────────────────────────────────────────────────── + +/** + * SQL Driver for ObjectStack. + * + * Implements the IDataDriver contract via Knex.js for optimal SQL + * generation against PostgreSQL, MySQL, SQLite and other SQL databases. + */ +/** + * One column's contribution to MySQL's per-ROW budget over DECLARED widths + * (#11565). + * + * `chars` is the `varchar(n)` width {@link SqlDriver.createColumn} emits for + * the field; `bytes` is what the server charges for it — the payload + * (`chars x bytesPerChar`) plus varchar's own length prefix. + */ +interface RowWidthContribution { + name: string; + chars: number; + bytes: number; +} + +/** The part of a better-sqlite3 `Database` that {@link reclaimBetterSqlite3} drives. */ +interface BetterSqlite3Connection { + exec(sql: string): unknown; + pragma(source: string, options?: { simple: boolean }): unknown; +} + +/** + * The better-sqlite3 arm of `SqlDriver.reclaimSpace`: return the whole freelist, + * and return the bytes it passes through the `-wal` sidecar too, without ever + * waiting on another connection. Every statement runs through the binding's + * `exec()` / `pragma()`, which step to completion. Synchronous by design: + * nothing else runs on the connection between the chunks, so the freelist + * only shrinks while the loop runs. + * + * Why chunks. In WAL mode one `PRAGMA incremental_vacuum` over a large + * freelist is one transaction whose dirty pages outgrow the page cache, so + * SQLite spills them into the WAL before the commit truncates them away. + * Measured at 25,754 free pages: the database file went to 16,384 bytes and + * the `-wal` sidecar to 94,430,432, held until the last connection closed. A + * chunk that stays inside the page cache writes only the pages its commit + * keeps. The chunk is a quarter of this connection's page cache — 1,000 pages + * at better-sqlite3's default `cache_size` (-16000 KiB) and 4 KiB pages. + * Readings with a reader pinning the WAL, so every frame written stays + * visible: 2,000-page chunks left 883 frames, 4,000 left 3,779, and one + * statement 22,920; with a 2 MB cache, 250-page chunks left 1,121 and + * 1,000-page chunks 15,491. + * + * Why these two checkpoints. A `PASSIVE` checkpoint after each chunk moves + * its frames into the database and lets the next chunk restart the WAL from + * its start; it never waits. What it cannot do is shrink the sidecar, which + * keeps its high-water size until something truncates it. So one + * `TRUNCATE` checkpoint closes the call, under a busy timeout of 0 for that + * one statement and the connection's own timeout put back afterwards: a + * `TRUNCATE` checkpoint waits for other connections' readers through the + * busy handler, and on this synchronous binding that wait blocks the whole + * process — measured at 5,333 ms against a reader in the same process, the + * connection's 5,000 ms timeout. When another connection is reading, the + * `PASSIVE` checkpoints move only the frames that reader no longer needs and + * the `TRUNCATE` one answers "busy" as a result row, not as an error: the + * pages are off the freelist, and their bytes leave the files at a later + * checkpoint (the next call, SQLite's own auto-checkpoint, or the last + * connection closing). Outside WAL mode both checkpoints are no-ops. + * + * The loop stops when the freelist is empty or a chunk frees nothing: a file + * whose `auto_vacuum` is still `NONE` never shrinks its freelist through + * this statement (one full `VACUUM` adopts INCREMENTAL, see `SqlDriver.connect`). + * + * Module-local, like {@link formatDuplicateGroups}: `SqlDriver`'s `.d.ts` + * carries its non-public members too, and this helper is no entry point. + */ +function reclaimBetterSqlite3(db: BetterSqlite3Connection): void { + const scalar = (pragma: string): number => Number(db.pragma(pragma, { simple: true })); + let free = scalar('freelist_count'); + if (free === 0) return; + const cacheSize = scalar('cache_size'); + const cachePages = cacheSize >= 0 ? cacheSize : Math.floor((-cacheSize * 1024) / scalar('page_size')); + const chunk = Math.max(1, Math.floor(cachePages / 4)); + for (;;) { + db.exec(`PRAGMA incremental_vacuum(${chunk})`); + db.exec('PRAGMA wal_checkpoint(PASSIVE)'); + const left = scalar('freelist_count'); + if (left === 0 || left >= free) break; + free = left; + } + const busyTimeout = scalar('busy_timeout'); + db.pragma('busy_timeout = 0'); + try { + db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); + } finally { + db.pragma(`busy_timeout = ${busyTimeout}`); + } +} + +// [#29520 codemod] Declared above without `export`, and read by `sql-driver.ts` or a section module. +export { + AUTONUMBER_COLLISION_RETRIES, CROSS_FIELD_COMPARISON_OPERATORS, DEFAULT_ID_LENGTH, RowWidthContribution, + SQL_AGGREGATE_FUNCTIONS, SQL_WINDOW_FUNCTIONS, SQL_WINDOW_SORT_DIRECTIONS, SqlAggregateLowering, TEXT_OPERATORS, + WITHHELD_FILTER_AUTHOR_TEXT, WITHHELD_FILTER_LOGGED, WITHHELD_FILTER_SUBTREE, assertCompilableComparand, + assertDefinedComparands, bareFieldReferenceError, betweenArityError, canonicalTimeOfDay, canonicalUtcDatetime, + declaresColumnNotNull, emptyFieldConstraintError, filterArrayReachedDriverError, insertedRowsNotReadBackError, + invalidWindowFunctionQueryError, isDeclaredFieldType, isDeferredDdlLockWaitRefusal, isEmptyFieldConstraint, + isUpsertFenceRefusal, jsonColumnOperatorError, normalizeSqliteDatetimeOutput, nowAnchorOf, + presentAuditTimestampOutput, reclaimBetterSqlite3, reduceFilterKey, reduceFilterNode, refusalSubtree, + refuseAggregateFunction, refuseCrossRowIdentityMerge, refuseDateBucketedGroupBy, + refuseDistinctAggregateWithoutField, refuseRejectedReferenceAlias, refuseUndeclaredFieldType, + retiredFilterOperatorError, uniqueProbeNotNullClause, unsupportedAggregationFilterError, + unsupportedFilterOperatorError, +}; diff --git a/packages/drivers/driver-sql/src/sql-driver-window-function-builder.ts b/packages/drivers/driver-sql/src/sql-driver-window-function-builder.ts new file mode 100644 index 00000000000..5d13314c417 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-window-function-builder.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The "Window function builder" section of `SqlDriver`: the + * bodies of `buildWindowFunction` moved out of `sql-driver.ts` whole by + * `scripts/codemod-split-sql-driver.mjs`. + * + * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * method of the same name exactly as it stood: not re-indented, not + * formatted, not edited. The method stays on the class with its docblock and + * signature, so the published `dist/*.d.ts`, an instance spy and a subclass + * override are what they were; the docblock there is the contract. + * + * The bodies reach the class through {@link WindowFunctionBuilderHost}: exactly the + * `SqlDriver` members they read. Each method hands its body one literal of the + * members that body reads, plus a `.call`: methods go as closures that call + * them on the driver at call time (the builder factory as a bound reference), + * so a subclass override or an instance spy is still the one reached; fields + * and accessors go as GETTERS, read when the body reads them, as they were on + * the class. + * + * The module imports `sql-driver.ts` for types only, so the two modules form + * no evaluation cycle. `index.ts` re-exports nothing from this module. + */ + +import { + SQL_WINDOW_FUNCTIONS, SQL_WINDOW_SORT_DIRECTIONS, invalidWindowFunctionQueryError, +} from './sql-driver-top-level.js'; +import type { SqlWindowFunctionSpec } from './sql-driver-top-level.js'; +import type { SqlDriver } from './sql-driver.js'; + +/** + * The `SqlDriver` members the functions below read — every one of them, and + * no other. Each is typed AS the class member (an indexed access on the + * class), so a change to a member's signature on the class is a change here + * too. `readonly` members are the getters. + */ +export interface WindowFunctionBuilderHost { + readonly knex: SqlDriver['knex']; + mapSortField: SqlDriver['mapSortField']; +} + +/** The body of `SqlDriver#buildWindowFunction`; the method's docblock there is its contract. */ +export function buildWindowFunction(this: Pick, spec: SqlWindowFunctionSpec): string { + const func = SQL_WINDOW_FUNCTIONS.get(String(spec.function).toLowerCase()); + if (func === undefined) { + throw invalidWindowFunctionQueryError( + `Window function ${JSON.stringify(String(spec.function))} is not one this door compiles. ` + + `Supported (argument-less, any case): ${[...SQL_WINDOW_FUNCTIONS.keys()].join(', ')}. ` + + `Fix the "function" key of the windowFunctions[] entry.`, + ); + } + const column = (field: string) => this.knex.ref(this.mapSortField(String(field))).toQuery(); + let sql = `${func}()`; + + const overParts: string[] = []; + + if (spec.partitionBy && Array.isArray(spec.partitionBy) && spec.partitionBy.length > 0) { + overParts.push(`PARTITION BY ${spec.partitionBy.map(column).join(', ')}`); + } + + if (spec.orderBy && Array.isArray(spec.orderBy) && spec.orderBy.length > 0) { + const orderFields = spec.orderBy + .map((s) => { + const order = SQL_WINDOW_SORT_DIRECTIONS.get(String(s.order || 'asc').toLowerCase()); + if (order === undefined) { + throw invalidWindowFunctionQueryError( + `Window sort direction ${JSON.stringify(String(s.order))} is not "asc" or "desc". ` + + `Fix the "order" key of the windowFunctions[].orderBy entry.`, + ); + } + return `${column(s.field)} ${order}`; + }) + .join(', '); + overParts.push(`ORDER BY ${orderFields}`); + } + + sql += overParts.length > 0 ? ` OVER (${overParts.join(' ')})` : ` OVER ()`; + return sql; + } diff --git a/packages/drivers/driver-sql/src/sql-driver-window-functions.ts b/packages/drivers/driver-sql/src/sql-driver-window-functions.ts new file mode 100644 index 00000000000..b1a2a8639fb --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-window-functions.ts @@ -0,0 +1,150 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The "Window Functions" section of `SqlDriver`: the bodies of + * `findWithWindowFunctions` moved out of `sql-driver.ts` whole by + * `scripts/codemod-split-sql-driver.mjs`. + * + * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * method of the same name exactly as it stood: not re-indented, not + * formatted, not edited. The method stays on the class with its docblock and + * signature, so the published `dist/*.d.ts`, an instance spy and a subclass + * override are what they were; the docblock there is the contract. + * + * The bodies reach the class through {@link WindowFunctionsHost}: exactly the + * `SqlDriver` members they read. Each method hands its body one literal of the + * members that body reads, plus a `.call`: methods go as closures that call + * them on the driver at call time (the builder factory as a bound reference), + * so a subclass override or an instance spy is still the one reached; fields + * and accessors go as GETTERS, read when the body reads them, as they were on + * the class. + * + * The module imports `sql-driver.ts` for types only, so the two modules form + * no evaluation cycle. `index.ts` re-exports nothing from this module. + */ + +import type { DriverOptions } from '@objectstack/spec/data'; +import type { SqlWindowFunctionQuery } from './sql-driver-top-level.js'; +import type { SqlDriver } from './sql-driver.js'; + +/** + * The `SqlDriver` members the functions below read — every one of them, and + * no other. Each is typed AS the class member (an indexed access on the + * class), so a change to a member's signature on the class is a change here + * too. `readonly` members are the getters. + */ +export interface WindowFunctionsHost { + aliasIdentifierSql: SqlDriver['aliasIdentifierSql']; + applyFilters: SqlDriver['applyFilters']; + applyTenantScope: SqlDriver['applyTenantScope']; + buildWindowFunction: SqlDriver['buildWindowFunction']; + formatOutput: SqlDriver['formatOutput']; + getBuilder: SqlDriver['getBuilder']; + readonly knex: SqlDriver['knex']; + mapSortField: SqlDriver['mapSortField']; +} + +/** The body of `SqlDriver#findWithWindowFunctions`; the method's docblock there is its contract. */ +export async function findWithWindowFunctions(this: Pick, object: string, query: SqlWindowFunctionQuery, options?: DriverOptions): Promise { + const builder = this.getBuilder(object, options); + // ROWS, so this is the read-side wall itself — not a consistency tidy-up + // (#6792). This door returned every tenant's rows to a caller that passed + // `options.tenantId`, because it built through `getBuilder` and then simply + // never reached the chokepoint all thirteen other doors route through. + // Measured on `main` at `6595262` with two tenants seeded: `tenantId: + // 'org_a'` returned `[a1, a2, b1, b2, p1]` here and `[a1, a2, p1]` through + // `find()` — org_b's rows, handed to org_a, at the driver layer. + // + // Placed BESIDE `getBuilder` and above the caller's `where`, which is the + // position `findRows()` uses: the predicate has to be on the builder before + // anything reads it, and `applyTenantScope` is what owns the equality and + // ADR-0105 D2 union semantics. Re-deriving either here + // would be a second, worse copy of the wall. + this.applyTenantScope(builder, object, options); + + builder.select('*'); + + if (query.where) { + this.applyFilters(builder, query.where, options); + } + + if (query.windowFunctions && Array.isArray(query.windowFunctions)) { + for (const wf of query.windowFunctions) { + const windowFunc = this.buildWindowFunction(wf); + builder.select(this.knex.raw(`${windowFunc} as ${this.aliasIdentifierSql(wf.alias)}`)); + } + } + + if (query.orderBy && Array.isArray(query.orderBy)) { + for (const sort of query.orderBy) { + builder.orderBy(this.mapSortField(sort.field), sort.order || 'asc'); + } + } + + // PRESENCE, not truthiness — the same test `findRows()` makes (#6577). + // `limit: 0` means "return no records" (#6485), and `0` is falsy, so + // `if (query.limit)` dropped the clause and answered a request for NOTHING + // with the WHOLE table. Measured on `main` before this line changed: three + // rows seeded, `{ limit: 0 }` returned 3 here and 0 through `find()` — one + // driver, two answers to one `QueryAST`. + if (query.limit !== undefined) builder.limit(query.limit); + if (query.offset !== undefined) builder.offset(query.offset); + + const rows = await builder; + if (!Array.isArray(rows)) return rows; + + // [commit 78bc4ad58] The last read door that returned STORAGE forms. It handed back + // `await builder` with no presentation at all, while `find()`/`findOne()` + // run every row through `formatOutput` and `aggregate()`/`distinct()` got + // `presentReadValue` under #3797/#3849 — so one driver answered one + // declared column two ways depending on which door you knocked on. + // Measured on `main` at `2e6a2ea4c9`, one row through the two doors: + // find() -> { ok: true, meta: { k: 1 } } + // window -> { ok: 1, meta: '{"k":1}' } + // i.e. a declared `Field.boolean` answered `1` and a declared `Field.object` + // answered the stored JSON TEXT. `formatOutput` rather than + // `presentReadValue` is what runs here, and that choice is load-bearing: + // {@link ReadPresentationKind} has no `json` member, so the per-value + // helper the other two doors use cannot present `meta` at all. These are + // ROWS, which is exactly what `formatOutput` takes. + // + // ── The alias columns are carved out, and that is the whole design ────── + // + // A window alias is a COMPUTED value, not a declared field, so no declared + // field's presentation rule may touch it. The case that forces the rule to + // be explicit is an alias that COLLIDES with a declared field name, and SQL + // has already decided that one: `select *` plus ` as ok` projects + // two columns named `ok` and the row object keeps the LAST, so the computed + // value wins the key and the declared column's value is not in the row at + // all. Measured on `main` at `2e6a2ea4c9` with `alias: 'ok'` over a + // declared `Field.boolean ok`: rows came back `ok: 1` and `ok: 2` — the + // ROW_NUMBERs, not the booleans (`true`/`false`), which is how you can tell + // them apart. So the alias wins the key BEFORE this change and still wins + // it after; what this carve-out prevents is presenting that computed number + // as the declared type, which would have turned ROW_NUMBER 1 and 2 into + // `true` and `true` and destroyed the very value the caller asked for. + // Pinned by `sql-driver-window-function-output.test.ts`. + // + // Snapshot-and-restore rather than a "which keys would `formatOutput` + // touch?" pre-computation: that question can only be answered by re-reading + // the declared-field registries `formatOutput` reads, which would be a + // second, worse copy of it — and one that goes silently stale the next time + // `formatOutput` learns a new rule. Restoring a value the pass never + // touched is a no-op, so the cheap-looking version buys nothing. + const aliases = Array.isArray(query.windowFunctions) + ? query.windowFunctions.map((wf) => String(wf.alias)) + : []; + + for (const row of rows) { + if (!row || typeof row !== 'object') continue; + const computed: [string, any][] = []; + for (const alias of aliases) { + if (Object.prototype.hasOwnProperty.call(row, alias)) computed.push([alias, row[alias]]); + } + // Mutates in place and returns the same row, as `findRows()` relies on. + this.formatOutput(object, row); + for (const [alias, value] of computed) row[alias] = value; + } + + return rows; + } diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 114477a4f44..dcef055c5aa 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -226,4027 +226,133 @@ import { // Turso's remote transport, `driver-memory` and `driver-mongodb`. import { findMisplacedReadableIds, refuseReadableIdsOnce } from '@objectstack/core'; import { isComposedReadableIds, type ComposedReadableIds } from '@objectstack/spec/data'; +// [#29520 codemod] The top-level declarations that moved whole to `sql-driver-top-level.ts`, as this file still reads them. +import { + AUDIT_TIMESTAMP_COLUMNS, AUTONUMBER_COLLISION_RETRIES, CROSS_FIELD_COMPARISON_OPERATORS, LIKE_ESCAPE_CHARACTER, + MYSQL_DATETIME_PRECISION, PRE_DDL_QUESTION_SCOPE, SEQUENCES_TABLE, TEXT_OPERATORS, WITHHELD_FILTER_AUTHOR_TEXT, + WITHHELD_FILTER_SUBTREE, assertCompilableComparand, bareFieldReferenceError, betweenArityError, canonicalTimeOfDay, + canonicalUtcDatetime, declaresColumnNotNull, fieldReferenceOf, isDeclaredFieldType, isDeferredDdlLockWaitRefusal, + isFilterNode, isMultiValuedColumn, isNowDefaultValue, isUnresolvableColumnError, jsonColumnOperatorError, + mysqlDatetimeLiteral, nowAnchorOf, nowAnchorPositionError, reduceFilterKey, reduceFilterNode, refusalSubtree, + refuseRejectedReferenceAlias, refuseUndeclaredFieldType, resolveSqliteAbsentFileTarget, retiredFilterOperatorError, + unsupportedFilterError, unsupportedFilterOperatorError, withheldFilterDiagnosticOf, +} from './sql-driver-top-level.js'; +import type { + AutoNumberReservation, ConflictTargetVerdict, IntrospectedColumn, IntrospectedForeignKey, IntrospectedSchema, + NowAnchor, ReadPresentationKind, RowWidthContribution, SqlAggregateLowering, SqlDriverConfig, + SqlWindowFunctionQuery, SqlWindowFunctionSpec, SqliteAbsentFileMode, SqliteJournalMode, TextMatchShape, +} from './sql-driver-top-level.js'; +// [#29520 codemod] Re-exported by name, so every importer of `./sql-driver.js` reads what it read. +export { + AUDIT_TIMESTAMP_COLUMNS, type AutoNumberReservation, CREATE_INDEX_DDL, type ConflictTargetVerdict, DROP_INDEX_DDL, + INDEX_DDL_PREFIX, type IntrospectedColumn, type IntrospectedForeignKey, type IntrospectedSchema, + type IntrospectedTable, LIKE_ESCAPE_CHARACTER, LOCK_WAIT_REFUSAL, MYSQL_DATETIME_PRECISION, NUMERIC_SCALAR_TYPES, + type NowAnchor, OWN_STATEMENT_FAULT, PRE_DDL_QUESTION_SCOPE, type ReadPresentationKind, SEQUENCES_TABLE, + SQLITE_NON_TEMPORAL_TEXT_REFS, SQLITE_TIME_EXPR_REFS, type SqlDriverConfig, type SqlWindowFunctionQuery, + type SqlWindowFunctionSpec, type SqliteAbsentFileMode, type SqliteJournalMode, type TextMatchShape, + crossFieldComparisonError, declaredValueShapes, fieldReferenceOf, isFilterNode, isMultiValuedColumn, + isNowDefaultValue, isUnresolvableColumnError, lastIdentifierSegment, mysqlDatetimeLiteral, nowAnchorPositionError, + postgresTableNameCollisionError, rawStatementFaultError, refuseUnbackedConflictTarget, + refuseUpsertConflictOutsideWrittenOrganization, resolveSqliteAbsentFileTarget, unquoteSqlIdentifier, + unsupportedFilterError, withheldFilterDiagnosticOf, withheldFilterError, +} from './sql-driver-top-level.js'; +// [#29520 codemod] Lifecycle: the methods of the same names below hand the bodies what they read (`LifecycleHost`). +import { connect } from './sql-driver-lifecycle.js'; +// [#29520 codemod] CRUD — IDataDriver core: the methods of the same names below hand the bodies what they read (`CrudHost`). +import { create, readBackInsertedRows, sequenceKeyHash } from './sql-driver-crud.js'; +// [#29520 codemod] The sequence semantics a second face shares, stated once: the methods of the same names below hand the bodies what they read (`SequenceSemanticsHost`). +import { assertMergeLandedInWrittenOrganization, assertMergeLandedOnSuppliedIdentity, autoNumberCounterKey, autoNumberValueExists, defineSequencesTable, update, upsert } from './sql-driver-sequence-semantics.js'; +// [#29520 codemod] Bulk & Batch Operations: the methods of the same names below hand the bodies what they read (`BulkOperationsHost`). +import { bulkCreate, bulkUpdate, count, rotatedUpdateById, updateMany } from './sql-driver-bulk-operations.js'; +// [#29520 codemod] Aggregation: the methods of the same names below hand the bodies what they read (`AggregationHost`). +import { aggregate, foldEmptyAggregateAnswers } from './sql-driver-aggregation.js'; +// [#29520 codemod] Distinct: the methods of the same names below hand the bodies what they read (`DistinctHost`). +import { distinct } from './sql-driver-distinct.js'; +// [#29520 codemod] Window Functions: the methods of the same names below hand the bodies what they read (`WindowFunctionsHost`). +import { findWithWindowFunctions } from './sql-driver-window-functions.js'; +// [#29520 codemod] Query Plan Analysis: the methods of the same names below hand the bodies what they read (`QueryPlanHost`). +import { analyzeQuery } from './sql-driver-query-plan.js'; +// [#29520 codemod] Schema Sync (syncSchema / init): the methods of the same names below hand the bodies what they read (`SchemaSyncSectionHost`). +import { reclaimSpace } from './sql-driver-schema-sync-section.js'; +// [#29520 codemod] Data-lifecycle rotation (ADR-0057 P2): the methods of the same names below hand the bodies what they read (`RotationHost`). +import { computeTenantField, registerManagedObjectMetadata } from './sql-driver-rotation.js'; +// [#29520 codemod] Managed-schema drift & reconcile (#2186): the methods of the same names below hand the bodies what they read (`ManagedDriftHost`). +import { buildRebuiltColumn, dropIndexIfExists, isProductionEnv, probeNullSafeUniqueDuplicates, syncTableIndexes } from './sql-driver-managed-drift.js'; +// [#29520 codemod] Internal helpers: the methods of the same names below hand the bodies what they read (`InternalHelpersHost`). +import { applyTenantScope, auditMissingTenant, injectTenantOnInsert } from './sql-driver-internal-helpers.js'; +// [#29520 codemod] Filter helpers: the methods of the same names below hand the bodies what they read (`FilterHelpersHost`). +import { coerceFilterValue, compileFilters, filterColumnExpr, logWithheldFilterDiagnostic, presentReadColumns, presentReadValue, readPresentationKind, sqliteCanonicalDatetimeSql, sqliteCanonicalTimeSql, sqliteNonTemporalTextSql, tableNameForBuilder, toDateOnly } from './sql-driver-filter-helpers.js'; +// [#29520 codemod] Field mapping: the methods of the same names below hand the bodies what they read (`FieldMappingHost`). +import { mapAggregateFunc, mapSortField, orderKeysFor } from './sql-driver-field-mapping.js'; +// [#29520 codemod] Window function builder: the methods of the same names below hand the bodies what they read (`WindowFunctionBuilderHost`). +import { buildWindowFunction } from './sql-driver-window-function-builder.js'; +// [#29520 codemod] Column creation helper: the methods of the same names below hand the bodies what they read (`ColumnCreationHost`). +import { decorateHashShadowDuplicate, rethrowWithRowSizeExplanation, schemaBytesPerChar } from './sql-driver-column-creation.js'; +// [#29520 codemod] Database helpers: the methods of the same names below hand the bodies what they read (`DatabaseHelpersHost`). +import { sqliteFilename } from './sql-driver-database-helpers.js'; +// [#29520 codemod] SQLite serialisation: the methods of the same names below hand the bodies what they read (`SqliteSerialisationHost`). +import { formatInput, formatOutput } from './sql-driver-sqlite-serialisation.js'; -/** - * [#20768] The async scope of a driver's own PRE-DDL question: the ADR-0104 - * media-arm resolver that {@link SqlDriver.resolveFileColumnsMoved} asks at the - * start of the first `initObjects`, before this driver has created any table. - * - * The resolver the engine supplies answers by reading `sys_migration`. On the - * first boot of a new database that table does not exist yet, because this - * very schema pass is what creates it. The backend refuses the read, and the - * resolver answers "not moved", which is the correct answer for a store with - * no columns. So the refusal is the ordinary state of a new database, and not - * a `DATABASE_ERROR` to put in front of an operator. - * - * Two questions read through this scope — that one, and the ADR-0131 D1 - * certification {@link SqlDriver.isTenantKeyCertified} asks of the same - * ledger — and only {@link SqlDriver.backendStatementFault} consults it. ⛔ It demotes one class, - * a missing table, recognised by the shared `isMissingTableError`. Any other - * refusal inside the scope still warns, and so does a missing table outside it. - * - * Module-level rather than per instance: the read the question issues goes to - * whichever driver serves the ledger. On a multi-datasource composition that - * can be another instance of this class, and it is in the same async chain. - */ -export const PRE_DDL_QUESTION_SCOPE = new AsyncLocalStorage(); - -/** - * [ADR-0131 D1] The `WHERE` of a unique key's duplicate probe: every plain key - * part NOT NULL, so the probe groups only the rows the index constrains (SQL - * UNIQUE is NULL-distinct). Empty when every part is NULL-safe. The Turso - * remote face spells the same clause with its own identifier quoting. - */ -function uniqueProbeNotNullClause( - columns: readonly string[], - nullSafe: ReadonlySet, - quote: (identifier: string) => string, -): string { - const plain = columns.filter((c) => !nullSafe.has(c)); - return plain.length === 0 ? '' : ` WHERE ${plain.map((c) => `${quote(c)} IS NOT NULL`).join(' AND ')}`; -} - -/** - * Default ID length for auto-generated IDs. - */ -const DEFAULT_ID_LENGTH = 16; - -/** - * [#21385] What every refusal line in this file knows about the dialect text it - * logs: a statement THIS driver sent raised it, so a statement may lead that - * text whatever word it opens with. The shared cut then runs without asking the - * leak predicate, exactly as it does at the engine's raw-statement door. - */ -export const OWN_STATEMENT_FAULT = { statementSent: true } as const; - -// ── Raw index DDL the driver executes on the framework's behalf (#4884) ────── -/** An SQL identifier in any dialect's quoting, or bare. */ -const SQL_IDENTIFIER = '(?:`[^`]+`|"[^"]+"|\\[[^\\]]+\\]|[A-Za-z_][A-Za-z0-9_$]*)'; -/** Cheap prefilter so ordinary `execute()` traffic never touches the parsers below. */ -export const INDEX_DDL_PREFIX = /^\s*(?:create|drop)\s+(?:unique\s+)?index\b/i; -export const CREATE_INDEX_DDL = new RegExp( - `^\\s*create\\s+(?:unique\\s+)?index\\s+(?:concurrently\\s+)?(?:if\\s+not\\s+exists\\s+)?` + - `(${SQL_IDENTIFIER})\\s+on\\s+(${SQL_IDENTIFIER}(?:\\.${SQL_IDENTIFIER})?)`, - 'i', -); -export const DROP_INDEX_DDL = new RegExp( - `^\\s*drop\\s+index\\s+(?:concurrently\\s+)?(?:if\\s+exists\\s+)?` + - `(${SQL_IDENTIFIER}(?:\\.${SQL_IDENTIFIER})?)`, - 'i', -); - -/** Peel one layer of `"…"` / `` `…` `` / `[…]` quoting off an identifier. */ -export function unquoteSqlIdentifier(raw: string): string { - const s = raw.trim(); - if (s.length >= 2) { - const first = s[0]; - const last = s[s.length - 1]; - if ((first === '"' && last === '"') || (first === '`' && last === '`') || (first === '[' && last === ']')) { - return s.slice(1, -1); - } - } - return s; -} - -/** - * The unquoted final segment of a possibly schema-qualified reference — - * `"public"."sys_metadata"` → `sys_metadata`. Tokenised rather than split on - * `.`, so a dot INSIDE a quoted identifier does not become a separator. - */ -export function lastIdentifierSegment(raw: string): string { - const parts = raw.match(new RegExp(SQL_IDENTIFIER, 'g')); - return unquoteSqlIdentifier(parts && parts.length > 0 ? parts[parts.length - 1] : raw); -} - -/** - * Internal table that persists per-(object, tenant, field) auto-number - * counters so sequences are monotonic, tenant-isolated, and resilient to - * concurrent writers. Lazily created on first autonumber-bearing insert. - */ -export const SEQUENCES_TABLE = '_objectstack_sequences'; - -/** - * How many times `create()` will re-seed a stale autonumber counter and retry - * a refused INSERT before giving the unique violation back to the caller - * (#5495). - * - * One is enough for the defect this bounds: a re-seed jumps the counter to the - * observed MAX in a single step, so the whole "one-time storm per database" - * collapses to one retry no matter how far behind the counter had fallen. The - * remaining budget is for concurrent writers taking the re-seeded number - * between our scan and our INSERT, which is a per-attempt race rather than a - * standing condition. The bound is what keeps a genuinely un-satisfiable insert - * from looping: at exhaustion the original error is rethrown unchanged. - */ -const AUTONUMBER_COLLISION_RETRIES = 3; - -/** - * One autonumber value this driver generated for a row it is about to insert - * (#5495) — the field, the value, and the coordinates under which that value's - * counter would be re-seeded. - * - * Its reason for existing is the distinction between "a number this driver - * handed out" and "a value the caller typed": only the former may be - * regenerated after a unique violation, and only the latter's 409 belongs to - * the client. - */ -export interface AutoNumberReservation { - /** The `auto_number` field on the object. */ - field: string; - /** Physical table the value lives in (external objects map away from the object name). */ - tableName: string; - /** Full rendered prefix — the LIKE anchor the re-seed scan uses. */ - prefix: string; - /** Rendered counter scope (date/`{field}` tokens); `''` for a fixed-prefix format. */ - scope: string; - /** Rendered text after the counter slot (#6468). */ - suffix: string; - /** Tenant column for this object, or `null` when it has none. */ - tenantField: string | null; - /** Tenant this row belongs to, or `null` for the global counter. */ - tenantId: string | null; - /** The rendered value written onto the row. */ - value: string; -} - -// GLOBAL_TENANT ('__global__') — the sentinel for the NULL-organization -// ("platform") bucket — is defined ONCE in schema-drift.ts and imported here: -// since ADR-0120 D3 it names the same bucket in two subsystems (the autonumber -// sequence table's tenant key, and the COALESCE organization key part of every -// organization-scoped unique index), and #3696's root lesson was two -// subsystems naming one concept differently. Storage stays NULL — see the -// definition site. - -/** - * Field types whose value is an array or object and must be stored as a JSON - * column (and JSON-(de)serialized at the driver boundary). SINGLE SOURCE for - * both the DDL column-type switch and `isJsonField` so the two can't drift — - * the drift between them is exactly what let array-valued fields (multiselect/ - * checkboxes/tags/repeater/vector) reach the SQLite binder un-serialized and - * crash with "SQLite3 can only bind numbers, strings, bigints, buffers, and - * null" (#field-zoo). `composite`/`address`/`location`/`record` are objects; - * the rest are arrays. - * - * ⚠️ The FILE family (`image`/`file`/`avatar`/`video`/`audio`) was a member - * until the ADR-0104 addendum ruled that its physical column holds the BARE - * `sys_file` id in a STRING column rather than a JSON-quoted id in a JSON - * column. Its membership is no longer a constant: a SINGLE-VALUE media field - * is a JSON column on a deployment that has not moved its columns and a string - * column on one that has, so the question is asked per driver instance through - * {@link SqlDriver.mediaColumnIsJson} rather than of this set. A - * `multiple: true` media field is unaffected — its value is a LIST of ids, it - * is a JSON column on every deployment, and `isMultiValueField` already says so - * above every type check (`file` / `image` are `MULTI_CAPABLE_TYPES` members; - * #17469 refuses `multiple: true` on `avatar` / `video` / `audio` at the - * authoring entrance rather than arrayifying their column). - * - * ⛔ Do not re-add the family here. Every reader of this set treats membership - * as deployment-independent, which is exactly what the family stopped being. - */ -export const JSON_COLUMN_TYPES = new Set([ - // Spec value-shape classes (ADR-0104 D1): structured JSON payloads and the - // inherently-array option types. Membership is owned by @objectstack/spec — - // a type added there becomes a JSON column here without touching this file. - ...STRUCTURED_JSON_TYPES, ...MULTI_OPTION_TYPES, - // Driver-internal aliases (external/introspected columns) — not authorable - // FieldTypes, so they stay a local extra. - 'object', 'array', -]); - -/** - * The `varchar(n)` width a moved single-value media column gets. - * - * ⚠️ NOT a taste: it is the width `os generate migration --format sql` already - * emits for the family (`VARCHAR(2048)` in `packages/cli/src/commands/ - * generate.ts`), and the maintainer ruling in ADR-0104's 2026-09-05 addendum is that the GENERATOR - * states the ruled end-state and does not move — the driver is the side that - * moves to meet it. A driver that created knex's default `varchar(255)` here - * would open a fresh divergence between the two producers of the same column - * on the very change that closes the old one. - * - * Mirrored by {@link SqlDriver.varcharColumnChars}, which is what the drift - * detector asks rather than restating the width; the two are pinned equal by - * `sql-driver-11565-row-byte-budget.test.ts` for every declared `FieldType`. - */ -const MEDIA_ID_VARCHAR_CHARS = 2048; - -/** - * Field types whose value is a numeric scalar. SINGLE SOURCE for the DDL - * column-type switch (these map to INTEGER/REAL columns) and the read-side - * coercion registry (`numericFields`). - * - * ⚠️ [#16318] The read coercion no longer reads this whole set on every - * dialect. The three ALIASES below are how an external, introspected column - * reaches the driver, and on PostgreSQL a `bigint` arrives as a STRING; the - * server-dialect arm therefore reads {@link SqlDriver.numericValueFields}, the - * authorable `NUMERIC_VALUE_TYPES` half, and SQLite keeps this full set for the - * legacy TEXT-affinity repair described below. The DDL switch is unchanged. - * - * The read coercion exists so the fix is robust on SQLite even when the column - * predates it: a `rating`/`slider`/`progress` column created before #2025 has - * TEXT affinity and returns '4' not 4, and SQLite never alters a column's type - * in-place (the reconciler only ADDS columns). Coercing numeric-looking strings - * back to numbers on read transparently repairs those legacy rows — mirroring - * how `dateFields` repairs legacy timestamp-typed `Field.date` rows — so the - * type fidelity no longer depends on column affinity alone. `toggle`/`record` - * already self-heal this way via `booleanFields`/`jsonFields`; this closes the - * gap for the numeric scalars. - */ -export const NUMERIC_SCALAR_TYPES = new Set([ - // Spec numeric value class (ADR-0104 D1) + driver-internal SQL aliases. - ...NUMERIC_VALUE_TYPES, - 'integer', 'int', 'float', -]); - -// [#20387, #21042] Whether a column holds FRACTIONS — the exact-decimal members -// of `NUMERIC_COLUMN_REPRESENTATION` and this driver's `float` alias — is the -// `'fractional'` class of `aggregandColumnClass` (`@objectstack/core`), the one -// predicate the analytics native-SQL face asks too. Read into -// {@link SqlDriver.fractionalNumericFields}. - -/** - * The builtin audit-timestamp columns every managed object carries. They are - * stamped to a single canonical instant format on SQLite (see - * `stampInsertTimestamps`/`update`) and presented on read — on EVERY dialect — - * as that same canonical instant text by {@link presentAuditTimestampOutput} - * ([ADR-0053 D-F1], #13973). - */ -export const AUDIT_TIMESTAMP_COLUMNS = ['created_at', 'updated_at'] as const; - -/** - * [#21241] The fractional-seconds precision of every MySQL `DATETIME` column - * this driver creates, and therefore of every server-clock expression that - * defaults or stamps one. `DATETIME(3)` keeps the canonical instant's - * milliseconds (#3942). - * - * ONE source, read by every site that spells it: the declared `Field.datetime` - * column ({@link SqlDriver.createColumn}), the builtin audit columns - * ({@link SqlDriver.createAuditTimestampColumn}), the `NOW()` column default - * ({@link SqlDriver.nowColumnDefault}), the UPDATE stamp - * ({@link SqlDriver.updatedAtStamp}) and the legacy `TIMESTAMP` widening - * ({@link SqlDriver.migrateMysqlDatetimeColumns}). - * - * A column and its default cannot disagree, and that is not a style point: - * MySQL refuses a `CURRENT_TIMESTAMP` default whose precision differs from its - * `DATETIME` column's (`ER_INVALID_DEFAULT`, "Invalid default value for …"), - * so the whole `CREATE TABLE` fails. The audit columns carried `now(3)` beside - * their `DATETIME(3)` as a second literal, and the declared-field default fell - * through to a bare `knex.fn.now()` beside the same `DATETIME(3)` column. - * Measured on MySQL 8.0.46: every table declaring a `defaultValue: 'NOW()'` - * datetime field (`sys_activity.timestamp`, `sys_presence.last_seen`) was - * never created, and its data door answered `500`. - */ -export const MYSQL_DATETIME_PRECISION = 3; - -/** - * Read-side repair for the builtin audit timestamps on SQLite. - * - * SQLite has no native timestamp type. Rows written before the canonical-format - * fix — or by a raw insert that fell back to the `CURRENT_TIMESTAMP` column - * default — hold a timezone-NAIVE, space-separated string - * (`'YYYY-MM-DD HH:MM:SS[.fff]'`). `Date.parse` reads such a zone-less string as - * LOCAL time, so a stored UTC wall-clock silently shifts by the host offset on - * any non-UTC runtime — the bug that made the objectos freshness probe never - * evict. Re-emit those values as canonical ISO-8601 with an explicit `Z`, - * interpreting the stored wall-clock as UTC (exactly what `CURRENT_TIMESTAMP` - * and the legacy UPDATE stamp both wrote). - * - * Idempotent and total: a value that already carries an explicit zone (`…Z` or - * `±HH:MM`) is returned unchanged, so re-reading a normalised row is a no-op - * (this keeps optimistic-lock `updated_at` tokens stable — see - * objectql `assertVersionMatch`). Non-strings (e.g. a `Field.datetime`-typed - * audit column stored as epoch-ms INTEGER) and unrecognised shapes pass - * through untouched. - */ -function repairNaiveUtcAuditTimestamp(value: unknown): unknown { - if (typeof value !== 'string') return value; - const s = value.trim(); - if (s === '') return value; - // Already zone-explicit (`…Z` or `±HH:MM`) — leave as-is (idempotent). - if (/[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return value; - // Zone-naive `YYYY-MM-DD[ T]HH:MM:SS[.fff]` → interpret the wall-clock as UTC. - const m = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/.exec(s); - if (!m) return value; - const d = new Date(`${m[1]}T${m[2]}Z`); - return Number.isNaN(d.getTime()) ? value : d.toISOString(); -} - -/** - * Fold a client library's `Date` — what node-pg materialises a `timestamptz` - * as and what mysql2 materialises a `DATETIME(3)` as — into the canonical - * instant text, `YYYY-MM-DDTHH:MM:SS.sssZ`. - * - * Total in the sense #14078 ruled for the shared consumer spelling: the ONE - * `Date` shape `toISOString()` refuses — an Invalid `Date`, whose time value - * is `NaN` — is handed back UNCHANGED rather than thrown on. Both live - * dialects are measured to produce it from rows that are already on disk (a - * MySQL zero `DATETIME`; a Postgres year past 275760, inside the server's - * range and outside JS's — `sql-driver-14078-invalid-date-materialisation.test.ts`), - * and it has no canonical text to fold to. It is neither nulled (that erases a - * stored value silently) nor spelled as the text `Invalid Date` (that would - * change the wire, where `JSON.stringify` already serialises it as `null`); it - * leaves as the client's `Date`, which is exactly the shape the ruled-B - * consumer arms (`canonicalIsoInstant` and kin) already absorb. - * [ADR-0053 D-F3]. - */ -function isoFromValidDate(value: Date): unknown { - return Number.isNaN(value.getTime()) ? value : value.toISOString(); -} - -/** - * What a record read door hands out for a builtin audit timestamp, on every - * dialect ([ADR-0053 D-F1], #13973): the canonical instant text. - * - * Two arms, one per shape the dialects actually produce. A `Date` — the - * client-level materialisation on Postgres (`timestamptz`) and MySQL - * (`DATETIME(3)`), which the driver deliberately does NOT alter at the client - * parser ([ADR-0053 D-F2]; see {@link SqlDriver.withPostgresCalendarDayAsText}) - * — is folded by {@link isoFromValidDate}. A string — SQLite's TEXT, canonical - * post-ADR-0074 or a legacy zone-naive `CURRENT_TIMESTAMP` — takes ADR-0074's - * {@link repairNaiveUtcAuditTimestamp}, which is idempotent on the canonical - * form. Every other shape passes through untouched, exactly as before. - * - * Before #13973 the string arm ran inside `if (this.isSqlite)` and the `Date` - * arm did not exist, so the two live dialects handed the client's `Date` - * through the read door — the divergence the #13973 census measured eight - * consumers to be wrong under (#13382 in production, #13993–#13999 by reading), - * every one in the direction "expected the text, received a `Date`". - */ -function presentAuditTimestampOutput(value: unknown): unknown { - if (value instanceof Date) return isoFromValidDate(value); - return repairNaiveUtcAuditTimestamp(value); -} - -/** - * Whether a field's `defaultValue` is the framework's `'NOW()'` convention - * ("use the database clock at insert time"). Case-insensitive, whitespace - * tolerant. - * - * Thin alias over the spec's {@link isNowDefaultToken} — the token vocabulary - * itself lives in `@objectstack/spec/data` so the engine's insert-time - * resolution and this driver's DDL read one set (#4560). - */ -export const isNowDefaultValue = isNowDefaultToken; - -/** - * Read-side normalization for user-declared `Field.datetime` columns on SQLite. - * - * SQLite has no native timestamp type, so one `datetime` column can hold MIXED - * storage: - * - an explicitly-written value bound through better-sqlite3 as a JS `Date` - * lands as INTEGER epoch milliseconds; - * - a value left to a `defaultValue: 'NOW()'` column default lands as TEXT — - * canonical ISO-8601-`Z` for columns created after this fix - * (`SqlDriver.nowColumnDefault`), or a legacy timezone-NAIVE - * `'YYYY-MM-DD HH:MM:SS'` (`CURRENT_TIMESTAMP`) for columns created before it. - * - * Present every shape as one canonical instant — full ISO-8601 with an explicit - * `Z` (`new Date(...).toISOString()`) — so reads are uniform and unambiguous - * regardless of how/when the row was written. A NAIVE string's wall-clock is - * interpreted as UTC, exactly what `CURRENT_TIMESTAMP` wrote; without this a - * zone-less string is read back by `Date.parse` as LOCAL time and the stored - * instant shifts by the host offset on a non-UTC runtime (the same class of bug - * ADR-0074 fixed for the builtin `created_at`/`updated_at` audit columns, and - * ADR-0053's "`datetime` is an instant stored as UTC" applied to user fields). - * - * Idempotent (an already zone-explicit `…Z`/`±HH:MM` string is preserved) and - * total (`null`/`undefined`/unparseable shapes pass through untouched). Reuses - * ADR-0074's `repairNaiveUtcAuditTimestamp` for the string shapes (the single - * source of the zone-naive→UTC rules) and adds the INTEGER epoch-ms / `Date` - * folding, mirroring the read-repair the `Field.date`/numeric-scalar paths do. - * - * Runs on EVERY dialect since #13973 ([ADR-0053 D-F1]): the name records the - * storage forms it was written to fold (SQLite's INTEGER epoch and naive TEXT), - * not where it runs. Postgres and MySQL store a real `timestamptz` / - * `DATETIME(3)` and never carry the mixed-storage ambiguity, but their client - * libraries hand the column back as a JS `Date`, and that `Date` used to leave - * the read door as-is — the SQLite-gated fold was the whole of the dialect - * divergence the #13973 census measured. The `Date` arm below is the one those - * two dialects take, and the client parser stays untouched ([ADR-0053 D-F2]). - */ -function normalizeSqliteDatetimeOutput(value: unknown): unknown { - if (value == null) return value; - // INTEGER/REAL epoch milliseconds — what better-sqlite3 binds a JS `Date` to. - if (typeof value === 'number') { - if (!Number.isFinite(value)) return value; - const d = new Date(value); - return Number.isNaN(d.getTime()) ? value : d.toISOString(); - } - // The client-level materialisation on Postgres and MySQL (never what - // better-sqlite3 returns): fold it to the canonical text, an Invalid `Date` - // passing through — see `isoFromValidDate` for why that is the total arm. - if (value instanceof Date) return isoFromValidDate(value); - if (typeof value !== 'string') return value; - const s = value.trim(); - if (s === '') return value; - // A bare integer rendered as TEXT (defensive) — treat as epoch milliseconds. - if (/^-?\d+$/.test(s)) { - const d = new Date(Number(s)); - return Number.isNaN(d.getTime()) ? value : d.toISOString(); - } - // Any other string — zone-explicit or zone-naive `YYYY-MM-DD HH:MM:SS` — takes - // the same shape rules as an audit timestamp; reuse that repair as the single - // source for the string-handling logic (idempotent on zone-explicit values). - return repairNaiveUtcAuditTimestamp(s); -} - -/** - * The CANONICAL on-disk form of a `Field.datetime` value: a fixed-width, - * zone-explicit UTC instant — `YYYY-MM-DDTHH:MM:SS.sssZ` (`Date#toISOString`). - * - * ADR-0053 already declares `datetime` to be "an instant stored as UTC"; this is - * the function that makes the STORAGE match the declaration instead of leaving - * it to whatever the caller happened to pass. It is applied on write - * ({@link SqlDriver.formatInput}) and to filter comparands - * ({@link SqlDriver.coerceFilterValue}) so both sides of every comparison are - * the same shape, on every dialect. - * - * Why THIS form (#3912): - * - Fixed width + UTC means lexicographic order IS chronological order, so a - * SQLite TEXT column sorts and range-compares correctly *through an index* — - * no expression wrapper, which is what an epoch-integer convention forces. - * - `strftime`/`julianday` parse it directly, so the date-bucket expression - * needs no epoch↔text CASE (#3773). - * - It is what `formatOutput`/`normalizeSqliteDatetimeOutput` ALREADY present - * on read, so storage and presentation stop disagreeing. - * - It matches the `Field.date` convention (ISO TEXT), so the platform has one - * temporal storage story rather than one per field type. - * - Postgres parses it into `timestamptz` unambiguously, which is precisely - * what a zone-naive string does NOT do (it is read in the SERVER's - * timezone — an 8-hour shift on an Asia/Shanghai server). - * - * Distinct from {@link repairNaiveUtcAuditTimestamp}, which is deliberately - * idempotent on any zone-EXPLICIT string and so preserves a `+08:00` offset. - * That is right for a read repair and wrong for a storage canon: `'…T12:00+08:00'` - * and `'…T04:00Z'` are the same instant but sort differently as text. Everything - * lands in `Z` here. - * - * Total: `null`/`undefined`, empty strings and unparseable junk pass through - * untouched rather than becoming `Invalid Date` — a value the driver cannot - * interpret is never silently rewritten. - */ -function canonicalUtcDatetime(value: unknown): unknown { - // [#20176] The rule is `@objectstack/core`'s — the same function - // `driver-memory` and the engine's per-aggregation `filter` / `having` read. - // This body was a word-for-word copy of it until the rule was lifted there. - return temporalStorageForm(value, 'datetime'); -} - -/** - * The canonical instant rendered as a MySQL datetime literal — the same UTC wall - * clock, spelled the only way MySQL will parse it (#3942). - * - * MySQL/MariaDB reject the ISO-8601 the rest of the platform speaks: neither the - * `T` separator nor the `Z` suffix is accepted in a datetime literal, so - * `'2026-03-20T12:34:56.789Z'` fails the INSERT outright with *Incorrect datetime - * value* (measured on MariaDB 10.11). MySQL 8.0.19+ added `±HH:MM` offsets, but - * still not `Z`, and the platform supports older servers — so the offset is - * dropped and the value is stored as the UTC wall clock in a `DATETIME(3)` - * column, which does no timezone conversion of its own. - * - * This is a PHYSICAL spelling, not a semantic change: the column still holds the - * same instant, and every layer above the bind — API payloads, filter authoring, - * CEL — keeps the canonical `…Z` form. Reads convert back (the connection is - * pinned to UTC, so mysql2 reconstructs the instant correctly). - * - * Total, and deliberately strict: only an exactly-canonical string is rewritten. - * Anything else — an unparseable value `canonicalUtcDatetime` passed through, or - * a year outside MySQL's 1000..9999 range, which `toISOString` renders in - * expanded `+0YYYYY` form — is handed to MySQL untouched, so it fails loudly - * rather than being silently reinterpreted. - */ -export function mysqlDatetimeLiteral(canonical: unknown): unknown { - if (typeof canonical !== 'string') return canonical; - const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2}\.\d{3})Z$/.exec(canonical); - return m ? `${m[1]} ${m[2]}` : canonical; -} - -/** - * The CANONICAL form of a `Field.time` value: a timezone-naive wall-clock - * time-of-day — `HH:MM:SS`, with a `.fff` millisecond suffix only when the - * milliseconds are non-zero (#3994). - * - * `Field.time` is a time-of-day, not an instant (#2004), so unlike - * {@link canonicalUtcDatetime} there is no zone marker — but the same - * write-unnormalised / repair-on-read drift produced the same broken window - * filters as #3912: a bound `Date` stored INTEGER epoch ms on SQLite (sorts - * before every TEXT row), a full ISO string stored as text beginning `'2026-…'` - * (sorts after every bare time-of-day), and `09:00 <= t <= 18:00` silently - * dropped both. This function is applied on write - * ({@link SqlDriver.formatInput}), to filter comparands - * ({@link SqlDriver.coerceFilterValue}) and on read - * ({@link SqlDriver.toTimeOnly}), so both sides of every comparison — and the - * presented value — are one shape. - * - * Why THIS form: - * - `.` sorts below every digit, so lexicographic order is chronological - * order even with the variable-width suffix (`'14:30:00.100' < - * '14:30:01'`), and a SQLite TEXT column range-compares through an index. - * - Deterministic per time-of-day: `'14:30'` and `'14:30:00'` are the same - * wall clock and canonicalise identically, so equality filters and - * `distinct()` cannot split one time into several values. - * - The zero-millisecond spelling is `HH:MM:SS` — the shape every dialect's - * native TIME emits and the field-zoo round-trip (#2022) already asserts — - * so converged common-case data never changes presentation. - * - Every dialect parses it: SQLite stores the text verbatim, Postgres - * `time` and MySQL `TIME(3)` both accept `HH:MM:SS[.fff]` literals — which - * the full-ISO spelling is precisely NOT (measured: `invalid input syntax - * for type time` on PG 16, `Incorrect time value` on MariaDB 10.11). - * - * A `Date` / epoch-ms / full-timestamp string folds to its **UTC** time-of-day - * (ADR-0053): the platform's instants are UTC everywhere else, and it matches - * what the SQLite read repair and `nowColumnDefault` already produced — - * crucially it does NOT depend on the Node process's local timezone, which is - * exactly what binding a raw `Date` to a Postgres TIME column did (pg - * serialised `14:30Z` as `09:30-05:00` on an America/New_York host). Fractions - * beyond milliseconds are truncated, matching `Date` resolution. - * - * Total: `null`/`undefined`, empty strings, out-of-range wall clocks (`'25:00'`) - * and unparseable junk pass through untouched — a value the driver cannot - * interpret is never silently rewritten. - */ -function canonicalTimeOfDay(value: unknown): unknown { - // [#20176] The rule is `@objectstack/core`'s — see {@link canonicalUtcDatetime}. - // A value that is not a bare time-of-day is read as an instant by the - // `datetime` rule there and keeps its UTC time-of-day, as it did here. - return temporalStorageForm(value, 'time'); -} - -/** - * How many times {@link SqlDriver.sqliteCanonicalTimeSql} spells its column - * reference — the binding count a caller must supply per use of the expression. - * (1 `typeof` + 3 per `strftime` CASE branch pair × 2 + 1 `coalesce` fallback.) - */ -export const SQLITE_TIME_EXPR_REFS = 8; - -/** - * How many times {@link SqlDriver.sqliteNonTemporalTextSql} spells its column - * reference — the binding count a caller must supply per use of the predicate. - * (1 `typeof` + 2 `instr` + 1 `julianday`.) - */ -export const SQLITE_NON_TEMPORAL_TEXT_REFS = 4; - -/** - * [#4436] A filter this driver cannot COMPILE — the caller sent an operator (or - * an operand shape) outside what the backend can express. - * - * This is a refusal the request caused, and #4209/#4029 already made it a - * refusal rather than a silent match-everything. What was missing is the wire - * IDENTITY of that refusal: the thrown `Error` carried no `code`, so - * `mapDataError`'s default branch served `{ "error": "" }` with no - * `code` at all — breaking the ADR-0112 contract that `error.code` is the - * schema-enforced SCREAMING_SNAKE vocabulary every sibling rejection on this - * route already speaks (`INVALID_FIELD`, `INVALID_FILTER`, `RECORD_NOT_FOUND`). - * - * `INVALID_FILTER` is the catalogued code for the condition, and the SAME one - * `metadata-protocol` emits for a filter that fails to parse upstream - * (`malformedFilterArrayError` / `unusableFilterError`): one condition — "this - * filter cannot run" — has one wire code however the caller reached it. - * - * `status: 400` puts the rejection on `@objectstack/rest`'s - * `isExpectedQueryRejection` list, so a client mistake stops being logged as an - * unhandled server error. - * - * It does NOT decide whether the message text survives, and the claim that it - * "makes `sendError` pass the message through instead of routing it to the - * SQL-leak heuristic" was backwards (#5423): WITHOUT a status these messages - * already reached the client verbatim through `mapDataError`'s final - * `{ status: 400, body: { error: raw } }` — the leak heuristics do not match - * this wording. WITH the status they entered the explicit-status passthrough, - * whose 500-character bound used to swap the whole body text for - * `'Request failed'` — so in that band adding the status made the message LESS - * readable, the opposite of what this comment promised. That bound now - * truncates instead of replacing, so the main clause survives either way; the - * tail (attribution, issue numbers) may be cut. Keep the actionable part — - * operator, field, path, what arrived, what the spec declares — at the FRONT. - * - * [#5489] The "without a status it reached the client verbatim" half is now - * history: that terminal branch answers a sanitised 500 (`INTERNAL_ERROR`). - * Declaring `status` + `code` at the throw site is therefore the ONLY way a - * refusal's words reach the caller at all — which is the contract-first - * arrangement #4436 wanted, no longer relying on a fallback that leaked. - * - * The `[sql-driver]` prefix these messages used to carry is GONE from the text: - * it is driver-internal wording, and shipping it to clients is exactly what the - * #3867 sanitiser exists to stop. The operator/field/vocabulary detail — the - * part a caller can act on — stays, EXCEPT where the caller may not be its - * author: see {@link withheldFilterError}. - * - * [#7929, maintainer ruling 2026-08-12] That exception is the cross-field - * `{ $field }` family. A read scope — an administrator's CEL sharing/permission - * rule, compiled by `compileCelToFilter` and ANDed into the query by the - * security middleware (`plugin-security`) or by `ObjectQLStrategy.withReadScope` - * (`service-analytics`) — reaches this compiler as a bare `FilterCondition` with - * NOTHING marking it as policy-authored. When it is refused, the "detail a - * caller can act on" is detail the caller never wrote: policy column names, and - * the identity of the tenant-isolation column. Those refusals therefore keep - * `code` and `status` and lose their operands; the full text goes to the server - * log. - * - * [#8197, maintainer ruling 2026-08-15] The exception is no longer only the - * cross-field family. The same argument reaches one step out: on a read-scope - * predicate the refused constraint's TARGET column is as administrator-authored - * as its referent, so the five refusals named in {@link refusalSubtree} join the - * same seam. The paragraph above still describes every refusal in this file that - * neither family covers. - * - * [#20039, the #8220 contract] On the FILTER-COMPILE path — everything - * {@link SqlDriver.applyFilters} runs — there is no such refusal any more: the - * class is closed. Every builder that path can throw from goes through - * {@link withheldFilterError} with the node it was raised from, so the provenance - * mark decides what the wire may name, and a builder added later that calls this - * function directly instead discloses by omission. That is what - * `sql-driver-compile-refusal-seam.test.ts` pins: it enumerates this file's - * direct callers of this function and of {@link withheldFilterError} and holds - * the first set to three named exceptions — the seam itself, the `'author'` - * re-issue in {@link SqlDriver.resolveWithheldFilterRefusal}, and - * {@link unresolvableFilterColumnError}, which is raised from a dialect error - * AFTER the statement ran and resolves its own provenance by name - * ({@link unresolvableColumnProvenance}) — and the second to the set its - * behavioural table drives under all three marks. - */ -export function unsupportedFilterError(message: string): Error { - const err = new Error(message) as Error & { code?: string; status?: number }; - err.code = StandardErrorCode.enum.INVALID_FILTER; - err.status = 400; - return err; -} - -/** - * [#8790] Does this dialect error say the statement named a column the backend - * could not resolve? - * - * ONE spelling of the question, read by both halves that have to answer it — - * {@link SqlDriver.findRows}' recovery ladder and {@link SqlDriver.count}. It - * used to be an inline predicate inside `findRows` alone, which is exactly how - * one condition ended up with two behaviours: `find()` swallowed it into `[]` - * and `count()`, having no copy of the test, threw the dialect's own error. - * - * REACH — all three dialects this driver speaks: SQLite (`no such column: x`), - * Postgres (`column "x" does not exist`) and, since #8926, MySQL - * (`Unknown column 'x' in 'where clause'` / `'field list'` / `'order clause'` - * — `ER_BAD_FIELD_ERROR` spells every clause position with one sentence). - * - * [#8926, maintainer ruling 2026-08-16] The MySQL arm is the late one, and it - * was ruled rather than assumed, because this predicate serves both consumers - * at once and widening it therefore moves the accept set in BOTH directions in - * one line: MySQL gains the #8790 refusal envelope (`INVALID_FILTER` / 400 - * naming the column, the dialect message with its inlined bound literals kept - * to the server log — the narrowing), AND it gains the #3821 projection and - * ORDER-BY recoveries it never had, so a query that used to throw the raw - * dialect error now returns rows (the widening). Both halves are intended — - * full dialect parity, one shared predicate; a split predicate (envelope - * without recoveries) was considered and refused as a second spelling of one - * fact. The widening is safe for the same structural reason #8790 could turn - * the ladder's terminal into a refusal: every rung is rebuilt from - * `buildBase()`, which unconditionally re-applies `query.where`, so a wider - * predicate hands MySQL a recovered projection or sort but can never drop a - * WHERE. - */ -export function isUnresolvableColumnError(error: unknown): boolean { - const message = (error as { message?: unknown } | null | undefined)?.message; - if (typeof message !== 'string') return false; - return ( - message.includes('no such column') || - (message.includes('column') && message.includes('does not exist')) || - message.includes("Unknown column '") - ); -} - -/** - * [#16019] The raw-execution twin of {@link backendStatementFaultError}: the - * envelope {@link SqlDriver.execute} raises when the backend refuses a - * statement it was handed verbatim. `no such function: translate` on a SQLite - * datasource whose analytics compiler emitted a function the dialect lacks - * (#16028) is the measured case; every other dialect refusal on this path is - * the same class. - * - * # Why this exists beside the read-exit terminal - * - * Every typed read exit (`find`, `count`, `aggregate`) already terminates in - * {@link SqlDriver.backendStatementFault}, so a dialect refusal there leaves - * the driver DECLARED — `code: DATABASE_ERROR`, `status: 500` — and the HTTP - * doors withhold its prose by that declaration (`declaresServerFault`). The - * raw path had no terminal at all: `execute()` awaited `knex.raw()` bare, so - * the dialect's own error object left the driver with no `status`, a `code` - * from the backend's vocabulary (`SQLITE_ERROR`) and knex's ` - - * ` message. Undeclared, it fell to `looksLikeInternalErrorLeak` - * at the doors — a phrasing heuristic that recognises `no such column:` and - * not `no such function:` — so whether a caller saw the engine's text was a - * property of which limb the message happened to match, and one transport - * over, of whether the message carried a statement prefix at all. - * - * Maintainer ruling 2026-09-06 (decision batch #57, option 3): the substring - * list is not grown; the driver declares its own fault and the doors classify - * on the declaration. This is that declaration for the raw path — the idiom - * this file already spells at its other terminals, not a second mechanism. - * - * # What the envelope carries, and what it does not - * - * The message is COMPOSED, for the reason `backendStatementFaultError` gives: - * there is no cut of a dialect's text that keeps its words and reliably drops - * a caller's inlined value. No statement, no diagnostic and no function name - * reaches the message. The dialect error travels whole under `cause`, - * NON-ENUMERABLE — readable by cause-following predicates - * (`isMissingTableError` still classifies a missing table on this path - * through it), invisible to `JSON.stringify` and `{ ...err }` — and the driver - * writes its diagnostic to the server log before composing, with the statement - * and its bound values cut (#21385). ⛔ No `DRIVER_TARGETED_TABLE` - * is declared here: a raw statement may reference any number of tables, and - * naming one would make a missing JOINED table read as the caller's own — the - * misclassification #13438 exists to prevent. - * - * @param cause - the dialect error, kept whole for the log and for - * cause-following predicates. - */ -export function rawStatementFaultError(cause: unknown): Error { - const err = new Error( - 'The database refused to run a raw statement. The driver could not attribute the failure ' + - 'to any part of the request, so no verdict about the statement is claimed here. The ' + - "backend's own diagnostic was written to the server log for an operator to read, with " + - 'the statement and its bound values cut.', - ) as Error & { code?: string; status?: number }; - err.code = StandardErrorCode.enum.DATABASE_ERROR; - err.status = 500; - Object.defineProperty(err, 'cause', { - value: cause, - enumerable: false, - writable: true, - configurable: true, - }); - return err; -} - -/** - * [#21227] The refusal {@link SqlDriver.readBackInsertedRows} raises when an - * INSERT was accepted but a row it wrote is not there to be read back. - * - * `create` and `bulkCreate` answer the stored record (`IDataDriver.create`). - * On a dialect whose INSERT returns no rows the driver reads what it wrote - * back by the id it wrote, and only a row removed between the two statements - * (a concurrent delete, a trigger) can be missing. There is then no stored - * record to answer. Answering the caller's payload in its place would look - * like a success while answering a row that is not stored, and would hide a - * read-back keyed on the wrong column for good, so the door refuses instead. - * - * `DATABASE_ERROR` / 500, the pair this file's other terminals declare for a - * fault the request did not cause. The message is composed and names only the - * object the caller passed. The ids, which are the driver's own values (the - * caller's `id` / `_id` or a minted nanoid, never a business column), travel - * under a non-enumerable `cause` for the server log, as in - * {@link backendStatementFaultError}. - */ -function insertedRowsNotReadBackError(object: string, missingIds: unknown[], writtenCount: number): Error { - const err = new Error( - `The database accepted the insert into object '${object}', but ${missingIds.length} of its ` + - `${writtenCount} row(s) could not be read back by the id this driver wrote, so the stored ` + - 'record cannot be answered. This database returns no rows from an INSERT, so the driver ' + - 'reads each written row back by its id, and a row removed between the two statements (a ' + - 'concurrent delete or a trigger) leaves nothing to read. The write was not retried, because ' + - 're-issuing it could duplicate a row that did land.', - ) as Error & { code?: string; status?: number }; - err.code = StandardErrorCode.enum.DATABASE_ERROR; - err.status = 500; - Object.defineProperty(err, 'cause', { - value: new Error(`no row carries the written id(s) ${JSON.stringify(missingIds)} after the insert`), - enumerable: false, - writable: true, - configurable: true, - }); - return err; -} - -/** [#9354] Marks the enveloped refusal below, so the flush's own catch can tell - * it from every other failure without re-running the recognizer on a wrapper. */ -export const LOCK_WAIT_REFUSAL = Symbol.for('objectstack.driver-sql.deferredDdlLockWaitRefusal'); - -/** [#9354] True only for the refusal {@link deferredDdlLockWaitError} built. */ -function isDeferredDdlLockWaitRefusal(err: unknown): boolean { - return Boolean(err && typeof err === 'object' && (err as Record)[LOCK_WAIT_REFUSAL]); -} - -/** - * [#7929] The full, operand-naming text of a refusal whose caller-visible - * message was redacted — carried on the Error under a SYMBOL key. - * - * A symbol rather than a string property because the carrier must not travel: - * `JSON.stringify(err)`, `{ ...err }`, `Object.keys`, `for…in` and the - * structured-clone boundary all skip symbol keys, so an error mapper that - * spreads or serialises the error cannot put the text back on the wire — which - * is the one way this redaction could be undone without anyone editing it. - * `Symbol.for` (the global registry) rather than a module-local symbol so a - * duplicated copy of this package still resolves the same key. - */ -const WITHHELD_FILTER_DIAGNOSTIC = Symbol.for('objectstack.driver-sql.withheldFilterDiagnostic'); - -/** - * [#7929] Set on an error once its diagnostic has been written to the log, so - * the seam can be applied at more than one frame without the operator reading - * one refusal twice. Needed because knex invokes a WHERE-group callback lazily, - * which puts nested refusals outside the filter entry point's `catch` — see - * {@link SqlDriver.withWithheldFilterLog}. - */ -const WITHHELD_FILTER_LOGGED = Symbol.for('objectstack.driver-sql.withheldFilterLogged'); - -/** - * [#8220] The filter subtree the refusal was raised FROM — the reference the - * throw site held — so {@link SqlDriver.resolveWithheldFilterRefusal} can ask - * the spec's provenance resolver where that node sits under the query's own - * `where` root. A symbol for the same non-travel reason as the diagnostic: - * nothing that serialises or spreads the error can carry a live tree reference - * to a place that could misread it. - */ -const WITHHELD_FILTER_SUBTREE = Symbol.for('objectstack.driver-sql.withheldFilterSubtree'); - -/** - * [#8220] The full author-facing message of a refusal whose subtree turns out - * to be positively marked `'author'` — the diagnostic plus the repair - * prescription the redacted message states only generically. Kept beside the - * diagnostic rather than derived from it at resolution time so each builder - * owns ONE composition of its own wording, redacted and restored alike. - */ -const WITHHELD_FILTER_AUTHOR_TEXT = Symbol.for('objectstack.driver-sql.withheldFilterAuthorText'); - -/** - * [#7929, maintainer ruling 2026-08-12, verbatim: 「接受你的全部建议。」 adopting - * "B now, A next"] An `INVALID_FILTER` refusal that answers the caller with - * `message` and keeps `diagnostic` — the half naming the operands — server-side. - * - * # Why the redaction is unconditional - * - * The driver cannot tell an author's filter from a policy's. Measured on both - * merge boundaries (#7929's analytics capture, #7988's CRUD capture): the - * predicate arrives as a bare `FilterCondition` in `where`, `DriverQuery` is - * `Omit< QueryAST, 'object' >` and `QueryAST` has no provenance slot, and the - * one thing that does cross (`context`) says who is asking, never which subtree - * they did not write. A driver-side guess at provenance is precisely the shape - * the triage lens rejected, so B withholds for EVERY caller. - * - * # BOTH operands are withheld, not just the `$field` one - * - * On a read-scope refusal the whole predicate is the administrator's: in - * `{ amount: { $gt: { $field: 'secret_policy_column' } } }` the target `amount` - * is as policy-authored as the referent. Echoing the target would leave half - * the disclosure live, so the redaction takes both operands, the operator, the - * list index and the boundary `reason` — everything derived from the predicate. - * What stays is the refusal's IDENTITY (`INVALID_FILTER` / 400), which of the - * three cross-field refusal classes fired, and the capability statement, none - * of which is derived from what the caller or the administrator wrote. - * - * # The accepted cost, named rather than hidden - * - * An author debugging their OWN cross-field filter used to get the redacted - * message too — B's accepted cost. [#8220] (A of the same ruling) is what pays - * it back: the refusal carries the subtree it was raised from, and - * {@link SqlDriver.resolveWithheldFilterRefusal} swaps in the full diagnostic - * when — and ONLY when — that subtree is positively marked `'author'` by a - * read-scope merge boundary (`markFilterSubtreeProvenance`, - * `@objectstack/spec/data`). Unmarked or ambiguous stays exactly here, on the - * withholding branch — the mark is permission to reveal, never a duty to prove - * secrecy. ⛔ Do not "fix" the remaining redaction by re-adding the names - * unconditionally, and ⛔ do not widen the REST boundary's 5xx-only withhold - * to 4xx instead (ruled out: it would delete #5367's tiering and #5667's - * legible-undeclared-5xx decision). - * - * `subtree` is the node the throw site held (the reference comparand, the - * list member, the field-spec map). `authorText` is what an `'author'` verdict - * puts on the wire; it defaults to `diagnostic` so no builder can end up with - * a restored message emptier than its own server-log line. - */ -export function withheldFilterError( - message: string, - diagnostic: string, - subtree?: unknown, - authorText?: string, -): Error { - const err = unsupportedFilterError(message); - Object.defineProperty(err, WITHHELD_FILTER_DIAGNOSTIC, { value: diagnostic, enumerable: false }); - if (subtree !== null && typeof subtree === 'object') { - Object.defineProperty(err, WITHHELD_FILTER_SUBTREE, { value: subtree, enumerable: false }); - } - Object.defineProperty(err, WITHHELD_FILTER_AUTHOR_TEXT, { - value: authorText ?? diagnostic, - enumerable: false, - }); - return err; -} - -/** - * [#7929] The withheld diagnostic carried by `err`, or `null` for any other - * error. The read half of {@link withheldFilterError} — used by - * {@link SqlDriver.logWithheldFilterDiagnostic} to write the text to the - * server log, and exported so a host that maps driver errors itself can do the - * same rather than re-deriving the seam. - */ -export function withheldFilterDiagnosticOf(err: unknown): string | null { - if (err === null || (typeof err !== 'object' && typeof err !== 'function')) return null; - const text = (err as Record)[WITHHELD_FILTER_DIAGNOSTIC]; - return typeof text === 'string' ? text : null; -} - -/** - * [#8197, maintainer ruling 2026-08-15] The node a TARGET-FIELD refusal hands - * {@link SqlDriver.resolveWithheldFilterRefusal} — and the one place this - * family's shape is written down. - * - * # The family, and why it is exactly these five - * - * #7929/B withheld the cross-field operands; this card measured what B did not - * reach. Five refusals in this file name the constraint's TARGET column, and - * every one of them is raised by a predicate an RLS rule can plausibly produce: - * {@link jsonColumnOperatorError} (the #7398 gate — a CEL permission rule over a - * `multiple: true` field lowers to a membership test on a JSON-stored column, - * the most reachable of the five), {@link emptyFieldConstraintError}, - * {@link unbindableComparandError} (which also answers a MALFORMED `{ $field }` - * — one that is not a `FieldReferenceSchema`, so the cross-field arm does not - * recognise it) and {@link betweenArityError}. On such a predicate the target - * column is as administrator-authored as the referent B already withholds, which - * is the argument the ruling accepted. - * - * # What redaction takes, and why it is more than the field name - * - * Everything DERIVED FROM THE PREDICATE: the target field, the operator, the - * comparand's contents, the filter path and any list index. What stays is the - * refusal's IDENTITY (`INVALID_FILTER` / 400), WHICH class fired, and the - * capability statement and prescription — none of which is derived from what the - * caller or the administrator wrote. That is #7929's own line, applied - * unchanged: it withheld both operands because "echoing the target would leave - * half the disclosure live", and a comparand preview (`received an object - * ({"a":1})`) is the administrator's literal just as surely as a column name is. - * ⛔ Do not soften this into "field name only" — one file must not carry two - * redaction disciplines. - * - * # Which node, and why the fallback is safe - * - * The DEEPEST resolvable node wins, because - * `resolveFilterSubtreeProvenance` reads the innermost mark on the ancestor - * chain: handing it an ancestor when a nearer node exists would drop a mark set - * closer to the leaf. `comparand` is that node when it is an object — the - * reference the throw site holds, findable under the query's `where` root by - * IDENTITY — and `enclosing` (the field-spec map, or the condition node) takes - * over when it is not. - * - * The fallback cannot widen disclosure, and the reason is structural rather than - * empirical: `markFilterSubtreeProvenance` silently no-ops on a non-object, so a - * primitive comparand can never carry a mark of its own, and its effective - * provenance IS its enclosing node's. The two candidates therefore differ only - * where the deeper one has nothing to say. - */ -function refusalSubtree(comparand: unknown, enclosing: unknown): unknown { - return comparand !== null && typeof comparand === 'object' ? comparand : enclosing; -} - -/** - * [#6409] How one declared aggregate function lowers into SQL. - * - * `sql` is the function NAME; `distinct` decides whether the argument list - * carries the `DISTINCT` keyword. Two fields rather than one string because - * `count_distinct` is the first entry in the vocabulary whose lowering is not a - * function name at all — `COUNT(DISTINCT x)` puts a keyword INSIDE the argument - * list, so a table of bare names has nowhere to say it. Encoding it as a - * template (`'count(distinct %s)'`) was the alternative and was rejected: the - * emitter binds the column as a knex identifier (`??`), and a template would - * have moved that binding into data. - * - * `distinct` also decides whether a FIELD is mandatory, which is why there is no - * third flag saying so: `COUNT(*)` is the spelling `AggregationNodeSchema` - * explicitly allows by making `field` optional, and `COUNT(DISTINCT *)` is a - * syntax error in every dialect this driver targets — there is nothing to - * deduplicate without a column. See {@link refuseDistinctAggregateWithoutField}. - */ -interface SqlAggregateLowering { - readonly sql: string; - readonly distinct: boolean; -} - -/** - * [#5907] The aggregate functions this driver LOWERS into SQL, and what each - * becomes. - * - * The refusals below read their "compiled here" list off THIS table instead of - * repeating it. A hand-written copy agrees with the compiler on the day it is - * typed and never again — the note already sitting over `driver-memory`'s - * `SUPPORTED_FIELD_OPERATORS` (#5345), applied to the aggregate vocabulary. - * - * A `Map` rather than a plain object on purpose: a caller-supplied name is - * looked up here, and `{}['constructor']` answers with a function. - * - * [#6409] `count_distinct` joined the table, and with it the table gained the - * `distinct` column. It is the ENFORCE leg of #6188's split ruling - * (2026-08-07): `array_agg`/`string_agg` left `AggregationFunction` because no - * SQL backend compiled them and none had one portable shape to compile TO, - * while `count_distinct` has exactly one — `COUNT(DISTINCT x)`, already the - * lowering `service-analytics`'s `AGGREGATE_SQL` uses for the Cube face — and - * stayed declared on the strength of it. With this entry the declared - * vocabulary and this driver's compiled vocabulary are the SAME SET; the values - * are pinned against the remote face by `AGGREGATION_CASES`. - */ -const SQL_AGGREGATE_FUNCTIONS: ReadonlyMap = new Map([ - ['count', { sql: 'count', distinct: false }], - ['sum', { sql: 'sum', distinct: false }], - ['avg', { sql: 'avg', distinct: false }], - ['min', { sql: 'min', distinct: false }], - ['max', { sql: 'max', distinct: false }], - ['count_distinct', { sql: 'count', distinct: true }], -]); - -// [#20335, #20889] `AGGREGATE_ANSWER_KIND` — what each declared aggregate -// function ANSWERS, and the one-double precision policy for that answer — lives -// in `@objectstack/core` (`utils/aggregate-answer.ts`), imported above with its -// `'number'` presenter, so the analytics native-SQL face presents a count or a -// total with this driver's own rule. - -// [#20387, #21042] `AGGREGATE_ACCUMULATION` — what each declared aggregate -// function ACCUMULATES IN on PostgreSQL and MySQL, the arithmetic half of the -// one-double policy — lives in `@objectstack/core` (`utils/aggregate-answer.ts`) -// with its docblock, beside the boolean-aggregand cast and the column-class -// predicate both read. {@link SqlDriver.aggregate} asks them through -// `aggregandOperandSql`, so the analytics native-SQL face accumulates the same -// operand with this driver's own rule. - -/** - * [#5907] The aggregate vocabulary the Query Protocol DECLARES, read from the - * spec rather than restated — `AggregationNodeSchema.function` is this enum, so - * "declared" has exactly one definition and this driver cannot drift from it. - */ -const DECLARED_AGGREGATE_FUNCTIONS: readonly string[] = AggregationFunction.options; - -/** - * [#5907] Class 1 — a function name the Query Protocol does not declare. - * - * The caller wrote something no backend can run (`median`), so this is a - * request-shaped mistake: `INVALID_QUERY` / 400, the catalogued - * `StandardErrorCode` for "malformed query syntax" and a member of - * `@objectstack/rest`'s `isExpectedQueryRejection` list, so a client mistake - * stops being logged as an unhandled server fault. - * - * `INVALID_QUERY` is not a new spelling for this condition — it is the one the - * PROTOCOL DOOR already gives it. `metadata-protocol`'s `invalidQueryError` - * refuses "a function outside the spec enum" on the aggregations axis with - * exactly `400 INVALID_QUERY` (#4254), so a caller who reaches this driver - * in-process gets the same wire identity as one who came through REST: one - * condition, one code, however the caller arrived — the argument - * {@link unsupportedFilterError} makes for `INVALID_FILTER`. - * - * The FIRST SENTENCE is shared verbatim with the twin in `driver-turso`'s - * `remote-transport.ts` (#5240 — one condition, one wording): a caller must not - * be able to tell which transport answered from the words it used. The parity is - * pinned by a test that compares the two RUNTIME messages, not two copies of a - * literal (`remote-transport-aggregate-function-refusal.test.ts`). - * - * Judged against the declared enum CASE-SENSITIVELY, which is what the enum is: - * `COUNT_DISTINCT` is not `count_distinct`, and answering "declared but not - * implemented" for it would be false. When this was written the remote transport - * still lowercased the name before its own lookup while this driver read it raw, - * so classifying on each face's post-normalisation name would have handed - * `COUNT_DISTINCT` a 400 here and a 501 there for one query. #6203 has since - * removed that lowercasing, so the two faces now normalise alike — but the - * case-sensitive judgement here is not merely a survivor of that fork: the enum - * IS case-sensitive (`AggregationFunction.parse('COUNT')` throws), so this is - * what "declared" means, whatever any transport does before asking. - */ -function undeclaredAggregateFunctionError(func: string): Error { - const err = new Error( - `Aggregate function "${func}" is not a declared aggregate function. ` + - `Declared functions: ${DECLARED_AGGREGATE_FUNCTIONS.join(', ')} ` + - `(@objectstack/spec AggregationFunction). Fix the "function" key of the aggregations[] ` + - `entry — the Query Protocol has no such function, so this is a query no backend can run, ` + - `not a gap in this one.`, - ) as Error & { code?: string; status?: number }; - err.code = StandardErrorCode.enum.INVALID_QUERY; - err.status = 400; - return err; -} - -/** - * [#5907] Class 2 — a DECLARED function this backend cannot compile. - * - * Distinct from {@link undeclaredAggregateFunctionError} on purpose, and this is - * the half that must not be collapsed into it: `count_distinct` is declared by - * `AggregationFunction` and implemented by other backends (`driver-mongodb`, - * and `driver-memory`'s analytics face), so telling a dashboard author their - * `count_distinct` is a typo would be false — the same line #5345 drew in - * `driver-memory`'s `filter-refusal.ts` between `unknownFieldOperatorError` and - * `uncompilableFieldOperatorError`. - * - * `array_agg` and `string_agg` used to belong to this class too. #6188 retired - * both from `AggregationFunction` (ADR-0049 — declared by the spec, compiled by - * no SQL backend), so they now fall to class 1 and answer 400: the protocol no - * longer has those names, which is a different fact from "this backend cannot - * lower them" and deserves the different answer. `count_distinct` was - * deliberately kept and took the enforce leg instead. - * - * ⚠️ [#6409] **This class is now EMPTY, and the producer is kept deliberately.** - * `count_distinct` was its last inhabitant; with its lowering landed, - * `SQL_AGGREGATE_FUNCTIONS` covers every member of `AggregationFunction` and - * nothing reaches this branch — pinned as a positive assertion by - * `sql-driver-out-of-contract-aggregate-function.test.ts` ("the - * declared-but-uncompiled set is empty"), not left to be rediscovered. - * - * Deleting it as dead code was considered and rejected. The branch is not an - * unenforced DECLARATION — the ADR-0049 shape — it is the classifier that - * decides which of two truths a future name gets told. Removing it does not - * remove the condition; it makes {@link refuseAggregateFunction} answer 400 for - * the FIRST function a later spec bump adds, telling the author of a - * correctly-spelled `median` that the protocol has no such function. That is - * precisely the misreport #5907 exists to prevent, and it would land in the - * window between a spec change and a driver change — the window this repo - * opens on purpose whenever it takes ADR-0049's enforce leg, as #6188 just did - * for this very name. The cost of keeping it is one unreachable branch; the - * cost of dropping it is a wrong answer at exactly the moment the vocabulary - * grows. - * - * `NOT_IMPLEMENTED` / 501 is the answer, from the ADR-0112 STANDARD catalog - * ("Feature not yet implemented"), whose own `HttpStatusErrorCodeMap` pairs it - * with 501 — so code and status are each other's mirror by construction rather - * than by this function's choice. It is the spelling the repo already uses for - * every "not supported by this protocol/runtime" answer, and the ledger's rule - * for a generic condition is the standard catalog over a registered synonym. - * The registered alternatives were measured and rejected: `UNSUPPORTED` is a - * 400 in both places that emit it (a share link that does not expose messages), - * `UNSUPPORTED_QUERY_PARAM` is a 400 on the client-mistake list, and - * `UNSUPPORTED_TRANSFORM` belongs to `@objectstack/rest`'s import mapper. - * - * Measured consequence, recorded so it is not rediscovered as a bug: on the - * `/data` routes `mapDataError`'s generic status passthrough spans 400-599, so - * this declared 501 DOES survive to the wire as `501` / `NOT_IMPLEMENTED` — - * pinned end-to-end by `rest-5xx-status-passthrough.test.ts`. This paragraph - * read the opposite until #7402 closed #5582: the passthrough was 4xx-ONLY, - * this was its first live producer, and the status fell to - * `UNCLASSIFIED_FAULT`'s `500 INTERNAL_ERROR`. What that band does drop is the - * PROSE — a 5xx body is the generic `Internal server error` — so the driver's - * job at the throw site is unchanged: state the condition truthfully - * (ADR-0112), which is what reaches every in-process caller and the operator - * log. - */ -function uncompilableAggregateFunctionError(func: string): Error { - const err = new Error( - `Aggregate function "${func}" is declared but not implemented by this backend. ` + - `Compiled here: ${[...SQL_AGGREGATE_FUNCTIONS.keys()].join(', ')}. The name is spelled ` + - `correctly and @objectstack/spec AggregationFunction declares it — this is a capability gap ` + - `in the backend, not a mistake in the query, which is why it answers NOT_IMPLEMENTED/501 ` + - `rather than a 400. Aggregate with a function this backend compiles; whether the declaration ` + - `itself should stand is ADR-0049's enforce-or-remove question.`, - ) as Error & { code?: string; status?: number }; - err.code = StandardErrorCode.enum.NOT_IMPLEMENTED; - err.status = 501; - return err; -} - -/** - * [#10576] An aggregation entry carries a per-aggregation `filter` - * (`AggregationNodeSchema.filter`, the contract half of #10413) and this - * backend compiles no conditional-aggregate expression (SQL `FILTER (WHERE …)` - * / `CASE WHEN`) for it — so it is refused rather than silently aggregating - * the UNFILTERED rows, which is exactly the wrong-numbers defect #10413 - * measured ("won deals" counting every row). Same class and envelope as - * {@link uncompilableAggregateFunctionError}: the query is spelled correctly - * and the spec declares the key, so this is a capability gap in the backend - * (NOT_IMPLEMENTED/501), not a mistake in the query (400). - * - * Unreachable through `engine.aggregate`, deliberately: the engine routes any - * call whose aggregations carry a filter through its in-memory lowering - * (`applyInMemoryAggregation`), which honours the predicate on every driver. - * This guard is the direct-caller half of "no silent drop, anywhere" — the - * refusal names the remedy so a direct caller knows the engine path works. - */ -function unsupportedAggregationFilterError(alias: string, backend: string): Error { - const err = new Error( - `Per-aggregation \`filter\` on "${alias}" is not supported by this backend (${backend}). ` + - `The query is spelled correctly and @objectstack/spec AggregationNodeSchema declares the key — ` + - `this backend compiles no conditional-aggregate (SQL FILTER (WHERE …) / CASE WHEN) expression ` + - `for it, so it is refused rather than silently aggregating the UNFILTERED rows, which ` + - `is why it answers NOT_IMPLEMENTED/501 rather than a 400. \`engine.aggregate\` lowers filtered ` + - `aggregations in memory for every driver without native support — route the query through the ` + - `engine, or drop the \`filter\` key.`, - ) as Error & { code?: string; status?: number }; - err.code = StandardErrorCode.enum.NOT_IMPLEMENTED; - err.status = 501; - return err; -} - -/** - * [#6409] `count_distinct` written with no `field` — nothing to deduplicate. - * - * `AggregationNodeSchema` makes `field` optional because `COUNT(*)` is a real - * spelling, and the schema has no way to say "optional for this function, - * required for that one". So the aggregation parses, reaches this driver, and - * asks for `COUNT(DISTINCT *)` — which no dialect this driver targets accepts. - * - * Refused HERE rather than emitted and left to the database, for the reason - * #5907 gives at length one function up: a syntax error raised by SQLite or - * Postgres arrives with the driver's SQL in it and no `code`/`status`, so - * `mapDataError` serves an opaque 500 for what is a completely legible mistake - * in the request. The class is 1, not 2 — `INVALID_QUERY` / 400: the FUNCTION - * is compiled here, it is this aggregation node that is malformed, and the - * remedy is a key the caller can add. - * - * The twin lives in `driver-turso`'s `remote-transport.ts`, first sentence for - * first sentence (#5240 — one condition, one wording), and the two are compared - * as runtime messages by `remote-transport-aggregate-function-refusal.test.ts`. - */ -function refuseDistinctAggregateWithoutField(func: string): never { - const err = new Error( - `Aggregate function "${func}" needs a "field" — there is nothing to deduplicate. ` + - `COUNT(*) counts rows and is the spelling that takes no field; a distinct count has to name ` + - `the column whose values are deduplicated. Add "field" to the aggregations[] entry.`, - ) as Error & { code?: string; status?: number }; - err.code = StandardErrorCode.enum.INVALID_QUERY; - err.status = 400; - throw err; -} - -/** - * [#5907] Which refusal a name that this face cannot compile deserves. - * - * The classification is written ONCE per face and shared by both faces' - * throw sites, so "is this the caller's mistake or ours?" cannot be answered two - * ways for one query. `func` is the name the CALLER wrote — not a normalised - * form — because that is what the enum is judged against and what the message - * has to quote back. - */ -function refuseAggregateFunction(func: string): never { - throw DECLARED_AGGREGATE_FUNCTIONS.includes(func) - ? uncompilableAggregateFunctionError(func) - : undeclaredAggregateFunctionError(func); -} - -/** - * The window functions {@link SqlDriver.findWithWindowFunctions} compiles — - * a CLOSED set, keyed by the lowercased name a caller may spell in any case. - * - * The door emits a function ARGUMENT-LESS (`FUNC() OVER (…)`, see - * {@link SqlWindowFunctionSpec}), so the set is exactly the standard ranking - * functions that take no argument, each one available on every dialect this - * driver targets (PostgreSQL, MySQL 8, SQLite 3.25+). The name used to be - * spliced into the statement verbatim; it is now looked up here and only the - * value from this table reaches SQL, so the statement's text is never the - * caller's. - */ -const SQL_WINDOW_FUNCTIONS: ReadonlyMap = new Map([ - ['row_number', 'ROW_NUMBER'], - ['rank', 'RANK'], - ['dense_rank', 'DENSE_RANK'], - ['percent_rank', 'PERCENT_RANK'], - ['cume_dist', 'CUME_DIST'], -]); - -/** The two `OVER (ORDER BY …)` directions, keyed by the lowercased spelling. */ -const SQL_WINDOW_SORT_DIRECTIONS: ReadonlyMap = new Map([ - ['asc', 'ASC'], - ['desc', 'DESC'], -]); - -/** - * A `windowFunctions[]` entry this door cannot compile: an unknown function - * name or sort direction. A malformed query (`INVALID_QUERY`, 400), the class - * {@link undeclaredAggregateFunctionError} answers at the aggregate door. - */ -function invalidWindowFunctionQueryError(message: string): Error { - const err = new Error(message) as Error & { code?: string; status?: number }; - err.code = StandardErrorCode.enum.INVALID_QUERY; - err.status = 400; - return err; -} - -/** - * [#6212] A `groupBy` entry asks for a date BUCKET this face cannot emit. - * - * `GroupByNodeSchema` declares `{ field, dateGranularity }` and `DateGranularity` - * declares all five names, so a granularity this dialect has no expression for is - * a CAPABILITY GAP in the backend, not a mistake in the query — the same 501 - * class {@link uncompilableAggregateFunctionError} answers for a - * declared-but-uncompiled aggregate function, and for the same reason (#5907, - * ADR-0112). It used to be a bare `throw new Error(...)`: `code`/`status` both - * `undefined`, so `mapDataError` served an opaque 500 for a named condition. - * - * WHICH granularities a backend buckets natively is PUBLISHED, per driver, as - * `supports.queryDateGranularity`, and the engine reads that record and falls - * back to in-memory bucketing for anything absent from it (objectql `engine.ts`, - * aggregate dispatch). Reaching this throw therefore means a caller went around - * that bit, so the message names the bit rather than the SQL. - * - * Written once per FACE — `RemoteTransport` carries the twin, first sentence for - * first sentence — because `TursoDriver` picks its face from `url` and one - * condition may not have two wire identities (#5240 / #5907). The two faces - * refuse different populations (this one refuses what its DIALECT cannot bucket, - * the remote transport refuses every granularity because it buckets none), but - * both refuse exactly `supports.queryDateGranularity[g] !== true`, which is one - * condition stated per face. - */ -function refuseDateBucketedGroupBy(granularity: string, bucketedHere: string[], face: string): never { - const err = new Error( - `Date bucketing by '${granularity}' is not supported by this backend. ` + - `Bucketed here: ${bucketedHere.length > 0 ? bucketedHere.join(', ') : 'none'} (${face}). ` + - `The query is spelled correctly and @objectstack/spec DateGranularity declares it — this is ` + - `a capability gap in the backend, not a mistake in the query, which is why it answers ` + - `NOT_IMPLEMENTED/501 rather than a 400. A driver publishes the granularities it buckets ` + - `natively as \`supports.queryDateGranularity\`; the engine reads that record and buckets ` + - `in memory for every granularity absent from it, which is always correct.`, - ) as Error & { code?: string; status?: number }; - err.code = StandardErrorCode.enum.NOT_IMPLEMENTED; - err.status = 501; - throw err; -} - -/** - * [#11567] A field reached DDL carrying `reference_to` — a key `FieldSchema` - * REFUSES — so this face refuses it too, in the spec's own words. - * - * ## Why the DDL seam needs a door the schema already has - * - * `reference` is the only relationship spelling the spec declares. ON THE - * AUTHORING FACE `reference_to` is a REJECTED ALIAS, not a normalised one. - * Measured on `origin/main`: - * - * ``` - * FieldSchema.safeParse({ name:'parent', type:'lookup', reference_to:'p' }) - * => success:false, issue.code = `unrecognized_keys` - * "Unrecognized key(s) on this field: `reference_to`. - * Did you mean `reference_to` → `reference`?" - * ``` - * - * ⚠️ That verdict is the authoring face and only the authoring face — do NOT - * read this door as "a stored `reference_to` stays verbatim forever". #13700 - * landed the `field-reference-to-alias` conversion (`toMajor: 18`, - * `retiredFromLoadPath: true`, in `packages/spec/src/conversions/registry.ts`), - * and its docblock already states the split this comment has to be read - * against: the entry "covers the two paths that serve or rewrite EXISTING data - * — stored rehydration and `os migrate meta`", where the key is NORMALISED to - * `reference` rather than refused, while "the DDL doors above keep guarding - * the third path (metadata handed straight to a driver, around both the gate - * and the stored pass); they are downstream of this entry, not replaced by - * it." This door is one of those DDL doors. Refused when authored, rewritten - * when already at rest, refused again here — three paths, and this comment - * speaks for the third. - * - * Until #11567 this driver read `reference_to` and ONLY `reference_to`, as the - * gate on a `table.foreign(name).references('id')`. So one key had TWO doors - * with opposite answers: the authoring door refused it, while the DDL door - * silently honoured it and changed the PHYSICAL SCHEMA — and the silent one - * was the one that touched the database. It was reachable only by metadata - * that went around Zod (`registerObject` deliberately skips it, #3896), which - * is why no authored deployment ever had these constraints: measured across - * all 44 exported platform objects on live Postgres 16.13 and MySQL 8.0.46, - * **zero** FOREIGN KEYs existed. The emission is retired; this is the door - * that stays, so the driver stops disagreeing with the spec in silence. - * - * ## Why refuse rather than ignore - * - * Ignoring the key would be a THIRD answer to one question. An author who - * wrote `reference_to` meant to point the field somewhere; a driver that drops - * it on the floor creates a column pointing nowhere and says nothing — the - * "dropped silently" shape #4001 closed at the schema, re-opened one layer - * down. The refusal is stated at the DDL seam because that is the last place - * the mistake is still cheap: before the column exists, not after rows are in - * it. - * - * `VALIDATION_ERROR`/400 rather than a 500 (ADR-0112): the metadata is the - * caller's, the condition is decided entirely by what the caller wrote, and - * the fix is a one-word rename. A bare `throw new Error` would leave - * `code`/`status` undefined and serve an opaque 500 through `mapDataError` for - * a named, caller-fixable condition — the trade {@link refuseDateBucketedGroupBy} - * records above (#6212). - */ -function refuseRejectedReferenceAlias(column: string): never { - const err = new Error( - `[sql-driver] field '${column}' declares \`reference_to\`, a rejected alias of \`reference\`. ` + - `Did you mean \`reference_to\` → \`reference\`? \`reference\` is the only relationship spelling ` + - `@objectstack/spec declares, and \`FieldSchema\` refuses this key with that same verdict ` + - `(\`unrecognized_keys\`) — so a field still carrying it at DDL time went around the schema ` + - `(raw \`registerObject\` skips Zod). Rename the key. The column is built from ` + - `\`reference\`, and referential integrity is enforced by the ENGINE via \`deleteBehavior\` ` + - `(the 409 DELETE_RESTRICTED), not by a database FOREIGN KEY: the FK DDL this ` + - `key used to gate is retired, because it could never fire for a spec-conformant lookup in the first place.`, - ) as Error & { code?: string; status?: number }; - err.code = StandardErrorCode.enum.VALIDATION_ERROR; - err.status = 400; - throw err; -} - -/** - * [#16319] Has this field declared a `type` at all? - * - * The whole predicate the DDL emitter needs, and deliberately no more: it asks - * PRESENCE, not membership. Membership is decided one layer up and for the whole - * object — see {@link refuseUndeclaredFieldType}. - */ -function isDeclaredFieldType(field: { type?: unknown }): boolean { - return typeof field.type === 'string' && field.type !== ''; -} - -/** - * [#17469] Is this column MULTI-VALUED — the one question, asked of the one - * predicate `@objectstack/spec` publishes. - * - * Maintainer ruling 2026-09-13 (decision batch #128 item 5, option 1′): there is - * ONE definition of "multi-valued", `isMultiValueField`, and storage follows it. - * Every site below used to read `field.multiple` raw, which answered `true` on - * types the spec predicate calls single-valued — so the DDL built a JSON array - * column, the read-side deserializer agreed with it, and a consumer shaping a - * query from the SPEC predicate composed `=` against that column and was - * answered 400. `FieldSchema` now refuses the flag on those types at the - * authoring entrance, and this is the storage half of the same ruling. - * - * Takes the resolved `type` rather than reading `field.type`, because the - * callers below have already applied their own resolution (`field.type || - * 'string'`, the driver-internal alias default) and two spellings of that - * default is how the drift this fixes started. - */ -export function isMultiValuedColumn(type: string, field: { multiple?: unknown } | null | undefined): boolean { - return isMultiValueField({ type, multiple: field?.multiple === true }); -} - -/** - * [#20444] The declared value shape of every field that declares a `type` — - * the {@link SqlDriver.valueShapeFields} entry for one object. Reads the RAW - * `type`, deliberately not the `field.type || 'string'` default the column - * registries apply: a field with no type has no row of the ruled 「is empty」 - * table, and inventing `'string'` for it here would answer `$empty` with a row - * nobody declared. - */ -export function declaredValueShapes(fields: Record | undefined): Record { - const shapes: Record = {}; - for (const [name, field] of Object.entries(fields ?? {})) { - const type = (field as { type?: unknown } | null | undefined)?.type; - if (typeof type !== 'string' || type === '') continue; - shapes[name] = { type, multiple: (field as { multiple?: unknown }).multiple === true }; - } - return shapes; -} - -/** - * [#17231] ADR-0113's physical column constraint, asked once for every column - * {@link SqlDriver.createColumn} builds. - * - * It exists because `createColumn` has TWO exits: the multi-value short-circuit - * above its per-type switch, and the shared tail below it. The constraint used - * to be read only at the tail, so `storage: { notNull: true }` on a multi-valued - * field was silently inert on the platform's own table while BOTH - * `os generate migration` formats emitted it — one declaration, two databases, - * an INSERT omitting the field accepted by the platform's table and refused by - * every generated one. One exit acquiring the constraint and the other not is - * exactly how that happened, so the predicate is stated once here instead of - * being spelled at each exit. - * - * Truthiness rather than `=== true` deliberately: that is the test the tail has - * always applied, and this move is about WHERE the question is asked, never - * about which values answer it. - */ -function declaresColumnNotNull(field: unknown): boolean { - return Boolean((field as { storage?: { notNull?: boolean } } | null | undefined)?.storage?.notNull); -} - -/** - * [#16319] DDL-time defence: a field declaration with NO `type` gets no column - * — it gets a refusal. - * - * MAINTAINER RULING, 2026-09-10 (director seat batch #111 item 2): 「一个没写 - * type(或拼错)的字段 应该禁止加载」, and 「下游默认值全部改拒绝:`createColumn` - * 的 `|| 'string'` … 一律改为响亮拒绝 … ⛔ 不再猜族;按构造它们应当不可达,拒绝是 - * 防御」. - * - * What it replaces is `createColumn`'s `const type = field.type || 'string'`, - * which sized the column from `declaredVarcharLength(field)` — the declared - * `maxLength` verbatim, knex's 255 without one — while BOTH `os generate - * migration` formats defaulted the SAME declaration to `TEXT`. Two families from - * one declaration: measured on live PostgreSQL 16.13, `{ maxLength: 100 }` with - * no `type` produced `character varying(100)` here and `TEXT` there, so the - * platform refused a 101-character value both generated tables accepted (#16319). - * - * ⭐ By construction this is now unreachable: `SchemaRegistry.registerObject` - * refuses the whole object declaration, and every route into `syncSchema` — - * boot rehydration, package and plugin manifests, programmatic registration — - * is fronted by it. It stays as defence, and it stays LOUD, because the - * alternative is a silent guess and reaching this line means the object was - * handed to the driver by a path the registry does not front. - * - * ⚠️ PRESENCE only, and that boundary is deliberate — ⛔ do not widen it to - * `FieldType` membership here without ruling on the cost. A NON-MEMBER `type` - * still falls to this switch's own catch-all arm, unchanged: membership is - * refused at the registration door for the whole object, which is where the - * ruling put the single point of closure and what its acceptance list means by - * 「驱动永远到不了」. Measured on this tree: 388 sites across ~100 of this - * package's own test files declare a non-member spelling (`'string'` 361, - * `'integer'` 17, `'auto_number'` 5, `'varchar'` 4, `'object'` 1) and drive - * `initObjects` directly, never through the registry — and `'string'` is a - * declared `case` arm of this very switch whose column shape differs from every - * member's, so re-typing them is a corpus migration with column consequences, - * not a spelling fix. - * - * `VALIDATION_ERROR` + 400, exactly as {@link refuseRejectedReferenceAlias} one - * function up: a standard-catalog member, so no new code is minted and this - * package's ledger entry is unchanged. - */ -function refuseUndeclaredFieldType(column: string): never { - const err = new Error( - `[sql-driver] field '${column}' declares no \`type\`, so no column is created for it. ` + - `⛔ The driver no longer guesses a family here: it used to build \`varchar(255)\` (or the ` + - `declared \`maxLength\`) while both \`os generate migration\` formats built \`TEXT\` from ` + - `the same declaration, so the platform refused values the generated tables accepted, in ` + - `both directions and silently. \`FieldSchema\` requires \`type\`, and ` + - `\`SchemaRegistry.registerObject\` refuses the whole object declaration before any DDL runs ` + - `— so reaching this line means the object was handed to \`syncSchema\` by a path that does ` + - `not go through the registry. Give the field a \`FieldType\` member, or remove the field.`, - ) as Error & { code?: string; status?: number }; - err.code = StandardErrorCode.enum.VALIDATION_ERROR; - err.status = 400; - throw err; -} - -/* - * [#8445 → #8567] `isUnbackedConflictTargetError` — "is this the conflict - * target is not a key failure?" — is imported from `@objectstack/types` - * (`unbacked-conflict-target.ts`), not written here. - * - * It began as a private regex on SQLite's sentence, because that was the only - * dialect #8445's container could raise the condition on and transcribing the - * other two from memory was ruled out as evidence. #8567 raised it on a real - * Postgres 16.13 through this same knex path and found a second, unrelated - * sentence — so the vocabulary is now per-dialect, which is the shape that - * belongs beside `isUniqueViolationError` rather than inside a driver. The - * measurements, the reason the `code` channel is unread on BOTH dialects - * (SQLite's is generic; Postgres' `42P10` also fires for an out-of-range - * `ORDER BY` position), and the reason MySQL has no limb at all are all - * recorded there. - * - * ⚠️ Deliberately NOT `isUniqueViolationError`, which is imported one line - * away: that predicate answers the OPPOSITE condition (a unique index exists - * and the row violated it). Confusing the two would report a working - * constraint as a missing one. - */ - -/** - * [#8445] A `conflictKeys` upsert whose target no unique index backs. - * - * # What the raw throw did - * - * `mapDataError` builds the response envelope from `error.code` / `error.status`. - * With neither set it falls through to its default branch and ships the thrown - * message as the whole body — and the message above is the STATEMENT, values - * included: `{ "error": "insert into `plain` (...) values ('a@b.com', ...) ..." }`. - * Unbranchable by any client, and an information leak of the statement text. - * That fall-through is not a guess about `mapDataError`; it is the shape - * `sql-driver-date-bucket.test.ts` already records for a `code`/`status`-less - * driver throw. - * - * # Why `VALIDATION_ERROR` / 400 - * - * Reused, deliberately, from the remote twin — the rationale is written out in - * full at `driver-turso`'s `refuseUnbackedConflictTarget` (#8413) and is not - * re-derived here. In one line: a client branching on why its call failed needs - * two facts — **do not retry** (the next attempt fails identically; nothing is - * transient, so a 5xx would invite a retry storm) and **nothing collided** (the - * merge could not be attempted at all, which is why it is not - * `RESOURCE_CONFLICT`/409 and not `DUPLICATE_VALUE`). `conflictKeys` is a - * request argument that did not validate against the target, which is exactly - * what the catalogued generic covers. `status: 400` puts the rejection on - * `@objectstack/rest`'s expected-rejection list, so a caller's un-satisfiable - * upsert stops being logged once per request as an unhandled SERVER fault. - * - * # The wording is the remote refusal's, first sentence for first sentence - * - * #5240 — one condition, one wording. `TursoDriver` picks its face from `url`, - * so this condition can be answered by either compiler in one deployment, and a - * second wording would make the answer a property of the connection string. - * The text below is #8413's, verbatim, including the "table created before its - * `unique` declaration was emitted as DDL" clause: that leg is not remote-only - * — this face creates declared indexes, but it cannot create one over a table - * that already holds duplicates (`syncDeclaredIndexes` degrades rather than - * rewriting rows), so the same table arrives here with the same missing index. - * - * [#8567] One clause moved, on BOTH faces in the same commit: "and SQLite - * refuses the statement" is now "and **the database** refuses the statement". - * This face serves three dialects, and once recognition covers Postgres a - * Postgres operator was being told SQLite had refused their statement — a - * sentence naming the wrong engine, which is worse than a vague one because it - * sends the reader to the wrong manual. #5240 still holds: one condition, one - * wording, so `driver-turso`'s copy moved with it and #8568's cross-face parity - * pin is what proves they moved together rather than drifting apart. - * - * ⚠️ The original error is kept as `cause` rather than discarded — the server's - * own text is the ground truth an operator debugging the table wants (SQLite's - * `ON CONFLICT clause does not match…`, Postgres' `there is no unique or - * exclusion constraint…`), and nothing above this layer can recover it once - * replaced. It is ASSIGNED rather than passed to the `Error` constructor, - * matching the twin: the two-argument form needs the ES2022 `ErrorOptions` - * overload. - */ -export function refuseUnbackedConflictTarget(object: string, mergeKeys: string[], cause: unknown): Error { - const keys = mergeKeys.map((k) => `"${k}"`).join(', '); - const err = new Error( - `Cannot upsert into "${object}" on conflict keys (${keys}): no PRIMARY KEY or UNIQUE index ` + - `backs them, so the merge target does not exist and the database refuses the statement. This is ` + - `usually a table created before its "unique" declaration was emitted as DDL, or conflict ` + - `keys naming columns that were never declared unique. Fix by declaring the column(s) ` + - `"unique: true" and re-running schema sync so the unique index is created — if the table ` + - `already holds duplicate values the index cannot be created until they are resolved, and ` + - `this driver does not rewrite stored rows to force it. Alternatively upsert on the primary ` + - `key by supplying "id" and omitting conflictKeys.`, - ) as Error & { code?: string; status?: number; cause?: unknown }; - err.code = StandardErrorCode.enum.VALIDATION_ERROR; - err.status = 400; - err.cause = cause; - return err; -} - -/** - * What the upsert pre-flight tells {@link SqlDriver.upsert} once it has judged - * the table's physical keys — a value rather than the bare `void` it returned - * while every verdict it could reach was either "proceed" or a throw. - * - * `verifyIdentity` is [#8807]'s third answer: the statement may run, but the row - * it lands on has to be checked afterwards. `rivals` and `tableName` are carried - * so the refusal can name the keys the introspection actually read instead of - * introspecting a second time to say the same thing. - */ -export interface ConflictTargetVerdict { - verifyIdentity: boolean; - rivals?: PhysicalIndex[]; - tableName?: string; -} - -/** - * [#8807] The merge landed on a row the caller never identified. - * - * # Why this is a different condition from both refusals above, per #5240 - * - * {@link refuseUnbackedConflictTarget} answers *no index backs your target* and - * {@link refuseAmbiguousConflictTarget} answers *your target is backed but - * cannot be honoured* — both to a caller who NAMED a target, both before the - * statement is compiled. This one answers a caller who named nothing at all - * (or named `id`, the identity the driver itself supplies), and it can only be - * answered AFTER the statement has run, because the question is which row the - * server picked. Reusing either sentence would instruct an author to change a - * `conflictKeys` argument they never wrote. - * - * The ruled principle it enforces (maintainer, 2026-08-15): *an `upsert` must - * never modify a row whose identity the caller did not supply and whose conflict - * key it did not name.* - * - * # Why the write is rolled back rather than reported - * - * "Never modify" is not satisfied by noticing afterwards. The check therefore - * runs inside a transaction with the statement, and throwing here is what undoes - * the wrong write — see {@link SqlDriver.upsert}. Reporting a completed - * cross-row overwrite would leave the corruption and merely narrate it. - * - * # Why `VALIDATION_ERROR` / 400 - * - * Same classifier, same answer as the two refusals above, and for the same - * reason: this is conditional on the TABLE, not on the dialect. The identical - * statement against the identical MySQL server is honoured the moment the table - * stops carrying a rival UNIQUE key, so the remedy is a schema change and not - * "wait for the backend". 400 also keeps the sentence on the wire — `rest` - * withholds the body of any 5xx, and this message is the deliverable. - * - * Measured on live MySQL 8.0.46 through the same knex + `mysql2` path, `email` - * and `tax_id` both `unique: true`, NO `conflictKeys` at all: - * - * ``` - * seed upsert({email:'d@b.com', tax_id:'T-9', title:'first'}) -> id=iVvD35rMk4BIayYc - * B upsert({email:'e@b.com', tax_id:'T-9', title:'second'}) -> RESOLVED, id=F-Fp1OGCQB-l5XRu - * ONE row, and it is the SEEDED one: iVvD35rMk4BIayYc, its `email` rewritten - * d@b.com -> e@b.com. The id the caller was handed back does not exist in the - * table at all. - * ``` - */ -function refuseCrossRowIdentityMerge( - object: string, - tableName: string, - id: unknown, - rivals: PhysicalIndex[], -): Error { - const named = rivals.map((i) => `${i.name}(${i.columns.join(', ')})`).join(', '); - const err = new Error( - `Cannot upsert into "${object}": the merge landed on a row this call never identified. No ` + - `conflict target was named, so the merge target is the primary key — but this backend is ` + - `MySQL, whose only merge statement is ON DUPLICATE KEY UPDATE, and that statement carries ` + - `no conflict target. The row did not collide on its primary key; it collided on another ` + - `UNIQUE key on "${tableName}" — ${named} — and MySQL merged there instead, overwriting a ` + - `DIFFERENT row than the one this call supplied. The write has been rolled back; nothing was ` + - `changed. Fix by naming the business key you meant to merge on (pass it as conflictKeys, so ` + - `the intent is checkable), by dropping or renaming the extra UNIQUE key(s) so the primary ` + - `key is the only one the row can collide on, or by running this object on a dialect that ` + - `honours the target — SQLite and PostgreSQL compile ON CONFLICT (...), which raises a ` + - `unique violation here instead of merging.`, - ) as Error & { code?: string; status?: number; cause?: unknown }; - err.code = StandardErrorCode.enum.VALIDATION_ERROR; - err.status = 400; - // Schema identifiers plus the id this call supplied — the same payload - // contract the two refusals above keep. The id is the driver's OWN value - // (minted here, or the caller's `id`/`_id`), never a business column value. - err.cause = new Error( - `no row on "${tableName}" carries id ${JSON.stringify(id)} after the merge; UNIQUE keys that ` + - `can absorb a primary-key-targeted merge: ${named}`, - ); - return err; -} - -/** - * [#21185] The wire identity of an upsert refused because its conflict landed - * on a row outside the organization the row is written under: the registered - * `UNIQUE_VIOLATION` / 409, the answer a colliding insert gets. - * - * ⛔ No new code, by the ruling on #21185 (record 5934879010). From the - * caller's organization the conflicting row does not exist — the caller cannot - * read it — so the call IS an insert, and that insert collides on an - * installation-wide key (the primary key, or a `unique: 'global'` column under - * ADR-0120 D1). A dedicated answer would tell the caller something `create()` - * does not; this one tells it exactly what `create()` already does, which is the - * oracle ADR-0120 records as the accepted cost of a `'global'` key. - */ -const UPSERT_UNIQUE_VIOLATION_CODE = 'UNIQUE_VIOLATION'; -/** @see {@link UPSERT_UNIQUE_VIOLATION_CODE} */ -const UPSERT_UNIQUE_VIOLATION_STATUS = 409; - -/** - * [#21185] Build the refusal above. One constructor for both faces — - * {@link SqlDriver.upsert} and the remote face `driver-turso` builds over it — - * so the two cannot answer one condition with two sentences (#5240). - * - * The sentence is the engine's duplicate-record sentence (`DuplicateRecordError` - * in `@objectstack/objectql`), in both of its forms: a single conflict key is - * named, several are not. ⛔ It names no organization, no tenant column and no - * value of the row the conflict landed on, and it carries no `cause` — there is - * no server error to attach, and anything read off that row would be the - * cross-organization detail the refusal exists to withhold. It does not begin - * with a SQL verb (the importer's sanitiser replaces such messages wholesale). - */ -export function refuseUpsertConflictOutsideWrittenOrganization(object: string, mergeKeys: string[]): Error { - const err = new Error( - `Duplicate record refused on '${object}': ` + - (mergeKeys.length === 1 - ? `a unique constraint on '${mergeKeys[0]}' already holds this value. ` - : 'a unique constraint already holds these values. ') + - 'No record was written.', - ) as Error & { code?: string; status?: number }; - err.code = UPSERT_UNIQUE_VIOLATION_CODE; - err.status = UPSERT_UNIQUE_VIOLATION_STATUS; - return err; -} - -/** [#21185] Is this the refusal above? Read off the envelope pair, which no dialect error carries. */ -function isUpsertFenceRefusal(error: unknown): boolean { - const e = error as { code?: unknown; status?: unknown } | null | undefined; - return e?.code === UPSERT_UNIQUE_VIOLATION_CODE && e?.status === UPSERT_UNIQUE_VIOLATION_STATUS; -} - -/** - * [#5158] A `FilterArray` reached the driver unlowered. - * - * `where` is a `FilterCondition` — `QueryASTSchema.where: FilterConditionSchema` - * — and `FilterArray` is INPUT-ONLY authoring sugar the spec declares separately - * (`spec/data/filter.zod.ts`, #5285). Both doors into the runtime lower it - * through `parseFilterAST` before any driver is reached: the protocol face - * (`metadata-protocol`, since #4121) and the engine (`ObjectQL`, ruling C). - * - * Until ruling C this driver carried a SECOND filter compiler for the array - * spelling — one that also accepted an INFIX join form (`[condA, 'or', condB]`) - * no schema ever declared and `parseFilterAST` cannot express. Two compilers - * for one query is the ADR-0053 D-A1 divergence, and it had already produced a - * live product fork: cloud's `RemoteTransport.buildWhereSQL` refuses the exact - * input this method used to compile (cloud#1075), with zero tests on either - * side of the split. Deleting the dialect converges them. - * - * The message names the lowering, not the SQL builder, because the fix is - * always at the caller: lower the value (or go through the engine, which does). - */ -function filterArrayReachedDriverError(filters: unknown[]): Error { - // [#20039, the #8220 contract] The serialized array is the WHOLE predicate — - // every field, operator and literal in it. A read scope handed to this - // driver as the `where` root in array form would be relayed entire, so the - // array itself is the node the seam resolves (it is the root, so its own - // mark is its verdict), and only an `'author'`-marked one gets it back. - return withheldFilterError( - `A filter ARRAY reached the driver. 'where' is a FilterCondition object; the array form ` + - `('FilterArray') is input-only authoring sugar and is lowered by @objectstack/spec ` + - `parseFilterAST() at the engine and protocol doors before any driver sees it. This driver ` + - `no longer carries a second compiler for it — call through ObjectQL, or lower the value ` + - `yourself with parseFilterAST(). Note the INFIX join form ([condA, "or", condB]) has no ` + - `lowering at all: write the prefix form ["or", condA, condB]. The array's contents are ` + - `withheld from the message; the full diagnostic is in the server log.`, - `A filter ARRAY reached the driver: ${JSON.stringify(filters)}. ` + - `'where' is a FilterCondition object; the array form ('FilterArray') is input-only ` + - `authoring sugar and is lowered by @objectstack/spec parseFilterAST() at the engine ` + - `and protocol doors before any driver sees it. This driver no longer carries a ` + - `second compiler for it — call through ObjectQL, or lower the value yourself with ` + - `parseFilterAST(). Note the INFIX join form ([condA, "or", condB]) has no lowering at ` + - `all: write the prefix form ["or", condA, condB].`, - filters, - ); -} - -/** - * [#5702] A RETIRED filter operator reached the compiler. - * - * Separate from {@link unknownFieldOperatorMessage}'s "this name is not in the - * vocabulary" on purpose: `$regex` and `$options` were not typos, they were - * spellings this driver ANSWERED until #4706 retired them, and the author who - * wrote one needs the replacement rather than a list to search. The - * prescription is `RETIRED_FILTER_OPERATORS[op].why`, printed verbatim — the - * spec table exists so that the five refusal sites stop each composing their - * own sentence about the same retirement. - * - * `siblings` are the other keys of the SAME field constraint, and every retired - * one among them is named too. `{ $regex: '^acme', $options: 'i' }` is ONE - * mistake with ONE fix (write `$icontains`), so a message naming only the key - * the loop happened to reach first would send its author back for a second - * round-trip on the other one. - * - * Returns `null` when `op` is not retired, so the caller can fall through to - * the ordinary unknown-operator refusal with one expression. - * - * [#20020, the #8220 contract] The operator, the field, the replacement and - * the retirement note all derive from the predicate, so a refusal raised from - * a read-scope subtree the caller never wrote keeps them in the server log; - * `subtree` is the field's operator map, and only a positively - * `'author'`-marked one gets the full text back. What stays on the wire is the - * class — a RETIRED operator, not an unknown one. - */ -function retiredFilterOperatorError( - op: string, - field: string, - siblings: readonly string[] = [], - subtree?: unknown, -): Error | null { - const guidance = RETIRED_FILTER_OPERATORS[op]; - if (!guidance) return null; - const replacement = guidance.to ? ` Write "${guidance.to}" instead.` : ''; - const alsoRetired = siblings.filter((key) => key !== op && RETIRED_FILTER_OPERATORS[key]); - const also = alsoRetired.length - ? ` The same field constraint also carries the retired ` + - `${alsoRetired.map((key) => `"${key}"`).join(', ')} — one "${guidance.to}" replaces the whole ` + - `shape, so this is ONE mistake with ONE fix, not one per key.` - : ''; - const full = - `Filter operator "${op}" on field "${field}" is RETIRED and is no longer evaluated by this ` + - `driver.${replacement} ${guidance.why}${also}`; - return withheldFilterError( - 'A filter operator in this filter is RETIRED and is no longer evaluated by this driver. The ' + - 'operator, the field it was aimed at and the operator that replaces it are withheld from ' + - 'the message; the full diagnostic is in the server log.', - full, - subtree, - ); -} - -/** - * The operator vocabulary {@link SqlDriver.applyFilterCondition}'s emitter - * compiles, as the refusal below states it. A capability statement, not a - * detail of the refused predicate, so it survives the redaction. - */ -const SUPPORTED_FILTER_OPERATORS_SENTENCE = - 'Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $between, $contains, ' + - '$notContains, $startsWith, $endsWith, $icontains, $like, $ilike, $null, $exists, $empty.'; - -/** - * An operator outside the emitter's vocabulary and outside the retired table. - * - * [#20020, the #8220 contract] The operator name and the field are the - * predicate's, so they go to the server log unless `subtree` — the field's - * operator map — is positively marked `'author'`. The supported vocabulary is - * the capability statement and stays on the wire either way. - */ -function unsupportedFilterOperatorError(op: string, field: string, subtree?: unknown): Error { - return withheldFilterError( - `A filter operator in this filter is not one this driver evaluates. ` + - `${SUPPORTED_FILTER_OPERATORS_SENTENCE} The operator and the field it was aimed at are ` + - `withheld from the message; the full diagnostic is in the server log.`, - `Unsupported filter operator "${op}" on field "${field}". ${SUPPORTED_FILTER_OPERATORS_SENTENCE}`, - subtree, - ); -} - -/** - * [#5702] `$icontains` received a comparand that is not a non-empty string. - * - * Two rejections, one constructor, because they are one mistake at the - * comparand position and the repair is the same sentence: - * - * - **non-string** — `StringOperatorSchema` declares `$icontains: z.string()`. - * Coercing `42` to `"42"` answers a query nobody wrote (the reading - * `applyLike`'s `String(value)` would otherwise give it). - * - **empty string** — every row contains the empty substring, so the predicate - * constrains nothing. A dropped predicate WIDENS a result set, and on an RLS - * read scope that is a permission bypass rather than a degraded filter - * (#3948) — the same reason #5240 refused `{ field: {} }` one level up. - */ -function icontainsComparandError(field: string, value: unknown, path: string, subtree?: unknown): Error { - const shown = typeof value === 'string' ? `""` : JSON.stringify(value) ?? String(value); - // [#20039, the #8220 contract] The field, the path and the comparand are the - // predicate's; the operator names the refusal's CLASS (one builder, one - // operator), so it stays on the wire with the capability statement. - return withheldFilterError( - `Operator "$icontains" in this filter requires a NON-EMPTY string comparand. "$icontains" is a ` + - `case-insensitive LITERAL substring search, so its comparand is the text to look for — an ` + - `empty one matches every row (a predicate that constrains nothing), and a non-string one ` + - `would have to be coerced into text this query never asked for. The field it was aimed at ` + - `and the value it received are withheld from the message; the full diagnostic is in the ` + - `server log.`, - `Operator "$icontains" on field "${field}" at ${path} requires a NON-EMPTY string comparand, ` + - `received ${shown}. "$icontains" is a case-insensitive LITERAL substring search, so its ` + - `comparand is the text to look for — an empty one matches every row (a predicate that ` + - `constrains nothing), and a non-string one would have to be coerced into text this query ` + - `never asked for.`, - subtree, - ); -} - -/** - * [#7536] `$like` / `$ilike` received a comparand that is not a string. - * - * The declared comparand is `z.string()`, and the coercion the emitter would - * otherwise apply is worse here than at `$icontains`: a `$like` comparand is a - * PATTERN, so `String(value)` does not merely invent text, it invents WILDCARDS - * — `String({})` is `[object Object]`, whose `[` opens a GLOB character class - * on the SQLite dialects. An empty string is deliberately NOT refused (see the - * gate's own note): `LIKE ''` matches only the empty string, which constrains - * plenty. - */ -function likePatternComparandError( - field: string, - op: string, - value: unknown, - path: string, - subtree?: unknown, -): Error { - // [#20039, the #8220 contract] `$like` and `$ilike` share this builder, so - // WHICH of the two fired is the predicate's, not the class's — withheld with - // the field, the path and the comparand, as the combinator door withholds - // which of `$and` / `$or` it was. - return withheldFilterError( - `A pattern operator ("$like" / "$ilike") in this filter requires a string comparand. It takes ` + - `a PATTERN — "%" matches any sequence, "_" matches one character, and a backslash escapes ` + - `either — so a non-string comparand cannot be coerced without inventing wildcards the caller ` + - `never wrote. For a literal substring search write "$contains", whose comparand IS text. ` + - `Which operator it was, the field it was aimed at and the value it received are withheld ` + - `from the message; the full diagnostic is in the server log.`, - `Operator "${op}" on field "${field}" at ${path} requires a string comparand, received ` + - `${describeFilterOperand(value)} (${safeShapePreview(value)}). "${op}" takes a PATTERN — ` + - `"%" matches any sequence, "_" matches one character, and a backslash escapes either — so ` + - `a non-string comparand cannot be coerced without inventing wildcards the caller never ` + - `wrote. For a literal substring search write "$contains", whose comparand IS text.`, - subtree, - ); -} - -/** - * [#7536] A `$like` / `$ilike` pattern ending in a lone unpaired backslash. - * - * Refused rather than given a meaning because no meaning survives all five - * backends: Postgres raises `LIKE pattern must not end with escape character` - * at query time, SQLite's GLOB has no escape character at all and would read - * the backslash as an ordinary one, and the JS faces would have to invent a - * third answer. A shape that cannot mean one thing everywhere is refused at the - * door — the direction #5041 / #5234 already settled for comparands this driver - * cannot compile faithfully. `hasDanglingLikeEscape` is the spec's shared test, - * so every face refuses the SAME patterns. - */ -function danglingLikeEscapeError( - field: string, - op: string, - pattern: string, - path: string, - subtree?: unknown, -): Error { - // [#20039, the #8220 contract] The pattern is the predicate's literal, and so - // are the field, the path and which of the two pattern operators it was. - return withheldFilterError( - `A pattern operator ("$like" / "$ilike") in this filter has a pattern ending in a lone unpaired ` + - `backslash. A backslash escapes the character after it, so a trailing one escapes nothing and ` + - `the backends disagree about what it means — Postgres rejects the pattern outright, SQLite's ` + - `GLOB has no escape character to reject. Write "\\\\\\\\" to match a literal backslash, or drop ` + - `the trailing one. Which operator it was, the field it was aimed at and the pattern are ` + - `withheld from the message; the full diagnostic is in the server log.`, - `Operator "${op}" on field "${field}" at ${path} has a pattern ending in a lone unpaired ` + - `backslash (${JSON.stringify(pattern)}). A backslash escapes the character after it, so a ` + - `trailing one escapes nothing and the backends disagree about what it means — Postgres ` + - `rejects the pattern outright, SQLite's GLOB has no escape character to reject. Write ` + - `"\\\\\\\\" to match a literal backslash, or drop the trailing one.`, - subtree, - ); -} - -/** - * [#20041] A `$like` / `$ilike` pattern holding U+0000 (NUL). - * - * Refused for the reason {@link danglingLikeEscapeError} is: no meaning survives - * every backend. This driver's SQLite arm compiles the pattern to `GLOB`, and - * SQLite reads a pattern only up to its first U+0000, so the pattern was CUT - * there and answered a different question than the JS faces, with nothing - * raised. Measured on better-sqlite3, sql.js and libSQL alike: `'%'` + U+0000 - * returned every non-NULL row, where `@objectstack/formula` returns only the - * values ending in U+0000. There is no NUL-safe SQLite pattern primitive to - * compile to instead. `hasNulInLikePattern` is the spec's shared test, asked on - * the same walk as the dangling escape, so every face refuses the SAME patterns - * — on every dialect of this driver, not only SQLite. - * - * Asked AFTER the dangling escape, so a pattern that has both keeps the refusal - * it already had. - */ -function nulLikePatternError( - field: string, - op: string, - pattern: string, - path: string, - subtree?: unknown, -): Error { - // [#20041, the #8220 contract] The pattern is the predicate's literal, and so - // are the field, the path and which of the two pattern operators it was. - return withheldFilterError( - `A pattern operator ("$like" / "$ilike") in this filter has a pattern holding the NUL character ` + - `U+0000. SQLite reads a pattern only up to its first NUL, so such a pattern would match a ` + - `different set of rows there than on the other backends, and no escape makes the character ` + - `portable. Remove it from the pattern. Which operator it was, the field it was aimed at and ` + - `the pattern are withheld from the message; the full diagnostic is in the server log.`, - `Operator "${op}" on field "${field}" at ${path} has a pattern holding the NUL character ` + - `U+0000 (${JSON.stringify(pattern)}). SQLite reads a pattern only up to its first NUL, so ` + - `such a pattern would match a different set of rows there than on the other backends, and ` + - `no escape makes the character portable. Remove it from the pattern.`, - subtree, - ); -} - -/** - * [#5041] The referenced field name when `value` is a Filter Protocol FIELD - * REFERENCE (`{ $field: 'other_column' }` — spec `FieldReferenceSchema` in - * `data/filter.zod.ts`), else `null`. - * - * The predicate deliberately mirrors `@objectstack/formula`'s `resolveValue` - * (`matches-filter.ts`): an object, not an array, carrying a `$field` key. The - * two execution paths must agree on **what a field reference is**; they differ - * only in what they DO with one — the in-memory evaluator resolves it against - * the record, this driver refuses it (below). A driver that recognised a - * narrower shape than the evaluator would silently bind the remainder as - * literal values again, which is precisely the defect being closed. - */ -export function fieldReferenceOf(value: unknown): string | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) return null; - const ref = (value as Record).$field; - return typeof ref === 'string' ? ref : null; -} - -/** - * [#25808] A `{ $now, addDays }` now-anchored comparand — the current instant - * shifted by whole days, compared against a temporal column of the row - * (`NowAnchorSchema`, `data/filter.zod.ts`; maintainer ruling 2026-10-26, - * letter A). Recognised by SHAPE — an object carrying a `$now` key — the way - * {@link fieldReferenceOf} recognises a reference, and for the same reason: the - * evaluator (`@objectstack/formula`'s `resolveValue`) reads the same shape, so - * the two paths agree on what an anchor IS and a malformed one is refused here - * rather than bound as a literal object. - * - * `$now` arrives in two spellings. `true` is the AUTHORED form; the engine's - * token resolver (`resolveFilterTokens`, `@objectstack/core`) pins it to the - * tree's instant — the ISO string of the same `now` every `{N_days_ago}` - * token in the tree resolved from — before any driver runs. A caller that - * reaches this driver without that seam (a direct `find`, a permission filter - * assembled in code) still carries `true`, and the driver reads its own clock - * for it, as the evaluator does. Anything else is `invalid`. - */ -export type NowAnchor = { kind: 'instant'; at: Date } | { kind: 'invalid'; reason: string }; - -function nowAnchorOf(value: unknown): NowAnchor | null { - if (!value || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) return null; - if (!('$now' in value)) return null; - const raw = (value as Record).$now; - if (raw === true) return { kind: 'instant', at: new Date() }; - if (typeof raw === 'string') { - const ms = Date.parse(raw); - if (Number.isFinite(ms)) return { kind: 'instant', at: new Date(ms) }; - return { kind: 'invalid', reason: `$now ${JSON.stringify(raw)} is not an instant the resolver could have pinned` }; - } - return { - kind: 'invalid', - reason: `$now must be the literal true (the resolver pins it to the current instant), not ${JSON.stringify(raw)}`, - }; -} - -/** - * [#25808] A `{ $now }` anchor reached a position this driver does not compile: - * a list member, a text pattern, the bare `{ field: { $now: true } }` spelling - * (which reaches the operator loop as an operator named `$now`). The same - * boundary as {@link crossFieldComparisonError}, for the same reason, with the - * same withholding (#7929): the anchor compiles only as the WHOLE comparand of - * one of the six scalar comparisons, where {@link SqlDriver.applyNowAnchorComparison} - * answers it. - */ -export function nowAnchorPositionError(field: string, op: string, subtree?: unknown, index?: number): Error { - const position = index === undefined ? '' : ` at index ${index} of its value list`; - const diagnostic = - `Operator "${op}" on field "${field}" compares against a now-anchored comparand ` + - `({ "$now": true, "addDays": … })${position}, a position SQL push-down does not compile.`; - return withheldFilterError( - `A now-anchored comparand ({ "$now": true, "addDays": … }) in this filter sits in a position SQL ` + - `push-down does not compile. It compiles only as the whole comparand of a scalar comparison ` + - `operator ($eq/$ne/$gt/$gte/$lt/$lte) on a date or datetime column. Compare against a literal ` + - `value here, or write the test as one of those comparisons. The field and the operator this ` + - `filter used are withheld from the message; the full diagnostic is in the server log.`, - diagnostic, - subtree, - `${diagnostic} It compiles only as the whole comparand of a scalar comparison operator ` + - `($eq/$ne/$gt/$gte/$lt/$lte) on a date or datetime column.`, - ); -} - -/** - * [#5041→#5222] `{ $field }` reached a position this driver does NOT compile to - * a column-to-column comparison. - * - * `FieldReferenceSchema` is declared in the spec and really is PRODUCED — - * `compileCelToFilter` emits `{ $field: path }` for a field-to-field comparison - * in a CEL permission/RLS rule. #5041 refused the shape everywhere (before it, - * the reference object was handed to Knex as a BIND VALUE and sqlite answered - * with a bare `TypeError`); #5222 narrowed that gate: the reference now - * COMPILES when it is the whole comparand of one of the six scalar comparison - * operators ({@link CROSS_FIELD_COMPARISON_OPERATORS} — see - * {@link SqlDriver.applyCrossFieldComparison}). This error answers every - * position still outside that boundary, deliberately: - * - * - **`$in` / `$nin` / `$between` list MEMBERS.** The memory evaluator resolves - * a member per record and `looseEq`s it, which SQL's `IN (…)` bind list has - * no slot for — compiling it would need per-member OR-expansion whose NULL - * and type-affinity behaviour was not proven conformance-equivalent, and an - * unproven push-down of a permission shape is the #3948 class. Before #5041 - * this position did not even crash: it compiled and returned ZERO ROWS. - * - **The string operators (`$contains` / `$startsWith` / … / `like`).** In - * memory the referenced value is matched as a LITERAL substring; in SQL it - * would become the TEXT of a LIKE pattern, so any `%` / `_` / `\` stored in - * the referenced column would act as WILDCARDS — the filter-bypass class - * `applyLike` escapes literal comparands to prevent. Escaping a column-side - * value portably needs a dialect REPLACE chain nobody has proven; refused. - * - **Any other operator** (`$null`, a retired spelling, …): no cross-field - * reading exists to compile. - * - * [#7929] The operands, the operator and the list index are withheld from the - * caller-visible half and kept in the server-log half — see - * {@link withheldFilterError} for why, and for what stays. [#8220] `subtree` - * is the offending node itself, so a positively author-marked filter gets the - * naming half back. - */ -export function crossFieldComparisonError( - field: string, - op: string, - ref: string, - subtree?: unknown, - index?: number, -): Error { - const position = index === undefined ? '' : ` at index ${index} of its value list`; - const diagnostic = - `Operator "${op}" on field "${field}" compares against another field ` + - `({ "$field": "${ref}" })${position}, a position SQL push-down does not compile.`; - return withheldFilterError( - `A cross-field comparison ({ "$field": … }) in this filter sits in a position SQL ` + - `push-down does not compile. Cross-field comparison compiles only as the whole comparand ` + - `of a scalar comparison operator ($eq/$ne/$gt/$gte/$lt/$lte) between two same-table ` + - `declared columns. Compare against a literal value here, or evaluate the rule in memory ` + - `(matchesFilter). The columns and the operator this filter used are withheld from the ` + - `message; the full diagnostic is in the server log.`, - diagnostic, - subtree, - `${diagnostic} Cross-field comparison compiles only as the whole comparand of a scalar ` + - `comparison operator ($eq/$ne/$gt/$gte/$lt/$lte) between two same-table declared columns. ` + - `Compare against a literal value here, or evaluate the rule in memory (matchesFilter).`, - ); -} - -/** - * [#5222] `{ field: { $field: 'other' } }` — a field reference used as a BARE - * field spec, i.e. in the implicit-equality position where a literal comparand - * would mean `field = value`. - * - * ## [#7597] It no longer has an AUTHORING route — and is still refused - * - * `parseFilterAST` used to lower the authored triple - * `['amount', '=', { $field: 'budget' }]` (and its `==` / `equals` / `eq` - * spellings) to exactly this shape, while `['amount', '>', …]` kept its - * operator and lowered to `{ $gt: … }` — one authoring dialect producing both - * a supported and an unsupported spelling of the same intent, with the - * unsupported one silent on the path that produced it. #7597 fixed the sink: - * an equality triple whose comparand is a `FieldReferenceSchema` now lowers to - * `{ $eq: ref }`, which this driver compiles. The array sugar therefore cannot - * reach this error any more. - * - * What CAN still reach it is a hand-authored `FilterCondition` carrying the - * bare form, and that keeps being refused rather than compiled to `$eq`, - * deliberately: the in-memory evaluator does NOT read this shape as an - * equality. `matches-filter.ts` `evalField` sees an all-`$` key set and - * dispatches `$field` to `evalOp`, which has no arm for it and answers `false` - * (its fail-closed default). Compiling a column-to-column equality here would - * make SQL answer rows for a filter the memory path answers `false` for — a - * NEW divergence. #7597 ruled that the evaluator's unknown-operator posture - * stays as #6520 left it and moved the LOWERING instead, so this message keeps - * pointing at the `$eq` spelling that does compile. - * - * [#7929] It points at that spelling with PLACEHOLDER names now. The old text - * wrote the corrected filter out with the two real column names — twice — which - * on a read-scope refusal hands the administrator's policy back to the tenant - * as a suggestion. The SHAPE survives the redaction; the names do not. - */ -function bareFieldReferenceError(field: string, ref: string, subtree?: unknown): Error { - const diagnostic = - `Field "${field}" is constrained by a bare field reference ({ "$field": "${ref}" }) with no ` + - `operator. The spelling that compiles is ` + - `{ "${field}": { "$eq": { "$field": "${ref}" } } }.`; - return withheldFilterError( - `A field in this filter is constrained by a bare field reference ({ "$field": … }) with no ` + - `operator. Write the comparison explicitly — { "TARGET_FIELD": { "$eq": { "$field": ` + - `"OTHER_FIELD" } } } — which compiles to a column-to-column comparison. The bare form is ` + - `refused because the in-memory evaluator does not read it as an equality (it matches no ` + - `record), so compiling it here would make the two execution paths answer this filter ` + - `differently. The columns this filter named are withheld from the message; the ` + - `full diagnostic is in the server log.`, - diagnostic, - subtree, - `${diagnostic} The bare form is refused because the in-memory evaluator does not read it as ` + - `an equality (it matches no record), so compiling it here would make the two execution ` + - `paths answer this filter differently.`, - ); -} - -/** - * [#5222] The operators whose `{ $field }` comparand compiles to a - * column-to-column comparison — the six scalar comparisons, in the `$`-form - * spelling the emitters read (array-triple / infix authorings are lowered to - * these before a driver ever runs; see `parseFilterAST` and #5158). - * - * Deliberately NOT the whole of {@link SCALAR_COMPARAND_OPERATORS}: `like` / - * `ilike` are in that set only for the bind gate, and a cross-field LIKE is - * refused (see {@link crossFieldComparisonError}). - */ -const CROSS_FIELD_COMPARISON_OPERATORS: ReadonlySet = new Set([ - '$eq', '$ne', '$gt', '$gte', '$lt', '$lte', -]); - -/** - * [#5041] Operators whose comparand is a single bound VALUE, in the Filter - * Protocol `$`-form read by {@link SqlDriver.applyFilterCondition} — the one - * spelling this driver still compiles. It also listed the canonicalised infix - * form, read by an `applyAstComparison` emitter deleted with the array dialect - * in #5158; the infix spellings stay in the set because - * {@link assertCompilableComparand} is called with an already-canonicalised - * operator on the reduction path. - * - * The list-shaped operators (`$in` / `$nin` / `$between`) are deliberately - * ABSENT: an array is their legitimate comparand, and they compile through - * their own `whereIn` / `whereBetween` arms. Only their MEMBERS are inspected — - * for `$field` (#5041) and, since #5234, for bindability — never their arity: - * the existing descriptive `$between` refusal stays the one that answers a - * malformed range. See {@link isListComparandOperator}. - */ -const SCALAR_COMPARAND_OPERATORS: ReadonlySet = new Set([ - '$eq', '$ne', '$gt', '$gte', '$lt', '$lte', - '=', '==', '!=', '<>', '>', '>=', '<', '<=', 'like', 'ilike', - // [#7536] The `$`-forms of the two infix spellings already here. They are - // SCALAR rather than {@link TEXT_PATTERN_OPERATORS} for the reason that set's - // note gives: their comparand ARRIVES as the pattern, so nothing wraps or - // escapes it and the question to ask of the value is "can this be bound", - // which is this set's question. - '$like', '$ilike', -]); - -/** - * Can this value be handed to a driver as a bound parameter at all? - * - * Same classification the write path applies in `formatInput` — anything that - * is not a primitive, a `Date` or a binary buffer is a shape better-sqlite3 - * refuses outright and the other dialects mangle. (`ArrayBuffer.isView` covers - * `Buffer`, which is a `Uint8Array`.) - * - * [#7872] The type membership is the shared comparand-type door's now - * (`isAcceptedFilterComparand`, `@objectstack/spec/data`) — this list and - * `RemoteTransport.serializeComparand`'s reached the identical six types twice - * independently, which is the measured fact the door was ruled from, so the SET - * is sourced there rather than duplicated here. Two driver-local extras stay, - * each recorded: `undefined` answers TRUE only because the #6050 walk refuses - * every undefined comparand FIRST with its purpose-written message, so this arm - * is unreachable on the filter path and flipping it would only swap which error - * fires for a direct caller; `ArrayBuffer.isView` is this driver's own bindable - * (blob columns) that the engine-level door does not admit — it remains - * reachable from direct driver calls, which is also where it was ever usable. - */ -function isBindableComparand(value: unknown): boolean { - if (value === undefined) return true; - return isAcceptedFilterComparand(value) || ArrayBuffer.isView(value); -} - -/** - * [#5234] Operators whose comparand becomes the TEXT of a `LIKE` pattern, i.e. - * the ones {@link SqlDriver.applyLike} serves. Kept separate from - * {@link SCALAR_COMPARAND_OPERATORS} because the two ask different questions of - * the same value: a scalar operator needs a value a driver can BIND, a pattern - * operator needs one that has a faithful TEXT rendering. Every value in the - * first set except a binary buffer is also in the second, but the reason is not - * the same reason, and the messages a caller needs differ. - * - * `like` / `ilike` (and, since #7536, their `$like` / `$ilike` spellings) are - * absent on purpose: they arrive already carrying a pattern and compile - * through the scalar bind arm, which already refuses an object. - */ -const TEXT_PATTERN_OPERATORS: ReadonlySet = new Set([ - '$contains', '$notContains', '$startsWith', '$endsWith', '$icontains', -]); - -/** - * [#7536] The two operators whose comparand IS a `LIKE` pattern — the caller's - * own wildcards, neither escaped nor wrapped. - * - * Kept as a named set beside {@link TEXT_PATTERN_OPERATORS} rather than folded - * into it because the two families ask OPPOSITE things of the same string: a - * `$contains` comparand is text that must be made literal (every `%` escaped), - * while a `$like` comparand is a pattern that must be left alone (every `%` is - * the caller's wildcard). Running one through the other's rule is precisely the - * defect #7536 closes. - */ -const LIKE_PATTERN_OPERATORS: ReadonlySet = new Set(['$like', '$ilike']); - -/** - * [#14079] Every operator that reads the column as TEXT — the union of - * {@link TEXT_PATTERN_OPERATORS} and {@link LIKE_PATTERN_OPERATORS}. The two - * families ask opposite things of their COMPARAND (the docblocks above), but - * they ask the same thing of the COLUMN: that its stored value be a string. The - * one place that question is answered at compile time is - * {@link SqlDriver.applyTextOperatorOverNonTextColumn}, and this set is what - * routes an operator there. - */ -const TEXT_OPERATORS: ReadonlySet = new Set([ - ...TEXT_PATTERN_OPERATORS, - ...LIKE_PATTERN_OPERATORS, -]); - -/** - * [#5234] Does this value have a faithful rendering as the text of a LIKE - * pattern? - * - * An ALLOW-list, deliberately, and the same one `driver-turso`'s - * `RemoteTransport.serializeComparand` settled on for the identical question in - * remote mode (cloud#1004 / #1058): a deny-list silently re-admits whatever - * value form is invented next, which is exactly how that bug survived its first - * fix. `undefined` is inside the fence only because it is not authorable (JSON - * has no `undefined`) and the analytics door normalises it to `null` rather than - * refusing it (#5526) — refusing it HERE would invent a disagreement rather than - * close one. See {@link unrenderableTextComparandError} for the rest. - */ -function isRenderableTextComparand(value: unknown): boolean { - // [#7872] The six-type membership is the shared door's; `undefined` stays a - // recorded local admission (see the paragraph above — the #6050 walk refuses - // it first, so the arm is unreachable on the filter path). - return value === undefined || isAcceptedFilterComparand(value); -} - -/** - * [#5041] The one gate every comparison comparand passes before it becomes a - * bind parameter, covering both halves of the gap the issue measured: - * - * 1. **`{ $field }`** — declared, produced, and implemented only in memory. - * Refused wherever it appears, including inside an `$in` / `$nin` / `$between` - * list. The list case matters more than it looks: a `$field` member did not - * even crash — the query compiled, ran, and returned ZERO ROWS. A silent - * wrong answer is what #3948 / #4209 settled is strictly worse than an error - * on a permission-scoped read. - * - * 2. **The general arm** — a known operator whose comparand is a shape no - * dialect can bind (a plain object, an array where one value belongs). The - * issue noted this branch had no rejection arm at all; measured, every such - * shape produced the same bare `TypeError`. Scoped to - * {@link SCALAR_COMPARAND_OPERATORS} so the legitimate array binds keep - * working untouched. - * - * 3. **[#5234] The two shapes #5041 deliberately left out** — a non-`$field` - * OBJECT member of an `$in`/`$nin` list, and an object comparand on the - * `LIKE` family. #5041 read them as a lesser class ("a filter applied - * nonsensically, not one that cannot be applied") whose direction was - * fail-closed, and stopped. Both halves of that reading were measured wrong - * on `main` before this change: - * - * - The direction is not uniformly fail-closed. `{status: {$nin: [{…}]}}` - * compiles to `NOT IN ('[object Object]')`, which excludes NOTHING — an - * exclusion the caller wrote that silently does not happen. On a - * read-scope lowering that is over-reach, the same direction #5347 / #5324 - * ruled on. `$notContains` does it too: it EXCLUDED the one fixture row - * whose text happened to be `[object Object]`. - * - The answers were never merely "zero rows". Against a row literally - * named `[object Object]`, `{name: {$contains: {}}}` MATCHED it. That is - * not a narrowed result set, it is a wrong one. - * - * Two more measurements decided the exact fence: - * - * - An ARRAY comparand on the LIKE family already forked INSIDE - * `service-analytics`: `{name: {$contains: ['al','be']}}` binds `%al,be%` - * through `read-scope-sql` (and here, via `String(array)`) but `%al%` - * through the analytics `where` door, which reads `values[0]`. Refusing - * the array closes a live split rather than opening one. - * - `driver-turso`'s `RemoteTransport` has refused BOTH of these shapes - * since cloud#1004 / #1058, with the reasoning written out: refusing an - * uncompilable comparand "in one family while tolerating it in the other - * would leave the failure mode alive at a different spelling." So local - * SQLite and remote SQLite answered the same query differently. This - * change is what converges them, and it copies that fix's ALLOW-list - * shape (see {@link isRenderableTextComparand}) rather than inventing a - * second policy. - * - * What stays accepted is measured, not assumed: `{$contains: 5}` → `%5%` - * and `{$contains: null}` → `%null%` agree across this driver, - * `driver-memory` and both analytics faces today, and #5526 pinned the - * `null` reading deliberately. Primitives are therefore untouched; only - * objects — for which `String()` has no faithful answer — are refused. - */ -function assertCompilableComparand( - field: string, - op: string, - value: unknown, - // [#8197] The node ENCLOSING this comparand — the field-spec map, or the - // filter node for the bare `{ field: value }` spelling. Used only when the - // comparand itself cannot carry a mark; see {@link refusalSubtree}. - enclosing?: unknown, -): void { - const ref = fieldReferenceOf(value); - if (ref !== null) throw crossFieldComparisonError(field, op, ref, value); - // [#25808] …and a now-anchored comparand, outside the six comparisons. - if (nowAnchorOf(value) !== null) throw nowAnchorPositionError(field, op, value); - - // [#5234] The pattern family answers first: an array IS an object here, so - // the member scan below would otherwise report `{$contains: ['a', {}]}` as a - // bad LIST member — a message about a list the operator never takes. - if (TEXT_PATTERN_OPERATORS.has(op) && !isRenderableTextComparand(value)) { - // [#20039] `value` is an object or an array here — markable, and the - // deepest node the throw site holds (see {@link refusalSubtree}). - throw unrenderableTextComparandError(field, op, value, refusalSubtree(value, enclosing)); - } - - if (Array.isArray(value)) { - for (const [index, member] of value.entries()) { - const memberRef = fieldReferenceOf(member); - if (memberRef !== null) throw crossFieldComparisonError(field, op, memberRef, member, index); - if (nowAnchorOf(member) !== null) throw nowAnchorPositionError(field, op, member, index); - // [#5234] Every member of a list operator's array is a comparand in its - // own right and gets the same bind test the whole comparand gets. Scoped - // to the operators for which an array is legitimate (the spec's list - // half, {@link isListComparandOperator}), so a scalar operator handed an - // array keeps answering with its own message ("requires a single - // comparable value") instead of reporting the first bad member of a list - // it should never have been given. - // [#20039] The member when it is an object, else the list that holds it. - if (isListComparandOperator(op) && !isBindableComparand(member)) { - throw unbindableListMemberError(field, op, member, index, refusalSubtree(member, value)); - } - } - // An array IS the comparand for the list operators; only a scalar operator - // is wrong to receive one, and that falls through to the check below. - } - - if (!SCALAR_COMPARAND_OPERATORS.has(op) || isBindableComparand(value)) return; - - throw unbindableComparandError(field, op, value, refusalSubtree(value, enclosing)); -} - -/** - * [#5041] A scalar operator's comparand that cannot become a bind parameter. - * - * [#8197] Extracted from {@link assertCompilableComparand}'s body — where it was - * an inline `unsupportedFilterError` — for one reason: it is a member of - * {@link refusalSubtree}'s family and needed somewhere to compose its redacted - * and restored wordings side by side, which is the arrangement every other - * withheld refusal in this file already uses. - * - * It also answers the MALFORMED cross-field spelling. `{ "$gt": { "$field": 42 } }` - * is not a `FieldReferenceSchema` (the referent must be a string), so - * {@link fieldReferenceOf} does not recognise it and the cross-field arm never - * sees it — it arrives here as an ordinary unbindable object. Its comparand - * preview used to print `{"$field":42}` beside the target column; both halves - * are predicate-derived and both are now redacted. - */ -function unbindableComparandError( - field: string, - op: string, - value: unknown, - subtree?: unknown, -): Error { - return withheldFilterError( - `A comparison in this filter requires a single comparable value, but received a value that ` + - `cannot be bound as a SQL parameter. Use ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} (or a ` + - `binary value); for a list use $in/$nin, and for a range use $between. The field, the ` + - `operator and the value this filter used are withheld from the message; the full ` + - `diagnostic is in the server log.`, - `Operator "${op}" on field "${field}" requires a single comparable value, but received ` + - `${Array.isArray(value) ? 'an array' : `an object (${safeShapePreview(value)})`}, which cannot be ` + - `bound as a SQL parameter. Use ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} (or a binary ` + - `value); for a list use $in/$nin, and for a range use $between.`, - subtree, - ); -} - -/** - * `$between` handed something that is not a two-element range. - * - * [#8197] The operator is NAMED in the redacted half, and it is the one thing in - * this family that is: `$between` here is the refusal's CLASS, not a variable of - * it — the same "which class fired" {@link crossFieldComparisonError} keeps. The - * three refusals whose operator varies within one class (the JSON-column gate, - * the bind gate) withhold theirs, because there the operator says WHICH - * constraint of the predicate failed rather than which rule refused it. - * - * The target field is the whole disclosure, so redacting it leaves a sentence - * that is still the complete repair: a `$between` takes `[min, max]`, and an - * author who wrote one malformed range in one filter has no second candidate to - * confuse it with. - */ -function betweenArityError(field: string, subtree?: unknown): Error { - return withheldFilterError( - `Operator "$between" in this filter requires a [min, max] value array. The field it was ` + - `aimed at is withheld from the message; the full diagnostic is in the server log.`, - `Operator "$between" on field "${field}" requires a [min, max] value array.`, - subtree, - ); -} - -/** - * [#5234] A member of an `$in` / `$nin` / `$between` list that cannot become a - * bind parameter. - * - * The list case is the one that hides. A scalar operator handed an object fails - * to bind and at least says so; a LIST simply loses the member — Knex binds it, - * the statement is valid, and the offending entry can never equal any stored - * value. So `{status: {$in: ['a', {…}]}}` answers exactly as if the author had - * written `{$in: ['a']}`, and `{status: {$nin: [{…}]}}` excludes nothing at all - * while claiming to exclude something. Neither is reported anywhere. - * - * The message names the INDEX because that is the only thing distinguishing the - * bad entry from its legitimate neighbours — the same reason - * {@link crossFieldComparisonError} takes one. - */ -function unbindableListMemberError( - field: string, - op: string, - value: unknown, - index: number, - subtree?: unknown, -): Error { - // [#20039, the #8220 contract] The index is what tells the bad member apart - // from its neighbours — for the AUTHOR. On a read-scope list it, the member's - // preview, the field and which of the three list operators it was are all the - // administrator's, so they go to the server log. - return withheldFilterError( - `A list operator ("$in" / "$nin" / "$between") in this filter has a member that cannot be ` + - `bound as a SQL parameter. Every member of an $in/$nin/$between list is a comparand in its ` + - `own right — use ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} (or a binary value). Refusing ` + - `rather than binding it: the member can equal no stored value, so the list silently loses ` + - `that entry (and a $nin loses the exclusion the caller wrote). Which operator it was, the ` + - `field it was aimed at, the member's position in the list and its value are withheld from ` + - `the message; the full diagnostic is in the server log.`, - `Operator "${op}" on field "${field}" has a value at index ${index} of its list that cannot be ` + - `bound as a SQL parameter: ${safeShapePreview(value)}. Every member of an $in/$nin/$between ` + - `list is a comparand in its own right — use ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} ` + - `(or a binary value). Refusing rather than binding it: the member can equal no stored value, ` + - `so the list silently loses that entry (and a $nin loses the exclusion the caller wrote).`, - subtree, - ); -} - -/** - * [#5234] An object where the `LIKE` family expects the text of a pattern. - * - * `applyLike` reaches the comparand through `String(value)`, and `String({})` is - * the literal `'[object Object]'`. The result is a syntactically perfect, - * parameterised `LIKE` against a string the author never wrote — and it is not - * merely always-false: a stored value that happens to READ `[object Object]` - * matches it, which is how this was measured. `$notContains` inverts that into - * excluding a real row for a reason nothing records. - * - * `filter.zod.ts`'s `StringOperatorSchema` declares every one of these - * comparands `z.string()`, so this refusal enforces a declaration that already - * exists rather than adding a rule (Prime Directive #12 — declared = enforced). - * It stops at OBJECTS on purpose: a number, boolean or `null` renders to text - * the same way on this driver, on `driver-memory` and on both `service-analytics` - * faces, and #5526 pinned `{$contains: null}` → `%null%` deliberately. Refusing - * those would break agreement instead of creating it. - */ -function unrenderableTextComparandError( - field: string, - op: string, - value: unknown, - subtree?: unknown, -): Error { - // [#20039, the #8220 contract] The comparand's preview is the predicate's - // literal, and the field and the operator (five share this builder) name - // which constraint failed, not which rule refused it. - return withheldFilterError( - `A text-matching operator in this filter matches against the TEXT of a pattern, but received a ` + - `comparand that is an object or an array. The spec declares this comparand a string ` + - `(filter.zod.ts StringOperatorSchema); ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} is ` + - `accepted. Refusing rather than stringifying it: String({}) is "[object Object]", so the ` + - `pattern that ran would be one the caller never wrote. The operator, the field it was aimed ` + - `at and the value it received are withheld from the message; the full diagnostic is in the ` + - `server log.`, - `Operator "${op}" on field "${field}" matches against the TEXT of a pattern, but received ` + - `${Array.isArray(value) ? 'an array' : 'an object'} (${safeShapePreview(value)}). The spec ` + - `declares this comparand a string (filter.zod.ts StringOperatorSchema); ` + - `${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} is accepted. Refusing rather than stringifying ` + - `it: String({}) is "[object Object]", so the pattern that ran was one the caller never ` + - `wrote — valid SQL, and a row storing that literal text would have matched it.`, - subtree, - ); -} - -/** - * [#7398] The column-type gate's operator set — `JSON_COLUMN_INCOMPATIBLE_OPERATORS` - * — and the words of its refusal live in `@objectstack/core` - * (`json-column-operator-refusal.ts`) since #21007, imported above under the - * same name, unchanged. They moved there because `@objectstack/objectql` - * evaluates a per-aggregation `filter` itself and now refuses the same operators - * on the same declared fields: one set and one sentence for both faces, rather - * than a copy each. The reasoning behind every member and every sentence is in - * that module's docblocks. - */ - -/** - * [#7398] A scalar-comparison operator met a column this driver stores as JSON - * TEXT, so the comparison can never mean what the caller wrote — the measured - * consequences are on {@link jsonColumnOperatorRefusalText}. - * - * ADR-0112 class 1 — a caller mistake, so `INVALID_FILTER` / 400, the same - * envelope {@link unsupportedFilterError} gives the unknown-operator refusal - * one arm over. - * - * The prescription is `$contains` (and an `$or` of `$contains` for any-of) - * because that is the membership spelling on such a column (the spec's - * `$contains` docblock); a closed-spec-set `$overlaps` / `$containsAny` is the - * issue's ask 2 and is deliberately not answered here. - * - * [#8197] The most reachable member of {@link refusalSubtree}'s family, and the - * one the card led with: a CEL permission rule over a multi-select field lowers - * to exactly this membership test, so the column this gate names is the - * administrator's. The prescription survives redaction with PLACEHOLDER names — - * the SHAPE is the repair, and the shape names nothing. - * - * [#21007] The TEXT is `@objectstack/core`'s; the CONSTRUCTOR stays here, - * because the #8220 provenance seam it goes through is this driver's. - * [#21067] That text no longer names this driver's storage form, since the - * engine and `driver-memory` print it too; the measured SQL consequences above - * live in the builder's docblock. - * - * [#21236] The `$contains` prescription is the multi-value class's. A - * single-value file-class field inside the ADR-0104 window is a JSON column - * here too, but it holds one JSON string, and `$contains` with its exact id - * answered no rows. So `fieldClass`, which the gate reads from this driver's - * registries ({@link SqlDriver.jsonColumnFieldClass}), picks the words, and - * that class is told to finish the media-column move. - */ -function jsonColumnOperatorError( - field: string, - op: string, - bare: boolean, - fieldClass: JsonColumnFieldClass, - subtree?: unknown, -): Error { - const { message, diagnostic } = jsonColumnOperatorRefusalText(field, op, bare, fieldClass); - return withheldFilterError(message, diagnostic, subtree); -} - -/** A short, non-throwing rendering of an offending comparand for the message. */ -function safeShapePreview(value: unknown): string { - try { - const json = JSON.stringify(value); - if (typeof json !== 'string') return typeof value; - return json.length > 80 ? `${json.slice(0, 77)}...` : json; - } catch { - return typeof value; - } -} - -/** - * Where the wildcard sits relative to the comparand. Named exactly as - * `service-analytics`'s `LikeShape` names its own, so the twin implementations - * read alike: `contains` → `%v%`, `starts` → `v%`, `ends` → `%v`. - */ -export type TextMatchShape = 'contains' | 'starts' | 'ends'; - -/** The character bound into every `ESCAPE` clause this driver emits. */ -export const LIKE_ESCAPE_CHARACTER = '\\'; - -/** - * [#5134] What a filter node is worth as a boolean, before any SQL is emitted. - * - * - `'true'` — matches every row; the compiler emits NO clause for it. - * - `'false'` — matches no row; the compiler emits the dialect FALSE constant. - * - `'clause'` — carries at least one real predicate; compile it normally. - * - * [#5659] The vocabulary is `@objectstack/spec`'s now, because the REDUCTION - * that produces it is — see {@link reduceFilterNode}. Kept as a local alias so - * every use site below still reads `FilterVerdict`. - */ -type FilterVerdict = SharedFilterVerdict; - -/** - * [#5134] Is `value` a Filter Protocol NODE — the shape `FilterConditionSchema` - * declares for every element of `$and`/`$or` and for the operand of `$not`? - * - * The prototype check is the load-bearing half, not pedantry. The identity - * reduction below turns "this node has no predicates" into "matches every row", - * so any object whose OWN ENUMERABLE KEYS are empty is read as TRUE. A `Date`, - * a `RegExp`, a `Map` or a class instance all satisfy `typeof x === 'object' && - * !Array.isArray(x)` while enumerating to nothing — accepting them would - * PROMOTE garbage from "silently ignored" (the old bug) to "matches all rows" - * (strictly worse). A filter condition always arrives as JSON or as the output - * of `compileCelToFilter`, i.e. a plain object, so requiring one costs nothing - * real and makes the reduction total. - */ -export function isFilterNode(value: unknown): value is Record { - if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; - const proto = Object.getPrototypeOf(value); - return proto === Object.prototype || proto === null; -} - -/** A short type name for an operand the filter compiler refuses. */ -function describeFilterOperand(value: unknown): string { - if (value === null) return 'null'; - if (Array.isArray(value)) return 'array'; - const kind = typeof value; - if (kind !== 'object') return kind; - const ctor = (value as { constructor?: { name?: string } }).constructor; - return ctor?.name && ctor.name !== 'Object' ? ctor.name : 'object'; -} - -/** - * [#5134] The gate that gives "this group compiled to empty" exactly ONE cause. - * - * Identity reduction is only sound once an empty compile can mean "the author - * wrote an empty group" and nothing else. Before this gate, `$or: [null]`, - * `$or: ['x']`, `$or: [[…]]` and `$or: [new Date()]` also produced no clause and - * vanished without a trace; reducing on top of that would have turned each of - * them into "matches every row". Refusing them here — loudly, in the ADR-0112 - * envelope every sibling filter refusal in this driver speaks — is what makes - * the reduction safe. Same discipline as cloud#1073 on Turso's - * `RemoteTransport.buildWhereSQL`. - */ -function assertFilterNode( - value: unknown, - path: string, - enclosing?: unknown, -): asserts value is Record { - if (isFilterNode(value)) return; - // [#20039, the #8220 contract] The position, the kind and the preview are the - // predicate's (a policy's literal, measured: `$or: ['']`). The - // node is the refused value when it is an object — an array, a `Date` — and - // otherwise `enclosing`: the `$and` / `$or` list that holds it, or the node - // carrying `$not`, whose provenance a primitive inherits. - throw withheldFilterError( - `A filter node in this filter is not a filter condition object. Every element of "$and"/"$or" ` + - `and the operand of "$not" must be a plain object of field constraints (e.g. { "status": ` + - `"active" }) or nested combinators — @objectstack/spec FilterConditionSchema declares this ` + - `position as a FilterCondition. It is refused rather than skipped because skipping it would ` + - `silently change which rows match. Where it sits, what it is and its value are withheld from ` + - `the message; the full diagnostic is in the server log.`, - `Filter node at ${path} is a ${describeFilterOperand(value)} (${safeShapePreview(value)}), not a filter ` + - `condition object. Every element of "$and"/"$or" and the operand of "$not" must be a plain object of ` + - `field constraints (e.g. { "status": "active" }) or nested combinators — @objectstack/spec ` + - `FilterConditionSchema declares this position as a FilterCondition. It is refused rather than skipped ` + - `because skipping it would silently change which rows match.`, - refusalSubtree(value, enclosing), - ); -} - -/** - * [#5240] `{ field: {} }` — a field constrained by ZERO operators. - * - * One declared shape, three answers across this repo: THIS driver refused it at - * the top level (the #5041 comparand gate) while DROPPING it inside - * `$and`/`$or`/`$not` — where a predicate that emits nothing means "matches - * every row" — and `driver-memory` / `@objectstack/formula` both answered - * "matches nothing". So `{ $or: [ { a: {} }, { b: 2 } ] }` returned a different - * row set per backend, and this driver contradicted ITSELF depending on whether - * the same constraint sat at the top level or one combinator deep. - * - * Ruled on #5240: refuse it everywhere, in the ADR-0112 envelope every sibling - * filter refusal here speaks. Not TRUE and not FALSE — because the shape is - * almost always an authoring accident (a filter builder that recorded a field - * and no operator, or generated metadata that lost its operator), and both - * silent readings answer it with a row count the author never asked for. The - * same reasoning #5041 applied one position over: a filter that cannot be given - * one meaning is refused at the producer, loudly, at authoring time. - */ -function emptyFieldConstraintError(field: string, path: string, subtree?: unknown): Error { - return withheldFilterError( - `A field constraint in this filter carries zero operators ({ "FIELD": {} }). A field ` + - `constraint must name at least one operator (e.g. { "FIELD": { "$eq": "value" } }) or be a ` + - `direct comparand (e.g. { "FIELD": "value" }). It is refused rather than ignored because ` + - `the backends disagreed on what it means — this driver dropped it inside $and/$or/$not ` + - `(matching EVERY row) while refusing it at the top level, and driver-memory / ` + - `@objectstack/formula answered "matches nothing". The field this filter named and ` + - `its position are withheld from the message; the full diagnostic is in the server ` + - `log.`, - `Field constraint at ${path} carries zero operators ({ "${field}": {} }). A field constraint ` + - `must name at least one operator (e.g. { "${field}": { "$eq": "value" } }) or be a direct ` + - `comparand (e.g. { "${field}": "value" }). It is refused rather than ignored because the ` + - `backends disagreed on what it means — this driver dropped it inside $and/$or/$not (matching ` + - `EVERY row) while refusing it at the top level, and driver-memory / @objectstack/formula ` + - `answered "matches nothing".`, - subtree, - ); -} - -/** [#5240] Is this field spec the zero-operator constraint refused above? */ -function isEmptyFieldConstraint(spec: unknown): boolean { - return isFilterNode(spec) && Object.keys(spec).length === 0; -} - -/** - * [#5348] A `$`-prefixed key in a NODE position that is not a declared - * combinator. - * - * `FilterConditionSchema` declares exactly three (`LOGICAL_OPERATORS`: `$and`, - * `$or`, `$not`); every other key of a node is a FIELD NAME. This driver's - * emitter is written on that assumption and nothing checked it, so `$where`, - * `$nor`, `$expr` and friends fell through to the field arms and were compiled - * as COLUMNS — `remoteColumn(table, '$where', …)`. On SQLite a double-quoted - * name that resolves to no column degrades to a string literal, so the query - * compiled, ran, and returned ZERO ROWS: - * - * ``` - * WHERE {"$where":"return true"} → SELECT … WHERE "$where" = 'return true' → [] - * WHERE {"$nor":[{"stage":"won"}]} → (its array value missed the object arm) → [] - * ``` - * - * Measured on better-sqlite3 in #5348. Other dialects reject the unknown - * identifier instead — a different symptom, the same cause, and neither is an - * answer to the filter that was asked. - * - * The refusal is this driver's FIELD-LEVEL posture (#3948 / #4436) finally - * reaching the node position: `{ stage: { $sounds_like: 'x' } }` has answered - * `INVALID_FILTER` / 400 for two releases while `{ $sounds_like: 'x' }` one - * level up answered "no rows". One driver, two positions, two answers — the - * same internal contradiction #5240 closed for `{ field: {} }`. - * - * The wording is `driver-memory`'s `unknownLogicalOperatorError`, verbatim - * through the vocabulary sentence, because #3948 made the backends AGREE that - * an uncompilable filter is a refusal and #5240 made one condition speak one - * wording. Only the closing clause differs: it names what THIS driver used to - * do with the key. - */ -function unknownLogicalOperatorError(key: string, path: string, subtree?: unknown): Error { - // [#20039, the #8220 contract] The key is the predicate's — `$`-prefixed or - // not, it is a name the filter's author wrote — and so is its position. The - // node is the one CARRYING the key (never its value, which may be marked on - // its own: the key belongs to the node above it). - return withheldFilterError( - `A filter node in this filter carries a $-prefixed key that is not a declared combinator. A ` + - `filter node's $-prefixed keys are the declared logical operators $and, $or and $not ` + - `(@objectstack/spec LOGICAL_OPERATORS); every other key is a field name. It is refused rather ` + - `than compiled as a COLUMN of that name, which would produce a predicate that matched no row ` + - `and reported nothing. The key and where it sits are withheld from the message; the full ` + - `diagnostic is in the server log.`, - `Unsupported filter combinator "${key}" at ${path}. A filter node's $-prefixed keys are the ` + - `declared logical operators $and, $or and $not (@objectstack/spec LOGICAL_OPERATORS); every ` + - `other key is a field name. It is refused rather than compiled as a COLUMN of that name, ` + - `which is what this driver used to do — producing a predicate that matched no row and ` + - `reported nothing, so a caller could not tell "no rows matched" from "the filter never ` + - `compiled".`, - subtree, - ); -} - -/** - * [#5347] `$null` whose comparand is not a boolean. - * - * `FieldOperatorsSchema` declares `$null: z.boolean()`, and nothing between an - * authored `where` and this driver validates against it — so a non-boolean - * really does arrive here. Every backend then read it, and they did NOT agree; - * measured in #5347 against one row with `stage: 'won'` and one with - * `stage: null`, on `{ stage: { $null: 'yes' } }`: - * - * | backend | compiled to | rows | - * |---|---|---| - * | driver-sql / driver-sqlite-wasm / Turso local | `IS NULL` (anything but `false`) | the NULL row | - * | driver-memory live path (mingo), driver-mongodb | `IS NOT NULL` (anything but `true`) | the valued row | - * | driver-memory reference matcher | nothing at all — the constraint vanished | BOTH rows | - * - * Three readings of one declared operator, and two of them are each other's - * exact complement. The cause is a pair of default branches hung on opposite - * sides: this driver's emitter asked `opValue === false`, the JS drivers asked - * `val === true`. Neither is a rule anyone wrote down; both are what a - * two-branch conditional does with a third value. - * - * Ruled on #5347: REFUSED, in every position, on every backend — the same - * disposition #5240 gave `{ field: {} }` and for the same reason. `$null: 0` - * read as IS NULL (this driver's rule) is almost certainly not what the author - * meant, `$null: 0` read as IS NOT NULL (the JS rule) is no better, and the - * string `"false"` — which an AI-authored or JSON-round-tripped scope produces - * readily — is truthy, so it lands on the opposite side from the `false` it was - * written to mean. There is no reading of a non-boolean here that is not a - * guess about the author's intent, so the driver stops guessing. - * - * `$exists` carries the identical `=== false` identity read one arm below and - * is deliberately NOT touched here: it diverges too, but on its own axis (what - * "exists" means for a null-valued key is #5299's open question), and #5347 - * ruled on `$null`. Filed separately rather than settled as a rider. - */ -/** - * [#5369, applying #5347's ruling A] `$exists` whose comparand is not a boolean. - * - * The symmetric twin of {@link nonBooleanNullComparandError}, and deliberately - * a copy of its disposition rather than a fresh judgement: `FieldOperatorsSchema` - * declares `$exists: z.boolean()` exactly as it declares `$null`, nothing between - * an authored `where` and this driver validates against it, and the backends - * split the same way on a third value — this emitter's `opValue === false` - * identity reads anything but `false` as IS NOT NULL, while the `=== true` - * spelling used elsewhere reads anything but `true` as IS NULL. Two complements, - * neither a rule anyone wrote down. - * - * #5347 ruled that shape REFUSED for `$null`; #5369 asked whether the same - * applies here and the 2026-08-06 ruling on #5298 said yes, "照 #5347-A". So the - * gate lands beside `$null`'s in {@link reduceFilterKey}, not in the emitter — - * same evaluation-order reason, same `INVALID_FILTER` envelope. - * - * Note what does NOT change with it: the emitter's `opValue === false` arm and - * the polarity table's `value === false` arm (this driver's - * `nullValueSatisfiesOperator` then; since #20822 the shared lowering's) stay - * exactly as they are. Once the gate holds, `true` and `false` are the only - * comparands that reach either, so both tests are already exhaustive two-way choices — the - * lenient-vs-strict question #5347 had to answer for `$null` does not arise - * here, because both spellings agree on the two surviving values. - */ -function nonBooleanExistsComparandError( - field: string, - value: unknown, - path: string, - subtree?: unknown, -): Error { - return withheldFilterError( - nonBooleanFlagWithheldMessage('$exists'), - `Operator "$exists" on field "${field}" requires a boolean comparand (true or false). ` + - `Received ${describeFilterOperand(value)} (${safeShapePreview(value)}) at ${path}. ` + - `@objectstack/spec FieldOperatorsSchema declares $exists as a boolean. It is refused rather ` + - `than coerced for the same reason $null is: a non-boolean lands on whichever side ` + - `the backend's two-branch conditional happens to default to, and those defaults point in ` + - `OPPOSITE directions — this driver's \`=== false\` test compiles IS NOT NULL for anything ` + - `but false, a \`=== true\` test compiles IS NULL for anything but true. Note "false" the ` + - `STRING is truthy, so it lands on the side opposite the false it was written to mean.`, - subtree, - ); -} - -function nonBooleanNullComparandError( - field: string, - value: unknown, - path: string, - subtree?: unknown, -): Error { - return withheldFilterError( - nonBooleanFlagWithheldMessage('$null'), - `Operator "$null" on field "${field}" requires a boolean comparand (true or false). ` + - `Received ${describeFilterOperand(value)} (${safeShapePreview(value)}) at ${path}. ` + - `@objectstack/spec FieldOperatorsSchema declares $null as a boolean. It is refused rather ` + - `than coerced because the backends read a non-boolean in OPPOSITE directions — this driver ` + - `compiled IS NULL (anything but false), driver-memory's query path and driver-mongodb ` + - `compiled IS NOT NULL (anything but true), and driver-memory's matcher dropped the ` + - `constraint entirely. Note "false" the STRING is truthy, so it landed on the side opposite ` + - `the false it was written to mean.`, - subtree, - ); -} - -/** - * [#20020, the #8220 contract] What the two flag refusals above say when the - * field's operator map is not positively marked `'author'`. The operator names - * the refusal's CLASS (the `$between` precedent in {@link betweenArityError}), - * and the declaration is the capability statement; the field, the value and - * the filter path are the predicate's, and go to the server log. - */ -function nonBooleanFlagWithheldMessage(op: '$null' | '$exists'): string { - return ( - `Operator "${op}" in this filter requires a boolean comparand (true or false). ` + - `@objectstack/spec FieldOperatorsSchema declares ${op} as a boolean, and a non-boolean is ` + - 'refused rather than coerced because the backends read one in OPPOSITE directions. The ' + - 'field it was aimed at and the value it received are withheld from the message; the full ' + - 'diagnostic is in the server log.' - ); -} - -// ── [#20444] The `$empty` operator ─────────────────────────────────────────── - -/** - * [#20444] A non-boolean `$empty` comparand. `FieldOperatorsSchema` declares - * `$empty: z.boolean()`, and the spec's save door already refuses anything else - * (`filter-save-door-refusals.ts` counts it among the boolean flags beside - * `$null` / `$exists`). Refused here on the validating walk for the reason its - * two siblings are: a two-branch emitter has a DEFAULT side, and a third value - * would silently land on it. - */ -function nonBooleanEmptyComparandError( - field: string, - value: unknown, - path: string, - subtree?: unknown, -): Error { - return withheldFilterError( - 'Operator "$empty" in this filter requires a boolean comparand (true or false). ' + - '@objectstack/spec FieldOperatorsSchema declares $empty as a boolean, and a non-boolean is ' + - 'refused rather than coerced: true asks for the empty rows, false for their exact ' + - 'complement, and any other value would land on whichever side a compiler defaults to. The ' + - 'field it was aimed at and the value it received are withheld from the message; the full ' + - 'diagnostic is in the server log.', - `Operator "$empty" on field "${field}" requires a boolean comparand (true or false). ` + - `Received ${describeFilterOperand(value)} (${safeShapePreview(value)}) at ${path}. ` + - `@objectstack/spec FieldOperatorsSchema declares $empty as a boolean: true asks for the ` + - `empty rows, false for their exact complement.`, - subtree, - ); -} - -/** - * [#6050] `undefined` in a COMPARAND position. - * - * Ruled REFUSED on 2026-08-07 (ruling B on #6050), the same disposition #5347-A - * gave a non-boolean `$null`, and for a sharper version of the same reason: - * there is no reading of `undefined` here that is not a guess, and the two - * candidate readings are each other's opposite. - * - * `undefined` cannot survive a JSON round trip, so it only ever arrives from - * IN-PROCESS code — which is exactly what makes it dangerous rather than - * academic. `{ owner_id: ctx.user?.id }` with a missing id is the shape that - * produced this issue: measured on `origin/main` (`cba7454df`, four rows, `d` - * valued on 1-2 and NULL on 3-4), the SAME `TursoDriver` answered it two ways, - * chosen by the `url` it was constructed with: - * - * | filter | LOCAL (this compiler) | REMOTE (`RemoteTransport`) | - * |---|---|---| - * | `{ d: undefined }` | bare knex `Undefined binding(s)` | `['3','4']` | - * | `{ d: { $eq: undefined } }` | bare knex `Undefined binding(s)` | `['3','4']` | - * | `{ $not: { d: undefined } }` | bare knex `Undefined binding(s)` | `['1','2']` | - * | `{ d: { $ne: undefined } }` | `['1','2']` | `['1','2']` | - * | `{ $not: { d: { $ne: undefined } } }` | `[]` | `['3','4']` | - * | `{ d: { $in: [undefined] } }` | bare knex `Undefined binding(s)` | `[]` | - * | `{ d: { $gt: undefined } }` | bare knex `Undefined binding(s)` | `[]` | - * - * Two defects in one gate: - * - * 1. The thrown rows carried NO ADR-0112 envelope — knex's `Undefined - * binding(s) detected when compiling SELECT` has neither `code` nor - * `status`, so `mapDataError` served an opaque 500 for what is a caller - * mistake in a filter (#1116 / #4436 catalogued this exact shape for other - * inputs; this one was missing from the list). - * 2. The `$not: { $ne: undefined }` row is this driver contradicting ITSELF: - * the `$ne` emitter reads `coerced == null` (LOOSE — so `undefined` compiled - * `IS NOT NULL`, a TOTAL predicate), while its polarity tables - * `operatorIsNullTotal` and `nullValueSatisfiesOperator` (deleted by #20822; - * the shared lowering carries the rule now) read `value === null` (STRICT — so they - * judged the same leaf non-total AND satisfied by a NULL row). The guard - * then wrapped a total predicate in `d IS NULL OR d IS NOT NULL`, a - * tautology, whose negation is FALSE — the `[]` above. That is the #5298 - * invariant broken at its own definition: a polarity table pins the spelling - * of ITS OWN emitter. - * - * Refusing the comparand kills both at once and does it BEFORE either can act: - * knex never sees an undefined binding, and guard-vs-emitter disagreement about - * `undefined` becomes unreachable rather than merely repaired. - * - * ⛔ What deliberately does NOT move: `null`. `{ f: null }`, `{ $eq: null }`, - * `{ $ne: null }` and `$null` keep their exact behaviour — `null` IS a declared - * comparand and IS the null predicate. The refusal is about the JS value that - * the language cannot distinguish from an ABSENT key: `{ f: undefined }` and - * `{}` are the same object to every reader, and they mean opposite things (a - * predicate vs no constraint at all). - */ -function undefinedComparandError(field: string, path: string, subtree?: unknown): Error { - // [#20039, the #8220 contract] The field and the position are the - // predicate's; the repair survives redaction with a PLACEHOLDER name, as the - // zero-operator refusal's does — the shape is the repair, and it names - // nothing. `undefined` itself can never carry a mark, so `subtree` is the - // nearest object holding it (the node, the operator map, or the list). - return withheldFilterError( - `A comparand in this filter is undefined. @objectstack/spec FieldOperatorsSchema declares no ` + - `undefined comparand, and in JavaScript { "FIELD": undefined } cannot be told apart from ` + - `omitting the key — yet the two mean OPPOSITE things (a predicate versus no constraint at ` + - `all), so there is no reading of it that is not a guess. Write null if you meant the null ` + - `predicate ({ "FIELD": null } or { "FIELD": { "$null": true } }), or omit the key when the ` + - `value is genuinely absent. The field it was aimed at and where it sits are withheld from ` + - `the message; the full diagnostic is in the server log.`, - `Comparand at ${path} is undefined. @objectstack/spec FieldOperatorsSchema declares no ` + - `undefined comparand, and in JavaScript { "${field}": undefined } cannot be told apart from ` + - `omitting the key — yet the two mean OPPOSITE things (a predicate versus no constraint at ` + - `all), so there is no reading of it that is not a guess. Write null if you meant the null ` + - `predicate ({ "${field}": null } or { "${field}": { "$null": true } }), or omit the key when ` + - `the value is genuinely absent (e.g. \`if (id !== undefined) where.${field} = id\`). It is ` + - `refused rather than compiled because the backends disagreed: driver-sql handed it to knex ` + - `and got a bare "Undefined binding(s)" Error carrying no code, while Turso's remote transport ` + - `compiled it to IS NULL — so \`{ owner_id: ctx.user?.id }\` with a missing id silently ` + - `matched every env-wide row instead of failing.`, - subtree, - ); -} - -/** - * [#6050] Refuse every `undefined` sitting in a comparand position of ONE field - * constraint. - * - * The positions are enumerated rather than swept, because "comparand" is a - * position and not a type: - * - * - the DIRECT comparand — `{ d: undefined }`, the implicit `=`; - * - an OPERATOR's comparand — `{ d: { $eq: undefined } }`, `$ne`, `$gt`, the - * LIKE family, and every other single-value operator; - * - a MEMBER of a list operator's array — `{ d: { $in: [undefined] } }`, - * `$nin`, `$between`. The array itself IS `$in`'s legitimate comparand; each - * element is a comparand in its own right, which is the same split - * {@link assertCompilableComparand} makes for `$field`. - * - * Two positions are deliberately NOT swept: - * - * - `$null` / `$exists`. Their comparand is a declared BOOLEAN — a flag, not a - * value to compare against — and `undefined` there is already refused by - * {@link nonBooleanNullComparandError} / {@link nonBooleanExistsComparandError} - * with a message that names the declared domain. Re-answering it here would - * swap a better message for a worse one and give one condition two wordings, - * which is what #5240 ruled against. - * - a bare ARRAY in DIRECT comparand position (`{ d: [1, undefined] }`). An - * array is not a comparand outside a list operator, and - * {@link assertCompilableComparand} already refuses it as a whole for that - * reason; inspecting its members here would relabel a shape that is refused - * either way. - */ -function assertDefinedComparands( - field: string, - spec: unknown, - path: string, - // [#20039] The filter node carrying `field` — the only object that holds a - // DIRECT `{ field: undefined }`. The two deeper positions hand over the - // nearest object of their own: the operator map, and the list. - enclosing?: unknown, -): void { - if (spec === undefined) throw undefinedComparandError(field, path, enclosing); - if (!isFilterNode(spec)) return; - for (const [op, opValue] of Object.entries(spec)) { - if (op === '$null' || op === '$exists') continue; - const opPath = `${path}.${op}`; - if (opValue === undefined) throw undefinedComparandError(field, opPath, spec); - if (!Array.isArray(opValue)) continue; - opValue.forEach((member, index) => { - if (member === undefined) throw undefinedComparandError(field, `${opPath}[${index}]`, opValue); - }); - } -} - -/** - * [#5134] `$and`/`$or` take a list; anything else is refused, never coerced. - * - * [#20020, the #8220 contract] The operand's preview and the combinator's - * position are the predicate's, so they go to the server log unless the - * refusal's node is positively marked `'author'`. That node is the operand - * itself when it is an object (the deepest markable node), and otherwise - * `enclosing` — the filter node that carries the combinator key, which a - * primitive operand inherits its provenance from. - */ -function assertFilterNodeList( - value: unknown, - key: string, - path: string, - enclosing?: unknown, -): asserts value is unknown[] { - if (Array.isArray(value)) return; - throw withheldFilterError( - 'A filter combinator ("$and" / "$or") in this filter requires an array of filter conditions. ' + - '@objectstack/spec FilterConditionSchema declares both as FilterCondition[]. Which one it ' + - 'was, where it sits and the value it received are withheld from the message; the full ' + - 'diagnostic is in the server log.', - `Filter combinator "${key}" at ${path} requires an array of filter conditions, but received a ` + - `${describeFilterOperand(value)} (${safeShapePreview(value)}). @objectstack/spec FilterConditionSchema ` + - `declares "${key}" as FilterCondition[].`, - refusalSubtree(value, enclosing), - ); -} - -/** - * [#5134] Reduce one filter node to its boolean verdict, validating shapes on - * the way down. - * - * A node is the AND of its entries, so FALSE dominates, and a node with no - * entries at all is TRUE (the empty conjunction) — which is why `{}` is a TRUE - * disjunct inside `$or` and why `{ $not: {} }` is FALSE. - * - * This walks the WHOLE tree without short-circuiting: a `$or: []` sibling must - * not stop the walk from reaching — and refusing — a malformed node further - * along, or the shape gate would be conditional on evaluation order. - * - * Deciding structurally, rather than by compiling and then asking Knex whether - * anything came out, is deliberate. The old defect WAS an observation of - * emptiness ("the group callback added nothing"), and an observation cannot tell - * "empty because the author wrote nothing" from "empty because something failed - * to compile". A structural verdict has no such blind spot, and it lets the - * emitter guarantee that every group it opens receives at least one clause. - * - * ## [#5659] The algebra is `@objectstack/spec`'s; the REFUSALS are this driver's - * - * Everything above describes a ruling (#5322/#5134) that four consumers had to - * agree on and implemented four times — here, in `driver-mongodb`, in - * `driver-memory`'s matcher, and nearly a fifth time inside `@objectstack/lint`, - * which declined to hand-write it and filed #5659 instead. The reduction now - * lives once, in {@link reduceFilterVerdict}, proven against the same - * `FILTER_LOGIC_CASES` table this driver's conformance suite runs. - * - * What stays here is what is genuinely this driver's: WHICH shapes it refuses - * and with which message. They are handed to the shared walk as - * {@link SQL_FILTER_VERDICT_HOOKS} and are invoked from exactly the positions - * they were invoked from before, so no wording, code or status moved — the - * conformance case-set is green on both sides of the change. - */ -function reduceFilterNode(node: Record, path: string): FilterVerdict { - return reduceFilterVerdict(node, { ...sqlFilterVerdictHooks(node, path), path }); -} - -/** - * [#5134] The verdict of ONE key of a filter node. `enclosing` is the node the - * key belongs to — see {@link sqlFilterVerdictHooks} for what reads it. - */ -function reduceFilterKey(key: string, value: unknown, path: string, enclosing?: unknown): FilterVerdict { - return reduceFilterKeyVerdict(key, value, { ...sqlFilterVerdictHooks(enclosing, path), path }); -} - -/** - * [#5659] This driver's half of the reduction: the shape refusals, at the - * positions the shared walk visits them. - * - * `assertFilterNodeList` / `assertFilterNode` are wrapped in arrows rather than - * passed by reference because they are TypeScript assertion functions, whose - * narrowing is meaningless — and whose declaration requirements are a nuisance - * — through a property reference. Nothing else about the call changes. - * - * [#20020] Built per reduction rather than shared, because the non-list - * combinator refusal needs the NODE that carries the combinator key, and the - * shared walk hands `assertNodeList` only the operand, the key and the path. - * The node is recovered from the path without widening that spec-declared hook - * signature: every node the walk reduces is either `root` (at `rootPath`) or - * an element / `$not` operand it announced through `assertNode` at the exact - * path it then reduces it under — so recording those announcements is a - * complete path-to-node index for the positions `assertNodeList` can name. The - * index is keyed only by paths built from combinator keys and list indices, - * never by a field name, so a field key containing a dot cannot collide with it. - * - * [#20039] Every refusal the walk raises now asks the same index, because every - * one of them is withheld through the #8220 seam and needs the node it was - * raised FROM: `classifyKey` gets the node carrying the key (the only object - * holding a direct `{ field: undefined }`, and the owner of an undeclared - * `$`-key), and `assertNode` gets what ENCLOSES a non-node element — the - * `$and` / `$or` list, recorded as `assertNodeList` announces it (the walk - * announces a list before it visits a single element), or the node carrying - * `$not`. Lists are kept in their own map, keyed by the combinator key's - * position, so the node index above stays exactly what it was. - */ -function sqlFilterVerdictHooks(root: unknown, rootPath: string): FilterVerdictHooks { - const nodeAt = new Map([[rootPath, root]]); - const listAt = new Map(); - // The walk names a key's position `${nodePath}.${key}` (bare `key` under an - // empty prefix), so stripping the key recovers the node's own path. - const nodeCarrying = (key: string, path: string): unknown => - nodeAt.get(path === key ? '' : path.slice(0, path.length - key.length - 1)); - return { - assertNodeList: (value, key, path) => { - listAt.set(path, value); - assertFilterNodeList(value, key, path, nodeCarrying(key, path)); - }, - assertNode: (value, path) => { - nodeAt.set(path, value); - // An element's position is `${listPath}[${index}]`; the `$not` operand's - // is the key's own position, so its enclosing node is the one carrying it. - const element = /\[\d+\]$/.exec(path); - assertFilterNode( - value, - path, - element ? listAt.get(path.slice(0, element.index)) : nodeCarrying('$not', path), - ); - }, - classifyKey: (key, value, here) => classifyFilterKey(key, value, here, nodeCarrying(key, here)), - }; -} - -/** - * [#5134] The verdict of ONE **non-combinator** key — and this driver's gate on - * everything a field constraint may not be. - * - * `here` is the already-joined path of the key, exactly as the reduction hands - * it over; the three combinator arms this used to open with are the shared - * walk's now, and the refusals below are unchanged from when they sat under - * them. - */ -function classifyFilterKey( - key: string, - value: unknown, - here: string, - // [#20039] The filter node this key belongs to — see - // {@link sqlFilterVerdictHooks}. Absent means withheld for every refusal - // below that would have used it, the fail-closed direction. - enclosing?: unknown, -): FilterVerdict { - // [#5348] Everything still `$`-prefixed at this point is an UNDECLARED - // combinator — the shared walk resolved the three declared ones before this - // key ever reached the hook (#5659). Refused here and - // not in the emitter for exactly the reason the two lines below are here, and - // the reason #5327 gave for `{ field: {} }`: this walk is exhaustive and does - // not short-circuit, while the emitter is skipped wholesale by a boolean - // identity. `{ $or: [ {}, { $where: '…' } ] }` reduces to TRUE on its first - // disjunct, so an emitter-side gate would refuse the `$where` or ignore it - // depending on its SIBLINGS — "a gate conditional on evaluation order", which - // this function's own doc comment warns against. - // - // It must also come BEFORE the field arms below, because that is precisely - // what those arms did wrong: they accepted `$where` as a field name. - if (key.startsWith('$')) throw unknownLogicalOperatorError(key, here, enclosing); - - // [#5240] `{ field: {} }` is refused HERE — on the validating walk, beside - // `assertFilterNode` / `assertFilterNodeList` — rather than in the emitter - // below, for the same reason those two sit here: the walk is exhaustive and - // does NOT short-circuit, so the refusal cannot be skipped by an identity that - // resolves the enclosing node first. Gating it in the compile branch instead - // would let `{ $or: [ { a: {} }, {} ] }` slip through untouched — the `{}` - // disjunct reduces the whole `$or` to TRUE, the emitter returns before it ever - // reaches `{ a: {} }`, and the shape would be refused or ignored depending on - // its SIBLINGS. That is the "gate conditional on evaluation order" this - // function's own doc comment warns against. - // - // Note what is deliberately NOT changed: the VERDICT. A field key still - // contributes `'clause'`, exactly as #5134 classified it. This adds a refusal, - // it does not reclassify a surviving shape, so every filter that compiled - // before compiles byte-identically now. - // [#8197] `value` IS the `{}` — an object, so it is markable and findable by - // identity under the query's `where` root. The reduction walks the same tree - // the merge boundaries marked, by reference, so nothing is copied between the - // mark and this throw. - if (isEmptyFieldConstraint(value)) throw emptyFieldConstraintError(key, here, value); - - // [#6050] `undefined` in a comparand position, refused on THIS walk for the - // two reasons the walk exists — it is exhaustive and it runs FIRST. - // - // "First" is the whole design here, not a preference. Both defects #6050 - // measured live downstream of this line: the emitter hands an undefined bind - // to knex (a bare `Undefined binding(s)` Error, outside ADR-0112), and the - // polarity tables of the `$not` rewrite this driver carried until #20822 - // (the shared lowering's rule 3 now) read it `=== null`, disagreeing with - // the `$ne` emitter's `== null` about exactly this value. - // `applyFilterCondition` runs the whole reduction before - // it reaches either, so a refusal raised here makes BOTH unreachable rather - // than repaired — and the polarity tables never have to answer a question - // nobody ruled on. See {@link undefinedComparandError} for the measured - // local/remote matrix and why `null` is untouched. - assertDefinedComparands(key, value, here, enclosing); - - // [#7929, maintainer ruling 2026-08-12] A `{ $field }` comparand at any - // operator OUTSIDE the six that compile one is the CROSS-FIELD refusal — - // answered here, ahead of the comparand-SHAPE gates below. - // - // Not a new refusal: every one of these shapes was already refused, and the - // set of accepted filters is byte-identical. What changes is WHICH refusal - // answers, and that is the point. `$icontains`, `$like`/`$ilike`, `$null` and - // `$exists` each carry their own comparand-shape gate on this walk, and each - // gate renders the offending comparand into its message — so a field - // reference came back to the caller as `received {"$field":"…"}`, naming the - // referenced column, while the same reference at `$contains` (no gate here, - // refused at the emitter) was answered by the cross-field refusal instead. - // Measured on `origin/main`: five operators disclosing, four not, decided by - // nothing a caller can see. One condition — "this comparand is a reference to - // another field, in a position SQL push-down does not compile" — now has one - // answer, and that answer is the redacted one. - // - // The six scalar comparison operators are skipped because a reference IS - // their compiled meaning ({@link SqlDriver.applyCrossFieldComparison}); their - // own boundary refusals are raised there with the same withhold. - if (isFilterNode(value)) { - for (const [op, comparand] of Object.entries(value)) { - if (CROSS_FIELD_COMPARISON_OPERATORS.has(op)) continue; - const ref = fieldReferenceOf(comparand); - if (ref !== null) throw crossFieldComparisonError(key, op, ref, comparand); - // [#25808] A now-anchored comparand under any other operator, likewise. - if (nowAnchorOf(comparand) !== null) throw nowAnchorPositionError(key, op, comparand); - } - } - - // [#5347] `$null`'s comparand is a boolean by declaration. Checked on this - // walk rather than in the emitter's `$null` arm for the same - // evaluation-order reason, and checked on the RAW value so the message names - // the shape the caller sent rather than whatever `coerceFilterValue` made of - // it. Only `$null` is inspected: the surrounding operator vocabulary is the - // emitter's `default: throw` to enforce, and widening this walk into a second - // vocabulary gate is how two lists drift apart (#3948). - // `hasOwnProperty` rather than `'$null' in value` so an inherited key can - // never trip the gate, and rather than `Object.hasOwn` because this package - // targets es2020. `{ $null: undefined }` still counts: the key is own and - // enumerable, and `undefined` is exactly one of the comparands the issue - // measured a divergence on. - if ( - isFilterNode(value) && - Object.prototype.hasOwnProperty.call(value, '$null') && - typeof value.$null !== 'boolean' - ) { - throw nonBooleanNullComparandError(key, value.$null, `${here}.$null`, value); - } - - // [#5369] `$exists`'s comparand is a boolean by the same declaration, refused - // on the same walk, for the same evaluation-order reason. Kept as a separate - // `if` rather than folded into a loop over a two-name list: each operator gets - // its own message naming its own emitter's default direction, and a shared - // loop would be the start of the second vocabulary gate the block above warns - // about (#3948). - if ( - isFilterNode(value) && - Object.prototype.hasOwnProperty.call(value, '$exists') && - typeof value.$exists !== 'boolean' - ) { - throw nonBooleanExistsComparandError(key, value.$exists, `${here}.$exists`, value); - } - - // [#20444] `$empty`'s comparand is a boolean by the same declaration, refused - // on the same walk for the same evaluation-order reason — its own `if`, not a - // loop over a flag list, for the reason the `$exists` gate above gives. - if ( - isFilterNode(value) && - Object.prototype.hasOwnProperty.call(value, '$empty') && - typeof value.$empty !== 'boolean' - ) { - throw nonBooleanEmptyComparandError(key, value.$empty, `${here}.$empty`, value); - } - - // [#5702] `$icontains`'s comparand is a NON-EMPTY string by declaration, - // refused on this walk for the same evaluation-order reason as the two gates - // above: an empty comparand makes the predicate match every row, and a gate - // the emitter carries alone is skipped wholesale whenever a boolean identity - // settles the enclosing node — so `{ $or: [ {}, { name: { $icontains: '' } } ] }` - // would be refused or silently widened depending on its SIBLINGS. - if ( - isFilterNode(value) && - Object.prototype.hasOwnProperty.call(value, '$icontains') && - (typeof value.$icontains !== 'string' || value.$icontains === '') - ) { - throw icontainsComparandError( - key, - value.$icontains, - `${here}.$icontains`, - refusalSubtree(value.$icontains, value), - ); - } - - // [#7536] `$like` / `$ilike` carry a PATTERN, gated on this same walk and for - // the same evaluation-order reason as the three above. - // - // Note what is NOT refused: an EMPTY pattern. `$icontains: ''` is refused - // because every row contains the empty substring, so it constrains nothing — - // but `LIKE ''` matches only the empty string, which is a narrow and perfectly - // well-formed predicate. Copying the sibling's rule here would refuse a - // legitimate query. - if (isFilterNode(value)) { - for (const op of LIKE_PATTERN_OPERATORS) { - if (!Object.prototype.hasOwnProperty.call(value, op)) continue; - const pattern = (value as Record)[op]; - // [#20039] The comparand when it is an object, else the operator map. - if (typeof pattern !== 'string') { - throw likePatternComparandError(key, op, pattern, `${here}.${op}`, refusalSubtree(pattern, value)); - } - if (hasDanglingLikeEscape(pattern)) { - throw danglingLikeEscapeError(key, op, pattern, `${here}.${op}`, value); - } - // [#20041] A pattern holding U+0000 — SQLite's GLOB cuts it there. The - // operator map is the node, as for the dangling escape. - if (hasNulInLikePattern(pattern)) { - throw nulLikePatternError(key, op, pattern, `${here}.${op}`, value); - } - } - } - - // A field key always contributes a predicate. - return 'clause'; -} - -/** - * [#24707] PostgreSQL only: the session's `search_path` reaches one table name - * in more than one schema, and {@link SqlDriver.introspectSchema} keys its - * answer by bare name — one key cannot hold two tables. - * - * This used to be silent: the later table was overwritten by the earlier one - * (`sales.customers` vanished behind `public.customers`), and nothing in the - * answer said a table was missing. Keeping the first and logging the second - * is not loud either — the HTTP and AI callers of the listing never see a - * server log, so to them it is the same silent drop. The answer is a refusal - * naming every colliding name with its schemas in `search_path` order, and - * the remedy: one schema per connection (the datasource's `schema` setting is - * what sets its `search_path`), or a rename. A connection that reaches one - * schema — the default `public` deployment — can never collide, so no caller - * that sees one schema is refused. - * - * `collisions` is non-empty; each entry's `schemas` has two or more members. - */ -export function postgresTableNameCollisionError(collisions: ReadonlyArray<{ table: string; schemas: string[] }>): Error { - const list = (names: string[]) => names.map((s) => `'${s}'`).join(', '); - const [first] = collisions; - const err = new Error( - `Cannot introspect this PostgreSQL connection: its search_path reaches ${collisions.length} table name(s) ` + - `in more than one schema, for example '${first.table}' in ${list(first.schemas)}. ` + - `Introspection keys each table by its bare name, so one key cannot hold both, and the bare name ` + - `resolves only to '${first.schemas[0]}.${first.table}'; listing it once would hide the other ` + - `table and its columns. Colliding: ` + - collisions.map((c) => `'${c.table}' (${list(c.schemas)})`).join('; ') + - `. Point the connection at one schema (the datasource's 'schema' setting sets its search_path), ` + - `or rename one of the tables.`, - ) as Error & { code?: string; status?: number }; - err.code = StandardErrorCode.enum.RESOURCE_CONFLICT; - err.status = 409; - return err; -} - -// ── Introspection Types ────────────────────────────────────────────────────── - -/** - * These are DERIVED from `packages/spec/src/contracts/schema-diff-service.ts`, - * never re-declared beside it. - * - * They used to be a second, independent declaration that happened to describe - * the same idea in a different vocabulary: this file spelled a column's key - * membership `isPrimary?`, the spec spells it `primaryKey`; the spec also - * declares `dialect` and a REQUIRED `introspectedAt` that this file's schema - * type did not mention and `introspectSchema` therefore never emitted. `plugin.ts` hands - * this driver's result straight to `ExternalDatasourceService`, which is typed - * against the spec — so the consumer read a key no driver ever set and every - * federated object drafted from a remote table silently lost its primary key. - * Nothing was type-unsound; the two contracts simply never met a compiler. - * - * Maintainer ruling, 2026-08-22 (live session, 「同意所有」 item 9 = - * 驱动侧对齐 spec 契约): `packages/spec` is the one contract and the DRIVER - * aligns to it. Deriving rather than copying is what makes that mechanical — - * a key added to the spec contract now fails this file's `tsc` until the - * driver emits it, which is exactly the failure that was missing. - * - * The `Omit` carve-outs that used to mark two remaining divergences - * (`defaultValue`, `indexes`) are RETIRED (#11122, maintainer ruling - * 2026-08-23 option B): the spec now declares `defaultValue` as the raw - * `unknown` this driver actually reports and `indexes` as optional, so both - * keys are inherited straight from the spec contract. What this layer still - * adds is EXTRA facts only — per-column `isUnique` / `maxLength`, per-table - * `foreignKeys` / `primaryKeys` — never a second spelling of a spec key. - */ -export interface IntrospectedColumn extends SpecIntrospectedColumn { - /** - * SQL-introspection extra: the column ALONE carries a single-column unique - * constraint — it is true iff some unique constraint covers this column and - * nothing else. - * - * Membership of a COMPOSITE constraint is deliberately not represented on - * this flag (#11202, maintainer ruling 2026-08-23 option A→B). `UNIQUE - * (a, b)` constrains the pair, and a per-column boolean cannot say that; - * setting it on both members would assert that each column is unique on its - * own, which is a different — and false — claim, and one a consumer acts on - * (`introspectedSchemaToObjects` in `@objectstack/objectql` turns this flag - * into a drafted field's `unique: true`). A real composite surface is - * option B and waits for demand; until it exists, an absent flag on a - * composite member means "not single-column unique", never "no constraint". - * - * A PRIMARY KEY is NOT a unique constraint to this flag (#11654), on any - * dialect and for any key type. `isUnique` means a *declared* single-column - * UNIQUE constraint; key membership has a lossless face of its own - * ({@link IntrospectedTable.primaryKeys} and `primaryKey` below), so - * excluding keys keeps the two flags non-overlapping and drops no fact. Note - * this is a statement about what KIND of constraint the flag reports, never - * a claim that a key column admits duplicates. A key column that separately - * carries its own single-column unique constraint is still flagged — the - * constraint is what is being reported, not the column. - * - * All three dialect arms of {@link SqlDriver.introspectUniqueConstraints} - * produce it through one predicate — see {@link singleColumnUniqueColumns}. - */ - isUnique?: boolean; - /** - * SQL-introspection extra: declared maximum length for string types — raw - * as knex `columnInfo()` reports it, which is a NUMBER on some dialects - * and a STRING on SQLite (measured live, 2026-08-23: `"255"` for a - * `t.string(…)` column). Consumers narrow via `typeof`, as - * `schema-drift.ts`'s varchar differ already does. - */ - maxLength?: number | string; -} - -/** - * No spec counterpart — foreign keys are a SQL-introspection extra. - * - * ## A composite key is ORDERED SIBLING ROWS, and that order is load-bearing - * - * This is deliberately a FLAT PER-COLUMN record with no ordinal field (#11324). - * An N-column foreign key is N records, and the contract every arm of - * {@link SqlDriver.introspectForeignKeys} owes is that those records arrive - * **contiguous, in declared key order, each pairing its own child column with - * its own parent column** — `(x, y) references p (a, b)` is exactly - * `x -> p.a` then `y -> p.b`. That is what makes the ordinal unnecessary rather - * than merely absent: the position in the array carries it. - * - * The reason it is written down here is that it was NOT true and nothing said - * so. The Postgres arm joined child columns to parent columns with no ordinal - * correlation and returned the N x N cartesian product — for the key above, - * four records including the phantom `x -> p.b` and `y -> p.a`, which no - * consumer of this flat shape can tell from the real two. Widening the record - * with an ordinal was considered and rejected: it would have let a wrong ORDER - * BY keep shipping wrong rows that merely *describe* their wrongness, where the - * pairing is a fact the query itself must get right. - */ -export interface IntrospectedForeignKey { - columnName: string; - /** - * The parent's BARE relation name, always — never conditionally qualified - * (#11377). When the parent lives outside the session's resolution scope, - * the qualification arrives as the separate {@link referencedSchema} key - * rather than as a `"schema.table"` spelling here: a key whose spelling - * depends on context is exactly the shape a consumer mis-parses. - */ - referencedTable: string; - referencedColumn: string; - constraintName?: string; - /** - * The parent table's schema, PRESENT WHEN AND ONLY WHEN the parent lives - * outside the session's own resolution scope — i.e. when the bare - * {@link referencedTable} name is NOT the name this session would resolve - * to that table (#11377). For an in-scope parent the key is ABSENT (never - * `undefined`), so an answer that carries it is saying something. - * - * Until #11324 a cross-schema foreign key contributed zero rows on - * Postgres, so this fact was unreachable; repairing that made the - * constraint visible under a bare name the session's `search_path` does - * not resolve — an answer true of the constraint and unusable as an - * address, or worse, one that collides with a same-named table in the - * current schema (the #11201 family). A consumer that turns - * `referencedTable` into an object reference MUST read this key and refuse - * to wire the bare name when it is present - * (`convertIntrospectedSchemaToObjects` in `@objectstack/objectql` is the - * reference consumer). - * - * Per dialect arm: - * - **Postgres**: the parent's schema, filled when that schema is not in - * `current_schemas(false)` — the same `search_path` scoping every other - * statement in the session resolves bare names against (#11201). - * - **MySQL**: filled symmetrically — the parent's database - * (`REFERENCED_TABLE_SCHEMA`) when it differs from the session's default - * database (`DATABASE()`); InnoDB permits cross-database foreign keys, - * and `KEY_COLUMN_USAGE` carries the parent's database on the same row - * the arm already reads. - * - **SQLite**: never present — SQLite has no schemas, and a foreign key - * cannot cross an ATTACHed database at all, so the fact this key carries - * cannot exist there. - */ - referencedSchema?: string; -} - -/** - * `indexes` is inherited from the spec contract, where it is OPTIONAL and - * absence is meaningful (#11122): this driver does not introspect indexes, - * so `introspectSchema` deliberately OMITS the key — `indexes: []` would - * tell a schema differ that a table HAS none when it merely was not asked, - * a worse answer than an absent key. Wiring the real read - * ({@link SqlDriver.introspectIndexes} exists) is explicitly a separate - * decision with its own `onFailure` ruling. - */ -export interface IntrospectedTable extends SpecIntrospectedTable { - columns: IntrospectedColumn[]; - /** SQL-introspection extra: outbound foreign keys. */ - foreignKeys: IntrospectedForeignKey[]; - /** SQL-introspection extra: the table's primary-key columns, in key order. */ - primaryKeys: string[]; -} - -/** - * `dialect` and `introspectedAt` are inherited from the spec contract, where - * `introspectedAt` is REQUIRED — so `tsc` now refuses an `introspectSchema` - * that omits them, which is the check that was missing while this type - * declared `{ tables }` alone. - */ -export interface IntrospectedSchema extends SpecIntrospectedSchema { - tables: Record; -} - -// ── Window Function Types (driver-private, #6212) ──────────────────────────── - -/** - * One entry of {@link SqlWindowFunctionQuery.windowFunctions} — the flat shape - * {@link SqlDriver.findWithWindowFunctions} actually reads. - * - * This type lives HERE, not in `packages/spec`, deliberately. #4286 retired the - * spec's window cluster (`WindowFunctionNodeSchema` and friends) precisely - * because it declared `field` / `over` / `frame` members this door never read — - * a vocabulary describing an input no executor accepts. Re-adding a window - * vocabulary to the spec would undo that judgement; window functions are a - * SQL-driver-private capability (the door is not on `IDataDriver`), so the - * driver declares its own shape at the layer that owns it. The spec's own - * removal note names this shape verbatim — `{ function, alias, partitionBy?, - * orderBy? }`, `packages/spec/src/data/query.zod.ts` — as does the published - * migration prescription (`query-window-functions-retired` in - * `packages/spec/src/migrations/registry.ts`), which points embedders at this - * door. Those two texts and this type must keep saying the same thing. - * - * Every member is what {@link SqlDriver.buildWindowFunction} consumes and - * nothing else: - * - `function` is emitted as `FUNC()` — uppercased, ARGUMENT-LESS. `lag(revenue)` - * renders as `LAG()`; the builder has no argument slot, which is why there is - * no `field` member to declare (the skills' aggregation rules say the same). - * - `orderBy`'s `order` is optional here even though a `DriverQuery`'s top-level - * `SortNode` requires it: the builder reads `s.order || 'asc'`, so absence is - * a spelling this door genuinely accepts. Declaring it required would reject - * input that works. - */ -export interface SqlWindowFunctionSpec { - /** - * Window function name, emitted argument-less and uppercased (`rank` → `RANK()`): - * one of `row_number`, `rank`, `dense_rank`, `percent_rank`, `cume_dist`, in any - * case. Any other name is refused with `INVALID_QUERY` (400). - */ - function: string; - /** Column alias the computed value is projected as. */ - alias: string; - /** `PARTITION BY` targets, mapped through the driver's storage-name mapping. */ - partitionBy?: string[]; - /** `ORDER BY` inside the `OVER (…)` clause; `order` defaults to `asc`, and any value but `asc` / `desc` (any case) is refused. */ - orderBy?: { field: string; order?: 'asc' | 'desc' }[]; -} - -/** - * The query {@link SqlDriver.findWithWindowFunctions} takes: a - * {@link DriverQuery} — the contract shape, minus the redundant `object` the - * first argument already carries (#5181) — carrying this driver's private - * `windowFunctions` array. - * - * `windowFunctions` is `Omit`ed off `DriverQuery` before being re-declared - * because the spec key is a `retiredKey()` TOMBSTONE: `QueryAST['windowFunctions']` - * resolves to `undefined`, so a plain intersection would leave the property - * unwritable and this door's own documented payload would not compile. The - * tombstone is correct — the REQUEST surface really has no window functions — - * and this type is what keeps the driver-level door open without reopening it. - */ -export type SqlWindowFunctionQuery = Omit & { - windowFunctions?: SqlWindowFunctionSpec[]; -}; - -// ── Configuration Types ────────────────────────────────────────────────────── - -/** - * SqlDriver configuration — passed directly to Knex. - * See https://knexjs.org/guide/#configuration-options - * - * `schemaMode` (ADR-0015) is an ObjectStack-level concern, not a Knex - * option: it is stripped before constructing the Knex instance and gates - * all schema-mutating DDL. Defaults to `'managed'` when omitted, preserving - * legacy behaviour. - */ -/** - * How a stored column value must be reshaped to become the value a `find()` row - * presents. One entry per rule `formatOutput` applies to a scalar column; the - * read paths that bypass `formatOutput` (`aggregate`, `distinct`) name the rule - * per column instead. See {@link SqlDriver.readPresentationKind}. - * - * `audit_timestamp` is the builtin `created_at` / `updated_at` rule — - * {@link presentAuditTimestampOutput}, the presenter `formatOutput` applies to - * those two columns — kept apart from `datetime` because the two presenters - * differ on a number: the datetime fold turns an epoch INTEGER into ISO text, - * the audit presenter passes it through (ADR-0074 §3), and a `find()` row - * takes the latter ([ADR-0053 D-F1], #13973). - */ -export type ReadPresentationKind = 'datetime' | 'date' | 'time' | 'boolean' | 'number' | 'audit_timestamp'; - -/** - * Journal modes the driver knows how to ask a file-backed SQLite database for. - * See {@link SqlDriver.applySqliteJournalMode} for why `'wal'` is the default - * and what `'delete'` is for. - */ -export type SqliteJournalMode = 'wal' | 'delete'; - -/** - * What to do when a **file-backed** SQLite datasource points at a path that - * does not exist yet (#6743). - * - * - `'create'` — SQLite's own behaviour, and the default: the file is brought - * into existence by the first statement on the connection. - * - `'empty-in-memory'` — do NOT create it. Open an ephemeral `:memory:` - * database instead, which introspects as a database with zero tables — the - * same thing a freshly-created empty file introspects as. For a read-only - * caller (`os migrate plan`) the report is therefore identical while the - * filesystem is left untouched. Only a file that `'create'` could bring into - * existence counts as absent: one that can be neither opened nor created is - * refused, as `'create'`'s connect refuses it (#24961) — see - * {@link resolveSqliteAbsentFileTarget}. - * - * `'empty-in-memory'` is for callers that only ever READ. A caller that may - * write MUST leave it at `'create'`: writes land in the ephemeral database and - * are lost at disconnect. `os migrate apply` is exactly that caller — it boots - * with `deferSchemaDdl` too, but flushes the deferred DDL after confirmation, - * so it must keep `'create'`. Deferring the DDL and never opening the file are - * different promises; #3917 made the first, this makes the second. - */ -export type SqliteAbsentFileMode = 'create' | 'empty-in-memory'; - -/** - * Resolve the filename a SQLite connection should actually open, given the - * declared target and the {@link SqliteAbsentFileMode} the host asked for. - * - * The ONE place the "does the target exist?" judgement is made (#6743). It is - * exported so the service layer's native→wasm step-down can resolve the same - * answer for its fallback rung rather than open-coding a second `existsSync` - * that is free to drift — the two-places objection that ruled out doing this - * check up in the CLI. - * - * Pseudo-filenames (`:memory:` and anything else `:`-prefixed) and non-file - * dialects are already fileless, so they are returned untouched. - * - * ## Absent is not the same as unopenable (#24961) - * - * Only a file a boot that may write could bring into existence is absent. One - * that can be neither opened nor created — a path under a regular file, a - * directory this process may not write — is refused here with the - * `SqliteDatabaseFileError` naming the file and the operating system's answer, - * the refusal `SqlDriver.connect()` gives the same path on a boot that writes. - * The judgement writes nothing: see `probeAbsentSqliteDatabaseFile` - * (`sqlite-database-file.ts`), which also names the one shape it cannot see - * without a write. It used to be - * `existsSync`, which answers `false` for every path it cannot stat, so a - * read-only boot opened an empty in-memory database over a file that cannot - * exist and reported the datasource connected. - * - * @throws SqliteDatabaseFileError under `'empty-in-memory'`, for a file that - * can be neither opened nor created. Both callers get the same answer: the - * driver's constructor, and the service layer's wasm rung. - */ -export function resolveSqliteAbsentFileTarget( - filename: string, - mode: SqliteAbsentFileMode | undefined, -): { filename: string; openedEmptyInMemory: boolean } { - if (mode !== 'empty-in-memory') return { filename, openedEmptyInMemory: false }; - if (typeof filename !== 'string' || filename === '' || filename.startsWith(':')) { - return { filename, openedEmptyInMemory: false }; - } - if (probeAbsentSqliteDatabaseFile(filename) === 'exists') return { filename, openedEmptyInMemory: false }; - return { filename: ':memory:', openedEmptyInMemory: true }; -} - -export type SqlDriverConfig = Knex.Config & { - schemaMode?: SchemaMode; - /** - * Dev-only schema auto-reconcile (issue #2186). When `'safe'`, `initObjects` - * automatically applies *non-destructive* alters (relax NOT NULL, widen - * varchar) so an existing database self-heals after a metadata change - * loosens a constraint. `'off'` (default) only warns. Never applies - * destructive DDL, and is force-disabled when `NODE_ENV==='production'`. - */ - autoMigrate?: 'off' | 'safe'; - /** - * Journal mode for a **file-backed** SQLite database (#3941). Defaults to - * `'wal'`, which is what lets a dev server and a CLI command share one file - * without serializing against each other. `'delete'` restores SQLite's - * built-in rollback journal — the escape hatch for a database on a network - * filesystem, where WAL cannot work. Ignored for `:memory:` and for - * non-SQLite dialects; overridable per deployment with - * `OS_DATABASE_SQLITE_JOURNAL_MODE`. - * - * @see {@link SqlDriver.applySqliteJournalMode} - */ - sqliteJournalMode?: SqliteJournalMode; - /** - * What to do when the **file-backed** SQLite target does not exist (#6743). - * Defaults to `'create'` — SQLite's own behaviour, and what every existing - * caller gets, since this option changes nothing unless it is passed. - * - * Ignored for `:memory:` and for non-SQLite dialects, neither of which can - * bring a database file into existence. - * - * @see {@link SqliteAbsentFileMode} - * @see {@link SqlDriver.sqliteOpenedEmptyInMemory} - */ - sqliteAbsentFile?: SqliteAbsentFileMode; - /** - * Has THIS deployment completed the ADR-0104 column move for the file family - * — the step that retypes the media columns and rewrites the values they - * hold into the bare-id encoding (`sys_migration.columns_moved_at`)? - * - * ⛔ This is NOT the `adr-0104-file-references` flag and must never be wired - * to it alone. Every creation-attested store since 17.0 — including every - * dogfood boot — and every deployment that ran `os migrate - * files-to-references --apply` before the column step existed carries that - * flag AND JSON-quoted ids in a JSON column. The flag alone therefore cannot - * key the write arm; `columns_moved_at` is the evidence that the columns - * themselves moved, written by the same act that moves them. - * - * ## Absence is the JSON arm, and that is the whole safety property - * - * Omitted, `false`, a resolver that throws, a resolver that never runs — all - * four answer "not moved", so the driver keeps today's JSON encoding. Every - * row that exists in the world today lacks `columns_moved_at`, and a host - * that has not been threaded this option at all is indistinguishable from - * one whose deployment has not moved. That is the required direction: a - * driver that guessed "moved" would write bare ids into a JSON column. - * - * A function is resolved ONCE per driver instance, at {@link - * SqlDriver.initObjects}, and memoized — the flag lives in `sys_migration`, - * which does not exist until schema sync has run, so it cannot be read at - * construction time. A host outside that path (`skipSchemaSync`, - * `registerObjectMetadata`-only) simply never resolves it and stays on the - * JSON arm, which is again the correct fail-toward. - * - * ## Naming it at all — in EITHER polarity — shuts out the engine's supply - * - * The ordinary composition does not set this key: `ObjectQL.registerDriver` - * hands the driver a resolver over `sys_migration.columns_moved_at` instead - * (see {@link SqlDriver.setFileColumnsMovedResolver}). Setting it here says - * the host knows its own storage better than the ledger does, so the engine - * will not contradict it — and that is true of `false` as much as of `true`, - * since a declared `false` overruled to `true` is bare ids in a JSON column. - * - * @see {@link SqlDriver.setFileColumnsMovedResolver} - */ - fileColumnsMoved?: boolean | (() => boolean | Promise); -}; - -// ── SQL Driver ─────────────────────────────────────────────────────────────── +// GLOBAL_TENANT ('__global__') — the sentinel for the NULL-organization +// ("platform") bucket — is defined ONCE in schema-drift.ts and imported here: +// since ADR-0120 D3 it names the same bucket in two subsystems (the autonumber +// sequence table's tenant key, and the COALESCE organization key part of every +// organization-scoped unique index), and #3696's root lesson was two +// subsystems naming one concept differently. Storage stays NULL — see the +// definition site. /** - * SQL Driver for ObjectStack. + * Field types whose value is an array or object and must be stored as a JSON + * column (and JSON-(de)serialized at the driver boundary). SINGLE SOURCE for + * both the DDL column-type switch and `isJsonField` so the two can't drift — + * the drift between them is exactly what let array-valued fields (multiselect/ + * checkboxes/tags/repeater/vector) reach the SQLite binder un-serialized and + * crash with "SQLite3 can only bind numbers, strings, bigints, buffers, and + * null" (#field-zoo). `composite`/`address`/`location`/`record` are objects; + * the rest are arrays. * - * Implements the IDataDriver contract via Knex.js for optimal SQL - * generation against PostgreSQL, MySQL, SQLite and other SQL databases. - */ -/** - * One column's contribution to MySQL's per-ROW budget over DECLARED widths - * (#11565). + * ⚠️ The FILE family (`image`/`file`/`avatar`/`video`/`audio`) was a member + * until the ADR-0104 addendum ruled that its physical column holds the BARE + * `sys_file` id in a STRING column rather than a JSON-quoted id in a JSON + * column. Its membership is no longer a constant: a SINGLE-VALUE media field + * is a JSON column on a deployment that has not moved its columns and a string + * column on one that has, so the question is asked per driver instance through + * {@link SqlDriver.mediaColumnIsJson} rather than of this set. A + * `multiple: true` media field is unaffected — its value is a LIST of ids, it + * is a JSON column on every deployment, and `isMultiValueField` already says so + * above every type check (`file` / `image` are `MULTI_CAPABLE_TYPES` members; + * #17469 refuses `multiple: true` on `avatar` / `video` / `audio` at the + * authoring entrance rather than arrayifying their column). * - * `chars` is the `varchar(n)` width {@link SqlDriver.createColumn} emits for - * the field; `bytes` is what the server charges for it — the payload - * (`chars x bytesPerChar`) plus varchar's own length prefix. + * ⛔ Do not re-add the family here. Every reader of this set treats membership + * as deployment-independent, which is exactly what the family stopped being. */ -interface RowWidthContribution { - name: string; - chars: number; - bytes: number; -} - -/** The part of a better-sqlite3 `Database` that {@link reclaimBetterSqlite3} drives. */ -interface BetterSqlite3Connection { - exec(sql: string): unknown; - pragma(source: string, options?: { simple: boolean }): unknown; -} +export const JSON_COLUMN_TYPES = new Set([ + // Spec value-shape classes (ADR-0104 D1): structured JSON payloads and the + // inherently-array option types. Membership is owned by @objectstack/spec — + // a type added there becomes a JSON column here without touching this file. + ...STRUCTURED_JSON_TYPES, ...MULTI_OPTION_TYPES, + // Driver-internal aliases (external/introspected columns) — not authorable + // FieldTypes, so they stay a local extra. + 'object', 'array', +]); /** - * The better-sqlite3 arm of `SqlDriver.reclaimSpace`: return the whole freelist, - * and return the bytes it passes through the `-wal` sidecar too, without ever - * waiting on another connection. Every statement runs through the binding's - * `exec()` / `pragma()`, which step to completion. Synchronous by design: - * nothing else runs on the connection between the chunks, so the freelist - * only shrinks while the loop runs. - * - * Why chunks. In WAL mode one `PRAGMA incremental_vacuum` over a large - * freelist is one transaction whose dirty pages outgrow the page cache, so - * SQLite spills them into the WAL before the commit truncates them away. - * Measured at 25,754 free pages: the database file went to 16,384 bytes and - * the `-wal` sidecar to 94,430,432, held until the last connection closed. A - * chunk that stays inside the page cache writes only the pages its commit - * keeps. The chunk is a quarter of this connection's page cache — 1,000 pages - * at better-sqlite3's default `cache_size` (-16000 KiB) and 4 KiB pages. - * Readings with a reader pinning the WAL, so every frame written stays - * visible: 2,000-page chunks left 883 frames, 4,000 left 3,779, and one - * statement 22,920; with a 2 MB cache, 250-page chunks left 1,121 and - * 1,000-page chunks 15,491. - * - * Why these two checkpoints. A `PASSIVE` checkpoint after each chunk moves - * its frames into the database and lets the next chunk restart the WAL from - * its start; it never waits. What it cannot do is shrink the sidecar, which - * keeps its high-water size until something truncates it. So one - * `TRUNCATE` checkpoint closes the call, under a busy timeout of 0 for that - * one statement and the connection's own timeout put back afterwards: a - * `TRUNCATE` checkpoint waits for other connections' readers through the - * busy handler, and on this synchronous binding that wait blocks the whole - * process — measured at 5,333 ms against a reader in the same process, the - * connection's 5,000 ms timeout. When another connection is reading, the - * `PASSIVE` checkpoints move only the frames that reader no longer needs and - * the `TRUNCATE` one answers "busy" as a result row, not as an error: the - * pages are off the freelist, and their bytes leave the files at a later - * checkpoint (the next call, SQLite's own auto-checkpoint, or the last - * connection closing). Outside WAL mode both checkpoints are no-ops. + * The `varchar(n)` width a moved single-value media column gets. * - * The loop stops when the freelist is empty or a chunk frees nothing: a file - * whose `auto_vacuum` is still `NONE` never shrinks its freelist through - * this statement (one full `VACUUM` adopts INCREMENTAL, see `SqlDriver.connect`). + * ⚠️ NOT a taste: it is the width `os generate migration --format sql` already + * emits for the family (`VARCHAR(2048)` in `packages/cli/src/commands/ + * generate.ts`), and the maintainer ruling in ADR-0104's 2026-09-05 addendum is that the GENERATOR + * states the ruled end-state and does not move — the driver is the side that + * moves to meet it. A driver that created knex's default `varchar(255)` here + * would open a fresh divergence between the two producers of the same column + * on the very change that closes the old one. * - * Module-local, like {@link formatDuplicateGroups}: `SqlDriver`'s `.d.ts` - * carries its non-public members too, and this helper is no entry point. + * Mirrored by {@link SqlDriver.varcharColumnChars}, which is what the drift + * detector asks rather than restating the width; the two are pinned equal by + * `sql-driver-11565-row-byte-budget.test.ts` for every declared `FieldType`. */ -function reclaimBetterSqlite3(db: BetterSqlite3Connection): void { - const scalar = (pragma: string): number => Number(db.pragma(pragma, { simple: true })); - let free = scalar('freelist_count'); - if (free === 0) return; - const cacheSize = scalar('cache_size'); - const cachePages = cacheSize >= 0 ? cacheSize : Math.floor((-cacheSize * 1024) / scalar('page_size')); - const chunk = Math.max(1, Math.floor(cachePages / 4)); - for (;;) { - db.exec(`PRAGMA incremental_vacuum(${chunk})`); - db.exec('PRAGMA wal_checkpoint(PASSIVE)'); - const left = scalar('freelist_count'); - if (left === 0 || left >= free) break; - free = left; - } - const busyTimeout = scalar('busy_timeout'); - db.pragma('busy_timeout = 0'); - try { - db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); - } finally { - db.pragma(`busy_timeout = ${busyTimeout}`); - } -} +const MEDIA_ID_VARCHAR_CHARS = 2048; export class SqlDriver implements IDataDriver { // IDataDriver metadata @@ -5802,29 +1908,17 @@ export class SqlDriver implements IDataDriver { // Lifecycle // =================================== - async connect(): Promise { - try { - // Ensure the database directory exists before any query can trigger - // better-sqlite3 to open the file (e.g. loadMetaFromDb on startup). - await this.ensureDatabaseExists(); - - if (this.isSqlite) { - // Both pragmas below are persistent properties of the FILE, so one - // connection setting them is enough for every process that follows. - // Each is READ first and set only when the file answers differently, so - // a connect to a file an earlier boot already configured writes nothing - // to it (#21734) — one rule for every connect, read-only boots included. - // A `false` means the very first statement on this connection failed — - // in practice a native addon that cannot load — so asking it for another - // pragma would only repeat the same warning. - if (await this.applyAutoVacuumIncremental()) { - await this.applySqliteJournalMode(); - } - } - } catch (error) { - if (this.isSqlite) await this.releaseRefusedSqliteConnection(); - throw error; - } + connect(): Promise { + // [#29520 codemod] The body moved to `sql-driver-lifecycle.ts` (see `LifecycleHost`). + const readIsSqlite = () => this.isSqlite; + const host: ThisParameterType = { + applyAutoVacuumIncremental: (...a) => this.applyAutoVacuumIncremental(...a), + applySqliteJournalMode: (...a) => this.applySqliteJournalMode(...a), + ensureDatabaseExists: (...a) => this.ensureDatabaseExists(...a), + get isSqlite() { return readIsSqlite(); }, + releaseRefusedSqliteConnection: (...a) => this.releaseRefusedSqliteConnection(...a), + }; + return connect.call(host); } /** @@ -6451,89 +2545,26 @@ export class SqlDriver implements IDataDriver { * where it does not ({@link insertReturnsStoredRows}: the MySQL family). The * type was the contract's all along; on MySQL the VALUE was the insert id. */ - async create(object: string, data: Record, options?: DriverOptions): Promise> { - const { _id, ...rest } = data; - const toInsert = { ...rest }; - - if (_id !== undefined && toInsert.id === undefined) { - toInsert.id = _id; - } else if (toInsert.id === undefined) { - toInsert.id = nanoid(DEFAULT_ID_LENGTH); - } - - this.auditMissingTenant(object, 'create', options, [toInsert]); - this.injectTenantOnInsert(object, toInsert, options); - - // #5495. The autonumber counter is bootstrapped from the data table exactly - // once, so any row that lands by a path bypassing `fillAutoNumberFields` - // leaves it permanently behind `MAX` — and every create then collided, - // burned a number and failed the request until it had ground past the - // seeded range one 409 at a time. Re-seed and retry instead, but ONLY when - // the collision is provably this counter's; see - // {@link collidingAutoNumberReservations} for how that is decided and why - // it cannot be decided from the error text alone. - // - // Retrying is confined to the no-caller-transaction case on purpose. Inside - // a caller's transaction there is nothing to repair here — the sequence - // UPDATE shares that transaction and rolls back with the refused INSERT, so - // no number is burned (measured) — and there is something to break: on - // Postgres a constraint failure aborts the whole transaction, so a retry - // issued on it could not succeed anyway. The caller owns that retry. - const mayRetry = options?.transaction === undefined; - for (let attempt = 0; ; attempt++) { - const reservations = await this.fillAutoNumberFields(object, toInsert, options); - - // Rotation (ADR-0057 P2): the base name is a read-only view — new rows - // land in the current shard. - const builder = this.getBuilder(this.rotationWriteTarget(object) ?? object, options); - const formatted = this.applyWriteColumnMap(object, this.formatInput(object, toInsert)); - this.stampInsertTimestamps(object, formatted); - - try { - if (this.insertReturnsStoredRows) { - const result = await builder.insert(formatted).returning('*'); - return this.formatOutput(object, result[0]); - } - await builder.insert(formatted); - } catch (error) { - // #11627: on a table whose UNIQUE index is carried by a hash shadow, - // `ER_DUP_ENTRY` quotes a binary digest and names the shadow index, so - // neither the operator nor this driver can read WHICH value conflicted - // — and a genuine duplicate is indistinguishable from a (near- - // impossible) digest collision. Resolve it by reading the source - // columns back, on the failure path only, and re-throw the SAME error - // carrying whichever answer is true. Returns the error untouched when - // this is not a shadow conflict. - const shadowed = await this.decorateHashShadowDuplicate( - this.rotationWriteTarget(object) ?? object, - formatted, - error, - ); - if (!mayRetry || attempt >= AUTONUMBER_COLLISION_RETRIES) throw shadowed; - const colliding = await this.collidingAutoNumberReservations(error, reservations, options); - if (colliding.length === 0) throw shadowed; - for (const reservation of colliding) { - await this.resyncSequenceToDataMax(reservation); - // Clear only what collided, so the next pass regenerates exactly - // those fields. A reservation that did NOT collide keeps its value - // rather than burning a second number for nothing. - delete toInsert[reservation.field]; - } - continue; - } - - // [#21227] Reached only when the INSERT landed and answered no rows. The - // read sits OUTSIDE the try on purpose: a fault in it is not an insert - // failure, so it must never reach the collision re-seed above, whose - // retry would re-issue a write that already landed. - const [stored] = await this.readBackInsertedRows( - object, - this.rotationWriteTarget(object) ?? object, - [toInsert], - options, - ); - return stored; - } + create(object: string, data: Record, options?: DriverOptions): Promise> { + // [#29520 codemod] The body moved to `sql-driver-crud.ts` (see `CrudHost`). + const readInsertReturnsStoredRows = () => this.insertReturnsStoredRows; + const host: ThisParameterType = { + applyWriteColumnMap: (...a) => this.applyWriteColumnMap(...a), + auditMissingTenant: (...a) => this.auditMissingTenant(...a), + collidingAutoNumberReservations: (...a) => this.collidingAutoNumberReservations(...a), + decorateHashShadowDuplicate: (...a) => this.decorateHashShadowDuplicate(...a), + fillAutoNumberFields: (...a) => this.fillAutoNumberFields(...a), + formatInput: (...a) => this.formatInput(...a), + formatOutput: (...a) => this.formatOutput(...a), + getBuilder: this.getBuilder.bind(this), + injectTenantOnInsert: (...a) => this.injectTenantOnInsert(...a), + get insertReturnsStoredRows() { return readInsertReturnsStoredRows(); }, + readBackInsertedRows: (...a) => this.readBackInsertedRows(...a), + resyncSequenceToDataMax: (...a) => this.resyncSequenceToDataMax(...a), + rotationWriteTarget: (...a) => this.rotationWriteTarget(...a), + stampInsertTimestamps: (...a) => this.stampInsertTimestamps(...a), + }; + return create.call(host, object, data, options); } /** @@ -6589,42 +2620,21 @@ export class SqlDriver implements IDataDriver { * Refused with {@link insertedRowsNotReadBackError}, never answered with the * payload: see that function for why. */ - private async readBackInsertedRows( + private readBackInsertedRows( object: string, writeTable: string, written: Record[], options?: DriverOptions, ): Promise[]> { - if (written.length === 0) return []; - const idColumn = this.remoteColumn(object, 'id', 'id'); - const tenantField = this.resolveTenantField(object); - const writtenTenants = tenantField - ? [ - ...new Set( - written - .map((row) => row[tenantField]) - .filter((value) => value !== undefined && value !== null && value !== '') - .map(String), - ), - ] - : []; - const everyRowCarriesTenant = - tenantField !== null - && written.every((row) => row[tenantField] !== undefined && row[tenantField] !== null && row[tenantField] !== ''); - const scopeOptions: DriverOptions = everyRowCarriesTenant - ? { ...options, tenantId: writtenTenants[0], tenantIds: writtenTenants } - : { ...options }; - const builder = this.getBuilder(writeTable, options); - this.applyTenantScope(builder, object, scopeOptions); - const stored: Record[] = await builder.whereIn( - idColumn, - written.map((row) => row.id), - ); - const byId = new Map>(); - for (const row of stored) byId.set(String(row[idColumn]), row); - const missing = written.filter((row) => !byId.has(String(row.id))).map((row) => row.id); - if (missing.length > 0) throw insertedRowsNotReadBackError(object, missing, written.length); - return written.map((row) => this.formatOutput(object, byId.get(String(row.id)))); + // [#29520 codemod] The body moved to `sql-driver-crud.ts` (see `CrudHost`). + const host: ThisParameterType = { + applyTenantScope: (...a) => this.applyTenantScope(...a), + formatOutput: (...a) => this.formatOutput(...a), + getBuilder: this.getBuilder.bind(this), + remoteColumn: (...a) => this.remoteColumn(...a), + resolveTenantField: (...a) => this.resolveTenantField(...a), + }; + return readBackInsertedRows.call(host, object, writeTable, written, options); } /** @@ -6703,9 +2713,8 @@ export class SqlDriver implements IDataDriver { /** SHA-256 of the composite counter key — the table's single-column PK. */ protected sequenceKeyHash(object: string, tenantId: string, field: string, scope: string): string { - return createHash('sha256') - .update(`${object}\u001f${tenantId}\u001f${field}\u001f${scope}`) - .digest('hex'); + // [#29520 codemod] The body moved to `sql-driver-crud.ts`. + return sequenceKeyHash(object, tenantId, field, scope); } // ── The sequence semantics a second face shares, stated once ────────────── @@ -6763,15 +2772,12 @@ export class SqlDriver implements IDataDriver { * and sends that, so both faces create byte-for-byte the same table. */ protected defineSequencesTable(t: Knex.CreateTableBuilder): void { - t.string('key_hash', 64).notNullable().primary(); - t.string('object').notNullable(); - t.string('tenant_id').notNullable(); - t.string('field').notNullable(); - // Non-indexed, so it is free of the PK length limit — a long `{plan_no}` - // composite scope fits. 1024 is far above any realistic rendered prefix. - t.string('scope', 1024).notNullable().defaultTo(''); - t.bigInteger('last_value').notNullable().defaultTo(0); - t.timestamp('updated_at').defaultTo(this.knex.fn.now()); + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts` (see `SequenceSemanticsHost`). + const readKnex = () => this.knex; + const host: ThisParameterType = { + get knex() { return readKnex(); }, + }; + return defineSequencesTable.call(host, t); } /** @@ -7217,16 +3223,12 @@ export class SqlDriver implements IDataDriver { * `getBuilder`, mirroring `scanMaxNumericTail`: this is the sequence's own * bookkeeping read against an explicit tenant, not a caller-facing read. */ - protected async autoNumberValueExists( + protected autoNumberValueExists( queryRunner: Knex | Knex.Transaction, reservation: AutoNumberReservation, ): Promise { - let builder = queryRunner(reservation.tableName).select(reservation.field).where(reservation.field, reservation.value); - if (reservation.tenantField && reservation.tenantId !== null) { - builder = builder.where(reservation.tenantField, reservation.tenantId); - } - const hit = await builder.first(); - return hit !== undefined && hit !== null; + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts`. + return autoNumberValueExists(queryRunner, reservation); } /** @@ -7309,13 +3311,12 @@ export class SqlDriver implements IDataDriver { * that drew from it. See {@link bulkCreate}. */ protected autoNumberCounterKey(reservation: AutoNumberReservation): string { - const resolvedTenantId = this.resolveSequenceTenantId( - reservation.tenantField, - reservation.tenantId, - reservation.tableName, - reservation.field, - ); - return this.sequenceKeyHash(reservation.tableName, resolvedTenantId, reservation.field, reservation.scope); + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts` (see `SequenceSemanticsHost`). + const host: ThisParameterType = { + resolveSequenceTenantId: (...a) => this.resolveSequenceTenantId(...a), + sequenceKeyHash: (...a) => this.sequenceKeyHash(...a), + }; + return autoNumberCounterKey.call(host, reservation); } /** @@ -7606,43 +3607,24 @@ export class SqlDriver implements IDataDriver { * compiler complaint; it is the contract's type now, pinned by * `sql-driver-update-declared-null.test.ts`. */ - async update(object: string, id: string | number, data: Record, options?: DriverOptions): Promise | null> { - this.auditMissingTenant(object, 'update', options); - const rotationShards = this.rotationShardsOf(object); - if (rotationShards) return this.rotatedUpdateById(object, rotationShards, id, data, options); - const formatted = this.applyWriteColumnMap(object, this.formatInput(object, data)); - - // One definition of the statement, so the speculative attempt, the fenced - // retry and the plain path cannot drift in WHERE or tenant scope. - const issue = (payload: Record, issueOptions?: DriverOptions): Promise => { - const builder = this.getBuilder(object, issueOptions).where('id', id); - this.applyTenantScope(builder, object, issueOptions); - return builder.update(payload) as unknown as Promise; + update(object: string, id: string | number, data: Record, options?: DriverOptions): Promise | null> { + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts` (see `SequenceSemanticsHost`). + const host: ThisParameterType = { + applyTenantScope: (...a) => this.applyTenantScope(...a), + applyWriteColumnMap: (...a) => this.applyWriteColumnMap(...a), + auditMissingTenant: (...a) => this.auditMissingTenant(...a), + formatInput: (...a) => this.formatInput(...a), + formatOutput: (...a) => this.formatOutput(...a), + getBuilder: this.getBuilder.bind(this), + keepSuppliedUpdatedAt: (...a) => this.keepSuppliedUpdatedAt(...a), + rotatedUpdateById: (...a) => this.rotatedUpdateById(...a), + rotationShardsOf: (...a) => this.rotationShardsOf(...a), + stampsUpdatedAt: (...a) => this.stampsUpdatedAt(...a), + updateWithPresumedTimestamp: (...a) => this.updateWithPresumedTimestamp(...a), + updatedAtStamp: (...a) => this.updatedAtStamp(...a), + updatedAtStampIsPresumed: (...a) => this.updatedAtStampIsPresumed(...a), }; - - if (this.stampsUpdatedAt(object) && !this.keepSuppliedUpdatedAt(formatted, options)) { - // [#24835] A FRESH object, never `formatted`: `formatInput` and - // `applyWriteColumnMap` are copy-on-write, so for a patch that needs no - // coercion `formatted` IS the caller's `data`. Assigning the stamp onto it - // put `updated_at` into the caller's patch — which ObjectQL publishes as - // `DataEvent.changes` — and on Postgres/MySQL that value is a knex `Raw` - // holding the client, its pool and a timer, so every reader that - // serialises the event (the webhook outbox first) threw. `updateMany`'s - // shape, below. - const stamped = { ...formatted, updated_at: this.updatedAtStamp() }; - if (this.updatedAtStampIsPresumed(object)) { - await this.updateWithPresumedTimestamp(object, stamped, options, issue); - } else { - await issue(stamped, options); - } - } else { - await issue(formatted, options); - } - - const readback = this.getBuilder(object, options).where('id', id); - this.applyTenantScope(readback, object, options); - const updated = await readback.first(); - return this.formatOutput(object, updated) || null; + return update.call(host, object, id, data, options); } /** @@ -8076,7 +4058,7 @@ export class SqlDriver implements IDataDriver { * row must be looked for. Reading the unsharded name would report every * rotated write as a cross-row merge. */ - private async assertMergeLandedOnSuppliedIdentity( + private assertMergeLandedOnSuppliedIdentity( object: string, writeTable: string, id: string | number, @@ -8084,24 +4066,14 @@ export class SqlDriver implements IDataDriver { writtenTenant: unknown, options?: DriverOptions, ): Promise { - const builder = this.getBuilder(writeTable, options); - // Scoped to the tenant this row was written under (see the docblock): the - // caller's org on an ordinary call, the explicitly named one on an admin - // cross-tenant write, and a no-op when neither exists. `tenantIds` is - // dropped deliberately — the group-union posture widens a READ to a - // membership set, and this is an identity probe for ONE row, not a read. - const tenantContext = options?.tenantId !== undefined && options?.tenantId !== null && options?.tenantId !== ''; - const scopeOptions: DriverOptions = { - ...options, - tenantId: - tenantContext && typeof writtenTenant === 'string' && writtenTenant !== '' ? writtenTenant : options?.tenantId, - tenantIds: undefined, + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts` (see `SequenceSemanticsHost`). + const readPhysicalTableByObject = () => this.physicalTableByObject; + const host: ThisParameterType = { + applyTenantScope: (...a) => this.applyTenantScope(...a), + getBuilder: this.getBuilder.bind(this), + get physicalTableByObject() { return readPhysicalTableByObject(); }, }; - this.applyTenantScope(builder, object, scopeOptions); - const landed = await builder.where('id', id).first('id'); - if (landed) return; - const tableName = this.physicalTableByObject[writeTable] ?? writeTable; - throw refuseCrossRowIdentityMerge(object, tableName, id, rivals); + return assertMergeLandedOnSuppliedIdentity.call(host, object, writeTable, id, rivals, writtenTenant, options); } /** @@ -8159,7 +4131,7 @@ export class SqlDriver implements IDataDriver { * ⛔ Nothing read here reaches the caller: the refusal names no organization * and no value of the row it landed on. */ - private async assertMergeLandedInWrittenOrganization( + private assertMergeLandedInWrittenOrganization( object: string, writeTable: string, mergeKeys: string[], @@ -8169,416 +4141,53 @@ export class SqlDriver implements IDataDriver { identity: { id: string | number; rivals: PhysicalIndex[] } | null, options?: DriverOptions, ): Promise { - // The tenant scope of a ONE-row probe: the written tenant, with - // `tenantIds` dropped for #8807's reason (the group-union posture widens a - // READ to a membership set; this asks about one organization). - const scopeOptions: DriverOptions = { ...options, tenantId: String(guard.value), tenantIds: undefined }; - - const landedProbe = this.getBuilder(writeTable, options); - this.applyTenantScope(landedProbe, object, scopeOptions); - landedProbe.where(guard.column, guard.value as any); - for (const [column, value] of Object.entries(matchOn)) landedProbe.where(column, value as any); - if (await landedProbe.first('id')) return; - - if (identity) { - for (const rival of identity.rivals) { - const probeable = rival.columns.length > 0 && rival.columns.every((c) => sent[c] !== undefined && sent[c] !== null); - if (!probeable) continue; - const rivalProbe = this.getBuilder(writeTable, options); - this.applyTenantScope(rivalProbe, object, scopeOptions); - rivalProbe.where(guard.column, guard.value as any); - for (const column of rival.columns) rivalProbe.where(column, sent[column] as any); - if (await rivalProbe.first('id')) { - const tableName = this.physicalTableByObject[writeTable] ?? writeTable; - throw refuseCrossRowIdentityMerge(object, tableName, identity.id, identity.rivals); - } - } - } - throw refuseUpsertConflictOutsideWrittenOrganization(object, mergeKeys); + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts` (see `SequenceSemanticsHost`). + const readPhysicalTableByObject = () => this.physicalTableByObject; + const host: ThisParameterType = { + applyTenantScope: (...a) => this.applyTenantScope(...a), + getBuilder: this.getBuilder.bind(this), + get physicalTableByObject() { return readPhysicalTableByObject(); }, + }; + return assertMergeLandedInWrittenOrganization.call(host, object, writeTable, mergeKeys, matchOn, sent, guard, identity, options); } // [commit be5c60291] The return is the contract's own type. It was `Promise>`, and the // reason #15267's census never named this door is worth carrying: that census // matched the literal string `Promise`, and here the `any` is NESTED - // inside a wider type, so the characters were not there to match. The door - // appeared in neither its repaired set nor its excluded set — it was never - // seen. Pinned at the type level, both halves, in - // `sql-driver-doors-declared-types.test.ts`. - async upsert(object: string, data: Record, conflictKeys?: string[], options?: DriverOptions): Promise> { - const { _id, ...rest } = data; - const toUpsert = { ...rest }; - - if (_id !== undefined && toUpsert.id === undefined) { - toUpsert.id = _id; - } else if (toUpsert.id === undefined) { - toUpsert.id = nanoid(DEFAULT_ID_LENGTH); - } - - this.auditMissingTenant(object, 'upsert', options); - this.injectTenantOnInsert(object, toUpsert, options); - - const mergeKeys = conflictKeys && conflictKeys.length > 0 ? conflictKeys : ['id']; - - // [#21185] The organization fence (ADR-0131 D8: the tenant predicate - // reaches every driver door, and the merge leg of an upsert was the one it - // did not). The conflict target is resolved against the WHOLE table — the - // primary key and a `unique: 'global'` column are installation-wide — so - // the row a tenant-scoped call collides with can belong to an organization - // the caller cannot read, and the merge leg used to write onto it, tenant - // column included. With a fence the merge may only land on a row whose - // stored tenant column equals the written one; a conflict anywhere else is - // refused with `UNIQUE_VIOLATION` and writes nothing. `null` for a call with - // no tenant context, which keeps the merge and relies on the tenant column - // being insert-only ({@link insertOnlyUpsertColumns}). See - // {@link upsertTenantGuard}. - const tenantGuard = this.upsertTenantGuard(object, toUpsert, options); - - // [#8621, #8755, #8807] Pre-flight the conflict target — see - // {@link assertConflictTargetHonoured} for the mechanism and why it is - // MySQL-only. Two placement facts, both load-bearing: - // - // - ⚠️ It now runs whether or not the CALLER named a target, and the - // reasoning that used to stop at the caller-named case is superseded - // rather than merely widened. That reasoning was *"a probe on the default - // path could only ever confirm what the driver just built"*, and it was - // right about the question #8621/#8755 were asking — "is `['id']` backed?" - // has one possible answer on a table this driver created. #8807 asks a - // different question of the same introspection — "does anything ELSE on - // this table absorb a collision?" — and that one is genuinely unknown - // until read. The read is cached per table - // ({@link physicalKeyIndexes}), so the hot path pays it once, and the - // verdict it returns is not a refusal: see below. - // - It runs BEFORE the retry loop, therefore before - // `fillAutoNumberFields`. Refusing after it would burn an autonumber - // reservation for a statement that never executes — a visible gap in an - // externally meaningful sequence, handed out for a rejected call. - const callerNamed = !!(conflictKeys && conflictKeys.length > 0); - const preflight = this.isMysql - ? await this.assertConflictTargetHonoured(object, mergeKeys, callerNamed) - : { verifyIdentity: false }; - - // [#8807] The primary-key-targeted merge on a table that carries a rival - // UNIQUE key: MySQL may land it on a row this call never identified, and - // nothing before the statement can tell whether it will. So the statement - // and the identity check run as ONE unit of work, and the check's failure - // is what rolls the wrong write back. - // - // The accept set moves for exactly the calls that corrupt data: a merge - // that lands on the supplied identity — every insert, and every legitimate - // re-upsert of the same row — passes the check untouched, because `id` is - // insert-only (#8622) and therefore a row merged on the primary key always - // still carries it. - const verifyIdentity = preflight.verifyIdentity === true; - - // [#21185] MySQL's fence is a check after the statement, inside the same - // unit of work — `ON DUPLICATE KEY UPDATE` takes no `WHERE` — so it is owed - // whenever the call is fenced, not only when a rival UNIQUE key exists. See - // {@link assertMergeLandedInWrittenOrganization}. - const mysqlFence = this.isMysql && tenantGuard !== null; - const checkAfterStatement = verifyIdentity || mysqlFence; - - // #6943. Measured: `upsert` does NOT share `bulkCreate`'s shape. It is - // single-row, so a stale counter costs it exactly one burned number per - // call — the same shape `create()` had before #5495, for the same reason - // (no re-seed, no retry). `ON CONFLICT (mergeKeys) DO UPDATE` absorbs a - // conflict on the merge key only; the tenanted autonumber lives under a - // DIFFERENT unique index, so its violation is still raised and still - // reaches here. The remedy is therefore `create()`'s, verbatim: re-seed and - // re-issue, but only when the collision is provably this counter's - // ({@link collidingAutoNumberReservations}), and only outside a caller - // transaction (inside one the sequence UPDATE rolls back with the refused - // INSERT, so nothing is burned and there is nothing to repair — measured). - const mayRetry = options?.transaction === undefined; - // The row the last attempt SENT, in storage form: the read-back below - // looks the landed row up by its conflict-key values. - let sent: Record = {}; - for (let attempt = 0; ; attempt++) { - const reservations = await this.fillAutoNumberFields(object, toUpsert, options); - - const formatted = this.applyWriteColumnMap(object, this.formatInput(object, toUpsert)); - sent = formatted; - this.stampInsertTimestamps(object, formatted); - // [#11176] …and the same slot filled on Postgres/MySQL, where the line - // above returns early. Without it `updated_at` is not in `formatted`, so - // it is not in the merge set built below, so the merge branch leaves the - // row reading its INSERT-time default forever. See - // {@link stampUpsertUpdatedAt} — no-op on SQLite (already filled) and on - // any object whose `updated_at` column this driver has not OBSERVED. - this.stampUpsertUpdatedAt(object, formatted); - - // Rotation: conflict-merge is scoped to the CURRENT shard (telemetry is - // effectively append-only; a cross-shard upsert would need a probe-first - // strategy nothing on the platform requires today). - const writeTable = this.rotationWriteTarget(object) ?? object; - // `created_at` is insert-only — never overwrite it when an existing row is - // merged on conflict (the stamped/seeded value belongs to the original - // insert). [#7011] `auto_number` columns are insert-only for the same - // reason, and a stronger one: the number is an externally visible - // business identifier once assigned (`CASE-00001`), and - // `fillAutoNumberFields` above reserved a FRESH value before the - // statement could know it would merge — leaving these columns in the - // merge set rewrote the existing row's number on every merge-path upsert - // (measured on a healthy counter: create → CASE-00001, two upserts of - // the same id → CASE-00002 then CASE-00003, one row throughout). The - // exclusion is unconditional — an explicit payload value does not - // renumber on merge either; `update()` is the deliberate renumbering - // path. The reservation itself still happens on the merge path (a gap, - // not a rewrite) — that pre-burn half is #6943's reseed family, not - // this exclusion's. - // - // [#8622] `id` is insert-only for the SAME argument at its strongest. - // `upsert` mints a nanoid whenever the caller supplies none (just above), - // so the merge set carried `id = excluded.id` — the freshly minted id of - // the insert that LOST. On the default `['id']` conflict target that - // clause is a no-op (both sides are the same value), which is precisely - // why it stayed invisible; the moment `conflictKeys` names a business key - // the merged row's identity is silently replaced. Measured here on SQLite - // and live Postgres 16.13, and on live MySQL 8.0.46 in #8592 — a - // dialect-independent defect, because the mechanism is the merge set this - // driver builds, not anything a server decides: - // - // upsert({email:'x@b.com', title:'first'}, ['email']) -> id=rSxhabt0… - // upsert({email:'x@b.com', title:'second'}, ['email']) -> id=hXqcSC45… - // ONE row, NEW primary key. - // - // `id` is what relationships, audit records, external id mappings and any - // client-held reference point at, so this dangles every one of them with - // no error on any dialect. The exclusion is unconditional for #7011's - // reason: an explicit payload `id` (or its `_id` alias, folded into `id` - // before `formatted` is built) does not re-key on merge either. `update()` - // is the deliberate path — it writes exactly the columns it is handed, - // `id` included. - // - // Everything else (incl. `updated_at`) merges as before, so an upsert - // that updates a row still advances `updated_at`. [#11176] That last - // clause used to hold on SQLite ONLY: it needs `updated_at` to BE in - // `formatted`, which before this card nothing put there on Postgres or - // MySQL. `stampUpsertUpdatedAt` above is what now makes the sentence true - // on all three. - const insertOnlyColumns = this.insertOnlyUpsertColumns(object); - const mergeColumns = Object.keys(formatted).filter((c) => !insertOnlyColumns.has(c)); - - // [#8622] Excluding `id` made a branch REACHABLE that could not fire - // before, and the bare `merge()` below is merge-ALL: it re-admits every - // insert-only column. Measured on live Postgres with an object whose only - // non-`id` column is an `auto_number` — `upsert({ id: seeded }, ['id'])` - // left `mergeColumns` empty and the merge-ALL fallback rewrote the row's - // `case_no` from `0003` to `0004`, defeating #7011's exclusion. (SQLite - // never reached it: `stampInsertTimestamps` puts a mergeable `updated_at` - // in the payload there. [#11176] `stampUpsertUpdatedAt` now does the same - // on Postgres and MySQL, so a TIMESTAMPED object no longer reaches this - // fallback on any dialect either — it is still reachable for an object - // with no observed `updated_at` column.) - // - // The remedy keeps the emitted SQL byte-identical to what `main` emits - // for this shape. Falling back to the CONFLICT-TARGET columns present in - // the payload is a provable no-op — a conflict means those columns matched, - // so `excluded.` IS the stored value — and it reproduces exactly - // the `merge(['id'])` `main` produces here, because `id` was `main`'s only - // mergeable column in this case. So this PR's net behavioural delta on the - // degenerate path is nil rather than a #7011 regression. - // - // [#8740] When the payload carries no conflict-target column either, there - // is nothing the merge leg may write, and the statement is a plain INSERT - // with no conflict clause: a collision surfaces as the driver's own - // unique-violation error and the stored row is left as it was. This used - // to fall back to knex's bare `merge()`, which is merge-ALL on every - // dialect: it re-admitted every insert-only column the lines above - // exclude. ⛔ Not `ignore()` either: knex lowers it on MySQL to - // `insert ignore`, which swallows unrelated errors and drops the conflict - // target. See {@link withUpsertConflictClause}. - const noopMergeColumns = Object.keys(formatted).filter((c) => mergeKeys.includes(c)); - const columnsToMerge = mergeColumns.length > 0 ? mergeColumns : noopMergeColumns; - // …and that plain INSERT still refuses a conflict target nothing backs, - // as every dialect does when there is something to merge. - if (columnsToMerge.length === 0) await this.assertConflictTargetBackedWithoutClause(object, writeTable, mergeKeys); - - // [#21185] The fence for THIS attempt: the tenant column, and the value - // the row is written under in storage form (what the statement sends). - const fence = tenantGuard && { - column: tenantGuard.column, - value: formatted[tenantGuard.column] ?? tenantGuard.value, - }; - - // The statement, plus [#8807]'s identity check when one is owed. The - // builder is built HERE rather than above the comment block so it can be - // bound to whichever transaction this write runs on — the caller's, the - // one opened just below, or none at all. The emitted INSERT is otherwise - // unchanged. - const runStatement = async (writeOptions?: DriverOptions): Promise => { - // Bound in two steps, not chained: this is the INSERT door, and - // `check:tenant-chokepoint` classifies a builder as a write by seeing - // `.insert` used on the BINDING. Collapsing these into one expression - // hides that from the gate and the door reads as an unscoped read — - // measured when this closure was first extracted. Insert-side tenancy - // is `injectTenantOnInsert`'s job (called above), which is exactly why - // this one is deliberately not scoped. - const builder = this.getBuilder(writeTable, writeOptions); - const insertion = builder.insert(formatted); - const { statement, merges } = withUpsertConflictClause(insertion, mergeKeys, columnsToMerge); - // [#21185] SQLite and PostgreSQL: the fence is a predicate INSIDE the - // merge statement — `… DO UPDATE SET … WHERE` the stored tenant column - // equals the written one — so a conflict on a row of another - // organization (or of none) leaves that row untouched, atomically, with - // no added round trip. The read-back below then finds no row under the - // written organization, and that is where the call refuses. NULL-safe - // on both (`IS` on SQLite, `IS NOT DISTINCT FROM` on PostgreSQL); the - // written value is never NULL here, so a stored NULL is "distinct", and - // a platform row with no organization is fenced off too. The target - // table is named by its bare physical name, which both dialects accept - // for the INSERT target in this clause, schema-qualified or not. - // MySQL takes no `WHERE` here; its fence runs after the statement. A - // plain INSERT ([#8740]) has no merge leg to fence. - await (merges && fence && !this.isMysql - ? statement.where(this.mergeLandsInWrittenOrganization(writeTable, fence.column)) - : statement); - if (mysqlFence) { - // [#21185] One read, under the written tenant exactly; #8807's - // identity verdict rides on it when a rival key made one owed. - const matchOn: Record = mergeKeys.every((k) => formatted[k] !== undefined && formatted[k] !== null) - ? Object.fromEntries(mergeKeys.map((k) => [k, formatted[k]])) - : { id: toUpsert.id }; - await this.assertMergeLandedInWrittenOrganization( - object, - writeTable, - mergeKeys, - matchOn, - formatted, - fence!, - verifyIdentity ? { id: toUpsert.id, rivals: preflight.rivals ?? [] } : null, - writeOptions, - ); - } else if (verifyIdentity) { - // The tenant the row was WRITTEN under, read off the payload after - // `injectTenantOnInsert` has run — the caller's org on an ordinary - // call, an explicitly supplied one on an admin cross-tenant write. - const tenantField = this.resolveTenantField(object); - await this.assertMergeLandedOnSuppliedIdentity( - object, - writeTable, - toUpsert.id, - preflight.rivals ?? [], - tenantField ? toUpsert[tenantField] : undefined, - writeOptions, - ); - } - }; - - try { - if (checkAfterStatement && options?.transaction === undefined) { - // [#8807] No caller transaction, so the driver opens one: the check - // is only worth making if its failure can UNDO the write it judged, - // and an autocommitted statement is already permanent by the time the - // row can be read back. Scoped to the calls a check is owed on — - // `verifyIdentity`, and since #21185 every fenced MySQL call — so the - // ordinary upsert (SQLite, PostgreSQL, and a MySQL call with neither) - // keeps its single autocommitted round trip. - // - // Inside a caller transaction #8807's wrapper is deliberately NOT - // added: the statement is already transactional, and throwing hands - // the rollback decision to the owner of that transaction, exactly as - // the autonumber path above reasons about the same boundary. - await this.knex.transaction(async (trx) => { - await runStatement({ ...options, transaction: trx }); - }); - } else if (mysqlFence) { - // [#21185] …but the organization fence does not hand that decision - // over. A cross-organization merge is never the caller's to keep, so - // inside a caller transaction the statement and its check run in a - // nested transaction — a SAVEPOINT on MySQL — and the check's throw - // rolls back to it: the other organization's row is restored before - // the refusal reaches the caller, whatever the caller then does with - // its own transaction. - await (options!.transaction as Knex.Transaction).transaction(async (savepoint) => { - await runStatement({ ...options, transaction: savepoint }); - }); - } else { - await runStatement(options); - } - break; - } catch (error) { - // [#21185] The fence's refusal is final: it is not the server's error, - // so neither the unbacked-target recogniser nor the autonumber re-seed - // below has anything to say about it, and a retry would be refused the - // same way. The pair is this driver's own envelope; no dialect error - // carries it. - if (isUpsertFenceRefusal(error)) throw error; - // [#8445] Classified BEFORE the autonumber retry logic, for three - // reasons that all point the same way. It is not an autonumber - // collision — `collidingAutoNumberReservations` gates on - // `isUniqueViolationError`, and this error is not a unique violation: - // no index existed for anything to violate. - // - // ⚠️ [#8567] That gate does NOT agree, and this comment used to claim it - // did ("which is false for this error"). Measured on the real SQLite - // error: `isUniqueViolationError` returns TRUE, because SQLite's - // missing-index sentence ends `…PRIMARY KEY or UNIQUE constraint` and - // the shared vocabulary matches the word pair `unique constraint` - // wherever it appears. Filed as #8590. So the ORDERING here is - // load-bearing rather than merely tidy: move this line below the branch - // and the reservation probe really does run — querying the sequences - // table for a collision that cannot exist, then rethrowing the raw - // error. Recognising first is what makes that unreachable. - // - // It is also not transient — the same statement fails identically on - // every attempt, so a retry is a wasted round trip by construction. And - // it is the only placement that envelopes BOTH exits below: a refusal - // recognised after the branch would still escape raw whenever - // `mayRetry` is false. - if (isUnbackedConflictTargetError(error)) throw refuseUnbackedConflictTarget(object, mergeKeys, error); - if (!mayRetry || attempt >= AUTONUMBER_COLLISION_RETRIES) throw error; - const colliding = await this.collidingAutoNumberReservations(error, reservations, options); - if (colliding.length === 0) throw error; - for (const reservation of colliding) { - await this.resyncSequenceToDataMax(reservation); - // Clear only what collided, so the next pass regenerates exactly - // those fields — a reservation that did NOT collide keeps its value - // rather than burning a second number for nothing. (Safe here in a - // way it is NOT for a batch: one row cannot collide with itself. - // See {@link bulkCreate}.) - delete toUpsert[reservation.field]; - } - } - } - - // [#21166] Read back the row the statement landed on, by the identity it - // MATCHED on: the conflict-key values. Reading it back by `toUpsert.id` - // answers the wrong row on exactly the call #8622 protects: a merge on a - // business key keeps the stored row's `id`, so the payload's `id` (or the - // nanoid minted above) names no row, the read finds nothing, and the - // fallback below answered the PAYLOAD. Measured on SQLite and live - // Postgres 16: the row stored as `row-a` answered `id: 'row-NEW'`, an id - // no stored row has. The conflict-key values name the landed row on both - // legs: the inserted row carries them, and the merged row is the one that - // matched them. A conflict key the row leaves empty cannot have matched - // (NULL never conflicts), so the statement inserted and the row carries - // `toUpsert.id`. On the default `['id']` target the two readings are the - // same query. The tenant scope is applied to either, as before. - const matchedOn = mergeKeys.every((k) => sent[k] !== undefined && sent[k] !== null); - const readback = this.getBuilder(object, options); - if (matchedOn) for (const k of mergeKeys) readback.where(k, sent[k]); - else readback.where('id', toUpsert.id); - if (tenantGuard) { - // [#21185] A fenced call reads back under the WRITTEN organization - // EXACTLY: the chokepoint scope for that tenant (`tenantIds` dropped, as - // for #8807's one-row probe), plus equality on the tenant column, which - // closes the scope's NULL-organization arm. A row the fence left alone — - // another organization's, or a platform row with none — is therefore not - // found, and that absence IS the refusal: the statement wrote nothing - // (SQLite / PostgreSQL), or rolled back (MySQL, where the same read ran - // inside the transaction first). Scoping to the caller's ACTIVE org - // instead would miss a row an admin really wrote under another - // organization and refuse a correct write. - const writtenTenant = sent[tenantGuard.column] ?? tenantGuard.value; - this.applyTenantScope(readback, object, { ...options, tenantId: String(writtenTenant), tenantIds: undefined }); - readback.where(tenantGuard.column, writtenTenant); - const landed = await readback.first(); - if (!landed) throw refuseUpsertConflictOutsideWrittenOrganization(object, mergeKeys); - return this.formatOutput(object, landed) || landed; - } - this.applyTenantScope(readback, object, options); - const result = await readback.first(); - return this.formatOutput(object, result) || toUpsert; + // inside a wider type, so the characters were not there to match. The door + // appeared in neither its repaired set nor its excluded set — it was never + // seen. Pinned at the type level, both halves, in + // `sql-driver-doors-declared-types.test.ts`. + upsert(object: string, data: Record, conflictKeys?: string[], options?: DriverOptions): Promise> { + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts` (see `SequenceSemanticsHost`). + const readIsMysql = () => this.isMysql; + const readKnex = () => this.knex; + const host: ThisParameterType = { + applyTenantScope: (...a) => this.applyTenantScope(...a), + applyWriteColumnMap: (...a) => this.applyWriteColumnMap(...a), + assertConflictTargetBackedWithoutClause: (...a) => this.assertConflictTargetBackedWithoutClause(...a), + assertConflictTargetHonoured: (...a) => this.assertConflictTargetHonoured(...a), + assertMergeLandedInWrittenOrganization: (...a) => this.assertMergeLandedInWrittenOrganization(...a), + assertMergeLandedOnSuppliedIdentity: (...a) => this.assertMergeLandedOnSuppliedIdentity(...a), + auditMissingTenant: (...a) => this.auditMissingTenant(...a), + collidingAutoNumberReservations: (...a) => this.collidingAutoNumberReservations(...a), + fillAutoNumberFields: (...a) => this.fillAutoNumberFields(...a), + formatInput: (...a) => this.formatInput(...a), + formatOutput: (...a) => this.formatOutput(...a), + getBuilder: this.getBuilder.bind(this), + injectTenantOnInsert: (...a) => this.injectTenantOnInsert(...a), + insertOnlyUpsertColumns: (...a) => this.insertOnlyUpsertColumns(...a), + get isMysql() { return readIsMysql(); }, + get knex() { return readKnex(); }, + mergeLandsInWrittenOrganization: (...a) => this.mergeLandsInWrittenOrganization(...a), + resolveTenantField: (...a) => this.resolveTenantField(...a), + resyncSequenceToDataMax: (...a) => this.resyncSequenceToDataMax(...a), + rotationWriteTarget: (...a) => this.rotationWriteTarget(...a), + stampInsertTimestamps: (...a) => this.stampInsertTimestamps(...a), + stampUpsertUpdatedAt: (...a) => this.stampUpsertUpdatedAt(...a), + upsertTenantGuard: (...a) => this.upsertTenantGuard(...a), + }; + return upsert.call(host, object, data, conflictKeys, options); } async delete(object: string, id: string | number, options?: DriverOptions): Promise { @@ -8611,161 +4220,26 @@ export class SqlDriver implements IDataDriver { * erased both the array and the row shape on the published `.d.ts`; it is the * contract's type now, pinned by `sql-driver-doors-declared-types.test.ts`. */ - async bulkCreate(object: string, data: any[], options?: DriverOptions): Promise[]> { - // Same client-side id assignment as create() (id/_id normalization, - // nanoid fallback when neither is supplied) — a row missing an id must - // not be silently inserted with a NULL primary key just because the - // engine batched it into an array. This path used to be rarely - // exercised; framework#2678 made it the common case for seed/import. - const rows = data.map((row) => { - if (!row || typeof row !== 'object') return row; - const { _id, ...rest } = row; - const toInsert: Record = { ...rest }; - if (_id !== undefined && toInsert.id === undefined) { - toInsert.id = _id; - } else if (toInsert.id === undefined) { - toInsert.id = nanoid(DEFAULT_ID_LENGTH); - } - return toInsert; - }); - this.auditMissingTenant(object, 'bulkCreate', options, rows); - for (const row of rows) { - if (row && typeof row === 'object') this.injectTenantOnInsert(object, row, options); - } - - // #6943. A stale counter costs this path more than it costs `create()`. - // Each row reserves its own number in its own committed transaction, and - // then the whole batch goes in as ONE insert — so a single colliding row - // does not burn one number, it burns every number the batch reserved and - // fails the whole request. Measured on `main` @ `c8ff269` (counter at 10, - // rows 11–39 landed by a bypass path): a 3-row `bulkCreate` threw, wrote - // ZERO rows and moved `last_value` 10 → 13; the next call moved it 13 → 16 - // and threw again. And the exposure is the worst available, because - // framework#2678 made this the common path for seed/import — which is - // precisely what *creates* the staleness (`isSystem` replay and - // `preserveAudit` import keep their explicit numbers and never enter - // `fillAutoNumberFields`, #5495/#5503). - // - // ## Batch semantics are deliberately NOT changed - // - // The card anticipated a decision about partial success and transaction - // boundaries. Measurement dissolves it: `insert(rows[])` is a SINGLE - // statement (`… select … union all select …` on SQLite, multi-row VALUES - // elsewhere), so the batch is ALREADY all-or-nothing — the failed batch - // above left the table exactly as it found it. Re-issuing and retrying the - // WHOLE batch therefore preserves the existing contract byte for byte: no - // partial success is introduced, no transaction is opened, no sibling- - // rollback question arises because siblings already fail together. - // Rejected instead: - // - **Per-row retry inside the batch** (the shape the card sketched). - // It requires splitting the one statement into N to learn which row - // failed, which INVENTS partial success where none exists, costs N - // round-trips on the hot seed/import path, and is what the engine's - // own #6806 note warns about ("re-writing the batch could DUPLICATE - // the rows that did land"). Nothing lands here, so nothing can. - // - **Wrapping the batch in a driver-opened transaction.** It would move - // the boundary the card was worried about, for no gain (the statement - // is already atomic), and it defeats the reservation model: each - // `getNextSequenceValue` commits on purpose, which is what makes a - // forward-only re-seed meaningful. On SQLite (pool max 1) it is also - // the deadlock `ensureSequencesTable` documents. - // - **Re-seed but do not re-issue** (what the engine does at #6806). - // Correct there, because the engine cannot know whether an arbitrary - // driver applied the batch partially. Inside THIS driver that is - // measurably false, so the caller can be handed a successful batch - // instead of an error it has to retry itself. - // - **Blanket retry on any unique violation.** Rejected for #5495's - // reason: it silently eats the caller's own 409. - const mayRetry = options?.transaction === undefined; - // What each row currently holds, ACROSS attempts. A retry regenerates only - // the fields it cleared, so `fillAutoNumberFields` reports only those; a - // value kept from the previous pass is still a live reservation and has to - // stay described here, or a second collision on a counter that did not go - // stale the first time would find nothing to route and rethrow blind. - const reservationsPerRow: AutoNumberReservation[][] = rows.map(() => []); - for (let attempt = 0; ; attempt++) { - // Reserve a persistent sequence value for each row's autonumber - // field(s) — the engine no longer pre-fills these (see #1603). - for (let i = 0; i < rows.length; i++) { - const row = rows[i]; - if (!row || typeof row !== 'object') continue; - const reissued = await this.fillAutoNumberFields(object, row, options); - if (reissued.length === 0) continue; - const replaced = new Set(reissued.map((r) => r.field)); - reservationsPerRow[i] = [...reservationsPerRow[i].filter((r) => !replaced.has(r.field)), ...reissued]; - } - - // Same write-side marshaling as create() (#2735): JSON-typed and - // object-valued fields must be serialized per row before they reach the - // knex binder — the raw batch used to hand `{lat, lng}` objects straight - // to SQLite ("Wrong API use: tried to bind a value of an unknown type"), - // silently failing the whole seed batch. Timestamp stamping runs on the - // FORMATTED copy, mirroring create(). - const formattedRows = rows.map((row) => { - if (!row || typeof row !== 'object') return row; - const formatted = this.applyWriteColumnMap(object, this.formatInput(object, row)); - this.stampInsertTimestamps(object, formatted); - return formatted; - }); - const builder = this.getBuilder(this.rotationWriteTarget(object) ?? object, options); - - try { - if (this.insertReturnsStoredRows) { - const result = await builder.insert(formattedRows).returning('*'); - // Read-back parity with create(): JSON columns come back as their stored - // strings from `returning('*')` — decode them so batch callers see the - // same shapes single-insert callers do. - return Array.isArray(result) - ? result.map((r) => this.formatOutput(object, r)) - : result; - } - await builder.insert(formattedRows); - } catch (error) { - if (!mayRetry || attempt >= AUTONUMBER_COLLISION_RETRIES) throw error; - const colliding = await this.collidingAutoNumberReservations(error, reservationsPerRow.flat(), options); - if (colliding.length === 0) throw error; - - // Re-seed each stale counter ONCE, not once per row that drew from it: - // the scan is identical for every row sharing a counter and the update - // is forward-only, so the repeats are pure cost. - const stale = new Set(); - for (const reservation of colliding) { - const key = this.autoNumberCounterKey(reservation); - if (stale.has(key)) continue; - stale.add(key); - await this.resyncSequenceToDataMax(reservation); - } - - // Re-issue EVERY row drawn from a stale counter — including rows whose - // own value did not collide. This is where the batch genuinely differs - // from `create()`, which keeps a non-colliding value to avoid burning a - // second number. Keeping them here is unsound: a batch that straddles - // the seeded range (say the counter is at 10, rows 11–39 exist, and the - // batch reserved 11–70) has its low rows collide and its high rows not, - // and regenerating only the low ones hands them numbers ABOVE the kept - // ones — an intra-batch duplicate this driver would have created - // itself. Counters that did NOT go stale keep their values, so another - // tenant's rows in the same batch are undisturbed. - for (let i = 0; i < rows.length; i++) { - for (const reservation of reservationsPerRow[i]) { - if (stale.has(this.autoNumberCounterKey(reservation))) delete rows[i][reservation.field]; - } - } - continue; - } - - // [#21227] Reached only when the INSERT landed and answered no rows (the - // MySQL family answered `[insertId]`: one element for the whole batch, - // which the engine's one-result-per-row guard then refused after every - // row had been stored). ONE read for the batch, by the ids written, in - // the written order. Outside the try for the reason `create` gives. - return this.readBackInsertedRows( - object, - this.rotationWriteTarget(object) ?? object, - rows, - options, - ); - } + bulkCreate(object: string, data: any[], options?: DriverOptions): Promise[]> { + // [#29520 codemod] The body moved to `sql-driver-bulk-operations.ts` (see `BulkOperationsHost`). + const readInsertReturnsStoredRows = () => this.insertReturnsStoredRows; + const host: ThisParameterType = { + applyWriteColumnMap: (...a) => this.applyWriteColumnMap(...a), + auditMissingTenant: (...a) => this.auditMissingTenant(...a), + autoNumberCounterKey: (...a) => this.autoNumberCounterKey(...a), + collidingAutoNumberReservations: (...a) => this.collidingAutoNumberReservations(...a), + fillAutoNumberFields: (...a) => this.fillAutoNumberFields(...a), + formatInput: (...a) => this.formatInput(...a), + formatOutput: (...a) => this.formatOutput(...a), + getBuilder: this.getBuilder.bind(this), + injectTenantOnInsert: (...a) => this.injectTenantOnInsert(...a), + get insertReturnsStoredRows() { return readInsertReturnsStoredRows(); }, + readBackInsertedRows: (...a) => this.readBackInsertedRows(...a), + resyncSequenceToDataMax: (...a) => this.resyncSequenceToDataMax(...a), + rotationWriteTarget: (...a) => this.rotationWriteTarget(...a), + stampInsertTimestamps: (...a) => this.stampInsertTimestamps(...a), + }; + return bulkCreate.call(host, object, data, options); } /** @@ -8829,25 +4303,14 @@ export class SqlDriver implements IDataDriver { // appeared in neither its repaired set nor its excluded set — it was never // seen. Pinned at the type level, both halves, in // `sql-driver-doors-declared-types.test.ts`. - async bulkUpdate(object: string, updates: Array<{ id: string | number; data: Record }>, options?: DriverOptions): Promise[]> { - // An empty batch issues no statement, exactly as before: a BEGIN/COMMIT - // pair — and the pool checkout behind it — buys nothing for zero rows. - if (updates.length === 0) return []; - - const applyAll = async (scoped: DriverOptions): Promise[]> => { - const results: Record[] = []; - for (const { id, data } of updates) { - const updated = await this.update(object, id, data, scoped); - if (updated) results.push(updated); - } - return results; + bulkUpdate(object: string, updates: Array<{ id: string | number; data: Record }>, options?: DriverOptions): Promise[]> { + // [#29520 codemod] The body moved to `sql-driver-bulk-operations.ts` (see `BulkOperationsHost`). + const readKnex = () => this.knex; + const host: ThisParameterType = { + get knex() { return readKnex(); }, + update: (...a) => this.update(...a), }; - - // The `(caller's transaction) ?? this.knex` runner idiom this file already - // uses (see {@link collidingAutoNumberReservations}); here the choice of - // runner is also the choice between a transaction and a savepoint. - const runner: Knex | Knex.Transaction = (options?.transaction as Knex.Transaction | undefined) ?? this.knex; - return runner.transaction(async (trx) => applyAll({ ...options, transaction: trx })); + return bulkUpdate.call(host, object, updates, options); } async bulkDelete(object: string, ids: Array, options?: DriverOptions): Promise { @@ -8909,45 +4372,23 @@ export class SqlDriver implements IDataDriver { * literal post-map column name, the spelling `created_at` carries everywhere in * this file. */ - async updateMany(object: string, query: DriverQuery, data: any, options?: DriverOptions): Promise { - this.auditMissingTenant(object, 'updateMany', options); - - // `data ?? {}` keeps the nullish tolerance this door already had on the - // stamping line below: a null payload normalises to "no keys", which emits - // the same stamp-only statement `main` emitted for it, instead of newly - // throwing inside `formatInput`. - const formatted = this.applyWriteColumnMap(object, this.formatInput(object, data ?? {})); - - // Read from `formatted` rather than the caller's `data`, exactly as - // {@link update} and {@link rotatedUpdateById} do. On an external object - // whose columnMap remaps `updated_at` the two spellings disagree, and this - // door must reach the SAME decision those two reach rather than a private one. - const stamping = this.stampsUpdatedAt(object) && !this.keepSuppliedUpdatedAt(formatted, options); - // Untouched when nothing is stamped, so a timestamp-less object's statement - // is byte-identical to the one `main` emits. - const payload = stamping ? { ...formatted, updated_at: this.updatedAtStamp() } : formatted; - // A rotation-managed object is recorded in `tablesWithTimestamps` by - // `initObjects` before `ensureRotation` runs, so it is never in the - // speculative state — the same property `rotatedUpdateById` relies on. The - // speculative path below therefore only ever sees the single-target loop. - const presumed = stamping && this.updatedAtStampIsPresumed(object); - - let total = 0; - for (const target of this.rotationShardsOf(object) ?? [object]) { - // One definition of the statement, so the speculative attempt, the fenced - // retry and the plain path cannot drift in WHERE or tenant scope. - const issue = (candidate: Record, issueOptions?: DriverOptions): Promise => { - const builder = this.getBuilder(target, issueOptions); - this.applyTenantScope(builder, object, issueOptions); - if (query.where) this.applyFilters(builder, query.where, issueOptions); - return builder.update(candidate) as unknown as Promise; - }; - total += - (presumed - ? await this.updateWithPresumedTimestamp(object, payload, options, issue) - : await issue(payload, options)) || 0; - } - return total; + updateMany(object: string, query: DriverQuery, data: any, options?: DriverOptions): Promise { + // [#29520 codemod] The body moved to `sql-driver-bulk-operations.ts` (see `BulkOperationsHost`). + const host: ThisParameterType = { + applyFilters: (...a) => this.applyFilters(...a), + applyTenantScope: (...a) => this.applyTenantScope(...a), + applyWriteColumnMap: (...a) => this.applyWriteColumnMap(...a), + auditMissingTenant: (...a) => this.auditMissingTenant(...a), + formatInput: (...a) => this.formatInput(...a), + getBuilder: this.getBuilder.bind(this), + keepSuppliedUpdatedAt: (...a) => this.keepSuppliedUpdatedAt(...a), + rotationShardsOf: (...a) => this.rotationShardsOf(...a), + stampsUpdatedAt: (...a) => this.stampsUpdatedAt(...a), + updateWithPresumedTimestamp: (...a) => this.updateWithPresumedTimestamp(...a), + updatedAtStamp: (...a) => this.updatedAtStamp(...a), + updatedAtStampIsPresumed: (...a) => this.updatedAtStampIsPresumed(...a), + }; + return updateMany.call(host, object, query, data, options); } async deleteMany(object: string, query: DriverQuery, options?: DriverOptions): Promise { @@ -8964,39 +4405,25 @@ export class SqlDriver implements IDataDriver { /** By-id update for a rotation-managed object: the row lives in exactly one * shard — probe newest-first, mirroring the un-rotated {@link update}. */ - protected async rotatedUpdateById( + protected rotatedUpdateById( object: string, shards: string[], id: string | number, data: Record, options?: DriverOptions, ): Promise | null> { - const formatted = this.applyWriteColumnMap(object, this.formatInput(object, data)); - // [commit 479fba50d] One definition of the decision, shared with {@link update}. No - // fallback is threaded here, and that is a property of the path rather than - // an omission: `rotationShardsOf` returns shards only once `ensureRotation` - // has run, and `initObjects` records the stronger `tablesWithTimestamps` - // fact on the line immediately before that call — so on this path the - // answer can never come from the declared-shape presumption, and there is - // nothing speculative to recover from. - // - // [#24835] Stamped into a FRESH object, for the reason {@link update} gives: - // `formatted` may be the caller's own `data`. - const payload = this.stampsUpdatedAt(object) && !this.keepSuppliedUpdatedAt(formatted, options) - ? { ...formatted, updated_at: this.updatedAtStamp() } - : formatted; - for (const shard of shards) { - const builder = this.getBuilder(shard, options).where('id', id); - this.applyTenantScope(builder, object, options); - const count = await builder.update(payload); - if (count > 0) { - const readback = this.getBuilder(shard, options).where('id', id); - this.applyTenantScope(readback, object, options); - const updated = await readback.first(); - return this.formatOutput(object, updated) || null; - } - } - return null; + // [#29520 codemod] The body moved to `sql-driver-bulk-operations.ts` (see `BulkOperationsHost`). + const host: ThisParameterType = { + applyTenantScope: (...a) => this.applyTenantScope(...a), + applyWriteColumnMap: (...a) => this.applyWriteColumnMap(...a), + formatInput: (...a) => this.formatInput(...a), + formatOutput: (...a) => this.formatOutput(...a), + getBuilder: this.getBuilder.bind(this), + keepSuppliedUpdatedAt: (...a) => this.keepSuppliedUpdatedAt(...a), + stampsUpdatedAt: (...a) => this.stampsUpdatedAt(...a), + updatedAtStamp: (...a) => this.updatedAtStamp(...a), + }; + return rotatedUpdateById.call(host, object, shards, id, data, options); } /** @@ -9097,37 +4524,16 @@ export class SqlDriver implements IDataDriver { return backendStatementFault.call(host, object, error); } - async count(object: string, query?: DriverQuery, options?: DriverOptions): Promise { - const builder = this.getBuilder(object, options); - this.applyTenantScope(builder, object, options); - - if (query?.where) { - this.applyFilters(builder, query.where, options); - } - - // [#8790] `count()` has no recovery ladder and needs none — it carries no - // projection and no ORDER BY, so the only clause an unresolvable column can - // be in is the WHERE, which is the one clause {@link SqlDriver.findRows}' - // ladder may not drop either. What it needs is the ladder's *terminal*: the - // same `INVALID_FILTER` / 400, naming the same column, so the two halves of - // one list view stop answering one predicate two different ways. Before - // this, the dialect error travelled out untouched — `SQLITE_ERROR`, no - // `status`, bound literals inlined in the message. - let result: { count: number }[]; - try { - result = await builder.count<{ count: number }[]>('* as count'); - } catch (error) { - if (isUnresolvableColumnError(error)) { - throw this.unresolvableFilterColumnRefusal(object, error, query?.where); - } - // [#8931] The terminal catch-all — see {@link SqlDriver.backendStatementFault}. - throw this.backendStatementFault(object, error); - } - if (result && result.length > 0) { - const row: any = result[0]; - return Number(row.count ?? row['count(*)'] ?? 0); - } - return 0; + count(object: string, query?: DriverQuery, options?: DriverOptions): Promise { + // [#29520 codemod] The body moved to `sql-driver-bulk-operations.ts` (see `BulkOperationsHost`). + const host: ThisParameterType = { + applyFilters: (...a) => this.applyFilters(...a), + applyTenantScope: (...a) => this.applyTenantScope(...a), + backendStatementFault: (...a) => this.backendStatementFault(...a), + getBuilder: this.getBuilder.bind(this), + unresolvableFilterColumnRefusal: (...a) => this.unresolvableFilterColumnRefusal(...a), + }; + return count.call(host, object, query, options); } // =================================== @@ -9346,276 +4752,31 @@ export class SqlDriver implements IDataDriver { * below is the contract's own, pinned at the type level in * `sql-driver-doors-declared-types.test.ts`. */ - async aggregate(object: string, query: DriverQuery, options?: DriverOptions): Promise[]> { - const builder = this.getBuilder(object, options); - this.applyTenantScope(builder, object, options); - - if (query.where) { - this.applyFilters(builder, query.where, options); - } - - // The same coercion key `applyFilters` just used, so every part of this - // statement agrees on how each column is stored — the WHERE window, the - // GROUP BY bucket expression (#3773) and the result presentation (#3797). - const table = this.coercionKey(builder); - - // Result columns and the presentation each takes, keyed by the column name - // the caller will read: a raw column VALUE (a group key, `min`/`max`) takes - // its column's presentation, and [#20335] a count or total derived from one - // takes the `'number'` presentation (see `AGGREGATE_ANSWER_KIND`). - // Collected while the statement is built because that is the only point - // where a column name and its meaning are both known: a `min()` lands under - // its alias (never under the field name), and a date-BUCKETED column lands - // under the field name while holding a label (`'2026-01'`), not a value. - // Matching on names after the fact gets both of those backwards. - // See {@link presentReadColumns}. - const presentedOutput = new Map(); - - // [#15546] Result columns whose NULL answer folds to the identity the - // platform declares for that aggregate over NOTHING (`emptyGroupValueFor`, - // spec `data/aggregation-policy.ts`), keyed like `presentedOutput` by the - // column name the caller will read. See {@link foldEmptyAggregateAnswers}. - const foldedOutput = new Map(); - - if (query.groupBy) { - // groupBy items may be plain strings ('region') or structured objects - // ({ field: 'closed_at', dateGranularity: 'quarter' }). For structured - // items we emit a dialect-specific bucket expression aliased as the - // projected column name so the resulting row keys match in-memory - // bucketDateValue — see the `outKey` note below for what that name is. - // [#6212] The element type is `GroupByNode` — the spec's own union — so - // the local `Array` restatement is - // gone. It had drifted from the declaration it was restating: `alias` was - // missing from it and `dateGranularity` was widened to `string`, which is - // what forced the `as any` on the `buildDateBucketExpr` call below. - for (const g of query.groupBy) { - if (typeof g === 'string') { - builder.groupBy(g); - builder.select(g); - const kind = this.readPresentationKind(table, g); - if (kind) presentedOutput.set(g, kind); - } else if (g && typeof g === 'object' && g.field) { - // [#6401] The projected column is named `alias ?? field` — the rule - // `AggregationNodeSchema.alias` already gets a few dozen lines below, - // and the one `in-memory-aggregation.ts` has always applied - // (`g.alias ?? g.field`). This face was the half that PARSED the key - // and ignored it, so one aggregate came back keyed by `closed_at` - // under pushdown and by `qtr` under the in-memory fallback — decided - // by a driver capability bit and a timezone the caller cannot see - // (`engine.ts`'s `allStructuredSupported && !tzRequiresInMemory` - // fork). GROUP BY still keys on the FIELD; only the projection is - // renamed, so the buckets are identical and only their column name - // moves. - const outKey = g.alias ?? g.field; - if (g.dateGranularity) { - const bucket = this.buildDateBucketExpr(g.field, g.dateGranularity, table); - if (!bucket) { - // [#6212] Was a bare `throw new Error(...)`; see - // {@link refuseDateBucketedGroupBy} for why it now carries the - // ADR-0112 envelope and what the remote face answers. - refuseDateBucketedGroupBy( - g.dateGranularity, - Object.entries(this.dateGranularityCapabilities).filter(([, on]) => on).map(([k]) => k), - `dialect '${(this.config as any).client}'`, - ); - } - builder.groupByRaw(bucket.sql, bucket.bindings); - builder.select(this.knex.raw(`${bucket.sql} as ${this.aliasIdentifierSql(outKey)}`, [...bucket.bindings])); - } else { - builder.groupBy(g.field); - // Aliased only when the name actually moves: an alias equal to - // the field would otherwise rewrite `select "region"` into - // `select "region" as "region"` on every dialect for no gain. - // [#13714] The alias half is {@link aliasIdentifierSql}, never a - // `??` binding — the FIELD stays a `??` reference (it may be - // qualified), the alias is one identifier by definition. - builder.select(outKey === g.field ? g.field : this.knex.raw(`?? as ${this.aliasIdentifierSql(outKey)}`, [g.field])); - // Keyed by the OUTPUT column, like the aggregation branch below — - // `presentReadColumns` matches on the name the row actually - // carries, so an aliased group value went unpresented before. - const kind = this.readPresentationKind(table, g.field); - if (kind) presentedOutput.set(outKey, kind); - } - } - } - } - - // [#6321] Was `query.aggregations || query.aggregate` / `agg.function || - // agg.func`. Neither `aggregate` nor `func` is declared anywhere in the - // Query Protocol — `QueryASTSchema` declares `aggregations` and - // `AggregationNodeSchema` declares `function` — so those two limbs were a - // private dialect this consumer tolerated, which is what PD#12 rejects. The - // only writers were this package's own fixtures and driver-sqlite-wasm's - // (#4984's family: a fixture spelling the alias keeps the lenient limb green - // forever, and nothing ever measures that deleting it costs nothing). Both - // fixture sets now spell the declared keys, the non-test writer count was - // zero when measured, and ADR-0049 says an unenforced tolerance goes. - const aggregates = query.aggregations; - if (aggregates) { - for (const agg of aggregates) { - // [#10576] A per-aggregation `filter` this compiler cannot express is - // refused before any statement is built — silently emitting the - // UNFILTERED aggregate is the #10413 defect. Unreachable through - // `engine.aggregate` (the engine lowers filtered aggregations in - // memory); this is the direct-caller guard. `{}` is the vacuous filter, - // same convention as `where` / `having`. - if (agg.filter && Object.keys(agg.filter).length > 0) { - throw unsupportedAggregationFilterError(agg.alias ?? agg.field ?? '(unaliased)', 'driver-sql'); - } - const funcName = agg.function; - const lowering = this.mapAggregateFunc(funcName); - // Spec: `field` is optional for COUNT (means COUNT(*)). - const fieldExpr = agg.field ?? '*'; - // [#6409] `count_distinct` lowers to `count(distinct ??)`. The keyword - // goes in the SQL FRAGMENT, never in a binding: `??` still binds the - // column as a knex identifier exactly as it did for the other five, so - // the caller's field name has no path into the statement text. And - // `COUNT(DISTINCT *)` is not valid SQL anywhere — a distinct aggregate - // with no field is refused rather than emitted, so the caller reads - // their own mistake instead of a dialect's syntax error wrapped in a - // 500 (see {@link refuseDistinctAggregateWithoutField}). - if (lowering.distinct && fieldExpr === '*') refuseDistinctAggregateWithoutField(funcName); - // [#11635] A boolean aggregand is CAST for the arithmetic/order - // aggregates on Postgres — the one dialect that stores `Field.boolean` - // as a real `boolean` column and defines no `sum`/`avg`/`min`/`max` - // over it (SQLSTATE `42883`, measured on PG 16.13; SQLite stores 0/1 - // INTEGER and MySQL `tinyint(1)`, so both compute natively). The - // answers are ruled: this face must ANSWER — refusing cannot satisfy - // the contract — and [#11152] (maintainer 2026-08-28, superseding - // #11249's `false`/`true` for the order statistics) pins ALL FOUR as - // numbers: `sum`/`avg` arithmetic over 1/0, `min`/`max` the `0`/`1` - // the cast computes, presented as-is (see the presentation note - // below). `count`/`count_distinct` are deliberately NOT cast (both - // lower to `count`, defined over boolean everywhere — their answers - // were correct before this and must not move). - // [#20387] `sum` / `avg` accumulate in double on PostgreSQL and MySQL, - // the arithmetic SQLite and the engine's rows path already use, so one - // query answers one number on every face (`AGGREGATE_ACCUMULATION`). - // [#21042] Both policies, and the order they compose in, are - // `aggregandOperandSql` (`@objectstack/core`), read with this column's - // class from the registries below — the rule the analytics native-SQL - // face applies too. Still one `??` binding: the cast and the wrap are - // SQL text around it, so the column stays a knex identifier binding. - const aggregandClass = fieldExpr === '*' ? undefined : this.aggregandColumnClassOf(table, fieldExpr); - const argExpr = aggregandOperandSql(funcName, aggregandClass, this.dialectName, '??'); - const rawFunc = lowering.distinct - ? `${lowering.sql}(distinct ${argExpr})` - : `${lowering.sql}(${argExpr})`; - if (agg.alias) { - if (fieldExpr === '*') { - builder.select(this.knex.raw(`${lowering.sql}(*) as ${this.aliasIdentifierSql(agg.alias)}`)); - } else { - builder.select(this.knex.raw(`${rawFunc} as ${this.aliasIdentifierSql(agg.alias)}`, [fieldExpr])); - } - // [#15546] What this aggregate answers over NOTHING, read from the - // policy rather than restated: `sum` (and the two counts, which never - // arrive as NULL) fold to `0`; `avg`/`min`/`max` have no identity and - // their NULL passes through. See {@link foldEmptyAggregateAnswers}. - const identity = emptyGroupValueFor(funcName); - if (identity !== undefined) foldedOutput.set(agg.alias, identity); - // [#20335] A count or a total is presented as the number it is, on - // every dialect: node-postgres hands `bigint` / `numeric` back as a - // string and mysql2 `DECIMAL`, so without this the native path - // answered `"2"` where the rows path and SQLite answer `2`. Keyed on - // the function asked for; the precision policy (one JS double, the - // loss above a double's precision declared) is stated on - // `AGGREGATE_ANSWER_KIND`. The fold above runs first, so a folded - // `0` is already a number and passes through. - if (AGGREGATE_ANSWER_KIND[funcName] === 'number') { - presentedOutput.set(agg.alias, 'number'); - } - // `min`/`max` are the only supported functions that hand back a value - // OF the column rather than a count/total derived from it, so they are - // the only ones whose result still needs the column's presentation. - // `alias` is required by `AggregationNodeSchema`; the unaliased branch - // below lands under a dialect-dependent column name - // (`max("closed_at")` on SQLite, `max` on Postgres) and is defensive - // only, so it is deliberately not tracked. - if (AGGREGATE_ANSWER_KIND[funcName] === 'column' && agg.field) { - // [#11152] A BOOLEAN aggregand is the ruled exception to "the - // result still needs the column's presentation": the maintainer's - // 2026-08-28 ruling (superseding #11249's `false`/`true`, which - // #11635 implemented here) pins that booleans aggregate as NUMBERS - // on every face, with no per-aggregate exception — `min`/`max` - // answer `0`/`1`, the same numeric domain `sum`/`avg` answer in. - // Every dialect's backend already computes exactly that number - // (SQLite 0/1 INTEGER storage, MySQL `tinyint(1)`, Postgres via - // the `cast(?? as int)` above), so the ruled answer is the value - // with NO boolean presentation — the `'boolean'` kind is skipped - // rather than mapped. ROW reads are untouched: `find()` still - // presents a declared boolean as a JSON boolean; it is the - // AGGREGATION context that is numeric by rule. Temporal kinds - // still present (`sql-driver-aggregate-temporal-output.test.ts`), - // and the SQLite-only numeric repair still applies. - const kind = this.readPresentationKind(table, agg.field); - if (kind && kind !== 'boolean') presentedOutput.set(agg.alias, kind); - } - } else { - if (fieldExpr === '*') { - builder.select(this.knex.raw(`${lowering.sql}(*)`)); - } else { - builder.select(this.knex.raw(rawFunc, [fieldExpr])); - } - } - } - } - - // [#11455] The third read exit joins the other two. `find()` and `count()` - // have carried the terminal envelope since #8931; this door executed BARE, - // so any dialect error the statement raised left the driver as the - // backend's own object — a raw SQLSTATE in `code`, `status` UNDEFINED, and - // the compiled statement as the message. Measured on live PostgreSQL 16.13: - // - // sum(flag) => THREW code=42883 status=undefined - // msg=select sum("flag") as "n" from "…" - - // function sum(boolean) does not exist - // - // A raw `42883` is on no list `@objectstack/rest` reads, so an ordinary - // analytics shape — a rate measure over a flag column — was logged as an - // UNHANDLED server fault. `mapDataError` then had nothing declared to - // forward, which is the #1116/#1117 gap {@link mapAggregateFunc} closed for - // the FUNCTION NAME while leaving the STATEMENT half open. - // - // When #11455 landed, whether a boolean aggregate should ANSWER or REFUSE - // was deliberately left to #11152 / #11249 — the envelope was the half - // that could land first. #11249 has since RULED (maintainer 2026-08-23): - // the face answers, and [#11635] delivered the Postgres cast in the - // statement builder above, so a boolean aggregand no longer reaches this - // exit at all. The envelope itself is unchanged — it never recognised - // `42883`, so removing one of its inputs removes nothing from it. - // - // ⛔ And no boolean-specific recognizer: the envelope comes from the EXIT, - // not from matching `42883` or the words `does not exist`. That is the - // #8926 lesson applied in advance — a predicate arm that matches a WORDING - // is an arm that silently fails to fire on the dialect nobody measured. - // - // [#11541] The unresolvable-column arm that {@link SqlDriver.count} runs - // first is here too now — but ⛔ NOT as the blanket arm #11455 refused to - // add. That refusal's words are "Filter on 'x' names a column that object - // 'o' has no column for", and THIS door names columns in three clauses — - // the WHERE, the `groupBy` fields and the aggregation `field`s. A blanket - // arm would tell the author of `avg('nosuchcol')` that their FILTER was - // wrong, which is the unsupportable claim the #8931 ruling refuses to - // make. So the dialect-named column is attributed to the clause the - // CALLER'S OWN QUERY names it in — see - // {@link SqlDriver.aggregateBackendFault}, which holds the three arms and - // the reason the no-name case keeps this terminal unchanged. - // - // Only the EXECUTION is guarded. Every refusal this method composes — - // {@link refuseAggregateFunction}, {@link refuseDistinctAggregateWithoutField}, - // {@link unsupportedAggregationFilterError}, {@link refuseDateBucketedGroupBy} - // — is raised while the statement is BUILT, upstream of this line, so the - // catch-all cannot bury a precise refusal under a generic 500. - let rows: unknown[]; - try { - rows = await builder; - } catch (error) { - // [#8931, #11541] Attribute what the caller's own query supports, then - // fall to the terminal catch-all — see - // {@link SqlDriver.aggregateBackendFault}. - throw this.aggregateBackendFault(object, query, error); - } - return this.presentReadColumns(this.foldEmptyAggregateAnswers(rows, foldedOutput), presentedOutput); + aggregate(object: string, query: DriverQuery, options?: DriverOptions): Promise[]> { + // [#29520 codemod] The body moved to `sql-driver-aggregation.ts` (see `AggregationHost`). + const readConfig = () => this.config; + const readDateGranularityCapabilities = () => this.dateGranularityCapabilities; + const readDialectName = () => this.dialectName; + const readKnex = () => this.knex; + const host: ThisParameterType = { + aggregandColumnClassOf: (...a) => this.aggregandColumnClassOf(...a), + aggregateBackendFault: (...a) => this.aggregateBackendFault(...a), + aliasIdentifierSql: (...a) => this.aliasIdentifierSql(...a), + applyFilters: (...a) => this.applyFilters(...a), + applyTenantScope: (...a) => this.applyTenantScope(...a), + buildDateBucketExpr: (...a) => this.buildDateBucketExpr(...a), + coercionKey: (...a) => this.coercionKey(...a), + get config() { return readConfig(); }, + get dateGranularityCapabilities() { return readDateGranularityCapabilities(); }, + get dialectName() { return readDialectName(); }, + foldEmptyAggregateAnswers: (...a) => this.foldEmptyAggregateAnswers(...a), + getBuilder: this.getBuilder.bind(this), + get knex() { return readKnex(); }, + mapAggregateFunc: (...a) => this.mapAggregateFunc(...a), + presentReadColumns: (...a) => this.presentReadColumns(...a), + readPresentationKind: (...a) => this.readPresentationKind(...a), + }; + return aggregate.call(host, object, query, options); } /** @@ -9684,14 +4845,8 @@ export class SqlDriver implements IDataDriver { * `AGGREGATION_CASES` (spec `data/aggregation-conformance.ts`). */ protected foldEmptyAggregateAnswers(rows: any, identities: Map): any { - if (identities.size === 0 || !Array.isArray(rows)) return rows; - for (const row of rows) { - if (!row || typeof row !== 'object') continue; - for (const [column, identity] of identities) { - if (row[column] === null) row[column] = identity; - } - } - return rows; + // [#29520 codemod] The body moved to `sql-driver-aggregation.ts`. + return foldEmptyAggregateAnswers(rows, identities); } /** @@ -9851,89 +5006,18 @@ export class SqlDriver implements IDataDriver { * * Held by `sql-driver-distinct-filter-narrowing.test.ts`. */ - async distinct(object: string, field: string, filters?: FilterCondition, options?: DriverOptions): Promise { - const builder = this.getBuilder(object, options); - // The third read door that skipped the chokepoint, and the one #6792 does - // NOT name — the card asserts the opposite ("called at 13 sites — …, - // `count`, `distinct`, …"). It is not among them; the 13th read site is - // `aggregate()`. Found by the gate this change ships, not by the card. - // - // VALUES rather than rows, which lowers the volume and not the class: - // measured on `main` at `6595262`, `distinct(account, 'name', undefined, - // { tenantId: 'org_a' })` returned `[A1, A2, B1, B2, P1]` — every other - // tenant's values for the named column. Documented with a runnable example - // (`content/docs/protocol/objectql/query-syntax.mdx`), so it is exposed the - // same way the window door is. - this.applyTenantScope(builder, object, options); - - if (filters) { - this.applyFilters(builder, filters, options); - } - - builder.distinct(field); - // [commit 7c2c5aedd] The THIRD read door joins the other two. `find()` and `count()` - // have carried the terminal envelope since #8931 and `aggregate()` since - // #11455; this one still executed BARE, so any dialect refusal the - // statement raised left the driver as the backend's own object — a raw - // SQLSTATE in `code`, `status` UNDEFINED, and the compiled statement as the - // message. Measured on live PostgreSQL 16.13: this driver stores every - // `multiple: true` column as `json`, and `json` defines no equality - // operator, so `SELECT DISTINCT` over one is refused by the backend: - // - // distinct(t, 'toggles') => THREW code=42883 status=undefined - // msg=select distinct "toggles" from "…" - - // could not identify an equality operator - // for type json - // - // Class-wide across every JSON column — `toggle`/`boolean`/`number` with - // `multiple: true` and `tags` all measured, with a scalar `boolean` column - // in the same table answering normally as the lit control. A raw `42883` is - // on no list `@objectstack/rest` reads, so `mapDataError` had nothing - // declared to forward and an ordinary caller shape — list the distinct - // values of this column — was logged as an UNHANDLED server fault. - // - // ⛔ NOT a decision about whether a JSON column should ANSWER a distinct - // read; that question belonged to the card commit e04a0aff2 closed, on the same columns. Whatever this door - // ends up doing, it must not leak the backend's own object — so the - // envelope is the half that lands here, exactly as #11455 landed it for - // `aggregate()` while its own answer question was still open. - // - // ⛔ And no `42883` recognizer: the envelope comes from the EXIT, not from - // matching a SQLSTATE or the words `equality operator` — the #8926 lesson, - // applied in advance. The all-dialect sweep in - // `sql-driver-17639-distinct-fault-envelope.test.ts` asserts that on a - // route (a table that was never provisioned) that has no JSON column in it. - // - // ⛔ And still NO BLANKET `isUnresolvableColumnError` arm — commit 9ccc4179e closed - // the gap commit 7c2c5aedd left FILED, and it closed it the way #11541 closed the - // `aggregate()` one: by ATTRIBUTING the column to a clause of the caller's - // own request first. A blanket arm remains forbidden for the reason stated - // here since commit 7c2c5aedd — it would tell the author of `distinct(o, 'nosuchcol')`, - // who passed no filter at all, that their FILTER was wrong — and - // {@link SqlDriver.distinctBackendFault} is what makes that claim - // unnecessary rather than what makes it safe. - // - // Only the EXECUTION is guarded. Every refusal this method composes upstream - // — `applyFilters`' `INVALID_FILTER` out of {@link assertCompilableComparand} - // among them — is raised while the statement is BUILT, so the classifier - // cannot bury a precise refusal under a generic 500. - let results: unknown[]; - try { - results = await builder; - } catch (error) { - throw this.distinctBackendFault(object, field, error, filters); - } - const values = results.map((row: any) => row[field]); - - // Same presentation `find()` gives the column (#3797, #3849) — a caller - // listing a datetime's values should not get epoch integers here and ISO - // strings there, nor `0`/`1` for a boolean. Re-deduplicate afterwards: SQL - // `DISTINCT` compares STORED values, and one SQLite `Field.datetime` column - // holds both INTEGER epoch ms and ISO TEXT, so two rows recording the same - // instant survive as two rows and then collapse to the same presented value. - const kind = this.readPresentationKind(this.coercionKey(builder), field); - if (!kind) return values; - return [...new Set(values.map((v: any) => this.presentReadValue(kind, v)))]; + distinct(object: string, field: string, filters?: FilterCondition, options?: DriverOptions): Promise { + // [#29520 codemod] The body moved to `sql-driver-distinct.ts` (see `DistinctHost`). + const host: ThisParameterType = { + applyFilters: (...a) => this.applyFilters(...a), + applyTenantScope: (...a) => this.applyTenantScope(...a), + coercionKey: (...a) => this.coercionKey(...a), + distinctBackendFault: (...a) => this.distinctBackendFault(...a), + getBuilder: this.getBuilder.bind(this), + presentReadValue: (...a) => this.presentReadValue(...a), + readPresentationKind: (...a) => this.readPresentationKind(...a), + }; + return distinct.call(host, object, field, filters, options); } /** @@ -10091,108 +5175,20 @@ export class SqlDriver implements IDataDriver { * exactly as they are on `find()`, instead of being erased along with the * driver-private part (#6212). */ - async findWithWindowFunctions(object: string, query: SqlWindowFunctionQuery, options?: DriverOptions): Promise { - const builder = this.getBuilder(object, options); - // ROWS, so this is the read-side wall itself — not a consistency tidy-up - // (#6792). This door returned every tenant's rows to a caller that passed - // `options.tenantId`, because it built through `getBuilder` and then simply - // never reached the chokepoint all thirteen other doors route through. - // Measured on `main` at `6595262` with two tenants seeded: `tenantId: - // 'org_a'` returned `[a1, a2, b1, b2, p1]` here and `[a1, a2, p1]` through - // `find()` — org_b's rows, handed to org_a, at the driver layer. - // - // Placed BESIDE `getBuilder` and above the caller's `where`, which is the - // position `findRows()` uses: the predicate has to be on the builder before - // anything reads it, and `applyTenantScope` is what owns the equality and - // ADR-0105 D2 union semantics. Re-deriving either here - // would be a second, worse copy of the wall. - this.applyTenantScope(builder, object, options); - - builder.select('*'); - - if (query.where) { - this.applyFilters(builder, query.where, options); - } - - if (query.windowFunctions && Array.isArray(query.windowFunctions)) { - for (const wf of query.windowFunctions) { - const windowFunc = this.buildWindowFunction(wf); - builder.select(this.knex.raw(`${windowFunc} as ${this.aliasIdentifierSql(wf.alias)}`)); - } - } - - if (query.orderBy && Array.isArray(query.orderBy)) { - for (const sort of query.orderBy) { - builder.orderBy(this.mapSortField(sort.field), sort.order || 'asc'); - } - } - - // PRESENCE, not truthiness — the same test `findRows()` makes (#6577). - // `limit: 0` means "return no records" (#6485), and `0` is falsy, so - // `if (query.limit)` dropped the clause and answered a request for NOTHING - // with the WHOLE table. Measured on `main` before this line changed: three - // rows seeded, `{ limit: 0 }` returned 3 here and 0 through `find()` — one - // driver, two answers to one `QueryAST`. - if (query.limit !== undefined) builder.limit(query.limit); - if (query.offset !== undefined) builder.offset(query.offset); - - const rows = await builder; - if (!Array.isArray(rows)) return rows; - - // [commit 78bc4ad58] The last read door that returned STORAGE forms. It handed back - // `await builder` with no presentation at all, while `find()`/`findOne()` - // run every row through `formatOutput` and `aggregate()`/`distinct()` got - // `presentReadValue` under #3797/#3849 — so one driver answered one - // declared column two ways depending on which door you knocked on. - // Measured on `main` at `2e6a2ea4c9`, one row through the two doors: - // find() -> { ok: true, meta: { k: 1 } } - // window -> { ok: 1, meta: '{"k":1}' } - // i.e. a declared `Field.boolean` answered `1` and a declared `Field.object` - // answered the stored JSON TEXT. `formatOutput` rather than - // `presentReadValue` is what runs here, and that choice is load-bearing: - // {@link ReadPresentationKind} has no `json` member, so the per-value - // helper the other two doors use cannot present `meta` at all. These are - // ROWS, which is exactly what `formatOutput` takes. - // - // ── The alias columns are carved out, and that is the whole design ────── - // - // A window alias is a COMPUTED value, not a declared field, so no declared - // field's presentation rule may touch it. The case that forces the rule to - // be explicit is an alias that COLLIDES with a declared field name, and SQL - // has already decided that one: `select *` plus ` as ok` projects - // two columns named `ok` and the row object keeps the LAST, so the computed - // value wins the key and the declared column's value is not in the row at - // all. Measured on `main` at `2e6a2ea4c9` with `alias: 'ok'` over a - // declared `Field.boolean ok`: rows came back `ok: 1` and `ok: 2` — the - // ROW_NUMBERs, not the booleans (`true`/`false`), which is how you can tell - // them apart. So the alias wins the key BEFORE this change and still wins - // it after; what this carve-out prevents is presenting that computed number - // as the declared type, which would have turned ROW_NUMBER 1 and 2 into - // `true` and `true` and destroyed the very value the caller asked for. - // Pinned by `sql-driver-window-function-output.test.ts`. - // - // Snapshot-and-restore rather than a "which keys would `formatOutput` - // touch?" pre-computation: that question can only be answered by re-reading - // the declared-field registries `formatOutput` reads, which would be a - // second, worse copy of it — and one that goes silently stale the next time - // `formatOutput` learns a new rule. Restoring a value the pass never - // touched is a no-op, so the cheap-looking version buys nothing. - const aliases = Array.isArray(query.windowFunctions) - ? query.windowFunctions.map((wf) => String(wf.alias)) - : []; - - for (const row of rows) { - if (!row || typeof row !== 'object') continue; - const computed: [string, any][] = []; - for (const alias of aliases) { - if (Object.prototype.hasOwnProperty.call(row, alias)) computed.push([alias, row[alias]]); - } - // Mutates in place and returns the same row, as `findRows()` relies on. - this.formatOutput(object, row); - for (const [alias, value] of computed) row[alias] = value; - } - - return rows; + findWithWindowFunctions(object: string, query: SqlWindowFunctionQuery, options?: DriverOptions): Promise { + // [#29520 codemod] The body moved to `sql-driver-window-functions.ts` (see `WindowFunctionsHost`). + const readKnex = () => this.knex; + const host: ThisParameterType = { + aliasIdentifierSql: (...a) => this.aliasIdentifierSql(...a), + applyFilters: (...a) => this.applyFilters(...a), + applyTenantScope: (...a) => this.applyTenantScope(...a), + buildWindowFunction: (...a) => this.buildWindowFunction(...a), + formatOutput: (...a) => this.formatOutput(...a), + getBuilder: this.getBuilder.bind(this), + get knex() { return readKnex(); }, + mapSortField: (...a) => this.mapSortField(...a), + }; + return findWithWindowFunctions.call(host, object, query, options); } // =================================== @@ -10220,78 +5216,25 @@ export class SqlDriver implements IDataDriver { * `DriverQuery` member — so it takes `DriverQuery`, which is what `explain()` * already declared and forwarded here (#6212). */ - async analyzeQuery(object: string, query: DriverQuery, options?: DriverOptions): Promise { - const builder = this.getBuilder(object, options); - // A PLAN, not rows — so this is a smaller fix than the one above, and it is - // made on its own merits rather than riding in on that one (#6792). It is - // the SAME defect #6577 fixed on this method one builder line lower: a plan - // is only worth reading if it explains the statement `find()` would - // actually run, and a missing tenant predicate is not a cosmetic - // difference — it changes selectivity and therefore which index the planner - // picks, so the EXPLAIN answers for a query nobody will execute. - // Measured on `main` at `6595262`, `tenantId: 'org_a'`: - // analyze -> select * from `os6792_account` - // find -> select * from `os6792_account` - // where (`organization_id` = ? or `organization_id` is null) - // order by `id` asc - this.applyTenantScope(builder, object, options); - - if (query.fields) { - builder.select(query.fields); - } else { - builder.select('*'); - } - - if (query.where) { - this.applyFilters(builder, query.where, options); - } - - if (query.orderBy && Array.isArray(query.orderBy)) { - for (const sort of query.orderBy) { - builder.orderBy(this.mapSortField(sort.field), sort.order || 'asc'); - } - } - - // PRESENCE, not truthiness — see `findWithWindowFunctions()` above (#6577). - // The stake is different here and no smaller: this door returns a PLAN, and - // a plan is only worth reading if it explains the statement `find()` would - // actually run. Measured on `main` before this line changed: `{ limit: 0 }` - // compiled to `select * from `orders`` while `find()` sent - // `select * from `orders` order by `id` asc limit ?` — an EXPLAIN for a - // different query, which is the one thing an EXPLAIN must never be. - if (query.limit !== undefined) builder.limit(query.limit); - if (query.offset !== undefined) builder.offset(query.offset); - - const sql = builder.toSQL(); - const client = (this.config as any).client; - let explainResults: any; - - try { - if (this.isPostgres) { - explainResults = await this.knex.raw(`EXPLAIN (FORMAT JSON, ANALYZE) ${sql.sql}`, sql.bindings); - } else if (this.isMysql) { - explainResults = await this.knex.raw(`EXPLAIN FORMAT=JSON ${sql.sql}`, sql.bindings); - } else if (this.isSqlite) { - explainResults = await this.knex.raw(`EXPLAIN QUERY PLAN ${sql.sql}`, sql.bindings); - } else { - return { - sql: sql.sql, - bindings: sql.bindings, - client, - note: 'EXPLAIN not supported for this database client', - }; - } - - return { sql: sql.sql, bindings: sql.bindings, client, plan: explainResults }; - } catch (error: any) { - return { - sql: sql.sql, - bindings: sql.bindings, - client, - error: error.message, - note: 'Failed to execute EXPLAIN.', - }; - } + analyzeQuery(object: string, query: DriverQuery, options?: DriverOptions): Promise { + // [#29520 codemod] The body moved to `sql-driver-query-plan.ts` (see `QueryPlanHost`). + const readConfig = () => this.config; + const readIsMysql = () => this.isMysql; + const readIsPostgres = () => this.isPostgres; + const readIsSqlite = () => this.isSqlite; + const readKnex = () => this.knex; + const host: ThisParameterType = { + applyFilters: (...a) => this.applyFilters(...a), + applyTenantScope: (...a) => this.applyTenantScope(...a), + get config() { return readConfig(); }, + getBuilder: this.getBuilder.bind(this), + get isMysql() { return readIsMysql(); }, + get isPostgres() { return readIsPostgres(); }, + get isSqlite() { return readIsSqlite(); }, + get knex() { return readKnex(); }, + mapSortField: (...a) => this.mapSortField(...a), + }; + return analyzeQuery.call(host, object, query, options); } // =================================== @@ -10338,19 +5281,15 @@ export class SqlDriver implements IDataDriver { * what a file-backed database in WAL mode (the default, see * {@link applySqliteJournalMode}) needs — see `reclaimBetterSqlite3`. */ - async reclaimSpace(_options?: DriverOptions): Promise { - if (!this.isSqlite) return; - const client = this.knex.client; - if (client.driverName === 'better-sqlite3') { - const connection = await client.acquireConnection(); - try { - reclaimBetterSqlite3(connection); - } finally { - await client.releaseConnection(connection); - } - return; - } - await this.knex.raw('PRAGMA incremental_vacuum'); + reclaimSpace(_options?: DriverOptions): Promise { + // [#29520 codemod] The body moved to `sql-driver-schema-sync-section.ts` (see `SchemaSyncSectionHost`). + const readIsSqlite = () => this.isSqlite; + const readKnex = () => this.knex; + const host: ThisParameterType = { + get isSqlite() { return readIsSqlite(); }, + get knex() { return readKnex(); }, + }; + return reclaimSpace.call(host, _options); } // ── Data-lifecycle rotation (ADR-0057 P2) ───────────────────────────────── @@ -10649,16 +5588,8 @@ export class SqlDriver implements IDataDriver { * multi-tenant column was injected by the kernel without a spec migration). */ protected computeTenantField(schema: { fields?: Record; tenancy?: any }): string | null { - const tenancyDecl = (schema as any)?.tenancy; - // Explicit opt-out wins over any column-presence heuristic. - if (isTenancyDisabled(schema)) return null; - const fields = schema?.fields; - if (tenancyDecl?.tenantField) { - const declared = String(tenancyDecl.tenantField); - if (fields && Object.prototype.hasOwnProperty.call(fields, declared)) return declared; - } - if (fields && Object.prototype.hasOwnProperty.call(fields, 'organization_id')) return 'organization_id'; - return null; + // [#29520 codemod] The body moved to `sql-driver-rotation.ts`. + return computeTenantField(schema); } /** @@ -10775,120 +5706,46 @@ export class SqlDriver implements IDataDriver { protected registerManagedObjectMetadata( obj: { name: string; fields?: Record; tenancy?: any; indexes?: any[] }, ): { tableName: string; tenantField: string | null } { - const tableName = StorageNameMapping.resolveTableName(obj); - // #2186: remember the authoritative metadata field set for this table so - // drift detection / `os migrate` can diff the physical schema against it. - this.managedObjectFields.set(tableName, obj.fields ?? {}); - // [#21259] Recomputed on every registration, so a re-registration that now - // declares a column stops treating it as undeclared. - this.undeclaredAuditTimestampFields[tableName] = AUDIT_TIMESTAMP_COLUMNS.filter( - (col) => !Object.prototype.hasOwnProperty.call(obj.fields ?? {}, col), - ); - // Always overwrite — a metadata change that REMOVES `indexes` must clear - // the previous entry, or drift detection keeps expecting an index nobody - // declares any more (and never reports it as orphaned). - if (Array.isArray(obj.indexes)) { - this.managedObjectIndexes.set(tableName, obj.indexes); - } else { - this.managedObjectIndexes.delete(tableName); - } - // [#8621] This call may create the table, or add a unique index to one - // that already exists, so whatever the upsert pre-flight introspected - // before it is stale. Dropping the entry is enough — the entry is - // re-read lazily, and a refusal re-reads unconditionally. - this.physicalKeyIndexes.delete(tableName); - - const jsonCols: string[] = []; - const mediaCols: string[] = []; - const booleanCols: string[] = []; - const numericCols: string[] = []; - const numericValueCols: string[] = []; - const fractionalCols: string[] = []; - const autoNumberCols: Array<{ name: string; format: string; tokens: AutonumberToken[]; tenantField: string | null }> = []; - // Tenant-isolation column: explicit tenancy opt-out → declared field → - // implicit `organization_id`. See {@link computeAndRecordTenantField} - // (shared with registerExternalObject so the two paths can't drift). - const tenantField = this.computeAndRecordTenantField(tableName, obj); - if (obj.fields) { - for (const [name, field] of Object.entries(obj.fields)) { - const type = field.type || 'string'; - if (this.isJsonField(type, field)) { - jsonCols.push(name); - } - // Unconditional, on BOTH arms — see {@link mediaFields}. The read-side - // legacy-encoding repair runs on a deployment that has not moved too. - if (!isMultiValuedColumn(type, field) && FILE_REFERENCE_TYPES.has(type)) { - mediaCols.push(name); - } - // `toggle` shares boolean storage/affinity, so it needs the same - // read coercion (stored 1/0 → JS true/false) or it leaks back as a - // number/string instead of a boolean (#field-zoo). - // [commit d46deba19] SCALAR only, like the three neighbours below: a - // `multiple: true` boolean/toggle is a JSON column, and the read - // coercion this registry exists for presents ONE JS boolean — which - // collapses the parsed array to `true` whatever it holds. See - // {@link booleanFields}. - if ((type === 'boolean' || type === 'toggle') && !isMultiValuedColumn(type, field)) { - booleanCols.push(name); - } - // Numeric scalars are coerced back to JS numbers on read so legacy - // TEXT-affinity columns (created before they were mapped to a numeric - // column) still return numbers, not strings — see NUMERIC_SCALAR_TYPES. - if (NUMERIC_SCALAR_TYPES.has(type) && !isMultiValuedColumn(type, field)) { - numericCols.push(name); - } - // [#16318] The authorable half only — see {@link numericValueFields}. - if (NUMERIC_VALUE_TYPES.has(type) && !isMultiValuedColumn(type, field)) { - numericValueCols.push(name); - } - // [#20387, #21042] See {@link fractionalNumericFields}: the predicate's - // `'fractional'` class, scalar only by the predicate's own reading. - if (aggregandColumnClass({ type, multiple: field?.multiple }) === 'fractional') { - fractionalCols.push(name); - } - if (type === 'date') { - (this.dateFields[tableName] ??= new Set()).add(name); - } - if (type === 'datetime') { - (this.datetimeFields[tableName] ??= new Set()).add(name); - } - if (type === 'time') { - (this.timeFields[tableName] ??= new Set()).add(name); - } - if (type === 'auto_number' || type === 'autonumber') { - const fmt = resolveAutonumberFormat(field); - // Tokenize once: the renderer resolves date tokens (`{YYYYMMDD}`), - // field interpolation (`{island_zone}`) and the sequence slot at - // fill time. The counter scopes to whatever renders before the slot. - const tokens = parseAutonumberFormat(fmt); - autoNumberCols.push({ name, format: fmt, tokens, tenantField }); - } - } - } - this.jsonFields[tableName] = jsonCols; - // [#20444] The declared value shapes `$empty` expands — see {@link valueShapeFields}. - this.valueShapeFields[tableName] = declaredValueShapes(obj.fields); - this.mediaFields[tableName] = mediaCols; - this.booleanFields[tableName] = booleanCols; - this.numericFields[tableName] = numericCols; - this.numericValueFields[tableName] = numericValueCols; - this.fractionalNumericFields[tableName] = fractionalCols; - this.autoNumberFields[tableName] = autoNumberCols; - this.tenantFieldByTable[tableName] = tenantField; - // [commit 479fba50d] The declared shape's answer to "does this table carry - // `updated_at`?", installed here because here is the one place a managed - // object reaches the driver on EVERY boot posture — `initObjects` calls - // this first, and a `skipSchemaSync` boot calls it and stops. Presumed - // rather than asserted (see {@link updatedAtColumnState}); DDL that - // OBSERVES the column still records the stronger fact in - // `tablesWithTimestamps`, so a table this driver built is never in the - // speculative state at all. Never downgrades an answer already resolved - // from the database — re-registration is idempotent metadata assignment - // and must not throw away a `columnInfo()` result. - if (!this.updatedAtColumnState.has(tableName)) { - this.updatedAtColumnState.set(tableName, 'presumed'); - } - return { tableName, tenantField }; + // [#29520 codemod] The body moved to `sql-driver-rotation.ts` (see `RotationHost`). + const readAutoNumberFields = () => this.autoNumberFields; + const readBooleanFields = () => this.booleanFields; + const readDateFields = () => this.dateFields; + const readDatetimeFields = () => this.datetimeFields; + const readFractionalNumericFields = () => this.fractionalNumericFields; + const readJsonFields = () => this.jsonFields; + const readManagedObjectFields = () => this.managedObjectFields; + const readManagedObjectIndexes = () => this.managedObjectIndexes; + const readMediaFields = () => this.mediaFields; + const readNumericFields = () => this.numericFields; + const readNumericValueFields = () => this.numericValueFields; + const readPhysicalKeyIndexes = () => this.physicalKeyIndexes; + const readTenantFieldByTable = () => this.tenantFieldByTable; + const readTimeFields = () => this.timeFields; + const readUndeclaredAuditTimestampFields = () => this.undeclaredAuditTimestampFields; + const readUpdatedAtColumnState = () => this.updatedAtColumnState; + const readValueShapeFields = () => this.valueShapeFields; + const host: ThisParameterType = { + get autoNumberFields() { return readAutoNumberFields(); }, + get booleanFields() { return readBooleanFields(); }, + computeAndRecordTenantField: (...a) => this.computeAndRecordTenantField(...a), + get dateFields() { return readDateFields(); }, + get datetimeFields() { return readDatetimeFields(); }, + get fractionalNumericFields() { return readFractionalNumericFields(); }, + isJsonField: (...a) => this.isJsonField(...a), + get jsonFields() { return readJsonFields(); }, + get managedObjectFields() { return readManagedObjectFields(); }, + get managedObjectIndexes() { return readManagedObjectIndexes(); }, + get mediaFields() { return readMediaFields(); }, + get numericFields() { return readNumericFields(); }, + get numericValueFields() { return readNumericValueFields(); }, + get physicalKeyIndexes() { return readPhysicalKeyIndexes(); }, + get tenantFieldByTable() { return readTenantFieldByTable(); }, + get timeFields() { return readTimeFields(); }, + get undeclaredAuditTimestampFields() { return readUndeclaredAuditTimestampFields(); }, + get updatedAtColumnState() { return readUpdatedAtColumnState(); }, + get valueShapeFields() { return readValueShapeFields(); }, + }; + return registerManagedObjectMetadata.call(host, obj); } /** @@ -11990,11 +6847,8 @@ export class SqlDriver implements IDataDriver { /** True only when running under `NODE_ENV=production` — auto-DDL is force-disabled there. */ protected isProductionEnv(): boolean { - try { - return (process.env.NODE_ENV ?? '').toLowerCase() === 'production'; - } catch { - return false; - } + // [#29520 codemod] The body moved to `sql-driver-managed-drift.ts`. + return isProductionEnv(); } /** @@ -12119,28 +6973,17 @@ export class SqlDriver implements IDataDriver { * nullable tenant column still holds — a group of them is no duplicate the * index will refuse. */ - protected async probeNullSafeUniqueDuplicates( + protected probeNullSafeUniqueDuplicates( table: string, columns: string[], nullSafeColumns: string[], ): Promise> { - const ns = new Set(nullSafeColumns); - const q = (id: string) => this.knex.ref(id).toQuery(); - const parts = columns.map((c) => - ns.has(c) ? `COALESCE(${q(c)}, '${GLOBAL_TENANT}')` : q(c), - ); - const selectList = parts.map((p, i) => `${p} AS k${i}`).join(', '); - const groupList = parts.join(', '); - const sql = - `SELECT ${selectList}, COUNT(*) AS n FROM ${q(table)}` + - `${uniqueProbeNotNullClause(columns, ns, q)} ` + - `GROUP BY ${groupList} HAVING COUNT(*) > 1 ORDER BY n DESC LIMIT 20`; - const res: any = await this.knex.raw(sql); - const rows: any[] = Array.isArray(res) ? (Array.isArray(res[0]) ? res[0] : res) : (res?.rows ?? []); - return rows.map((r: any) => ({ - key: columns.map((c, i) => `${c}=${JSON.stringify(r[`k${i}`])}`).join(', '), - rows: Number(r.n ?? r.N ?? 0), - })); + // [#29520 codemod] The body moved to `sql-driver-managed-drift.ts` (see `ManagedDriftHost`). + const readKnex = () => this.knex; + const host: ThisParameterType = { + get knex() { return readKnex(); }, + }; + return probeNullSafeUniqueDuplicates.call(host, table, columns, nullSafeColumns); } /** @@ -12421,18 +7264,8 @@ export class SqlDriver implements IDataDriver { * below, so the rebuild is stable in both eras without special-casing. */ protected buildRebuiltColumn(t: Knex.CreateTableBuilder, c: IntrospectedColumn): any { - if (c.name === 'id') return t.string('id').primary(); - const ty = (c.type || 'text').toLowerCase(); - if (ty.includes('int')) return t.integer(c.name); - if (/(real|floa|doub|num|dec)/.test(ty)) return t.float(c.name); - if (ty.includes('bool')) return t.boolean(c.name); - if (ty.includes('datetime') || ty.includes('timestamp')) return t.timestamp(c.name); - if (ty === 'date') return t.date(c.name); - if (ty === 'time') return t.time(c.name); - if (ty.includes('json')) return t.json(c.name); - if (ty.includes('blob') || ty.includes('binary')) return t.binary(c.name); - if (ty.includes('text')) return t.text(c.name); - return t.string(c.name); + // [#29520 codemod] The body moved to `sql-driver-managed-drift.ts`. + return buildRebuiltColumn(t, c); } /** @@ -12604,30 +7437,17 @@ export class SqlDriver implements IDataDriver { * * Returns true when something was actually dropped. */ - protected async dropIndexIfExists(tableName: string, indexName: string): Promise { - const attempts: string[] = this.isPostgres - ? [ - `ALTER TABLE ?? DROP CONSTRAINT IF EXISTS ??`, - `DROP INDEX IF EXISTS ??`, - ] - : this.isMysql - ? [`ALTER TABLE ?? DROP INDEX ??`] - : [`DROP INDEX IF EXISTS ??`]; - - let dropped = false; - for (const sql of attempts) { - try { - const bindings = sql.startsWith('ALTER TABLE') ? [tableName, indexName] : [indexName]; - await this.knex.raw(sql, bindings); - dropped = true; - } catch (e: any) { - const msg = String(e?.message ?? e); - // "does not exist" / "check that column/key exists" — nothing to drop. - if (/does not exist|doesn't exist|no such index|check that column/i.test(msg)) continue; - throw e; - } - } - return dropped; + protected dropIndexIfExists(tableName: string, indexName: string): Promise { + // [#29520 codemod] The body moved to `sql-driver-managed-drift.ts` (see `ManagedDriftHost`). + const readIsMysql = () => this.isMysql; + const readIsPostgres = () => this.isPostgres; + const readKnex = () => this.knex; + const host: ThisParameterType = { + get isMysql() { return readIsMysql(); }, + get isPostgres() { return readIsPostgres(); }, + get knex() { return readKnex(); }, + }; + return dropIndexIfExists.call(host, tableName, indexName); } /** @@ -12647,21 +7467,19 @@ export class SqlDriver implements IDataDriver { * `os migrate apply` — and still auto-reconciled at boot in dev, via the same * `autoMigrate: 'safe'` policy that governs every other safe drift. */ - protected async syncTableIndexes( + protected syncTableIndexes( tableName: string, fields: Record, declaredIndexes: any[] | undefined, tenantField: string | null, physicalColumns: Set, ): Promise { - const fromFields = this.uniqueIndexesFromFields(tableName, fields, tenantField); - const declared = Array.isArray(declaredIndexes) ? declaredIndexes : []; - if (fromFields.length === 0 && declared.length === 0) return; - // Pass the tenant column through rather than letting `syncDeclaredIndexes` - // re-resolve it: every caller of this method already holds the value the - // registration recorded, and a declared `unique: 'organization'` index - // (ADR-0120 D1) must scope against exactly that column. - await this.syncDeclaredIndexes(tableName, [...fromFields, ...declared], physicalColumns, tenantField, fields); + // [#29520 codemod] The body moved to `sql-driver-managed-drift.ts` (see `ManagedDriftHost`). + const host: ThisParameterType = { + syncDeclaredIndexes: (...a) => this.syncDeclaredIndexes(...a), + uniqueIndexesFromFields: (...a) => this.uniqueIndexesFromFields(...a), + }; + return syncTableIndexes.call(host, tableName, fields, declaredIndexes, tenantField, physicalColumns); } /** @@ -12941,36 +7759,11 @@ export class SqlDriver implements IDataDriver { object: string, options?: DriverOptions, ): Knex.QueryBuilder { - const tenantId = options?.tenantId; - if (tenantId === undefined || tenantId === null || tenantId === '') return builder; - const field = this.resolveTenantField(object); - if (!field) return builder; - // [ADR-0105 D2 / #3623] Union tenant scope: under the `group` posture the - // engine threads the caller's whole membership set as `tenantIds`, and the - // native wall widens to `IN (...)` — the SAME union the Layer 0 - // authorization wall enforces above. Equality here would AND under that - // union and collapse group reads to active-org reach. A malformed or - // empty set falls through to the equality path: fail toward isolation, - // never toward exposure. Insert-side injection (injectTenantOnInsert) - // deliberately keeps `tenantId` — the active org is the write target (D5). - const rawIds = options?.tenantIds; - const tenantIds = Array.isArray(rawIds) - ? rawIds.filter((v: unknown) => typeof v === 'string' && v !== '') - : []; - if (tenantIds.length > 0) { - return builder.whereIn(field, tenantIds.map(String)); - } - // `field = :tenantId`, and nothing else. [ADR-0131 D1, D14] NULL is not an - // owner: an organization-less row is no tenant's, so no tenant scope reaches - // it. The v17 reading (#2734) added an `OR field IS NULL` arm here, under - // which such a row stood for platform state; ADR-0131 replaced that reading - // (D2, D6, D7) and the 18.x upgrade ceremony retired the rows it served: a - // certified database holds no NULL organization, a fresh install never - // writes one, and a v17 database is refused at boot until it is certified. - // A composition that registers no organization object can still write - // organization-less rows, and it never reaches this line with a tenant: no - // session, guest or system write there carries an organization. - return builder.where(field, String(tenantId)); + // [#29520 codemod] The body moved to `sql-driver-internal-helpers.ts` (see `InternalHelpersHost`). + const host: ThisParameterType = { + resolveTenantField: (...a) => this.resolveTenantField(...a), + }; + return applyTenantScope.call(host, builder, object, options); } /** @@ -13013,13 +7806,11 @@ export class SqlDriver implements IDataDriver { row: Record, options?: DriverOptions, ): void { - const tenantId = options?.tenantId; - if (tenantId === undefined || tenantId === null || tenantId === '') return; - const field = this.resolveTenantField(object); - if (!field) return; - if (row[field] === undefined || row[field] === null || row[field] === '') { - row[field] = String(tenantId); - } + // [#29520 codemod] The body moved to `sql-driver-internal-helpers.ts` (see `InternalHelpersHost`). + const host: ThisParameterType = { + resolveTenantField: (...a) => this.resolveTenantField(...a), + }; + return injectTenantOnInsert.call(host, object, row, options); } /** @@ -13059,42 +7850,16 @@ export class SqlDriver implements IDataDriver { options?: DriverOptions, insertRows?: readonly unknown[], ): void { - if (process.env.OS_TENANT_AUDIT === '0') return; - if (options?.bypassTenantAudit === true) return; - // A write that DID carry its tenant is the case this audit has nothing to - // say about, and it is the overwhelmingly common one — so it exits here, - // before the posture read below. Ordering only (both guards are pure - // predicates over independent facts); it is what makes `isMultiTenantMode()` - // affordable as a live read now that it no longer memoises (#5262). - const tenantId = options?.tenantId; - if (tenantId !== undefined && tenantId !== null && tenantId !== '') return; - // Only meaningful in multi-tenant deployments. Single-tenant stacks have no - // tenant isolation, yet the kernel now ALWAYS provisions an `organization_id` - // column (its existence is decoupled from the tenant flag). Column presence - // alone therefore no longer implies "tenant-scoped" — without this gate every - // system/sudo write (e.g. the notification/http delivery dispatchers' claim - // updates) would spam a meaningless warning on single-tenant boots. - if (!this.isMultiTenantMode()) return; - const field = this.resolveTenantField(object); - if (!field) return; - // Empty is spelled as `injectTenantOnInsert` spells it: a row it would - // stamp is a row that does not carry its organization. - const carriesTenant = (row: unknown): boolean => { - const value = row && typeof row === 'object' ? (row as Record)[field] : undefined; - return value !== undefined && value !== null && value !== ''; + // [#29520 codemod] The body moved to `sql-driver-internal-helpers.ts` (see `InternalHelpersHost`). + const readLogger = () => this.logger; + const readTenantAuditWarned = () => this.tenantAuditWarned; + const host: ThisParameterType = { + isMultiTenantMode: (...a) => this.isMultiTenantMode(...a), + get logger() { return readLogger(); }, + resolveTenantField: (...a) => this.resolveTenantField(...a), + get tenantAuditWarned() { return readTenantAuditWarned(); }, }; - if (insertRows !== undefined && insertRows.length > 0 && insertRows.every(carriesTenant)) return; - const key = `${object}:${op}`; - if (this.tenantAuditWarned.has(key)) return; - this.tenantAuditWarned.add(key); - this.logger.warn( - `[tenant-audit] ${op} on tenant-scoped object "${object}" without options.tenantId — the statement is not confined to one organization. ` - + 'If the write belongs to one organization, run it in that organization\'s context (an ExecutionContext carrying its organization); ' - + 'the row\'s own organization column does not scope an update or a delete. ' - + 'If it crosses organizations on purpose (a platform admin the organization wall exempts, or a system job over every organization), ' - + 'nothing needs fixing: the caller declares it with bypassTenantAudit:true, which also silences this line.', - { object, op, tenantField: field }, - ); + return auditMissingTenant.call(host, object, op, options, insertRows); } // ── Filter helpers ────────────────────────────────────────────────────────── @@ -13105,9 +7870,8 @@ export class SqlDriver implements IDataDriver { * `initObjects`). Returns null when the builder is not table-scoped yet. */ protected tableNameForBuilder(builder: any): string | null { - const t = builder?._single?.table; - if (typeof t === 'string') return t; - return null; + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts`. + return tableNameForBuilder(builder); } /** @@ -13167,10 +7931,8 @@ export class SqlDriver implements IDataDriver { * mirror direction, and `sql-driver-11389-date-tz-skew.test.ts` pins it. */ protected toDateOnly(value: any): any { - // [#20176] The rule — UTC getters included — is `@objectstack/core`'s - // `temporalStorageForm`, the function `driver-memory` and the engine's - // per-aggregation `filter` / `having` read too; this body was a copy of it. - return temporalStorageForm(value, 'date'); + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts`. + return toDateOnly(value); } /** @@ -13210,16 +7972,14 @@ export class SqlDriver implements IDataDriver { * `Field.date` keeps ISO TEXT, normalised to `YYYY-MM-DD` (ADR-0053 Phase 1). */ protected coerceFilterValue(table: string | null, field: string, value: any): any { - if (value == null || !table) return value; - if (Array.isArray(value)) return value.map((v) => this.coerceFilterValue(table, field, v)); - - const kind = this.temporalFieldKind(table, field); - if (!kind) return value; - if (kind === 'datetime') return this.storageDatetimeValue(value); - // `Field.time` (#3994) takes the same treatment for the same reason: the - // comparand must be the canonical time-of-day text the column stores. - if (kind === 'time') return canonicalTimeOfDay(value); - return this.toDateOnly(value); + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). + const host: ThisParameterType = { + coerceFilterValue: (...a) => this.coerceFilterValue(...a), + storageDatetimeValue: (...a) => this.storageDatetimeValue(...a), + temporalFieldKind: (...a) => this.temporalFieldKind(...a), + toDateOnly: (...a) => this.toDateOnly(...a), + }; + return coerceFilterValue.call(host, table, field, value); } /** @@ -13284,11 +8044,8 @@ export class SqlDriver implements IDataDriver { * becoming "no value". */ protected sqliteCanonicalDatetimeSql(columnSql: string): string { - return ( - `(case when typeof(${columnSql}) in ('integer','real') ` + - `then strftime('%Y-%m-%dT%H:%M:%fZ', ${columnSql}/1000.0, 'unixepoch') ` + - `else coalesce(strftime('%Y-%m-%dT%H:%M:%fZ', ${columnSql}), ${columnSql}) end)` - ); + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts`. + return sqliteCanonicalDatetimeSql(columnSql); } /** @@ -13344,11 +8101,8 @@ export class SqlDriver implements IDataDriver { * callers bind accordingly. */ protected sqliteNonTemporalTextSql(columnSql: string): string { - return ( - `(typeof(${columnSql}) not in ('integer','real') ` + - `and instr(${columnSql}, '-') = 0 and instr(${columnSql}, ':') = 0 ` + - `and julianday(${columnSql}) is not null)` - ); + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts`. + return sqliteNonTemporalTextSql(columnSql); } /** @@ -13380,15 +8134,8 @@ export class SqlDriver implements IDataDriver { * {@link SQLITE_TIME_EXPR_REFS} times; callers bind accordingly. */ protected sqliteCanonicalTimeSql(columnSql: string): string { - const canonText = (args: string) => - `case when strftime('%H:%M:%f', ${args}) like '%.000' ` + - `then strftime('%H:%M:%S', ${args}) ` + - `else strftime('%H:%M:%f', ${args}) end`; - return ( - `(case when typeof(${columnSql}) in ('integer','real') ` + - `then ${canonText(`${columnSql}/1000.0, 'unixepoch'`)} ` + - `else coalesce(${canonText(columnSql)}, ${columnSql}) end)` - ); + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts`. + return sqliteCanonicalTimeSql(columnSql); } /** @@ -13450,20 +8197,19 @@ export class SqlDriver implements IDataDriver { table: string | null | undefined, field: string, ): ReadPresentationKind | null { - if (!table) return null; - const temporal = this.temporalFieldKind(table, field); - if (temporal) return temporal; - if ((AUDIT_TIMESTAMP_COLUMNS as readonly string[]).includes(field)) return 'audit_timestamp'; - if ((this.isSqlite || this.isMysql) && this.booleanFields[table]?.includes(field)) { - return 'boolean'; - } - // [#16318] Every dialect, for the reason `formatOutput`'s own numeric pass - // records: an exact-decimal column is handed back as a STRING by - // node-postgres and by mysql2, so `aggregate()` / `distinct()` would present - // a declared numeric field as a string on exactly the dialects `find()` now - // presents it as a number. One class, one answer, on every door. - if (this.numericFields[table]?.includes(field)) return 'number'; - return null; + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). + const readBooleanFields = () => this.booleanFields; + const readIsMysql = () => this.isMysql; + const readIsSqlite = () => this.isSqlite; + const readNumericFields = () => this.numericFields; + const host: ThisParameterType = { + get booleanFields() { return readBooleanFields(); }, + get isMysql() { return readIsMysql(); }, + get isSqlite() { return readIsSqlite(); }, + get numericFields() { return readNumericFields(); }, + temporalFieldKind: (...a) => this.temporalFieldKind(...a), + }; + return readPresentationKind.call(host, table, field); } /** @@ -13503,41 +8249,12 @@ export class SqlDriver implements IDataDriver { * pins the agreement on the shapes the ruling covers. */ protected presentReadValue(kind: ReadPresentationKind, value: any): any { - if (value == null) return value; - switch (kind) { - case 'date': - return this.toDateOnly(value); - case 'time': - // Every dialect, like `date`: canonicalising also re-pads the fraction - // Postgres trims (`.5` → `.500`), so `distinct()`/`aggregate()` present - // exactly what `find()` presents (#3994, the F6 gap of the #3849 fix). - return this.toTimeOnly(value); - case 'datetime': - // Every dialect ([ADR-0053 D-F1]): the same fold `formatOutput` applies - // to a `find()` row, so `min`/`max`/`distinct` over a declared - // `Field.datetime` (the audit columns too, when the engine declares - // them so) present the canonical text and never the client's `Date`. - return normalizeSqliteDatetimeOutput(value); - case 'audit_timestamp': - // Every dialect ([ADR-0053 D-F1]): the ONE presenter `formatOutput` - // applies to `created_at` / `updated_at` — a `Date` folds to the - // canonical text, a zone-naive legacy string is repaired (ADR-0074), - // and a number passes through as it does on a `find()` row (ADR-0074 - // §3) — so `max(created_at)` and `distinct('updated_at')` answer the - // value `find()` answers, type for type. `normalizeSqliteDatetimeOutput` - // would fold the number too, and did between #13973's first cut and - // its contract review: an author-declared `created_at: number` read - // `1700000000000` off `find()` and `"2023-11-14T22:13:20.000Z"` off - // `distinct()` — the divergence the conformance file's §D pins closed. - return presentAuditTimestampOutput(value); - case 'boolean': - return Boolean(value); - case 'number': - // [#20889] The presenter lives in `@objectstack/core`, beside - // `AGGREGATE_ANSWER_KIND`, so the analytics native-SQL face presents - // a count or a total with this same function. - return presentAsNumber(value); - } + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). + const host: ThisParameterType = { + toDateOnly: (...a) => this.toDateOnly(...a), + toTimeOnly: (...a) => this.toTimeOnly(...a), + }; + return presentReadValue.call(host, kind, value); } /** @@ -13553,14 +8270,11 @@ export class SqlDriver implements IDataDriver { * Rows are mutated in place, as `formatOutput` does. */ protected presentReadColumns(rows: any, columns: Map): any { - if (columns.size === 0 || !Array.isArray(rows)) return rows; - for (const row of rows) { - if (!row || typeof row !== 'object') continue; - for (const [column, kind] of columns) { - if (row[column] !== undefined) row[column] = this.presentReadValue(kind, row[column]); - } - } - return rows; + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). + const host: ThisParameterType = { + presentReadValue: (...a) => this.presentReadValue(...a), + }; + return presentReadColumns.call(host, rows, columns); } /** @@ -13603,19 +8317,14 @@ export class SqlDriver implements IDataDriver { field: string, column: string, ): { sql: string; bindings: any[] } | null { - if (this.needsLegacyDatetimeRepair(table, field)) { - return { - sql: this.sqliteCanonicalDatetimeSql('??'), - bindings: [column, column, column, column], - }; - } - if (this.needsLegacyTimeRepair(table, field)) { - return { - sql: this.sqliteCanonicalTimeSql('??'), - bindings: Array(SQLITE_TIME_EXPR_REFS).fill(column), - }; - } - return null; + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). + const host: ThisParameterType = { + needsLegacyDatetimeRepair: (...a) => this.needsLegacyDatetimeRepair(...a), + needsLegacyTimeRepair: (...a) => this.needsLegacyTimeRepair(...a), + sqliteCanonicalDatetimeSql: (...a) => this.sqliteCanonicalDatetimeSql(...a), + sqliteCanonicalTimeSql: (...a) => this.sqliteCanonicalTimeSql(...a), + }; + return filterColumnExpr.call(host, table, field, column); } /** @@ -14111,22 +8820,12 @@ export class SqlDriver implements IDataDriver { * say on the wire already). */ protected logWithheldFilterDiagnostic(err: unknown): void { - const diagnostic = withheldFilterDiagnosticOf(err); - if (diagnostic === null) return; - const marked = err as Record; - if (marked[WITHHELD_FILTER_LOGGED] === true) return; - Object.defineProperty(err as object, WITHHELD_FILTER_LOGGED, { value: true, enumerable: false }); - // [#8197] The wording no longer says "cross-field reference refused": this - // seam now carries the target-field family too, and a log line that names - // the wrong refusal class sends the operator reading it to the wrong part - // of the filter. What it states instead is the fact that is true of every - // line it writes — the predicate was not positively marked author-written, - // so the naming half went here rather than to the caller. - this.logger.warn( - `[sql-driver] INVALID_FILTER — refusal detail withheld from the response because the ` + - `predicate was not positively marked as author-written. ` + - `Full diagnostic: ${diagnostic}`, - ); + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). + const readLogger = () => this.logger; + const host: ThisParameterType = { + get logger() { return readLogger(); }, + }; + return logWithheldFilterDiagnostic.call(host, err); } /** @@ -14158,92 +8857,17 @@ export class SqlDriver implements IDataDriver { } private compileFilters(builder: Knex.QueryBuilder, filters: any, options?: DriverOptions) { - if (!filters) return; - - // [#5158] `where` is a `FilterCondition` OBJECT. It always was — the spec - // declares `QueryASTSchema.where: FilterConditionSchema` — but this method - // used to carry a SECOND compiler for the array spelling, including an - // INFIX dialect (`[condA, 'or', condB]`) that no schema ever declared and - // that `parseFilterAST` cannot express. `FilterArray` is now declared as - // INPUT-ONLY authoring sugar (`spec/data/filter.zod.ts`, #5285) and BOTH - // doors into the runtime lower it before a driver is reached: the protocol - // face (`metadata-protocol`) and the engine (`ObjectQL.find`/`findOne`/ - // `count`/`aggregate`/`update`/`delete`). So an array here is a bug in the - // caller, not a dialect to compile — and refusing it is what converges this - // driver with cloud's `RemoteTransport.buildWhereSQL`, which has refused - // the same input since cloud#1075. That fork had zero tests on either side. - if (Array.isArray(filters)) { - // `[]` keeps its meaning — "no filter", not a failed filter. Unchanged - // from every previous version of this method, and the same reading - // `parseFilterAST([])` gives it. - if (filters.length === 0) return; - // [#20039] The array IS the `where` root, so its own mark is its verdict. - throw filterArrayReachedDriverError(filters); - } - - const table = this.coercionKey(builder); - - if (typeof filters === 'object') { - const hasMongoOperators = Object.keys(filters).some( - (k) => - k.startsWith('$') || - (typeof filters[k] === 'object' && - filters[k] !== null && - Object.keys(filters[k]).some((op) => op.startsWith('$'))), - ); - - if (hasMongoOperators) { - this.applyFilterCondition(builder, filters, 'and', table, filters, options); - return; - } - - for (const [key, value] of Object.entries(filters)) { - if (['limit', 'offset', 'fields', 'orderBy'].includes(key)) continue; - const column = this.remoteColumn(table, key, key); - // #5240 — `{ field: {} }` reaches this loop when NO key of the filter - // carries an operator; the combinator path refuses it on the reduction - // walk. Refused here with the SAME message so one condition has one - // wording wherever the author wrote it — this position used to answer - // with #5041's generic "cannot be bound as a SQL parameter", which - // describes a comparand and not a constraint with no operator at all. - if (isEmptyFieldConstraint(value)) { - throw emptyFieldConstraintError(key, `filter.${key}`, value); - } - // #6050 — the same position as the `undefined` gate on the reduction - // walk, reached the same way `{ field: {} }` above reaches its own: this - // loop runs when NO key of the filter carries an operator, so the walk - // never sees the node. `{ d: undefined }` IS that shape — `typeof - // undefined` is not `'object'`, so it cannot make `hasMongoOperators` - // true — and it is the single most likely spelling of the defect - // (`{ owner_id: ctx.user?.id }`). ONE function answers both call sites - // so the two positions cannot drift into two verdicts, which is the - // #5240 lesson this driver already paid for once. - // [#20039] `filters` is the node carrying `key`, as for the gate below. - assertDefinedComparands(key, value, `filter.${key}`, filters); - // #5041 — the plain `{ field: value }` map compiles to an implicit `=`, - // so it is a comparison emitter too and gets the same gate. - // [#8197] `filters` is both this loop's node and the query's `where` - // root — this branch runs only when NO key carries an operator, so - // there is no nearer enclosing node to hand over. - assertCompilableComparand(column, '=', value, filters); - // #7398 — and the column-type question the comparand gate cannot ask. - // This loop IS the bare `{ field: value }` spelling, one of the four - // the issue measured as silently answering zero rows on an array column. - this.assertOperatorAppliesToColumn(table, key, column, '=', true, refusalSubtree(value, filters)); - const coerced = this.coerceFilterValue(table, key, value); - const expr = this.filterColumnExpr(table, key, column); - if (expr && this.applyNormalizedComparison(builder, 'and', expr, '=', coerced)) continue; - builder.where(column, coerced as any); - } - return; - } - - // A truthy non-object, non-array `where` (`'active'`, `42`) emits no - // predicate. Pre-existing behaviour on a shape only a cast can produce — - // the protocol face rejects it (`unusableFilterError`) and `FilterCondition` - // does not describe it. Untouched here on purpose: #5158 is about the ARRAY - // dialect, and widening the refusal is a separate change with its own - // blast radius. + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). + const host: ThisParameterType = { + applyFilterCondition: (...a) => this.applyFilterCondition(...a), + applyNormalizedComparison: (...a) => this.applyNormalizedComparison(...a), + assertOperatorAppliesToColumn: (...a) => this.assertOperatorAppliesToColumn(...a), + coerceFilterValue: (...a) => this.coerceFilterValue(...a), + coercionKey: (...a) => this.coercionKey(...a), + filterColumnExpr: (...a) => this.filterColumnExpr(...a), + remoteColumn: (...a) => this.remoteColumn(...a), + }; + return compileFilters.call(host, builder, filters, options); } /** @@ -15153,9 +9777,8 @@ export class SqlDriver implements IDataDriver { // ── Field mapping ─────────────────────────────────────────────────────────── protected mapSortField(field: string): string { - if (field === 'createdAt') return 'created_at'; - if (field === 'updatedAt') return 'updated_at'; - return field; + // [#29520 codemod] The body moved to `sql-driver-field-mapping.ts`. + return mapSortField(field); } /** @@ -15240,42 +9863,15 @@ export class SqlDriver implements IDataDriver { query: DriverQuery, opts?: { singleRowLookup?: boolean }, ): Array<{ field: string; direction: 'asc' | 'desc' }> { - const keys: Array<{ field: string; direction: 'asc' | 'desc' }> = []; - if (Array.isArray(query.orderBy)) { - for (const item of query.orderBy) { - if (item.field) { - keys.push({ field: item.field, direction: item.order === 'desc' ? 'desc' : 'asc' }); - } - } - } - - const paged = - !opts?.singleRowLookup && (query.limit !== undefined || query.offset !== undefined); - if (keys.length === 0 && !paged) return keys; - - const tieBreaker = this.paginationTieBreaker(object); - if (tieBreaker && !keys.some((k) => k.field === tieBreaker)) { - keys.push({ field: tieBreaker, direction: keys[keys.length - 1]?.direction ?? 'asc' }); - } else if (!tieBreaker && keys.length === 0) { - // Paged, unsorted, and no column this driver can trust to order by: the - // read goes out exactly as before, and its pages are not a partition. - // Say so once per object rather than leave it to be discovered by a user - // counting records — a guarantee the contract states as MUST, quietly - // unavailable, is the same invisible failure it was written against. - // (Sorted reads on such a table keep the caller's own ORDER BY, so only - // the ties within it reshuffle; this case has nothing at all.) - if (!this.nondeterministicPagingWarned.has(object)) { - this.nondeterministicPagingWarned.add(object); - this.logger.warn( - `Paged read of '${object}' is NOT deterministic: this driver did not create the table, ` - + 'so it cannot name a unique column to order by, and the query asked for no sort of ' - + 'its own. Walking the pages may serve one row twice and never serve another. ' - + 'Give the query an `orderBy` on a unique column, or declare the ' - + 'object so this driver manages its table.', - ); - } - } - return keys; + // [#29520 codemod] The body moved to `sql-driver-field-mapping.ts` (see `FieldMappingHost`). + const readLogger = () => this.logger; + const readNondeterministicPagingWarned = () => this.nondeterministicPagingWarned; + const host: ThisParameterType = { + get logger() { return readLogger(); }, + get nondeterministicPagingWarned() { return readNondeterministicPagingWarned(); }, + paginationTieBreaker: (...a) => this.paginationTieBreaker(...a), + }; + return orderKeysFor.call(host, object, query, opts); } /** @@ -15321,9 +9917,8 @@ export class SqlDriver implements IDataDriver { * the text the previous `string` return produced. */ protected mapAggregateFunc(func: string): SqlAggregateLowering { - const lowering = SQL_AGGREGATE_FUNCTIONS.get(func); - if (lowering !== undefined) return lowering; - refuseAggregateFunction(func); + // [#29520 codemod] The body moved to `sql-driver-field-mapping.ts`. + return mapAggregateFunc(func); } // ── Window function builder ───────────────────────────────────────────────── @@ -15337,41 +9932,13 @@ export class SqlDriver implements IDataDriver { * (`knex.ref`, the idiom the rest of this file quotes columns with). */ protected buildWindowFunction(spec: SqlWindowFunctionSpec): string { - const func = SQL_WINDOW_FUNCTIONS.get(String(spec.function).toLowerCase()); - if (func === undefined) { - throw invalidWindowFunctionQueryError( - `Window function ${JSON.stringify(String(spec.function))} is not one this door compiles. ` + - `Supported (argument-less, any case): ${[...SQL_WINDOW_FUNCTIONS.keys()].join(', ')}. ` + - `Fix the "function" key of the windowFunctions[] entry.`, - ); - } - const column = (field: string) => this.knex.ref(this.mapSortField(String(field))).toQuery(); - let sql = `${func}()`; - - const overParts: string[] = []; - - if (spec.partitionBy && Array.isArray(spec.partitionBy) && spec.partitionBy.length > 0) { - overParts.push(`PARTITION BY ${spec.partitionBy.map(column).join(', ')}`); - } - - if (spec.orderBy && Array.isArray(spec.orderBy) && spec.orderBy.length > 0) { - const orderFields = spec.orderBy - .map((s) => { - const order = SQL_WINDOW_SORT_DIRECTIONS.get(String(s.order || 'asc').toLowerCase()); - if (order === undefined) { - throw invalidWindowFunctionQueryError( - `Window sort direction ${JSON.stringify(String(s.order))} is not "asc" or "desc". ` + - `Fix the "order" key of the windowFunctions[].orderBy entry.`, - ); - } - return `${column(s.field)} ${order}`; - }) - .join(', '); - overParts.push(`ORDER BY ${orderFields}`); - } - - sql += overParts.length > 0 ? ` OVER (${overParts.join(' ')})` : ` OVER ()`; - return sql; + // [#29520 codemod] The body moved to `sql-driver-window-function-builder.ts` (see `WindowFunctionBuilderHost`). + const readKnex = () => this.knex; + const host: ThisParameterType = { + get knex() { return readKnex(); }, + mapSortField: (...a) => this.mapSortField(...a), + }; + return buildWindowFunction.call(host, spec); } // ── Column creation helper ────────────────────────────────────────────────── @@ -16245,25 +10812,17 @@ export class SqlDriver implements IDataDriver { * still fails correctly; it just still reports MySQL's binary digest. That * gap is deliberate and named rather than left to be discovered. */ - protected async decorateHashShadowDuplicate( + protected decorateHashShadowDuplicate( tableName: string, values: Record, error: unknown, ): Promise { - let explanation: string | null = null; - try { - explanation = await this.explainHashShadowDuplicate(tableName, values, error); - } catch { - // The disambiguation is a diagnostic. If it cannot be made, the original - // failure is still the failure — never mask it with this one. - return error; - } - if (!explanation) return error; - this.logDurabilityFailure(explanation); - return Object.assign(new Error(`${explanation} (server said: ${String((error as { message?: string })?.message ?? error)})`), { - code: (error as { code?: string })?.code, - cause: error, - }); + // [#29520 codemod] The body moved to `sql-driver-column-creation.ts` (see `ColumnCreationHost`). + const host: ThisParameterType = { + explainHashShadowDuplicate: (...a) => this.explainHashShadowDuplicate(...a), + logDurabilityFailure: (...a) => this.logDurabilityFailure(...a), + }; + return decorateHashShadowDuplicate.call(host, tableName, values, error); } /** @@ -16487,22 +11046,13 @@ export class SqlDriver implements IDataDriver { * `null` when the read fails: a diagnostic must not turn into a second * failure, and the advice holds with the assumed multiplier. */ - protected async schemaBytesPerChar(): Promise<{ bytesPerChar: number; charset: string } | null> { - try { - const res: any = await this.knex.raw( - 'select cs.CHARACTER_SET_NAME as charset, cs.MAXLEN as maxlen ' + - 'from information_schema.CHARACTER_SETS cs ' + - 'where cs.CHARACTER_SET_NAME = @@character_set_database', - ); - const row = (Array.isArray(res) ? res[0]?.[0] : (res?.rows?.[0] ?? res?.[0])) ?? null; - const maxlen = Number(row?.maxlen ?? row?.MAXLEN); - const charset = String(row?.charset ?? row?.CHARSET ?? ''); - if (!Number.isInteger(maxlen) || maxlen <= 0 || !charset) return null; - return { bytesPerChar: maxlen, charset }; - } catch { - // Introspection is a nicety here; the advice below holds without it. - return null; - } + protected schemaBytesPerChar(): Promise<{ bytesPerChar: number; charset: string } | null> { + // [#29520 codemod] The body moved to `sql-driver-column-creation.ts` (see `ColumnCreationHost`). + const readKnex = () => this.knex; + const host: ThisParameterType = { + get knex() { return readKnex(); }, + }; + return schemaBytesPerChar.call(host); } /** @@ -16633,21 +11183,19 @@ export class SqlDriver implements IDataDriver { * statement, which for the object that trips this is thousands of characters * of `varchar(...)` and buries the one sentence the server actually said. */ - private async rethrowWithRowSizeExplanation( + private rethrowWithRowSizeExplanation( tableName: string, fields: Record | undefined, keyedColumns: ReadonlyMap, addedColumns: string[] | null, e: any, ): Promise { - const overflow = await this.explainRowSizeOverflow(tableName, fields, keyedColumns, addedColumns, e); - if (!overflow) throw e; - const said = String(e?.sqlMessage ?? e?.message ?? e); - this.logDurabilityFailure(overflow, said); - throw Object.assign(new Error(`${overflow} (server said: ${said})`), { - code: (e as { code?: string }).code, - cause: e, - }); + // [#29520 codemod] The body moved to `sql-driver-column-creation.ts` (see `ColumnCreationHost`). + const host: ThisParameterType = { + explainRowSizeOverflow: (...a) => this.explainRowSizeOverflow(...a), + logDurabilityFailure: (...a) => this.logDurabilityFailure(...a), + }; + return rethrowWithRowSizeExplanation.call(host, tableName, fields, keyedColumns, addedColumns, e); } /** @@ -17287,19 +11835,16 @@ export class SqlDriver implements IDataDriver { * each connection itself and the target is not ours to read. */ protected sqliteFilename(): string | null { - if (!this.isSqlite) return null; - // Redirected to `:memory:` because the declared file did not exist and the - // host asked not to create one (#6743). `this.config` still names the - // declared target — on purpose, so the CLI can print it — but NO on-disk - // file backs this connection, which is precisely what this method reports. - // Both callers depend on that reading: `ensureDatabaseExists` would - // otherwise `mkdir` the state directory this mode exists to avoid, and - // `applySqliteJournalMode` would try to put an in-memory database in WAL. - if (this.openedEmptyInMemory) return null; - const conn = (this.config as any).connection; - const filename = typeof conn === 'string' ? conn : conn?.filename; - if (typeof filename !== 'string' || filename === '') return null; - return filename.startsWith(':') ? null : filename; + // [#29520 codemod] The body moved to `sql-driver-database-helpers.ts` (see `DatabaseHelpersHost`). + const readConfig = () => this.config; + const readIsSqlite = () => this.isSqlite; + const readOpenedEmptyInMemory = () => this.openedEmptyInMemory; + const host: ThisParameterType = { + get config() { return readConfig(); }, + get isSqlite() { return readIsSqlite(); }, + get openedEmptyInMemory() { return readOpenedEmptyInMemory(); }, + }; + return sqliteFilename.call(host); } protected async ensureDatabaseExists() { @@ -17579,478 +12124,59 @@ export class SqlDriver implements IDataDriver { // ── SQLite serialisation ──────────────────────────────────────────────────── protected formatInput(object: string, data: any): any { - let copy: any = data; - let copied = false; - - // Insert/update-time safety net: any caller that passes the literal - // string 'NOW()' (often because a field defaultValue leaked unresolved) - // gets it replaced with a real ISO timestamp here, before it hits the - // wire. Applies to every driver, not just SQLite. - if (data && typeof data === 'object') { - const now = new Date().toISOString(); - for (const key of Object.keys(data)) { - const v = (data as any)[key]; - if (typeof v === 'string' && /^now\(\)$/i.test(v.trim())) { - if (!copied) { copy = { ...data }; copied = true; } - copy[key] = now; - } - } - } - - // ADR-0053: a `Field.datetime` is an instant stored as UTC. Make the STORAGE - // say so — collapse every accepted input shape (JS `Date`, epoch number, ISO - // string, zone-naive wall clock, bare calendar day) to one canonical - // `YYYY-MM-DDTHH:MM:SS.sssZ` before it hits the wire (#3912). - // - // This is the write half of the fix; `coerceFilterValue` applies the SAME - // function to comparands, so the two sides of a comparison can no longer - // disagree about shape. It runs on EVERY dialect, for two different reasons: - // - SQLite has no temporal type, so whatever is bound is what is stored. A - // `Date` landed as INTEGER epoch and a REST/JSON write as ISO TEXT, in - // the same column — the mixed storage that made every window filter - // return the wrong rows, and that still makes ORDER BY sort all INTEGER - // rows before all TEXT ones (#3928). - // - Postgres/MySQL do have one, but a zone-NAIVE string bound into it is - // interpreted in the SERVER's timezone, not UTC — measured at 8 hours off - // on an `Asia/Shanghai` server. Sending an explicit `Z` removes the - // server's timezone from the write path entirely. - const datetimeFields = this.datetimeFields[object]; - if (datetimeFields && datetimeFields.size > 0 && copy && typeof copy === 'object') { - for (const field of datetimeFields) { - const v = copy[field]; - if (v == null) continue; - // `NOW()` was already replaced with an ISO instant above; anything else - // that is not interpretable as a time passes through untouched. - const normalized = this.storageDatetimeValue(v); - if (normalized !== v) { - if (!copied) { copy = { ...copy }; copied = true; } - copy[field] = normalized; - } - } - } - - // [#21259] The same rule for a builtin audit timestamp the object does NOT - // declare (see {@link undeclaredAuditTimestampFields}). Its column is the - // `DATETIME(3)` / `timestamptz` / canonical-text column this driver created, - // and the engine's audit hook stamps it as `toISOString()` text, which - // MySQL refuses as a datetime literal. Measured on live MySQL 8.0.46: every - // insert into `sys_jwks` (declares `created_at` only) and `sys_member` was - // refused on `updated_at`, so no JWT signing key was ever stored. On SQLite - // and Postgres the hook's text is already the canonical form and is bound - // unchanged; a `Date` lands as that same text instead of an epoch INTEGER. - const undeclaredAudit = this.undeclaredAuditTimestampFields[object]; - if (undeclaredAudit && undeclaredAudit.length > 0 && copy && typeof copy === 'object') { - for (const field of undeclaredAudit) { - const v = copy[field]; - if (v == null) continue; - const normalized = this.storageDatetimeValue(v); - if (normalized !== v) { - if (!copied) { copy = { ...copy }; copied = true; } - copy[field] = normalized; - } - } - } - - // ADR-0053 Phase 1: a `Field.date` is a timezone-naive calendar day, not - // an instant. Collapse any `Date` or full-ISO value to `YYYY-MM-DD` before - // it hits the wire so storage matches the date-only contract the filter - // layer (`coerceFilterValue`) already enforces — the write/filter - // asymmetry was the root cause of the silent date-equality miss. - const dateFields = this.dateFields[object]; - if (dateFields && dateFields.size > 0 && copy && typeof copy === 'object') { - for (const field of dateFields) { - const v = copy[field]; - if (v == null) continue; - const normalized = this.toDateOnly(v); - if (normalized !== v) { - if (!copied) { copy = { ...copy }; copied = true; } - copy[field] = normalized; - } - } - } - - // #3994: a `Field.time` is a wall-clock time-of-day. Collapse every accepted - // input shape (bare `HH:MM[:SS[.fff]]`, JS `Date`, epoch number, full ISO or - // zone-naive timestamp) to the canonical `HH:MM:SS[.fff]` before it hits the - // wire — the write half of the same fix `coerceFilterValue` applies to - // comparands. On SQLite this ends the mixed TEXT/INTEGER storage that broke - // window filters and ORDER BY; on Postgres/MySQL it turns shapes the native - // TIME type rejects outright (full ISO — measured failing on both) or - // resolves against the process's local timezone (a bound `Date` on pg) into - // the one literal every dialect parses the same way. - const timeFields = this.timeFields[object]; - if (timeFields && timeFields.size > 0 && copy && typeof copy === 'object') { - for (const field of timeFields) { - const v = copy[field]; - if (v == null) continue; - const normalized = canonicalTimeOfDay(v); - if (normalized !== v) { - if (!copied) { copy = { ...copy }; copied = true; } - copy[field] = normalized; - } - } - } - - // ── JSON field serialisation: ONE encoding, every dialect (commit 4045b954d) ────── - // - // `JSON.stringify` unconditionally, which is what Postgres and MySQL have - // always done here. This DELETES the SQLite branch rather than adding one, - // and the reason is the declared contract, not strictness: `json`'s stored - // contract is `z.unknown()` (`spec/src/data/field-value.zod.ts`, "openness - // is now an explicit decision, not an accident of nobody checking"), so - // `123` the number and `'123'` the string are BOTH legal values of one - // field and no driver has license to collapse them onto one representation. - // - // What the deleted branch did, measured on live SQLite/PG 16.13/MySQL - // 8.0.46 before this change (PG and MySQL: 17/17 faithful; SQLite: 13/17 - // type-changed). It stored a non-object AS-IS, which broke the round trip - // through two INDEPENDENT mechanisms: - // - // 1. READ-side. `formatOutput` `JSON.parse`s every string in a json - // column, so a stored string whose CONTENT is valid JSON came back - // type-changed: `'true'` → boolean, `'null'` → null, `'[]'` → array, - // `'{"a":1}'` → object. Reversible — the bytes were still on disk. - // 2. WRITE-side, and NOT reversible. The column is declared type `json`, - // which contains none of `INT`/`CHAR`/`CLOB`/`TEXT`/`BLOB`/`REAL`/ - // `FLOA`/`DOUB`, so SQLite's affinity rules fall through to **NUMERIC** - // — and a bound number-like string is converted to INTEGER/REAL BEFORE - // storage. `'0123'`, `' 123 '`, `'1e5'`, `'1.0'` and `'-0'` were - // destroyed on disk, indistinguishable from the numbers they became. - // - // Stringifying closes BOTH without touching the DDL, because the encoded - // form of a string carries its quotes (`'123'` → `"123"`) and `"123"` is - // not a well-formed numeric literal, so NUMERIC affinity leaves it TEXT. - // Measured, not reasoned — see `sql-driver-12380-json-roundtrip.test.ts`. - // - // The on-disk delta for NEW writes is therefore exactly two classes: - // strings (now quoted) and booleans (now TEXT `true`/`false` instead of - // INTEGER 1/0, which is mechanism (3) the read path could never repair — - // `formatOutput`'s `booleanFields` pass is keyed to declared - // `Field.boolean` COLUMNS, not to booleans living inside a json payload). - // Objects, arrays and null are byte-identical to before, and so are - // numbers: `123` bound as a number and `"123"` bound as text both land as - // INTEGER 123 under NUMERIC affinity. Existing rows are converged by - // {@link backfillCanonicalJsonEncoding}. - const jsonFields = this.jsonFields[object]; - if (jsonFields && jsonFields.length > 0) { - for (const field of jsonFields) { - if (copy[field] === undefined || copy[field] === null) continue; - if (!copied) { copy = { ...copy }; copied = true; } - copy[field] = JSON.stringify(copy[field]); - } - } - - // ── ADR-0104: the MOVED media arm ─────────────────────────────────────── - // - // On a deployment whose media columns have moved, a single-value media - // field is NOT in `jsonFields` (see {@link isJsonField}), so the loop above - // did not touch it and the id reaches the wire BARE — which is the whole - // point of the ruling: the column holds the actual `sys_file` id. - // - // What is left to do is the one value shape the moved column cannot hold. - // A legacy INLINE metadata object can still be handed to a write on a - // deployment that admits lax media values (`OS_ALLOW_LAX_MEDIA_VALUES`), - // and binding a plain object to a `varchar` column is a raw driver-level - // TypeError on SQLite and an unusable `[object Object]` on the server - // dialects. Serialising it degrades that write to a stored string — the - // SAME safety-net posture the SQLite arm below has always taken, applied - // on every dialect because after the move every dialect holds this family - // in a character column. `formatOutput`'s media pass reads it straight back - // as the object it was, so the round trip is unchanged for that population. - if (this.fileColumnsMoved) { - const mediaFields = this.mediaFields[object]; - if (mediaFields && mediaFields.length > 0) { - for (const field of mediaFields) { - const v = copy[field]; - if (v === undefined || v === null || typeof v === 'string') continue; - if (!copied) { copy = { ...copy }; copied = true; } - copy[field] = JSON.stringify(v); - } - } - } - - if (!this.isSqlite) return copy; - - // Safety net: better-sqlite3 can only bind numbers/strings/bigints/buffers/ - // null. Any value still an array or plain object here (a field type not - // classified as JSON, a `Field.multiple` we didn't catch, or an ad-hoc - // payload) would otherwise throw a raw TypeError mid-insert. Serialize it - // to JSON so the write degrades to a stored string instead of a 500. - for (const key of Object.keys(copy)) { - const v = copy[key]; - if (v !== null && typeof v === 'object' && !(v instanceof Date) && !Buffer.isBuffer(v)) { - if (!copied) { copy = { ...copy }; copied = true; } - copy[key] = JSON.stringify(v); - } - } - return copy; + // [#29520 codemod] The body moved to `sql-driver-sqlite-serialisation.ts` (see `SqliteSerialisationHost`). + const readDateFields = () => this.dateFields; + const readDatetimeFields = () => this.datetimeFields; + const readFileColumnsMoved = () => this.fileColumnsMoved; + const readIsSqlite = () => this.isSqlite; + const readJsonFields = () => this.jsonFields; + const readMediaFields = () => this.mediaFields; + const readTimeFields = () => this.timeFields; + const readUndeclaredAuditTimestampFields = () => this.undeclaredAuditTimestampFields; + const host: ThisParameterType = { + get dateFields() { return readDateFields(); }, + get datetimeFields() { return readDatetimeFields(); }, + get fileColumnsMoved() { return readFileColumnsMoved(); }, + get isSqlite() { return readIsSqlite(); }, + get jsonFields() { return readJsonFields(); }, + get mediaFields() { return readMediaFields(); }, + storageDatetimeValue: (...a) => this.storageDatetimeValue(...a), + get timeFields() { return readTimeFields(); }, + toDateOnly: (...a) => this.toDateOnly(...a), + get undeclaredAuditTimestampFields() { return readUndeclaredAuditTimestampFields(); }, + }; + return formatInput.call(host, object, data); } protected formatOutput(object: string, data: any): any { - if (!data) return data; - - // External columnMap (ADR-0015): rename physical remote-column keys to local - // field names BEFORE coercion (which is keyed by local field). No-op for - // managed objects and external objects without a columnMap. - const colToField = this.columnFieldByObject[object]; - if (colToField && typeof data === 'object') { - for (const [remoteCol, localField] of Object.entries(colToField)) { - if (remoteCol !== localField && Object.prototype.hasOwnProperty.call(data, remoteCol)) { - // Explicit columnMap wins: the remote column is the source of truth for - // this local field, even if a same-named native column also exists. - data[localField] = data[remoteCol]; - delete data[remoteCol]; - } - } - } - - if (this.isSqlite) { - // The exact inverse of `formatInput`'s `JSON.stringify` (commit 4045b954d). Postgres - // and MySQL need no arm here because their clients already parse a native - // `json`/`jsonb` column; SQLite hands back the stored TEXT, so the driver - // parses it. One codec, three dialects, same answer. - // - // Only strings are parsed, and that is load-bearing rather than - // incidental: a json NUMBER is stored under NUMERIC affinity as INTEGER/ - // REAL and comes back as a JS number, which is already the value that was - // written — parsing is neither possible nor needed for it. - // - // ── Why the catch survives, and what it now means ─────────────────────── - // - // Nothing this driver writes can reach it: every new value on disk is the - // output of `JSON.stringify`, so it parses by construction. It is the - // READ-SIDE REPAIR for rows written before commit 4045b954d — a pre-fix plain string - // was stored raw (`America/New_York`), and re-quoting it is exactly what - // {@link backfillCanonicalJsonEncoding} does on the next `syncSchema`. - // Keeping it here is the same posture `backfillCanonicalDatetimes` takes: - // correctness must NEVER be contingent on a migration having run, so an - // un-migrated (or un-migratable, e.g. `skipSchemaSync`) deployment reads a - // legacy plain string back as that same string — byte-identical to what it - // read before this change — instead of throwing mid-row. - // - // ⚠️ It cannot repair the two classes the pre-fix encoding made ambiguous, - // and must not pretend to: a legacy TEXT cell that DOES parse (a stored - // object, or a stored string whose content was valid JSON) and a legacy - // INTEGER/REAL cell (a number, a boolean, or a number-like string eaten by - // NUMERIC affinity) are collisions already resolved on disk. Those rows - // read exactly as they read before this change — the class stops growing; - // it is not retroactively repaired. See the decision recorded in commit 4045b954d's message. - const jsonFields = this.jsonFields[object]; - if (jsonFields && jsonFields.length > 0) { - for (const field of jsonFields) { - if (data[field] !== undefined && typeof data[field] === 'string') { - try { - data[field] = JSON.parse(data[field]); - } catch { - // Row from before commit 4045b954d: keep the raw string, which IS its value. - } - } - } - } - - } - - // ── ADR-0104: the media family reads BOTH encodings, on every dialect ──── - // - // The addendum's dual-encoding window requires the driver to read a bare id - // and a legacy JSON-quoted id throughout, on every dialect. SQLite already - // did — its `jsonFields` pass above parses a quoted cell and its `catch` - // keeps a bare one, which is why the gap was never a three-dialect one — - // but Postgres and MySQL had no arm at all: MEASURED on live PG 16.13, a - // JSON-quoted id sitting in a `varchar` column reads back WITH ITS QUOTES. - // That column is not hypothetical: `os generate migration --format sql` - // emits `VARCHAR(2048)` for this family, so a generator-built deployment on - // a server dialect has been storing `"file_01HXYZ"` and handing it back - // verbatim (#15771's silent corruption, reproduced on a live cell). - // - // ⚠️ Runs on BOTH arms, and it is a REPAIR rather than an arm: - // - // - moved deployment — the cell is bare, no leading delimiter, no-op; - // a cell step 3 has not converted yet still reads correctly. - // - unmoved, json column — the client (or the pass above) already parsed - // it, so the value here is not a string, no-op. - // - unmoved, character column — the corrupt population above. THIS is - // the only place the pass changes an answer, and the answer it changes - // it to is the id that was written. - // - // ⛔ The delimiter test is load-bearing and must not be relaxed to "try - // parsing everything": a `sys_file` id is word characters and `-` - // (`isFileIdToken`), so an all-digit id would `JSON.parse` to a NUMBER and - // `null` / `true` / `false` to non-strings. Only a leading `"`, `{` or `[` - // can be a JSON encoding of a media value, and none of the three can begin - // an id, a resolver URL (`https:` / `/api/` / `data:` / `blob:`) or a - // `data:` URI. A cell that starts with one and does NOT parse keeps its raw - // string, exactly as the SQLite arm above does. - const mediaFields = this.mediaFields[object]; - if (mediaFields && mediaFields.length > 0) { - for (const field of mediaFields) { - const v = data[field]; - if (typeof v !== 'string' || v.length === 0) continue; - const head = v[0]; - if (head !== '"' && head !== '{' && head !== '[') continue; - try { - data[field] = JSON.parse(v); - } catch { - // Not an encoding after all — the raw string IS its value. - } - } - } - - // Numeric scalars handed back as STRINGS are coerced to numbers, on EVERY - // dialect, so the declared type wins regardless of which dialect stored the - // value or when the column was created. Only strings are touched — a - // REAL/INTEGER column already yields a number — and a genuinely non-numeric - // value (junk legacy data) is left intact rather than turned into NaN. - // See NUMERIC_SCALAR_TYPES. - // - // ⚠️ [#16318] This pass was SQLite-only until the numeric family moved to an - // exact-decimal column, on the stated premise that string-valued numerics - // "exist for legacy TEXT-affinity columns, which no other dialect has". - // That premise is now false and it is THIS change that falsified it, so it - // is corrected here rather than left as a fossil: measured on live - // PostgreSQL 16.13, node-postgres parses `real` to a JS `number` and - // `numeric` to a STRING (`1234567.89` arrives as - // `'1234567.890000000000000000000000000000'`), and mysql2 does the same for - // `DECIMAL`. Without this line every `number` / `currency` / `percent` / - // `slider` / `progress` / `summary` field would start reading back as a - // string on the two server dialects — a wire-contract break, since - // `valueSchemaFor` gives the whole class `z.number().finite()`. - // - // Two SQLite readings stay exactly as they were: the legacy TEXT-affinity - // repair this pass was written for, and a fresh column, which knex declares - // `float` for both the old float arm and the new decimal one. - // - // ⚠️ [#16318] The registry is chosen PER DIALECT, and the narrowing is the - // "new tables only" bound, not a taste. `numericFields` carries the - // driver-internal aliases `integer` / `int` / `float`, which is how an - // EXTERNAL, introspected table's columns reach this driver. A PostgreSQL - // `bigint` is handed back by node-postgres as a STRING precisely because it - // does not fit a JS double, so running it through `Number()` would silently - // round it above 2^53 — on a table this change never created. SQLite is the - // one dialect where the wider set is right, because there the pass exists - // for legacy TEXT-affinity columns of exactly those alias types. - // - // ⚠️ The repair is bounded by the wire contract it restores, and that bound - // is binary64: `valueSchemaFor` gives this whole class `z.number().finite()` - // (ADR-0104 D1), so a `find()` result is a JS double however exact the - // COLUMN is. Measured: a value the driver itself wrote from a JS number - // round-trips exactly ('1234567.890000000000000000000000000000' → 1234567.89), - // because the shortest representation is what was stored; a value that was - // never a double does not ('1234567890123456.123' → 1234567890123456, - // 2^53+1 → 2^53). ⇒ the exactness this change buys is exact-column-through- - // a-double: SQL-side writers, `summary` roll-ups computed in SQL and any - // magnitude at or above 2^53 are bounded by the read seam, not by the - // column. Widening that is a wire-contract change and is NOT in #16318. - const numericFields = this.isSqlite - ? this.numericFields[object] - : this.numericValueFields[object]; - if (numericFields && numericFields.length > 0) { - for (const field of numericFields) { - const v = data[field]; - if (typeof v === 'string' && v.trim() !== '') { - const n = Number(v); - if (!Number.isNaN(n)) data[field] = n; - } - } - } - - // [ADR-0053 D-F1] (#13973) — the two instant classes present as ONE shape - // on EVERY dialect: the canonical `YYYY-MM-DDTHH:MM:SS.sssZ` text. Both - // loops below sat inside the `if (this.isSqlite)` arm above until #13973, - // and that gate WAS the divergence the card's census measured: on - // Postgres and MySQL the client library's `Date` left this door as-is, - // while SQLite handed out text — one value, two runtime types, through the - // same `any`-shaped record, invisible to the type system. Eight consumers - // were wrong under it (#13382 in production, #13993–#13999 by reading), - // all in the direction "expected the text, received a `Date`", none the - // reverse. The client parsers are NOT touched ([ADR-0053 D-F2]); the - // driver canonicalises at its own read boundary, here and in - // `presentReadValue` for the `aggregate()`/`distinct()` doors. - - // Builtin audit timestamps. On SQLite: repair any legacy/raw row stored as - // a zone-naive, space-separated string (CURRENT_TIMESTAMP or the pre-fix - // UPDATE stamp) to canonical ISO-8601 with `Z`, idempotent on - // already-canonical values (ADR-0074). On Postgres/MySQL: fold the - // client's `Date`. See `presentAuditTimestampOutput`. - for (const col of AUDIT_TIMESTAMP_COLUMNS) { - if (data[col] !== undefined) data[col] = presentAuditTimestampOutput(data[col]); - } - - // Present every `Field.datetime` value as one canonical instant — - // ISO-8601 with an explicit `Z` — regardless of its on-disk storage form. - // A SQLite `datetime` column mixes forms: an explicit value bound as a JS - // `Date` is stored as INTEGER epoch ms, while a `defaultValue: 'NOW()'` - // slot is TEXT (canonical ISO-`Z` post-fix, or a legacy timezone-naive - // `CURRENT_TIMESTAMP` string). Without this, reads leak the raw integer or - // a zone-naive string that `Date.parse` mis-reads as LOCAL time. Folds all - // shapes to UTC ISO-`Z` and transparently repairs legacy rows with no data - // migration — mirroring the `Field.date`/numeric read-repairs above and - // the audit-column repair just above. A Postgres/MySQL `Date` takes the - // same fold. See `normalizeSqliteDatetimeOutput`. - const datetimeFields = this.datetimeFields[object]; - if (datetimeFields && datetimeFields.size > 0) { - for (const field of datetimeFields) { - if (data[field] !== undefined) { - data[field] = normalizeSqliteDatetimeOutput(data[field]); - } - } - } - - // [#11782] Present a declared `Field.boolean` as a JSON boolean on the - // dialects whose STORAGE form is a number: SQLite (INTEGER 0/1) and MySQL - // (`tinyint(1)`, which mysql2 hands back as a JS number). Postgres stores a - // real `boolean` and node-pg already parses it, so its stored form IS the - // presented form and it deliberately stays outside the gate — the same - // per-dialect posture {@link readPresentationKind} takes for the read doors - // that return raw builder output (`distinct`; `aggregate` tracks its own - // result columns per #11635). Before this, the gate was SQLite-only and a - // declared boolean answered `1`/`0` on MySQL's row-read door while - // answering `true`/`false` on the other two dialects — and, once #11635 - // presented `min`/`max` everywhere, `find()` and `aggregate()` gave - // OPPOSITE answers for the same column on the same MySQL connection. - if (this.isSqlite || this.isMysql) { - const booleanFields = this.booleanFields[object]; - if (booleanFields && booleanFields.length > 0) { - for (const field of booleanFields) { - if (data[field] !== undefined && data[field] !== null) { - data[field] = Boolean(data[field]); - } - } - } - } - - // ADR-0053 Phase 1: present `Field.date` as a timezone-naive `YYYY-MM-DD` - // string, slicing any stored time component. This transparently repairs - // legacy rows written as a full timestamp before this normalization, so - // date-equality works without a data migration. Runs for every dialect. - const dateFields = this.dateFields[object]; - if (dateFields && dateFields.size > 0) { - for (const field of dateFields) { - const v = data[field]; - if (v == null) continue; - const normalized = this.toDateOnly(v); - if (normalized !== v) data[field] = normalized; - } - } - - // Present `Field.time` as the canonical wall-clock time-of-day (#2004, - // #3994) — the same `canonicalTimeOfDay` the write and filter paths apply, - // so storage, comparand and presentation are one shape. On read this - // transparently repairs legacy rows (full-timestamp text from the old - // `CURRENT_TIMESTAMP` default, epoch ms from a bound `Date`) with no data - // migration, and re-pads the fraction Postgres trims (`.5` → `.500`). Runs - // for every dialect. See `toTimeOnly`. - const timeFields = this.timeFields[object]; - if (timeFields && timeFields.size > 0) { - for (const field of timeFields) { - const v = data[field]; - if (v == null) continue; - const normalized = this.toTimeOnly(v); - if (normalized !== v) data[field] = normalized; - } - } - - return data; + // [#29520 codemod] The body moved to `sql-driver-sqlite-serialisation.ts` (see `SqliteSerialisationHost`). + const readBooleanFields = () => this.booleanFields; + const readColumnFieldByObject = () => this.columnFieldByObject; + const readDateFields = () => this.dateFields; + const readDatetimeFields = () => this.datetimeFields; + const readIsMysql = () => this.isMysql; + const readIsSqlite = () => this.isSqlite; + const readJsonFields = () => this.jsonFields; + const readMediaFields = () => this.mediaFields; + const readNumericFields = () => this.numericFields; + const readNumericValueFields = () => this.numericValueFields; + const readTimeFields = () => this.timeFields; + const host: ThisParameterType = { + get booleanFields() { return readBooleanFields(); }, + get columnFieldByObject() { return readColumnFieldByObject(); }, + get dateFields() { return readDateFields(); }, + get datetimeFields() { return readDatetimeFields(); }, + get isMysql() { return readIsMysql(); }, + get isSqlite() { return readIsSqlite(); }, + get jsonFields() { return readJsonFields(); }, + get mediaFields() { return readMediaFields(); }, + get numericFields() { return readNumericFields(); }, + get numericValueFields() { return readNumericValueFields(); }, + get timeFields() { return readTimeFields(); }, + toDateOnly: (...a) => this.toDateOnly(...a), + toTimeOnly: (...a) => this.toTimeOnly(...a), + }; + return formatOutput.call(host, object, data); } // ── Introspection internals ───────────────────────────────────────────────── diff --git a/packages/spec/liveness/query.json b/packages/spec/liveness/query.json index 8930adcd106..031403a1fae 100644 --- a/packages/spec/liveness/query.json +++ b/packages/spec/liveness/query.json @@ -72,7 +72,7 @@ "filter": { "status": "live", "verifiedAt": "2026-08-28", - "evidence": "packages/objectql/src/in-memory-aggregation.ts#aggregateBucket (`const aggFilter = agg.filter` — a non-vacuous predicate narrows the rows THIS aggregation reads while its siblings keep the full bucket); packages/objectql/src/having-filter.ts#matchesAggregationFilter (the per-row evaluation); packages/objectql/src/engine.ts#aggregate (forces the in-memory lowering when any aggregation carries a non-empty filter); packages/drivers/driver-sql/src/sql-driver.ts#unsupportedAggregationFilterError, packages/drivers/driver-turso/src/remote-transport.ts#refusePerAggregationFilter, packages/drivers/driver-mongodb/src/mongodb-aggregation.ts#refusePerAggregationFilter and packages/drivers/driver-memory/src/memory-driver.ts#performAggregation (the four native faces REFUSE the key NOT_IMPLEMENTED/501 when reached directly, rather than silently aggregating the unfiltered rows); packages/plugins/plugin-security/src/predicate-guard.ts#collectQueryFields (the FLS guard walks the predicate's fields); pinned by packages/objectql/src/engine-aggregate-filter.test.ts", + "evidence": "packages/objectql/src/in-memory-aggregation.ts#aggregateBucket (`const aggFilter = agg.filter` — a non-vacuous predicate narrows the rows THIS aggregation reads while its siblings keep the full bucket); packages/objectql/src/having-filter.ts#matchesAggregationFilter (the per-row evaluation); packages/objectql/src/engine.ts#aggregate (forces the in-memory lowering when any aggregation carries a non-empty filter); packages/drivers/driver-sql/src/sql-driver-top-level.ts#unsupportedAggregationFilterError, packages/drivers/driver-turso/src/remote-transport.ts#refusePerAggregationFilter, packages/drivers/driver-mongodb/src/mongodb-aggregation.ts#refusePerAggregationFilter and packages/drivers/driver-memory/src/memory-driver.ts#performAggregation (the four native faces REFUSE the key NOT_IMPLEMENTED/501 when reached directly, rather than silently aggregating the unfiltered rows); packages/plugins/plugin-security/src/predicate-guard.ts#collectQueryFields (the FLS guard walks the predicate's fields); pinned by packages/objectql/src/engine-aggregate-filter.test.ts", "note": "ENFORCED 2026-08-21 (#10576, the contract half of #10413's ruling — maintainer 「其他接受」 accepting option A 「给引擎聚合契约加逐聚合过滤,一次修对所有驱动」). SQL FILTER (WHERE …) semantics: the predicate narrows the SOURCE rows the one aggregation reads while siblings keep the full group. Engine-owned via the in-memory lowering (the dateGranularity/having correct-first two-tier shape — a driver that grows native conditional aggregation must advertise a capability flag); a driver face reached directly refuses rather than silently aggregating the unfiltered rows, which was #10413's measured defect on the ObjectQL analytics path. 2026-08-28: RE-ANCHORED (commit 8cb96ec41) and REPOINTED — nothing here had ROTTED (this entry was re-verified 2026-08-21, the youngest in the file), but nothing here was CHECKABLE either: the three repo-rooted legs were path-only, and the four driver faces plus the FLS guard were written as bare package+filename prose (`driver-sql sql-driver.ts`, `plugin-security predicate-guard.ts:89`), which PATH_RE cannot parse — so the ledger contributed no resolvable pointer for five of this entry's eight consumers and the gate was never asked about them. That is the webhook.json class, on the file's best-verified entry. All eight are now repo-rooted anchors. Re-closed by hand against 93ea19bca." }, "function": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2578af28370..e9acb34627b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: svelte: specifier: ^5.55.7 version: 5.57.3(@typescript-eslint/types@8.72.1) + ts-morph: + specifier: ^28.0.0 + version: 28.0.0 tsup: specifier: ^8.5.1 version: 8.5.1(patch_hash=d1c8d6bd9db5a776a639cd37b802f8b962f7846a6e3758af4478b0e0bde082e4)(@microsoft/api-extractor@7.58.2(@types/node@26.6.5))(@swc/core@1.15.33(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.32)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(typescript@6.0.3)(yaml@2.9.1) @@ -8712,6 +8715,9 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@ts-morph/common@0.29.0': + resolution: {integrity: sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==} + '@turbo/darwin-64@2.11.10': resolution: {integrity: sha512-By4idRap5+nLnMuRzLB4qkOPRlvNdyY1ne4VJYDyUoUB11H94k7uif9mbe3JT8JSNXs1D68uMcBj6hJ/jMQRvA==} cpu: [x64] @@ -9776,6 +9782,9 @@ packages: resolution: {integrity: sha512-rH0jBKeLkVrK7NsZ5Ba2l7WdMBmm1k0FMpABeXUU1PgTUxbz3261gEuCSsrYuXo4BwAe3yCcIbQ93YyS52lOGQ==} hasBin: true + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -13321,6 +13330,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-morph@28.0.0: + resolution: {integrity: sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -16613,6 +16625,12 @@ snapshots: dependencies: '@testing-library/dom': 10.4.2 + '@ts-morph/common@0.29.0': + dependencies: + minimatch: 10.2.6 + path-browserify: 1.0.1 + tinyglobby: 0.2.17 + '@turbo/darwin-64@2.11.10': optional: true @@ -17701,6 +17719,8 @@ snapshots: cnfast@0.1.0: {} + code-block-writer@13.0.3: {} + collapse-white-space@2.1.0: {} color-convert@2.0.1: @@ -21796,6 +21816,11 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-morph@28.0.0: + dependencies: + '@ts-morph/common': 0.29.0 + code-block-writer: 13.0.3 + tslib@2.8.1: {} tsup@8.5.1(patch_hash=d1c8d6bd9db5a776a639cd37b802f8b962f7846a6e3758af4478b0e0bde082e4)(@microsoft/api-extractor@7.58.2(@types/node@26.6.5))(@swc/core@1.15.33(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.32)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(typescript@6.0.3)(yaml@2.9.1): diff --git a/scripts/adr-anchors/packages__drivers__driver-sql__src__sql-driver-filter-helpers.ts.json b/scripts/adr-anchors/packages__drivers__driver-sql__src__sql-driver-filter-helpers.ts.json new file mode 100644 index 00000000000..d1f661f92e1 --- /dev/null +++ b/scripts/adr-anchors/packages__drivers__driver-sql__src__sql-driver-filter-helpers.ts.json @@ -0,0 +1,7 @@ +{ + "file": "packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts", + "adrs": [ + "ADR-0053" + ], + "invariant": "ADR-0053 D-F1: `presentReadValue`'s `datetime` and `audit_timestamp` arms run on EVERY dialect — what `distinct()` / `aggregate()` present for a declared `datetime` or a `created_at`/`updated_at` column (`min`, `max`, a distinct value) is the same canonical `YYYY-MM-DDTHH:MM:SS.sssZ` fold `formatOutput` applies to a `find()` row, never the client library's `Date`, and the fold stays at the driver's read boundary, never at the pg/mysql2 type parser. Re-gating either arm on `isSqlite` reverts a maintainer ruling (#13973). The body moved here from `sql-driver.ts` (#29520 codemod); `SqlDriver#presentReadValue` there is the stub." +} diff --git a/scripts/adr-anchors/packages__drivers__driver-sql__src__sql-driver-sqlite-serialisation.ts.json b/scripts/adr-anchors/packages__drivers__driver-sql__src__sql-driver-sqlite-serialisation.ts.json new file mode 100644 index 00000000000..eb81fb8c8d3 --- /dev/null +++ b/scripts/adr-anchors/packages__drivers__driver-sql__src__sql-driver-sqlite-serialisation.ts.json @@ -0,0 +1,7 @@ +{ + "file": "packages/drivers/driver-sql/src/sql-driver-sqlite-serialisation.ts", + "adrs": [ + "ADR-0053" + ], + "invariant": "ADR-0053 D-F1/D-F2/D-F3: `formatOutput`'s audit-column and `Field.datetime` folds run on EVERY dialect — a read door presents `created_at`/`updated_at` and every declared `datetime` as the canonical `YYYY-MM-DDTHH:MM:SS.sssZ` text and never the client library's `Date`; the fold lives at the driver's read boundary, never at the pg/mysql2 type parser, and an Invalid `Date` passes through unchanged. Re-gating the fold on `isSqlite` reverts a maintainer ruling (#13973). The body moved here from `sql-driver.ts` (#29520 codemod); `SqlDriver#formatOutput` there is the stub every read door still calls." +} diff --git a/scripts/adr-anchors/packages__drivers__driver-sql__src__sql-driver-top-level.ts.json b/scripts/adr-anchors/packages__drivers__driver-sql__src__sql-driver-top-level.ts.json new file mode 100644 index 00000000000..1406a50e7f8 --- /dev/null +++ b/scripts/adr-anchors/packages__drivers__driver-sql__src__sql-driver-top-level.ts.json @@ -0,0 +1,7 @@ +{ + "file": "packages/drivers/driver-sql/src/sql-driver-top-level.ts", + "adrs": [ + "ADR-0053" + ], + "invariant": "ADR-0053 D-F1/D-F2/D-F3: the fold helpers `formatOutput` and `presentReadValue` call — `normalizeSqliteDatetimeOutput`, `presentAuditTimestampOutput`, `isoFromValidDate` and `repairNaiveUtcAuditTimestamp` — apply on EVERY dialect: a `Date` becomes the canonical `YYYY-MM-DDTHH:MM:SS.sssZ` text, an Invalid `Date` is handed back unchanged (never nulled, never thrown on), and nothing here alters the pg/mysql2 client parser. Re-gating them on `isSqlite` reverts a maintainer ruling (#13973). They moved here from `sql-driver.ts` with the other top-level declarations (#29520 codemod)." +} diff --git a/scripts/check-tenant-chokepoint.mjs b/scripts/check-tenant-chokepoint.mjs index 7d69ddee0f1..9a24ac68983 100644 --- a/scripts/check-tenant-chokepoint.mjs +++ b/scripts/check-tenant-chokepoint.mjs @@ -77,6 +77,14 @@ * inherit this chokepoint (`driver-sqlite-wasm`, `driver-turso`). Scanning the * subclasses is what stops a new door being added one layer down. * + * And every `sql-driver-*.ts` module beside `sql-driver.ts` (tests excepted), + * listed from the directory (#29520, ruling 6591753301). Splitting the class + * moves method bodies into those modules as functions whose `this` is a host + * the method hands them, so a door can sit one FILE over as well as one layer + * down. A module-level function is read as a member is, under its own name; + * the readings line (`getBuilder` bindings, read doors, tenant predicates, D8 + * arms) is what a split compares before and after, and it must not move. + * * `driver-memory` and `driver-mongodb` are outside it, and NOT because #5499 * froze them: they do not use this mechanism at all. Neither file contains * `getBuilder` or `applyTenantScope` (mongodb enforces its own wall in @@ -157,7 +165,7 @@ * (no class found, a builder it cannot classify, or a DISCOVERED floor missed). */ -import { readFileSync, existsSync } from 'node:fs'; +import { readFileSync, existsSync, readdirSync } from 'node:fs'; import { join, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { requireDefaultExport } from './import-prerequisite.mjs'; @@ -167,11 +175,38 @@ import { isEntrypoint } from './invoked-as.mjs'; const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); -/** The `SqlDriver` family — every class that inherits this chokepoint. */ +/** + * Which basenames of `driver-sql/src` are seam modules this gate reads: + * `sql-driver-.ts`, never a test or spec file. A pure function of + * the listing, so the self-test can drive it without the repo. + */ +export function seamModulesOf(basenames) { + return basenames + .filter((n) => /^sql-driver-.+\.ts$/.test(n) && !/\.(test|spec)\.ts$/.test(n) && !n.endsWith('.d.ts')) + .sort(); +} + +/** A directory's basenames, or none when it is absent (main() then refuses on the driver file). */ +function listing(rel) { + return existsSync(join(ROOT, rel)) ? readdirSync(join(ROOT, rel)) : []; +} + +/** + * The `SqlDriver` family — every class that inherits this chokepoint — and + * every `sql-driver-*.ts` module its methods' bodies have moved into (#29520, + * ruling 6591753301). A moved body keeps its `this.getBuilder(…)` and + * `this.applyTenantScope(…)` calls on the host its method hands it, so the + * door it builds is still a door, one file over; a gate that read only + * `sql-driver.ts` would count fewer doors after every cut while reporting + * clean. The population is LISTED from the directory, not enumerated by hand, + * so a new seam module is read the day it lands. + */ const SCAN_FILES = [ join('packages', 'drivers', 'driver-sql', 'src', 'sql-driver.ts'), join('packages', 'drivers', 'driver-sqlite-wasm', 'src', 'sqlite-wasm-driver.ts'), join('packages', 'drivers', 'driver-turso', 'src', 'turso-driver.ts'), + ...seamModulesOf(listing(join('packages', 'drivers', 'driver-sql', 'src'))) + .map((n) => join('packages', 'drivers', 'driver-sql', 'src', n)), ]; /** @@ -181,9 +216,9 @@ const SCAN_FILES = [ * derivation — which reads SOURCE TEXT — saw a handful of bare words and no path * at all. This gate was scored `undetermined` for EVERY card: absent from every * dispatch brief and every `--commands` harvest, while CI ran it on each pull - * request. Worse than the usual cost, because this gate's population is THREE - * FILES: the cards that actually implicate it are rare and precisely nameable, - * and it was reaching none of them. + * request. Worse than the usual cost, because this gate's population is a + * handful of FILES: the cards that actually implicate it are rare and + * precisely nameable, and it was reaching none of them. * * The spelling is `DECLARED_WATCH_HINTS` rather than the directory idiom: this * is an enumerated population, not a walked subtree, and each hint is a whole @@ -192,7 +227,17 @@ const SCAN_FILES = [ * * ⛔ Not `packages/drivers/**`, and not a whole-tree marker. Both would put this * gate on cards it does not read a byte for; a lead that is wrong on thousands - * of files is worse than the silence it replaces. + * of files is worse than the silence it replaces. ⛔ And not + * `sql-driver-*.ts` either, although that is the population's own shape: the + * same directory holds a few hundred `sql-driver-*.test.ts` files this gate + * never reads, and a glob would name it on every one of them. Measured through + * `hintCovers` itself (#29520): the extractor does accept that glob and judges + * it as a pattern, and it covers 31 seam modules and 234 test files, plus any + * `.d.ts`. A hint has no negation, and `?` and `[…]` are not admitted, so no + * spelling covers the modules and leaves the tests out. So the seam modules + * are declared one by one, and the self-test holds the list to the directory's + * listing in both directions. A seam module added, renamed or removed reds the + * self-test, and the failure names the exact string to add or remove here. * * The self-test derives the coupling from `SCAN_FILES` on both sides rather than * re-spelling it, so a driver added to or removed from the family cannot leave @@ -204,6 +249,37 @@ const DECLARED_WATCH_HINTS = [ 'packages/drivers/driver-sql/src/sql-driver.ts', 'packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver.ts', 'packages/drivers/driver-turso/src/turso-driver.ts', + 'packages/drivers/driver-sql/src/sql-driver-aggregation.ts', + 'packages/drivers/driver-sql/src/sql-driver-autonumber.ts', + 'packages/drivers/driver-sql/src/sql-driver-balanced-group.ts', + 'packages/drivers/driver-sql/src/sql-driver-bulk-operations.ts', + 'packages/drivers/driver-sql/src/sql-driver-column-creation.ts', + 'packages/drivers/driver-sql/src/sql-driver-cross-field-comparison.ts', + 'packages/drivers/driver-sql/src/sql-driver-crud.ts', + 'packages/drivers/driver-sql/src/sql-driver-database-helpers.ts', + 'packages/drivers/driver-sql/src/sql-driver-date-bucket.ts', + 'packages/drivers/driver-sql/src/sql-driver-distinct.ts', + 'packages/drivers/driver-sql/src/sql-driver-field-mapping.ts', + 'packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts', + 'packages/drivers/driver-sql/src/sql-driver-internal-helpers.ts', + 'packages/drivers/driver-sql/src/sql-driver-introspection.ts', + 'packages/drivers/driver-sql/src/sql-driver-lifecycle.ts', + 'packages/drivers/driver-sql/src/sql-driver-managed-drift.ts', + 'packages/drivers/driver-sql/src/sql-driver-predicate-emitters.ts', + 'packages/drivers/driver-sql/src/sql-driver-presumed-timestamp.ts', + 'packages/drivers/driver-sql/src/sql-driver-query-plan.ts', + 'packages/drivers/driver-sql/src/sql-driver-raw-execution.ts', + 'packages/drivers/driver-sql/src/sql-driver-read-exit-fault.ts', + 'packages/drivers/driver-sql/src/sql-driver-rotation.ts', + 'packages/drivers/driver-sql/src/sql-driver-schema-sync-section.ts', + 'packages/drivers/driver-sql/src/sql-driver-schema-sync.ts', + 'packages/drivers/driver-sql/src/sql-driver-sequence-semantics.ts', + 'packages/drivers/driver-sql/src/sql-driver-sqlite-serialisation.ts', + 'packages/drivers/driver-sql/src/sql-driver-top-level.ts', + 'packages/drivers/driver-sql/src/sql-driver-transactions.ts', + 'packages/drivers/driver-sql/src/sql-driver-upsert-conflict-target.ts', + 'packages/drivers/driver-sql/src/sql-driver-window-function-builder.ts', + 'packages/drivers/driver-sql/src/sql-driver-window-functions.ts', ]; /** @@ -579,6 +655,13 @@ export function analyzeSource(fileName, text) { } } } + // A module-level function is a moved method body (#29520): its `this` is + // the host its method hands it, so `this.getBuilder(…)` there builds the + // same door. Read under the function's name, which is the method's. + // Top-level only: a function nested in a member is walked with the member. + if (ts.isFunctionDeclaration(node) && node.name && ts.isSourceFile(node.parent)) { + visitMethod(node.name.text, node.body); + } ts.forEachChild(node, walkTop); }; walkTop(sf); @@ -638,6 +721,9 @@ const SELF_TEST_VERDICT = 'check-tenant-chokepoint self-test reached its verdict // does not make. The hoisted single battery is the shape PR #14896 and PR #15003 // landed for exactly this case. // +// #29520 adds a third, `moved bodies (#29520)`, opened by its own `battery()` +// call ahead of the population declaration: the widened scan's own cases. +// // ⛔ A pinned TOTAL is not the repair: a battery dropping from 9 cases to 3 // keeps a total "right" the moment a sibling grows. // @@ -647,11 +733,12 @@ const SELF_TEST_VERDICT = 'check-tenant-chokepoint self-test reached its verdict const SELF_TEST_BATTERIES = Object.freeze({ 'check-tenant-chokepoint self-test': 17, 'D8 null-arm rule': 24, + 'moved bodies (#29520)': 9, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 2; +const SELF_TEST_BATTERY_FLOOR = 3; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -972,18 +1059,72 @@ function selfTest() { ); } + // ── #29520: a body moved to a sql-driver-*.ts module is still a door ────── + battery('moved bodies (#29520)'); + { + // The shape the codemod writes: the body as a module function whose `this` + // is the host its method hands it. + const moved = (body) => analyzeSource('m.ts', `export async function door(this: Pick, object: string, options?: unknown) {\n${body}\n}\n`); + const unscoped = moved(` const builder = this.getBuilder(object, options);\n return await builder;`); + assert(unscoped.builders.length === 1 && violationsOf('m.ts', unscoped).length === 1, + 'a moved body that builds and never scopes must REPORT — the module is no hiding place'); + const scoped = moved(` const builder = this.getBuilder(object, options);\n this.applyTenantScope(builder, object, options);\n return await builder;`); + assert(scoped.builders.length === 1 && violationsOf('m.ts', scoped).length === 0, + 'a moved body that scopes its builder is counted and silent'); + assert(scoped.builders[0]?.method === 'door', 'a moved body is read under its function name, which is its method\'s'); + const inline = moved(` return await this.getBuilder(object, options).select('*');`); + assert(violationsOf('m.ts', inline).some((p) => p.fatal), 'an inline getBuilder() in a moved body is fatal, as on the class'); + // A function nested in a class member is walked with the member, once. + const nested = analyzeSource('t.ts', wrap(`async outer(object, options) { + function inner() { return 1; } + const builder = this.getBuilder(object, options); + this.applyTenantScope(builder, object, options); + return await builder; + }`)); + assert(nested.builders.length === 1, 'a member is not counted twice'); + // The stub's host hands the factory as a bound reference: not a call, so + // neither a binding nor an inline read — the call is the moved body's. + const stub = analyzeSource('t.ts', wrap(`async door(object, options) { + const host = { getBuilder: this.getBuilder.bind(this), applyTenantScope: (...a) => this.applyTenantScope(...a) }; + return door.call(host, object, options); + }`)); + assert(stub.builders.length === 0 && stub.unclassifiable.length === 0, + 'a bound getBuilder reference in a stub is neither a door nor an unclassifiable read'); + // The D8 rule already walks module functions; a moved arm is still refused. + assert(nullArmsOf('m.ts', `export function applyTenantScope(this: H, builder: any, object: string) { + const field = this.resolveTenantField(object); + builder.where(field, 'org').orWhereNull(field); + }`).arms.length === 1, 'D8: an IS NULL arm in a moved body reports'); + // The population: every sql-driver-*.ts module, never a test. + const listed = seamModulesOf(['sql-driver.ts', 'sql-driver-crud.ts', 'sql-driver-crud.test.ts', + 'sql-driver-x.spec.ts', 'sql-driver-types.d.ts', 'schema-drift.ts', 'sql-driver-top-level.ts']); + assert(JSON.stringify(listed) === JSON.stringify(['sql-driver-crud.ts', 'sql-driver-top-level.ts']), + `the seam population is sql-driver-*.ts minus tests, specs and declaration files (got ${listed.join(', ')})`); + assert(SCAN_FILES.length > 3, 'the live scan reads seam modules beside the three driver files'); + } + // ── the dispatch-gates population declaration ──────────────────────────── battery('check-tenant-chokepoint self-test'); const scannedPosix = SCAN_FILES.map((f) => f.split(sep).join('/')); + // Each failure names the exact entries and the one edit that fixes it: a new + // sql-driver-*.ts module lands from an unrelated PR (#29911 added + // sql-driver-balanced-group.ts), and its author meets this line first. + const undeclared = scannedPosix.filter((f) => !DECLARED_WATCH_HINTS.includes(f)); assert( - scannedPosix.every((f) => DECLARED_WATCH_HINTS.includes(f)), + undeclared.length === 0, 'every SCAN_FILES entry is declared for dispatch-gates — a join()-assembled path is invisible to ' - + 'the hint extractor, which is how this gate came to declare nothing at all', + + 'the hint extractor, which is how this gate came to declare nothing at all. Fix: add ' + + undeclared.map((f) => `'${f}'`).join(', ') + + ' to DECLARED_WATCH_HINTS in this gate (one string per module; a new sql-driver-*.ts module ' + + 'is scanned the day it lands, so it is declared the same day)', ); + const unread = DECLARED_WATCH_HINTS.filter((h) => !scannedPosix.includes(h)); assert( - DECLARED_WATCH_HINTS.every((h) => scannedPosix.includes(h)), + unread.length === 0, 'and nothing is declared that this gate does not read — a declaration that has drifted from the ' - + 'scan replaces a silent gate with a lying one', + + 'scan replaces a silent gate with a lying one. Fix: remove ' + + unread.map((h) => `'${h}'`).join(', ') + + ' from DECLARED_WATCH_HINTS in this gate (a module that was renamed or removed)', ); assert( DECLARED_WATCH_HINTS.every((h) => !h.includes('*')), @@ -1056,7 +1197,9 @@ function main() { const problems = []; const nullArms = []; let tenantPredicates = 0; + let armsSeen = 0; let discovered = 0; + let readDoors = 0; let scannedFiles = 0; for (const rel of SCAN_FILES) { @@ -1073,10 +1216,12 @@ function main() { const text = readFileSync(abs, 'utf8'); const analysis = analyzeSource(rel, text); discovered += analysis.builders.length; + readDoors += analysis.builders.filter((b) => !b.write).length; const posix = relative(ROOT, abs).replace(/\\/g, '/'); problems.push(...violationsOf(posix, analysis)); const d8 = nullArmsOf(rel, text); tenantPredicates += d8.predicates; + armsSeen += d8.arms.length; nullArms.push(...nullArmViolationsOf(posix, d8.arms)); } @@ -1157,6 +1302,14 @@ NULL_ARM_EXEMPT in scripts/check-tenant-chokepoint.mjs — which reverses an ADR decision and needs a superseding ADR first.`); } + // The readings, on one line a base and a head can be compared by (#29520): + // a split that moves a door out of the files read changes one of them. + console.log( + `check:tenant-chokepoint readings: ${discovered} getBuilder() binding(s), ${readDoors} read door(s), ` + + `${discovered - readDoors} insert builder(s), ${tenantPredicates} tenant predicate(s), ` + + `${armsSeen} D8 arm(s) across ${scannedFiles} file(s)`, + ); + if (problems.length > 0 || nullArms.length > 0) process.exit(1); console.log( diff --git a/scripts/codemod-split-sql-driver.mjs b/scripts/codemod-split-sql-driver.mjs new file mode 100644 index 00000000000..0b4f38e87a8 --- /dev/null +++ b/scripts/codemod-split-sql-driver.mjs @@ -0,0 +1,2017 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * codemod-split-sql-driver — move whole sections of `SqlDriver`'s method + * bodies out of `packages/drivers/driver-sql/src/sql-driver.ts` into section + * modules, deterministically, and prove the move. + * + * node scripts/codemod-split-sql-driver.mjs # plan: print the section table, write nothing + * node scripts/codemod-split-sql-driver.mjs --write # apply the split, then verify it + * node scripts/codemod-split-sql-driver.mjs --verify --base + * # re-prove a split on disk against sql-driver.ts at + * node scripts/codemod-split-sql-driver.mjs --dts + * # the declaration-file bar on two built dists + * node scripts/codemod-split-sql-driver.mjs --self-test + * node scripts/codemod-split-sql-driver.mjs --help # this usage, on any tree (split or not) + * + * ## Why a script, and what it repeats + * + * The hand series that split this file cut by cut (21 cuts) settled the cut + * pattern; this script applies it to whole sections at once, so the split can + * be regenerated on the latest `main` instead of rebased (ruling A on the + * codemod card, `6583399215`). The pattern, unchanged: + * + * - The method stays on the class with its docblock, modifiers and signature + * byte-for-byte, so the published `.d.ts` keeps its names and positions and + * an instance spy or a subclass override is still the one callers reach. + * Only `async` is dropped from a stub, as every async stub of the series did. + * - The body moves, unedited, into an exported function of the same name in + * `sql-driver-
.ts`, whose `this` is one named host: the `SqlDriver` + * members the body reads, typed AS the class members (`SqlDriver['x']`). + * - The stub hands the body that host: a method as a closure that calls it on + * the driver at call time (the builder factory as a bound reference, see + * `BOUND_HOST_METHODS`), a field or accessor as a GETTER that reads it when + * the body reads it. + * + * Ruling `6591753301` (option A) widened the pattern two ways, and this script + * applies both: + * + * - (a) A section module may import VALUES from `sql-driver.ts`. A staying + * top-level name a moved body reads is exported for it by one generated + * `export { … }` list (the declaration's own text is not touched), and + * `index.ts` re-exports by name, so none reaches the package. Every such + * use is inside a function body, so the cycle is never read at evaluation. + * - (b) The top-level declarations ahead of the class (`const` / `function` / + * `interface` / `type`) move WHOLE, with the comments above them, into one + * leaf module, `LEAF_MODULE`, in base order. The leaf never imports + * `sql-driver.ts` or a `sql-driver-*` module: a declaration that names the + * class, a seam import or a declaration that stays, stays itself. + * `sql-driver.ts` imports back what it still reads and re-exports, by name, + * every moved declaration it exported, so every importer of + * `./sql-driver.js` (`index.ts`, the hand-series seams, the tests) reads + * what it read. + * + * `check-tenant-chokepoint` reads every `sql-driver-*.ts` module since the + * same ruling, so a body carrying the builder factory or the tenant scope + * moves like any other; its readings must be equal before and after. + * + * ⛔ The moved text is not re-indented and not formatted: the body is the + * method's `{ … }` exactly as it stood, so byte-identity is equality of text, + * not equality after a transform. + * + * ## What stays, and why (each reason is mechanical) + * + * A method stays whole on the class when moving it would change what it does, + * what the published `.d.ts` says, or what a gate or test that reads + * `sql-driver.ts` BY PATH sees. `STAY_REASONS` lists every reason for a method + * and `TOP_STAY_REASONS` every reason for a top-level declaration; the plan + * prints every one that stayed and why. The reasons that are tables rather + * than rules are `PATH_READER_PINS` (a name whose text a reader extracts from + * `sql-driver.ts` by path), `PATH_READER_NEEDLES` (literal text a reader + * requires to find there) and `CLASS_PATH_READER_NEEDLES` (the same, for a + * reader that also reads the leaf, so only a method carrying it stays). + * + * ## The proof (`--write` runs it on its own output; `--verify` re-runs it) + * + * 1. Byte-identity: every generated function's body, parameters and return + * type are the base method's text, exactly; every leaf declaration is its + * base statement's text with the comments above it, exactly; and putting + * every body back (and `async` back on its stub), taking the generated + * imports and exports out, and splicing the leaf back between the + * statements that stayed rebuilds the base `sql-driver.ts` byte-for-byte, + * so nothing else in it moved. The leaf's relative imports are followed on + * disk: none may reach `sql-driver.ts`. + * 2. The declaration-file bar of ruling `6348891160` item 1, on the built + * `dist/index.d.ts` and `dist/index.d.mts` (`--dts`): the same declaration + * set; byte-identical after sorting the members of each string-literal + * union (the ruling's one normalisation, `normaliseUnionOrder`); the same + * export list; and a positive control (one private member made public) + * that the normaliser does not hide. + * + * ## Determinism + * + * The input is the text of `sql-driver.ts`. It is parsed in an in-memory + * project (no `node_modules`, no build output, no clock), every collection is + * emitted in source order or sorted, and the output is a pure function of the + * input: two runs on one input write byte-identical trees. + * + * ts-morph is a root devDependency (`package.json` declares `ts-morph` + * `^28.0.0`), the way the other root scripts take `typescript`, `yaml` or + * `semver`: it is resolved from the root manifest, so a fresh + * `pnpm install --frozen-lockfile` is all this script needs. It used to borrow + * `@objectstack/cli`'s closure; #29711 dropped `ts-morph` from the CLI, and no + * workspace package declares it. + * + * Exit codes: 0 done · 1 a proof failed (stop: the generated split is never + * hand-edited to pass) · 2 the script could not run (unknown banner, missing + * prerequisite, bad arguments) · 3 an input does not parse (`ts-parse.mjs`'s + * refusal, which names the parse errors). A tree with no install at all stops + * before this script's own ts-morph check: `ts-parse.mjs` loads `typescript` + * through `import-prerequisite.mjs`, whose refusal is also exit 3 and names + * `pnpm install`. + */ + +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { isEntrypoint } from './invoked-as.mjs'; +import { parseSourceFile } from './ts-parse.mjs'; + +const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); +const SRC_REL = 'packages/drivers/driver-sql/src'; +const SRC_DIR = join(ROOT, SRC_REL); +const HOST_BASENAME = 'sql-driver.ts'; +const HOST_PATH = join(SRC_DIR, HOST_BASENAME); +const CLASS_NAME = 'SqlDriver'; +const CARD = '#29520'; + +/** The marker every generated file and stub carries; `--verify` keys on it. */ +const MARK = `[${CARD} codemod]`; + +/** + * Every banner inside `SqlDriver`, in the file's two spellings + * (`// ====` / `// Title` / `// ====`, and `// ── Title ───`), mapped to the + * module its section's bodies move to. A banner the table does not name stops + * the script (exit 2): a new section is a decision someone writes here, never a + * name the script makes up. `null` keeps the section's methods on the class. + * + * Three titles name a section whose seam module the hand series already wrote + * (`sql-driver-raw-execution.ts`, `sql-driver-transactions.ts`, + * `sql-driver-schema-sync.ts`); the generated module never shares a file with a + * hand-written one, so those three take a `-section` suffix. + */ +const SECTION_MODULES = Object.freeze({ + 'Lifecycle': 'sql-driver-lifecycle.ts', + 'CRUD — IDataDriver core': 'sql-driver-crud.ts', + 'The sequence semantics a second face shares, stated once': 'sql-driver-sequence-semantics.ts', + 'Bulk & Batch Operations': 'sql-driver-bulk-operations.ts', + 'Raw Execution': 'sql-driver-raw-execution-section.ts', + 'Transactions': 'sql-driver-transactions-section.ts', + 'Aggregation': 'sql-driver-aggregation.ts', + 'Distinct': 'sql-driver-distinct.ts', + 'Window Functions': 'sql-driver-window-functions.ts', + 'Query Plan Analysis': 'sql-driver-query-plan.ts', + 'Schema Sync (syncSchema / init)': 'sql-driver-schema-sync-section.ts', + 'Data-lifecycle rotation (ADR-0057 P2)': 'sql-driver-rotation.ts', + 'Deferred schema DDL (#3917)': 'sql-driver-deferred-ddl.ts', + 'Managed-schema drift & reconcile (#2186)': 'sql-driver-managed-drift.ts', + 'Schema Introspection': 'sql-driver-schema-introspection.ts', + 'Internal helpers': 'sql-driver-internal-helpers.ts', + 'Filter helpers': 'sql-driver-filter-helpers.ts', + 'Field mapping': 'sql-driver-field-mapping.ts', + 'Window function builder': 'sql-driver-window-function-builder.ts', + 'Column creation helper': 'sql-driver-column-creation.ts', + 'Database helpers': 'sql-driver-database-helpers.ts', + 'SQLite serialisation': 'sql-driver-sqlite-serialisation.ts', + 'Introspection internals': 'sql-driver-introspection-internals.ts', +}); + +/** + * The leaf module the top-level declarations ahead of the class move to, whole + * (lever (b) of ruling `6591753301`). A leaf: it never imports `sql-driver.ts` + * or any `sql-driver-*` module, so it is evaluated before the class and forms + * no cycle with it. + */ +const LEAF_MODULE = 'sql-driver-top-level.ts'; + +/** + * Names whose text a reader takes out of `sql-driver.ts` BY PATH. Moving it + * would leave that reader reading a stub, or nothing. A class method or a + * top-level declaration of the name stays. Each row names the reader. A row + * naming nothing the file declares stops the script (exit 2), so the table + * cannot rot into names nobody reads. + */ +const PATH_READER_PINS = Object.freeze({ + createColumn: 'packages/cli generate-{field-type-vocabulary,string-family-width,multiple-json-column} pins and ' + + 'scripts/check-keyed-text-bounds.mjs slice `protected createColumn(` to its type switch', + createAuditTimestampColumn: 'packages/cli generate-builtin-id-column pin reads 600 characters after its signature', +}); + +/** + * Literal text a reader requires to find in `sql-driver.ts` BY PATH. A class + * method or a top-level declaration whose text carries one stays, so the + * reader still finds it where it looks. Each row names the reader. + * + * A reader that also reads the leaf (`sql-driver-top-level.ts`) has its rows in + * `CLASS_PATH_READER_NEEDLES` below instead: there a top-level declaration + * carrying the text may move, and only a method carrying it stays. + */ +const PATH_READER_NEEDLES = Object.freeze({ + "table.string('id').primary();": 'packages/cli generate-builtin-id-column and generate-field-type-vocabulary pins', + 'table.timestamp(name).defaultTo(this.knex.fn.now());': 'packages/cli generate-builtin-id-column pin', + '...STRUCTURED_JSON_TYPES, ...MULTI_OPTION_TYPES,': 'packages/cli generate-field-type-vocabulary and generate-string-family-width pins', + 'const MEDIA_ID_VARCHAR_CHARS = ': 'packages/cli generate-file-reference-width and generate-field-type-vocabulary pins', + 'table.string(name, MEDIA_ID_VARCHAR_CHARS)': 'packages/cli generate-file-reference-width and generate-field-type-vocabulary pins', + 'this.createColumn(table, name, field, keyedColumns.get(name));': 'packages/cli generate-string-family-width pin', + 'const keyedColumns = indexedKeyColumns({': 'packages/cli generate-string-family-width pin', + 'keyed ? this.keyableTextLength(field) : null': 'scripts/check-keyed-text-bounds.mjs EMITTER_ANCHOR and the generate-string-family-width pin', + 'JSON_COLUMN_TYPES.has(type) ? this.jsonColumn(table, name) : table.string(name)': 'packages/cli generate-field-type-vocabulary and generate-string-family-width pins', + 'if (FILE_REFERENCE_TYPES.has(type)) {': 'packages/cli generate-string-family-width pin', + // check-durability-degradation-log-level judges the `catch` around a + // durability call by following SAME-FILE helpers (its `functionBodies` is + // per source file): `this.logDurabilityFailure(…)` resolves only in + // sql-driver.ts, so a moved catch reads as "swallowed, no log" (measured: + // migrateMysqlDatetimeColumns / migrateMysqlTimeColumns red when moved). + 'this.runWideningAlters(': 'scripts/check-durability-degradation-log-level.mjs resolves the catch\'s log helper in the same file', +}); + +/** + * Literal text a reader requires to find in `sql-driver.ts` OR the leaf, but + * reads in no section module. A method whose text carries one stays on the + * class; a top-level declaration carrying one moves as any other, because the + * reader reads the leaf it moves to. The same stale-needle refusal applies. + * + * All three rows are `sql-driver-compile-refusal-seam.test.ts`'s enumeration: + * it parses `sql-driver.ts`, the leaf and three named seam modules, and asserts + * the EXACT set of functions that call `unsupportedFilterError` / + * `withheldFilterError` or write the `INVALID_FILTER` code, by enclosing name. + * Its `sources` gained the leaf by ruling 6597555122 (#29520 round 3, the path + * reader migrated in the same PR as #25521 ask 3 asks), so these rows left + * `PATH_READER_NEEDLES`. A method still stays: its body would move to a section + * module the test does not read, and `SqlDriver.resolveWithheldFilterRefusal`, + * a name the test pins, would drop out of the set. + */ +const CLASS_PATH_READER_NEEDLES = Object.freeze({ + 'unsupportedFilterError(': 'sql-driver-compile-refusal-seam.test.ts enumerates its callers (it reads the leaf, no section module)', + 'withheldFilterError(': 'sql-driver-compile-refusal-seam.test.ts enumerates its callers (it reads the leaf, no section module)', + '.INVALID_FILTER': 'sql-driver-compile-refusal-seam.test.ts enumerates the INVALID_FILTER code writers (it reads the leaf, no section module)', +}); + +/** + * The `SqlDriver` methods a host hands its body as a BOUND reference + * (`this.m.bind(this)`) rather than as a closure that calls it. Only the + * builder factory: `check-tenant-chokepoint` refuses a `this.getBuilder(...)` + * call whose result is never bound to a name, and a forwarding closure in a + * stub is exactly that shape. The bound reference calls the same method on + * the same driver (a subclass override included); the real call, and the + * binding the gate counts, stay in the moved body, where the widened gate + * reads them. + */ +const BOUND_HOST_METHODS = new Set(['getBuilder']); + +/** Reserved words a function declaration may not be named. */ +const RESERVED = new Set(('break case catch class const continue debugger default delete do else enum export extends ' + + 'false finally for function if import in instanceof new null return super switch this throw true try typeof var ' + + 'void while with implements interface let package private protected public static yield await').split(' ')); + +/** Every reason a method stays, with the one-line why the plan prints. */ +const STAY_REASONS = Object.freeze({ + 'no-section': 'declared before the first section banner', + 'section-kept': 'its section maps to no module', + 'static': 'a static member (the host is an instance)', + 'no-body': 'an overload signature or abstract member', + 'generator': 'a generator', + 'overloaded': 'an overloaded method', + 'decorated': 'a decorated method', + 'not-identifier-name': 'a computed, string or #private name', + 'reserved-name': 'its name is reserved for a function declaration', + 'inferred-return-type': 'a non-private method whose return type is inferred (the .d.ts would infer it through the module)', + 'type-parameters': 'a generic method (Function#call does not carry its type parameters)', + 'parameter-shape': 'a destructured, defaulted or `this` parameter (the stub cannot forward it unchanged)', + 'comment-in-parameters': 'a comment inside its parameter list (one-line parameters would drop it)', + 'path-reader': 'a reader takes its text out of sql-driver.ts by path (PATH_READER_PINS / PATH_READER_NEEDLES / CLASS_PATH_READER_NEEDLES)', + 'arguments': 'its body reads `arguments`', + 'super': 'its body reads `super`', + 'new-target': 'its body reads `new.target`', + 'this-type': 'it names the polymorphic `this` type', + 'bare-this': 'it uses `this` other than as `this.member`', + 'unknown-member': 'it reads a `this.member` the class does not declare', + 'this-write': 'it assigns a `this.member` (a host getter cannot be written)', + 'method-as-value': 'it reads a method as a value instead of calling it', + 'field-call': 'it calls a field or accessor as a function (the host would change its receiver)', + 'overloaded-host-method': 'it calls an overloaded method (the host closure cannot be typed)', + 'non-public-static': 'it reads a private or protected static of SqlDriver (unreachable from a module function)', + 'module-write': 'it assigns a module-scope binding of sql-driver.ts (an import is read-only)', + 'already-moved': 'its body is already a hand-series seam stub', + 'name-taken': 'its name is already a module-scope binding in sql-driver.ts', + 'name-collision': 'its name collides with an import its module needs', + 'parameter-shadows': 'a parameter shadows the moved function or a stub local', + 'no-yield': 'its stub would be no shorter than its body (moving it would grow sql-driver.ts)', +}); + +/** Every reason a top-level declaration ahead of the class stays in sql-driver.ts. */ +const TOP_STAY_REASONS = Object.freeze({ + 'top-kind': 'not a const / function / interface / type declaration of plain names', + 'merged-declaration': 'its name is declared by more than one statement (an overload set or a merged declaration)', + 'path-reader': 'a reader takes its text out of sql-driver.ts by path (PATH_READER_PINS / PATH_READER_NEEDLES)', + 'class-reference': 'it names the class (the leaf would import sql-driver.ts back)', + 'seam-import': 'it uses an import from a sql-driver-* module (the leaf would reach sql-driver.ts back through it)', + 'needs-stayer': 'it names a declaration that stays (the leaf would import sql-driver.ts back)', +}); + +// ── Loading ts-morph ───────────────────────────────────────────────────────── + +let tsMorph = null; + +/** ts-morph from the root manifest's devDependencies; a missing install is exit 2, never a stack trace. */ +async function loadTsMorph() { + if (tsMorph) return tsMorph; + const manifest = join(ROOT, 'package.json'); + try { + const resolved = createRequire(manifest).resolve('ts-morph'); + const mod = await import(pathToFileURL(resolved).href); + tsMorph = mod.default ?? mod; + return tsMorph; + } catch (error) { + console.error( + `✗ codemod-split-sql-driver: PREREQUISITE NOT MET — ts-morph does not resolve from ${manifest} ` + + `(${error && error.code ? error.code : 'error'}). Run \`pnpm install\` in this worktree.`, + ); + process.exit(2); + } +} + +/** + * One in-memory project per parse: no file system, no lib, no build output. + * The text goes through `ts-parse.mjs`'s checked parse first, which refuses a + * source with parse errors (its exit 3) instead of handing back a recovered + * partial tree; ts-morph then reads the same text, already vouched for. + */ +function parse(tm, fileName, text) { + parseSourceFile(fileName, text); + const project = new tm.Project({ + useInMemoryFileSystem: true, + compilerOptions: { target: tm.ts.ScriptTarget.ES2020, strict: true, noLib: true, allowJs: false }, + }); + project.getFileSystem().writeFileSync(fileName, text); + return project.addSourceFileAtPath(fileName); +} + +/** A reason the script cannot run at all (exit 2) — never a verdict on the split. */ +class CodemodRefusal extends Error {} + +function refuse(message) { + throw new CodemodRefusal(message); +} + +// ── Reading the sections ───────────────────────────────────────────────────── + +/** Banner lines inside the class body: `{ line, title }`, 1-based, in order. */ +function readBanners(lines, fromLine, toLine) { + const banners = []; + for (let i = fromLine; i < toLine; i += 1) { + const line = lines[i - 1]; + const rule = /^ {2}\/\/ ={3,}$/; + if (rule.test(line) && i + 2 <= toLine && rule.test(lines[i + 1])) { + const m = /^ {2}\/\/ (\S.*)$/.exec(lines[i]); + if (m) { + banners.push({ line: i, title: m[1].trim() }); + i += 2; + continue; + } + } + const dash = /^ {2}\/\/ ── (.+?) ─+$/.exec(line); + if (dash) banners.push({ line: i, title: dash[1].trim() }); + } + return banners; +} + +// ── Analysis ───────────────────────────────────────────────────────────────── + +/** The names sql-driver.ts binds at module scope, and which of them it exports. */ +function moduleScope(tm, sf) { + const { Node } = tm; + const bindings = new Map(); // name -> { kind: 'import' | 'local', specifier?, form?, imported?, typeOnly? } + const exported = new Set(); + const importOrder = new Map(); // specifier -> first position among the import declarations + for (const st of sf.getStatements()) { + if (Node.isImportDeclaration(st)) { + const specifier = st.getModuleSpecifierValue(); + const order = importOrder.has(specifier) ? importOrder.get(specifier) : importOrder.size; + importOrder.set(specifier, order); + const declTypeOnly = st.isTypeOnly(); + const def = st.getDefaultImport(); + if (def) bindings.set(def.getText(), { kind: 'import', specifier, form: 'default', typeOnly: declTypeOnly }); + const ns = st.getNamespaceImport(); + if (ns) bindings.set(ns.getText(), { kind: 'import', specifier, form: 'namespace', typeOnly: declTypeOnly }); + for (const spec of st.getNamedImports()) { + bindings.set((spec.getAliasNode() ?? spec.getNameNode()).getText(), { + kind: 'import', specifier, form: 'named', imported: spec.getNameNode().getText(), + typeOnly: declTypeOnly || spec.isTypeOnly(), + }); + } + continue; + } + if (Node.isExportDeclaration(st)) { + if (!st.getModuleSpecifier()) for (const spec of st.getNamedExports()) exported.add(spec.getNameNode().getText()); + continue; + } + const isExported = Node.isExportable(st) && st.isExported(); + const names = []; + if (Node.isVariableStatement(st)) { + for (const decl of st.getDeclarations()) { + for (const id of [decl.getNameNode(), ...decl.getNameNode().getDescendantsOfKind(tm.SyntaxKind.Identifier)]) { + if (Node.isIdentifier(id)) names.push(id.getText()); + } + } + } else if (typeof st.getName === 'function' && st.getName()) { + names.push(st.getName()); + } + for (const name of names) { + bindings.set(name, { kind: 'local' }); + if (isExported) exported.add(name); + } + } + return { bindings, exported, importOrder }; +} + +/** Is `node` inside a type node, below `stop`? */ +function inTypePosition(tm, node, stop) { + for (let n = node.getParent(); n && n !== stop; n = n.getParent()) { + if (tm.ts.isTypeNode(n.compilerNode)) return true; + } + return false; +} + +/** The node that binds the `this` a `this` keyword at `node` reads. */ +function thisContainer(tm, node) { + const { Node } = tm; + for (let n = node.getParent(); n; n = n.getParent()) { + if ( + Node.isFunctionDeclaration(n) || Node.isFunctionExpression(n) || Node.isMethodDeclaration(n) || + Node.isConstructorDeclaration(n) || Node.isGetAccessorDeclaration(n) || Node.isSetAccessorDeclaration(n) || + Node.isClassDeclaration(n) || Node.isClassExpression(n) || Node.isClassStaticBlockDeclaration(n) + ) return n; + } + return null; +} + +/** Is the property access `pa` written (assigned, updated, deleted, destructured into)? */ +function isWritten(tm, pa) { + const { Node, SyntaxKind } = tm; + let target = pa; + for (let p = target.getParent(); p; target = p, p = p.getParent()) { + if ( + Node.isParenthesizedExpression(p) || Node.isArrayLiteralExpression(p) || Node.isObjectLiteralExpression(p) || + Node.isPropertyAssignment(p) || Node.isShorthandPropertyAssignment(p) || Node.isSpreadElement(p) || + Node.isSpreadAssignment(p) + ) continue; + break; + } + const parent = target.getParent(); + if (!parent) return false; + if (Node.isBinaryExpression(parent) && parent.getLeft() === target) { + const op = parent.getOperatorToken().getKind(); + return op >= SyntaxKind.FirstAssignment && op <= SyntaxKind.LastAssignment; + } + if (target === pa && (Node.isPrefixUnaryExpression(parent) || Node.isPostfixUnaryExpression(parent))) { + const op = parent.getOperatorToken(); + return op === SyntaxKind.PlusPlusToken || op === SyntaxKind.MinusMinusToken; + } + if (target === pa && Node.isDeleteExpression(parent)) return true; + if ((Node.isForOfStatement(parent) || Node.isForInStatement(parent)) && parent.getInitializer() === target) return true; + return false; +} + +/** The class's instance members by name: `{ kind: 'method' | 'field' | 'getter' | 'setter', overloaded }`. */ +function instanceMembers(tm, cls) { + const { Node } = tm; + const members = new Map(); + const note = (name, kind, extra = {}) => { + const prev = members.get(name); + if (prev && prev.kind === 'setter' && kind === 'getter') { members.set(name, { kind: 'getter', ...extra }); return; } + if (prev && prev.kind === 'getter' && kind === 'setter') return; + members.set(name, { kind, ...extra }); + }; + for (const m of cls.getMembers()) { + if (Node.isConstructorDeclaration(m)) { + for (const p of m.getParameters()) if (p.isParameterProperty()) note(p.getName(), 'field'); + continue; + } + if (typeof m.isStatic === 'function' && m.isStatic()) continue; + const nameNode = typeof m.getNameNode === 'function' ? m.getNameNode() : null; + if (!nameNode || !Node.isIdentifier(nameNode)) continue; + const name = nameNode.getText(); + if (Node.isMethodDeclaration(m)) note(name, 'method', { overloaded: m.getOverloads().length > 0 }); + else if (Node.isPropertyDeclaration(m)) note(name, 'field'); + else if (Node.isGetAccessorDeclaration(m)) note(name, 'getter'); + else if (Node.isSetAccessorDeclaration(m)) note(name, 'setter'); + } + return members; +} + +/** + * Read one method: why it must stay (empty = it moves), what its body reads + * through the host, and which imports its module needs. + */ +function readMethod(tm, ctx, m) { + const { Node, SyntaxKind } = tm; + const reasons = []; + const add = (code, detail) => { + const tag = detail ? `${code}: ${detail}` : code; + if (!reasons.includes(tag)) reasons.push(tag); + }; + const host = new Map(); // member -> 'method' | 'getter' + const imports = new Map(); // local -> { value } + const moduleRefs = new Map(); // sql-driver.ts top-level name -> { value } + + if (m.isStatic()) add('static'); + if (!m.getBody()) add('no-body'); + if (m.isAbstract && m.isAbstract()) add('no-body'); + if (m.isGenerator()) add('generator'); + if (m.getOverloads().length > 0) add('overloaded'); + if (m.getDecorators().length > 0) add('decorated'); + const nameNode = m.getNameNode(); + if (!Node.isIdentifier(nameNode)) { add('not-identifier-name'); return { reasons, host, imports, moduleRefs }; } + const name = nameNode.getText(); + if (RESERVED.has(name)) add('reserved-name'); + if (!m.getReturnTypeNode() && m.getScope() !== 'private') add('inferred-return-type'); + if (m.getTypeParameters().length > 0) add('type-parameters'); + for (const p of m.getParameters()) { + if (!Node.isIdentifier(p.getNameNode()) || p.hasInitializer() || p.getName() === 'this') add('parameter-shape', p.getName()); + } + const body = m.getBody(); + if (!body) return { reasons, host, imports, moduleRefs }; + + // A comment between the parentheses that no parameter's own text carries. + const params = m.getParameters(); + const open = m.getFirstChildByKindOrThrow(SyntaxKind.OpenParenToken); + const close = m.getFirstChildByKindOrThrow(SyntaxKind.CloseParenToken); + let between = ctx.text.slice(open.getEnd(), close.getStart()); + for (const p of [...params].reverse()) { + between = between.slice(0, p.getStart() - open.getEnd()) + between.slice(p.getEnd() - open.getEnd()); + } + if (/\/\/|\/\*/.test(between)) add('comment-in-parameters'); + + const methodText = m.getText(); + for (const needle of Object.keys(ctx.needles)) if (methodText.includes(needle)) add('path-reader', JSON.stringify(needle)); + for (const needle of Object.keys(ctx.classNeedles)) if (methodText.includes(needle)) add('path-reader', JSON.stringify(needle)); + if (Object.prototype.hasOwnProperty.call(ctx.pins, name)) add('path-reader', name); + + const regions = [...m.getTypeParameters(), ...params, m.getReturnTypeNode(), body].filter(Boolean); + const checker = ctx.sf.getProject().getTypeChecker().compilerObject; + for (const region of regions) { + const nodes = [region, ...region.getDescendants()]; + for (const node of nodes) { + const kind = node.getKind(); + if (kind === SyntaxKind.SuperKeyword) add('super'); + if (kind === SyntaxKind.ThisType) add('this-type'); + if (Node.isMetaProperty(node) && node.getKeywordToken() === SyntaxKind.NewKeyword) add('new-target'); + if (kind === SyntaxKind.ThisKeyword) { + if (thisContainer(tm, node) !== m) continue; + const parent = node.getParent(); + if (!Node.isPropertyAccessExpression(parent) || parent.getExpression() !== node) { add('bare-this'); continue; } + const memberNameNode = parent.getNameNode(); + if (!Node.isIdentifier(memberNameNode)) { add('bare-this'); continue; } + const member = memberNameNode.getText(); + const info = ctx.members.get(member); + if (!info) { add('unknown-member', member); continue; } + if (isWritten(tm, parent)) add('this-write', member); + const outer = parent.getParent(); + const called = Node.isCallExpression(outer) && outer.getExpression() === parent; + if (info.kind === 'method') { + if (!called) add('method-as-value', member); + if (info.overloaded) add('overloaded-host-method', member); + host.set(member, 'method'); + } else if (info.kind === 'setter') { + add('this-write', member); + } else { + if (called) add('field-call', member); + host.set(member, 'getter'); + } + continue; + } + if (kind !== SyntaxKind.Identifier) continue; + if (node.getText() === 'arguments') { + const parent = node.getParent(); + const isName = Node.isPropertyAccessExpression(parent) && parent.getNameNode() === node; + if (!isName) add('arguments'); + } + const parent = node.getParent(); + let symbol; + if (Node.isShorthandPropertyAssignment(parent) && parent.getNameNode() === node) { + symbol = checker.getShorthandAssignmentValueSymbol(parent.compilerNode); + } else { + symbol = checker.getSymbolAtLocation(node.compilerNode); + } + if (!symbol || !symbol.declarations) continue; + for (const decl of symbol.declarations) { + if (decl.getSourceFile() !== ctx.sf.compilerNode) continue; + const ts = tm.ts; + const typePos = inTypePosition(tm, node, m); + if (ts.isImportSpecifier(decl) || ts.isImportClause(decl) || ts.isNamespaceImport(decl)) { + const local = node.getText(); + const prev = imports.get(local); + imports.set(local, { value: Boolean(prev && prev.value) || !typePos }); + continue; + } + if (!isModuleScopeDecl(ts, decl)) continue; + // Lever (a) of ruling 6591753301: a module-scope name is imported — + // from the leaf when it moved there, from `sql-driver.ts` (exported + // for it) when it stayed. Two things a module function cannot do: + // reach a non-public static of the class, and assign an import. + const declName = decl.name && ts.isIdentifier(decl.name) ? decl.name.text : node.getText(); + if (ts.isClassDeclaration(decl) && declName === CLASS_NAME && !typePos) { + const access = node.getParent(); + if (Node.isPropertyAccessExpression(access) && access.getExpression() === node) { + const member = access.getName(); + const scope = ctx.statics.get(member); + if (scope && scope !== 'public') add('non-public-static', member); + } + } + if (!typePos && isWritten(tm, node)) add('module-write', declName); + const prev = moduleRefs.get(declName); + moduleRefs.set(declName, { value: Boolean(prev && prev.value) || !typePos }); + } + } + } + + const binding = ctx.scope.bindings.get(name); + if (binding) { + if (binding.kind === 'import' && /^\.\/sql-driver-[a-z0-9-]+\.js$/.test(binding.specifier)) add('already-moved', binding.specifier); + else add('name-taken'); + } + for (const p of params) { + const pn = p.getName(); + if (pn === name || pn === 'host' || /^read[A-Z]/.test(pn)) add('parameter-shadows', pn); + } + return { reasons, host, imports, moduleRefs }; +} + +function isModuleScopeDecl(ts, decl) { + if (ts.isVariableDeclaration(decl)) { + const statement = decl.parent && decl.parent.parent; + return Boolean(statement && ts.isVariableStatement(statement) && ts.isSourceFile(statement.parent)); + } + if (ts.isBindingElement(decl)) { + let n = decl; + while (n && !ts.isVariableDeclaration(n)) n = n.parent; + return Boolean(n && isModuleScopeDecl(ts, n)); + } + return Boolean(decl.parent && ts.isSourceFile(decl.parent)) && + (ts.isFunctionDeclaration(decl) || ts.isClassDeclaration(decl) || ts.isInterfaceDeclaration(decl) || + ts.isTypeAliasDeclaration(decl) || ts.isEnumDeclaration(decl) || ts.isModuleDeclaration(decl)); +} + +// ── The plan ───────────────────────────────────────────────────────────────── + +/** `sql-driver-raw-execution-section.ts` -> `RawExecutionSectionHost`. */ +function hostNameOf(moduleFile) { + const slug = moduleFile.replace(/^sql-driver-/, '').replace(/\.ts$/, ''); + return `${slug.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join('')}Host`; +} + +/** Is `node` inside a JSDoc comment? A `{@link X}` there is prose, not a use. */ +function inJsDoc(tm, node) { + for (let n = node.compilerNode; n; n = n.parent) { + if (n.kind >= tm.SyntaxKind.FirstJSDocNode && n.kind <= tm.SyntaxKind.LastJSDocNode) return true; + } + return false; +} + +/** The statement of `sf` (a direct child of the source file) a declaration belongs to. */ +function topStatementOf(ts, decl) { + let n = decl; + while (n.parent && !ts.isSourceFile(n.parent)) n = n.parent; + return n.parent ? n : null; +} + +/** The names a top-level statement declares, or null when one is not a plain identifier. */ +function declaredNames(tm, st) { + const { Node } = tm; + if (Node.isVariableStatement(st)) { + const names = []; + for (const d of st.getDeclarations()) { + if (!Node.isIdentifier(d.getNameNode())) return null; + names.push(d.getName()); + } + return names; + } + if (Node.isFunctionDeclaration(st) || Node.isInterfaceDeclaration(st) || Node.isTypeAliasDeclaration(st)) { + return st.getName() ? [st.getName()] : null; + } + return null; +} + +/** + * Lever (b): read every top-level statement between the last import and the + * class, and decide which move whole into the leaf. A mover never names the + * class, a `sql-driver-*` module's import, or a statement that stays — so the + * leaf it lands in never imports `sql-driver.ts` back. Pure. + */ +function planTopLevel(tm, ctx) { + const { Node, SyntaxKind } = tm; + const ts = tm.ts; + const stmts = ctx.sf.getStatements(); + const clsIndex = stmts.indexOf(ctx.cls); + let lastImport = -1; + stmts.forEach((st, i) => { if (Node.isImportDeclaration(st)) lastImport = i; }); + const checker = ctx.sf.getProject().getTypeChecker().compilerObject; + const nameCount = new Map(); + for (const st of stmts) { + for (const n of declaredNames(tm, st) ?? []) nameCount.set(n, (nameCount.get(n) ?? 0) + 1); + } + const records = []; + const byStatement = new Map(); + for (let i = lastImport + 1; i < clsIndex; i += 1) { + const st = stmts[i]; + const names = declaredNames(tm, st); + const record = { + node: st, names: names ?? [], label: names ? names.join(', ') : `(${st.getKindName()})`, + exported: Node.isExportable(st) && st.isExported(), reasons: [], + deps: new Set(), imports: new Map(), + startLine: st.getStartLineNumber(true), lines: st.getEndLineNumber() - st.getStartLineNumber(true) + 1, + typeOnly: Node.isInterfaceDeclaration(st) || Node.isTypeAliasDeclaration(st), + }; + const add = (code, detail) => { + const tag = detail ? `${code}: ${detail}` : code; + if (!record.reasons.includes(tag)) record.reasons.push(tag); + }; + if (!names || (Node.isVariableStatement(st) && st.hasDeclareKeyword()) || + (Node.isFunctionDeclaration(st) && (!st.getBody() || st.hasDeclareKeyword()))) add('top-kind'); + for (const n of record.names) if ((nameCount.get(n) ?? 0) > 1) add('merged-declaration', n); + for (const n of record.names) if (Object.prototype.hasOwnProperty.call(ctx.pins, n)) add('path-reader', n); + const fullText = st.getFullText(); + for (const needle of Object.keys(ctx.needles)) if (fullText.includes(needle)) add('path-reader', JSON.stringify(needle)); + for (const node of st.getDescendantsOfKind(SyntaxKind.Identifier)) { + if (inJsDoc(tm, node)) continue; + const parent = node.getParent(); + const symbol = Node.isShorthandPropertyAssignment(parent) && parent.getNameNode() === node + ? checker.getShorthandAssignmentValueSymbol(parent.compilerNode) + : checker.getSymbolAtLocation(node.compilerNode); + if (!symbol || !symbol.declarations) continue; + for (const decl of symbol.declarations) { + if (decl.getSourceFile() !== ctx.sf.compilerNode) continue; + const typePos = inTypePosition(tm, node, st); + if (ts.isImportSpecifier(decl) || ts.isImportClause(decl) || ts.isNamespaceImport(decl)) { + const local = node.getText(); + const binding = ctx.scope.bindings.get(local); + if (binding && /^\.\/sql-driver/.test(binding.specifier)) add('seam-import', local); + const prev = record.imports.get(local); + record.imports.set(local, { value: Boolean(prev && prev.value) || !typePos }); + continue; + } + if (!isModuleScopeDecl(ts, decl)) continue; + const owner = topStatementOf(ts, decl); + if (!owner || owner === st.compilerNode) continue; + if (owner === ctx.cls.compilerNode) { add('class-reference'); continue; } + record.deps.add(owner); + } + } + records.push(record); + byStatement.set(st.compilerNode, record); + } + // A mover may not name a statement that stays: close over the stayers until stable. + for (let changed = true; changed;) { + changed = false; + for (const r of records) { + if (r.reasons.length > 0) continue; + for (const owner of r.deps) { + const dep = byStatement.get(owner); + if (!dep || dep.reasons.length > 0) { + const depName = dep ? dep.label : (declaredNames(tm, ctx.sf.getStatements().find((s) => s.compilerNode === owner)) ?? ['?']).join(', '); + r.reasons.push(`needs-stayer: ${depName}`); + changed = true; + break; + } + } + } + } + const movers = records.filter((r) => r.reasons.length === 0); + const moverNames = new Map(); + for (const r of movers) for (const n of r.names) moverNames.set(n, r); + return { records, movers, moverNames }; +} + +/** + * Which moved top-level names the text that STAYS in `sql-driver.ts` still + * reads, value or type: everything outside the moved statements and the moved + * method bodies, JSDoc excepted. Those are what `sql-driver.ts` imports back + * from the leaf. + */ +function hostUsesOfMovers(tm, ctx, top, methodRecords) { + const { SyntaxKind } = tm; + const ts = tm.ts; + const checker = ctx.sf.getProject().getTypeChecker().compilerObject; + const skip = []; + for (const r of top.movers) skip.push([r.node.getFullStart(), r.node.getEnd()]); + for (const r of methodRecords) if (r.reasons.length === 0) skip.push([r.node.getBody().getStart(), r.node.getBody().getEnd()]); + const uses = new Map(); + for (const node of ctx.sf.getDescendantsOfKind(SyntaxKind.Identifier)) { + const at = node.getStart(); + if (skip.some(([a, b]) => at >= a && at < b)) continue; + if (inJsDoc(tm, node)) continue; + const name = node.getText(); + if (!top.moverNames.has(name)) continue; + const symbol = checker.getSymbolAtLocation(node.compilerNode); + if (!symbol || !symbol.declarations) continue; + const mover = top.moverNames.get(name); + if (!symbol.declarations.some((d) => topStatementOf(ts, d) === mover.node.compilerNode)) continue; + const typePos = inTypePosition(tm, node, ctx.sf); + const prev = uses.get(name); + uses.set(name, { value: Boolean(prev && prev.value) || !typePos }); + } + return uses; +} + +/** + * Read `text` (a `sql-driver.ts`) and decide, per method, where its body goes, + * and per top-level declaration ahead of the class, whether it moves to the + * leaf. Pure: the same text gives the same plan. + */ +function plan(tm, text, { + sections = SECTION_MODULES, pins = PATH_READER_PINS, needles = PATH_READER_NEEDLES, classNeedles = CLASS_PATH_READER_NEEDLES, +} = {}) { + const sf = parse(tm, HOST_PATH, text); + const cls = sf.getClass(CLASS_NAME); + if (!cls) refuse(`no class ${CLASS_NAME} in ${HOST_BASENAME}`); + const lines = text.split('\n'); + const banners = readBanners(lines, cls.getStartLineNumber(), cls.getEndLineNumber()); + const unknown = banners.filter((b) => !Object.prototype.hasOwnProperty.call(sections, b.title)); + if (unknown.length > 0) { + refuse(`banner(s) the section table does not name: ${unknown.map((b) => `"${b.title}" (line ${b.line})`).join(', ')}. ` + + 'Add each to SECTION_MODULES (a module name, or null to keep the section on the class).'); + } + const statics = new Map(); + for (const m of cls.getMembers()) { + if (typeof m.isStatic === 'function' && m.isStatic() && typeof m.getName === 'function') { + statics.set(m.getName(), typeof m.getScope === 'function' ? m.getScope() : 'public'); + } + } + const ctx = { text, sf, cls, members: instanceMembers(tm, cls), scope: moduleScope(tm, sf), statics, pins, needles, classNeedles }; + const methodNames = new Set(cls.getMethods().map((m) => m.getName())); + const stalePins = Object.keys(pins).filter((n) => !methodNames.has(n) && !ctx.scope.bindings.has(n)); + if (stalePins.length > 0) refuse(`PATH_READER_PINS names what ${HOST_BASENAME} no longer declares: ${stalePins.join(', ')}`); + const staleNeedles = Object.keys(needles).filter((n) => !text.includes(n)); + if (staleNeedles.length > 0) refuse(`PATH_READER_NEEDLES names text ${HOST_BASENAME} no longer carries: ${staleNeedles.map((n) => JSON.stringify(n)).join(', ')}`); + const staleClassNeedles = Object.keys(classNeedles).filter((n) => !text.includes(n)); + if (staleClassNeedles.length > 0) refuse(`CLASS_PATH_READER_NEEDLES names text ${HOST_BASENAME} no longer carries: ${staleClassNeedles.map((n) => JSON.stringify(n)).join(', ')}`); + + const top = planTopLevel(tm, ctx); + + const records = []; + const seen = new Set(); + for (const m of cls.getMethods()) { + const startLine = m.getStartLineNumber(true); + let banner = null; + for (const b of banners) if (b.line < startLine) banner = b; + const read = readMethod(tm, ctx, m); + const name = m.getName(); + const firstOfName = !seen.has(name); + seen.add(name); + if (!banner) read.reasons.unshift('no-section'); + else if (sections[banner.title] === null) read.reasons.unshift('section-kept'); + const lineCount = m.getEndLineNumber() - startLine + 1; + const body = m.getBody(); + const bodyLines = body ? body.getEndLineNumber() - body.getStartLineNumber() + 1 : 0; + const record = { + name, node: m, section: banner ? banner.title : null, module: banner ? sections[banner.title] : null, + startLine, lineCount, bodyLines, firstOfName, ...read, + }; + if (record.reasons.length === 0) { + record.stubLines = stubBody(record, record.module, ' ').split('\n').length; + if (record.stubLines >= bodyLines) record.reasons.push('no-yield'); + } + records.push(record); + } + + // A function name its module's imports already use: drop it, recompute, until stable. + for (let changed = true; changed;) { + changed = false; + const byModule = groupMovers(records); + for (const [moduleFile, movers] of byModule) { + const taken = new Set([CLASS_NAME, hostNameOf(moduleFile), 'ThisParameterType', 'Pick']); + for (const r of movers) { + for (const local of r.imports.keys()) taken.add(local); + for (const local of r.moduleRefs.keys()) taken.add(local); + } + for (const r of movers) { + if (taken.has(r.name)) { r.reasons.push('name-collision'); changed = true; } + } + } + } + const modules = groupMovers(records); + const hostUses = hostUsesOfMovers(tm, ctx, top, records); + + // What each side imports across the cut: the section modules read moved + // names from the leaf and staying names from sql-driver.ts (lever (a)); + // sql-driver.ts reads moved names from the leaf. + const sectionLeafNames = new Set(); + const hostExportsForSections = new Map(); // staying name -> { value } + for (const movers of modules.values()) { + for (const r of movers) { + for (const [n, use] of r.moduleRefs) { + if (top.moverNames.has(n)) { sectionLeafNames.add(n); continue; } + if (n === CLASS_NAME || ctx.scope.exported.has(n)) continue; + const prev = hostExportsForSections.get(n); + hostExportsForSections.set(n, { value: Boolean(prev && prev.value) || use.value }); + } + } + } + const leafExports = []; + for (const r of top.movers) { + if (r.exported) continue; + for (const n of r.names) if (hostUses.has(n) || sectionLeafNames.has(n)) leafExports.push(n); + } + return { sf, cls, ctx, banners, records, modules, top, hostUses, leafExports, hostExportsForSections }; +} + +/** Movers grouped by module, modules in the order their first banner appears. */ +function groupMovers(records) { + const byModule = new Map(); + for (const r of records) { + if (r.reasons.length > 0 || !r.module) continue; + if (!byModule.has(r.module)) byModule.set(r.module, []); + byModule.get(r.module).push(r); + } + return byModule; +} + +// ── Generation ─────────────────────────────────────────────────────────────── + +const readerName = (member) => `read${member[0].toUpperCase()}${member.slice(1)}`; + +const byName = ([a], [b]) => (a < b ? -1 : a > b ? 1 : 0); +const leafSpecifier = () => `./${LEAF_MODULE.replace(/\.ts$/, '.js')}`; + +/** + * One `import` / `export` statement over `names`: on one line when it fits in + * 120 columns, else one name per wrapped run, two-space indented. + */ +function namedStatement(head, names, tail) { + const one = `${head} { ${names.join(', ')} }${tail}`; + if (one.length <= 120) return one; + const out = [`${head} {`]; + let line = ' '; + for (const n of names) { + if (line.length + n.length + 2 > 118 && line !== ' ') { out.push(line); line = ' '; } + line += ` ${n},`; + } + out.push(line); + out.push(`}${tail}`); + return out.join('\n'); +} + +/** `import { … }` and `import type { … }` from one specifier, values first. */ +function importPair(specifier, uses) { + const out = []; + const entries = [...uses.entries()].sort(byName); + const values = entries.filter(([, u]) => u.value).map(([n]) => n); + const types = entries.filter(([, u]) => !u.value).map(([n]) => n); + if (values.length > 0) out.push(namedStatement('import', values, ` from '${specifier}';`)); + if (types.length > 0) out.push(namedStatement('import type', types, ` from '${specifier}';`)); + return out; +} + +/** + * The import lines a module needs: sql-driver.ts's own imports it uses, in + * sql-driver.ts's import order; then the moved top-level names, from the leaf; + * then what stayed in sql-driver.ts (lever (a)), including the class. + */ +function importLines(scope, users, { needsHost = false, moduleRefs = new Map(), leafNames = new Map() } = {}) { + const bySpecifier = new Map(); + for (const r of users) { + for (const [local, use] of r.imports) { + const b = scope.bindings.get(local); + if (!b || b.kind !== 'import') refuse(`import binding ${local} not found`); + if (!bySpecifier.has(b.specifier)) bySpecifier.set(b.specifier, new Map()); + const slot = bySpecifier.get(b.specifier); + const prev = slot.get(local); + slot.set(local, { ...b, value: Boolean(prev && prev.value) || (use.value && !b.typeOnly) }); + } + } + const out = []; + const specifiers = [...bySpecifier.keys()].sort((a, b) => scope.importOrder.get(a) - scope.importOrder.get(b)); + for (const specifier of specifiers) { + const entries = [...bySpecifier.get(specifier).entries()].sort(byName); + const named = (value) => entries + .filter(([, e]) => e.form === 'named' && e.value === value) + .map(([local, e]) => (e.imported === local ? local : `${e.imported} as ${local}`)); + const def = entries.find(([, e]) => e.form === 'default'); + const ns = entries.find(([, e]) => e.form === 'namespace'); + const valueNamed = named(true); + const defValue = def && def[1].value ? def[0] : null; + if (defValue || valueNamed.length > 0) { + const parts = [defValue, valueNamed.length > 0 ? `{ ${valueNamed.join(', ')} }` : null].filter(Boolean); + out.push(`import ${parts.join(', ')} from '${specifier}';`); + } + if (def && !def[1].value) out.push(`import type ${def[0]} from '${specifier}';`); + if (ns) out.push(`import ${ns[1].value ? '' : 'type '}* as ${ns[0]} from '${specifier}';`); + const typeNamed = named(false); + if (typeNamed.length > 0) out.push(`import type { ${typeNamed.join(', ')} } from '${specifier}';`); + } + const fromLeaf = new Map(); + const fromHost = new Map(); + for (const [n, use] of moduleRefs) (leafNames.has(n) ? fromLeaf : fromHost).set(n, use); + if (needsHost && !fromHost.has(CLASS_NAME)) fromHost.set(CLASS_NAME, { value: false }); + out.push(...importPair(leafSpecifier(), fromLeaf)); + out.push(...importPair('./sql-driver.js', fromHost)); + return out; +} + +/** `text` wrapped into ` * ` comment lines no wider than `width`. */ +function wrapComment(words, width = 80) { + const lines = []; + let line = ' *'; + for (const w of words) { + if (line.length + 1 + w.length > width && line !== ' *') { lines.push(line); line = ' *'; } + line += ` ${w}`; + } + if (line !== ' *') lines.push(line); + return lines; +} + +function paramsText(m) { + return m.getParameters().map((p) => p.getText()); +} + +function forwardList(m) { + return m.getParameters().map((p) => (p.isRestParameter() ? `...${p.getName()}` : p.getName())); +} + +function moduleText(moduleFile, title, movers, scope, leafNames) { + const hostName = hostNameOf(moduleFile); + const hostMembers = new Map(); + for (const r of movers) for (const [member, kind] of r.host) hostMembers.set(member, kind); + const needsHost = hostMembers.size > 0; + const moduleRefs = new Map(); + for (const r of movers) { + for (const [n, use] of r.moduleRefs) { + const prev = moduleRefs.get(n); + moduleRefs.set(n, { value: Boolean(prev && prev.value) || use.value }); + } + } + const readsHostValues = [...moduleRefs].some(([n, use]) => use.value && !leafNames.has(n)); + const names = movers.map((r) => `\`${r.name}\`${r === movers[movers.length - 1] ? '' : ','}`); + const out = []; + out.push('// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.'); + out.push(''); + out.push('/**'); + out.push(...wrapComment([MARK, 'The', `"${title}"`, 'section', 'of', '`SqlDriver`:', 'the', 'bodies', 'of', + ...names, 'moved', 'out', 'of', '`sql-driver.ts`', 'whole', 'by', '`scripts/codemod-split-sql-driver.mjs`.'])); + out.push(' *'); + out.push(' * A MOVE, not a redesign. Each function below is the body of the `SqlDriver`'); + out.push(' * method of the same name exactly as it stood: not re-indented, not'); + out.push(' * formatted, not edited. The method stays on the class with its docblock and'); + out.push(' * signature, so the published `dist/*.d.ts`, an instance spy and a subclass'); + out.push(' * override are what they were; the docblock there is the contract.'); + out.push(' *'); + if (needsHost) { + out.push(` * The bodies reach the class through {@link ${hostName}}: exactly the`); + out.push(' * `SqlDriver` members they read. Each method hands its body one literal of the'); + out.push(' * members that body reads, plus a `.call`: methods go as closures that call'); + out.push(' * them on the driver at call time (the builder factory as a bound reference),'); + out.push(' * so a subclass override or an instance spy is still the one reached; fields'); + out.push(' * and accessors go as GETTERS, read when the body reads them, as they were on'); + out.push(' * the class.'); + } else { + out.push(' * No body reads a `SqlDriver` member, so each method calls its function'); + out.push(' * directly.'); + } + out.push(' *'); + if (readsHostValues) { + out.push(' * Values that stayed in `sql-driver.ts` are imported from it (lever (a) of'); + out.push(' * ruling 6591753301); every use is inside a function body, so the import'); + out.push(' * cycle is never read at evaluation time. `index.ts` re-exports nothing from'); + out.push(' * this module.'); + } else { + out.push(' * The module imports `sql-driver.ts` for types only, so the two modules form'); + out.push(' * no evaluation cycle. `index.ts` re-exports nothing from this module.'); + } + out.push(' */'); + out.push(''); + out.push(...importLines(scope, movers, { needsHost, moduleRefs, leafNames })); + if (needsHost) { + out.push(''); + out.push('/**'); + out.push(' * The `SqlDriver` members the functions below read — every one of them, and'); + out.push(' * no other. Each is typed AS the class member (an indexed access on the'); + out.push(' * class), so a change to a member\'s signature on the class is a change here'); + out.push(' * too. `readonly` members are the getters.'); + out.push(' */'); + out.push(`export interface ${hostName} {`); + for (const member of [...hostMembers.keys()].sort()) { + out.push(` ${hostMembers.get(member) === 'getter' ? 'readonly ' : ''}${member}: SqlDriver['${member}'];`); + } + out.push('}'); + } + for (const r of movers) { + const m = r.node; + const picks = [...r.host.keys()].sort().map((k) => `'${k}'`).join(' | '); + const params = [...(r.host.size > 0 ? [`this: Pick<${hostName}, ${picks}>`] : []), ...paramsText(m)]; + const ret = m.getReturnTypeNode() ? `: ${m.getReturnTypeNode().getText()}` : ''; + out.push(''); + out.push(`/** The body of \`SqlDriver#${r.name}\`; the method's docblock there is its contract. */`); + out.push(`export ${m.isAsync() ? 'async ' : ''}function ${r.name}(${params.join(', ')})${ret} ${m.getBody().getText()}`); + } + out.push(''); + return out.join('\n'); +} + +/** + * The leaf: the moved top-level statements, each its base text exactly (its + * leading comments and blank lines included), in base order, after the + * imports they use; then one `export { … }` of the moved names base did not + * export and something outside the leaf now imports. + */ +function leafText(scope, top, leafExports) { + // The header is `//` lines, not a `/** */` docblock: the bundled `.d.ts` + // keeps a JSDoc block that leads a module's first statement, and this module + // is bundled FIRST, so a docblock here would be published text the base did + // not carry (measured: 14 added lines in dist/index.d.ts). + const out = []; + out.push('// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.'); + out.push(''); + out.push(...wrapComment([MARK, 'The', 'top-level', 'declarations', 'ahead', 'of', '`SqlDriver`', 'in', '`sql-driver.ts`', + 'that', 'moved', 'out', 'whole', 'by', '`scripts/codemod-split-sql-driver.mjs`', '(lever', '(b)', 'of', 'ruling', + '6591753301).']).map((l) => l.replace(/^ \*/, '//'))); + out.push('//'); + out.push('// A MOVE, not a redesign: each declaration below is its `sql-driver.ts` text'); + out.push('// exactly, with the comments above it, in the order it stood there. A LEAF:'); + out.push('// it imports neither `sql-driver.ts` nor any `sql-driver-*` module, so it is'); + out.push('// evaluated before the class and forms no cycle with it. `sql-driver.ts`'); + out.push('// re-exports, by name, every declaration here it exported before, so every'); + out.push('// importer of `./sql-driver.js` reads what it read.'); + out.push(''); + out.push(...importLines(scope, top.movers)); + let text = out.join('\n'); + for (const r of top.movers) text += r.node.getFullText(); + if (leafExports.length > 0) { + text += `\n\n// ${MARK} Declared above without \`export\`, and read by \`sql-driver.ts\` or a section module.\n`; + text += `${namedStatement('export', [...leafExports].sort(), ';')}`; + } + return `${text}\n`; +} + +function stubBody(r, moduleFile, closeIndent) { + const ind = `${closeIndent} `; + const hostName = hostNameOf(moduleFile); + const out = ['{']; + const args = forwardList(r.node).join(', '); + if (r.host.size === 0) { + out.push(`${ind}// ${MARK} The body moved to \`${moduleFile}\`.`); + out.push(`${ind}return ${r.name}(${args});`); + } else { + out.push(`${ind}// ${MARK} The body moved to \`${moduleFile}\` (see \`${hostName}\`).`); + const members = [...r.host.keys()].sort(); + for (const member of members) { + if (r.host.get(member) === 'getter') out.push(`${ind}const ${readerName(member)} = () => this.${member};`); + } + out.push(`${ind}const host: ThisParameterType = {`); + for (const member of members) { + if (r.host.get(member) === 'getter') out.push(`${ind} get ${member}() { return ${readerName(member)}(); },`); + else if (BOUND_HOST_METHODS.has(member)) out.push(`${ind} ${member}: this.${member}.bind(this),`); + else out.push(`${ind} ${member}: (...a) => this.${member}(...a),`); + } + out.push(`${ind}};`); + out.push(`${ind}return ${r.name}.call(host${args ? `, ${args}` : ''});`); + } + out.push(`${closeIndent}}`); + return out.join('\n'); +} + +/** + * Apply a plan to `text`. Returns `{ host, modules }`: the new `sql-driver.ts` + * and a Map of module basename -> text (the leaf among them). Pure. + */ +function generate(tm, text, planned) { + const { sf, ctx, modules, top, hostUses, leafExports, hostExportsForSections } = planned; + const edits = []; + const importBlock = []; + if (top.movers.length > 0) { + const leafUses = new Map([...hostUses].filter(([n]) => top.moverNames.has(n))); + const lines = importPair(leafSpecifier(), leafUses); + if (lines.length > 0) { + importBlock.push(`\n// ${MARK} The top-level declarations that moved whole to \`${LEAF_MODULE}\`, as this file still reads them.`); + for (const l of lines) importBlock.push(`\n${l}`); + } + const reexports = top.movers.filter((r) => r.exported) + .flatMap((r) => r.names.map((n) => [n, r.typeOnly ? `type ${n}` : n])) + .sort(byName).map(([, spelled]) => spelled); + if (reexports.length > 0) { + importBlock.push(`\n// ${MARK} Re-exported by name, so every importer of \`./sql-driver.js\` reads what it read.`); + importBlock.push(`\n${namedStatement('export', reexports, ` from '${leafSpecifier()}';`)}`); + } + for (const r of top.movers) edits.push({ start: r.node.getFullStart(), end: r.node.getEnd(), text: '' }); + } + for (const [moduleFile, movers] of modules) { + const title = movers[0].section; + const hostName = hostNameOf(moduleFile); + const anyHost = movers.some((r) => r.host.size > 0); + importBlock.push(`\n// ${MARK} ${title}: the methods of the same names below hand the bodies` + + `${anyHost ? ` what they read (\`${hostName}\`)` : ' their arguments'}.`); + importBlock.push(`\nimport { ${movers.map((r) => r.name).sort().join(', ')} } from './${moduleFile.replace(/\.ts$/, '.js')}';`); + for (const r of movers) { + const body = r.node.getBody(); + const lineStart = text.lastIndexOf('\n', body.getEnd() - 1) + 1; + const closeIndent = text.slice(lineStart, body.getEnd() - 1); + if (!/^ *$/.test(closeIndent)) refuse(`${r.name}: the body's closing brace does not open its own line`); + edits.push({ start: body.getStart(), end: body.getEnd(), text: stubBody(r, moduleFile, closeIndent) }); + const asyncKw = r.node.getModifiers().find((x) => x.getKind() === tm.SyntaxKind.AsyncKeyword); + if (asyncKw) edits.push({ start: asyncKw.getStart(), end: r.node.getNameNode().getStart(), text: '' }); + } + } + if (hostExportsForSections.size > 0) { + const spelled = [...hostExportsForSections.keys()].sort().map((n) => { + const st = ctx.sf.getStatements().find((s) => (declaredNames(tm, s) ?? []).includes(n)); + return st && (tm.Node.isInterfaceDeclaration(st) || tm.Node.isTypeAliasDeclaration(st)) ? `type ${n}` : n; + }); + importBlock.push(`\n// ${MARK} Exported for the section modules above, which import them (lever (a)); \`index.ts\` re-exports by name, so none reaches the package.`); + importBlock.push(`\n${namedStatement('export', spelled, ';')}`); + } + const lastImport = sf.getImportDeclarations().at(-1); + if (!lastImport) refuse('sql-driver.ts has no import declaration to place the generated imports after'); + if (importBlock.length > 0) edits.push({ start: lastImport.getEnd(), end: lastImport.getEnd(), text: importBlock.join('') }); + edits.sort((a, b) => b.start - a.start || b.end - a.end); + let out = text; + let floor = Infinity; + for (const e of edits) { + if (e.end > floor) refuse('overlapping edits'); + out = out.slice(0, e.start) + e.text + out.slice(e.end); + floor = e.start; + } + const moduleTexts = new Map(); + const leafNames = top.moverNames; + if (top.movers.length > 0) moduleTexts.set(LEAF_MODULE, leafText(ctx.scope, top, leafExports)); + for (const [moduleFile, movers] of modules) { + moduleTexts.set(moduleFile, moduleText(moduleFile, movers[0].section, movers, ctx.scope, leafNames)); + } + return { host: out, modules: moduleTexts }; +} + +// ── Proof 1: byte-identity ─────────────────────────────────────────────────── + +const sha256 = (text) => createHash('sha256').update(text).digest('hex'); + +/** + * Prove a split against its base, from the texts alone (it shares no state with + * `generate`): every generated function is its base method's text, every leaf + * declaration is its base statement's text, and undoing the split rebuilds the + * base `sql-driver.ts` byte-for-byte. + * + * Undoing is: put each body back on its stub (and `async` back), drop the + * generated import / export statements, then splice the leaf's statements back + * between the statements that stayed — the interleaving read off the base by + * NAME only, so every byte of the result comes from the head or the leaf. + * + * Returns `{ failures, moved, movedTop, linesBefore, linesAfter }`; no failure = proved. + */ +function verify(tm, baseText, headText, moduleTexts, { readSibling = null } = {}) { + const { Node } = tm; + const failures = []; + const moved = []; + const movedTop = []; + const baseSf = parse(tm, HOST_PATH, baseText); + const baseCls = baseSf.getClass(CLASS_NAME); + const headSf = parse(tm, HOST_PATH, headText); + const headCls = headSf.getClass(CLASS_NAME); + const none = { failures: [`no class ${CLASS_NAME} at base or head`], moved, movedTop, linesBefore: 0, linesAfter: 0 }; + if (!baseCls || !headCls) return none; + + const modules = new Map(); + let leaf = null; + for (const [file, text] of moduleTexts) { + if (!text.includes(MARK)) continue; + const sf = parse(tm, join(SRC_DIR, file), text); + if (file === LEAF_MODULE) { + const statements = []; + const stmts = sf.getStatements(); + // The generated header is leading trivia of the first statement when no + // import precedes it; a moved statement's text starts after it. + const headerEnd = text.indexOf('\n\n', text.indexOf(MARK)) + 1; + stmts.forEach((st, i) => { + if (Node.isImportDeclaration(st)) { + if (/^\.\/sql-driver/.test(st.getModuleSpecifierValue())) { + failures.push(`${file}: imports ${st.getModuleSpecifierValue()} — a leaf never imports sql-driver.ts or a sql-driver-* module`); + } + return; + } + if (i === stmts.length - 1 && Node.isExportDeclaration(st) && !st.getModuleSpecifier() && st.getFullText().includes(MARK)) return; + const names = declaredNames(tm, st); + if (!names) { failures.push(`${file}: a statement that is not a moved declaration: ${st.getText().slice(0, 60)}`); return; } + statements.push({ names, text: text.slice(Math.max(st.getFullStart(), headerEnd), st.getEnd()), node: st }); + }); + leaf = { file, sf, statements }; + if (readSibling) { + // The leaf's relative imports, followed on disk: none may reach sql-driver.ts. + const seen = new Set(); + const queue = sf.getImportDeclarations().map((d) => d.getModuleSpecifierValue()).filter((s) => s.startsWith('./')); + while (queue.length > 0) { + const spec = queue.shift(); + if (seen.has(spec)) continue; + seen.add(spec); + if (/^\.\/sql-driver(\.js|-)/.test(spec)) { failures.push(`${file}: reaches ${spec} through its imports — not a leaf`); continue; } + const sibling = readSibling(spec.replace(/^\.\//, '').replace(/\.js$/, '.ts')); + if (sibling === null) continue; + for (const m of sibling.matchAll(/^\s*(?:import|export)\s[^;]*?from\s+'(\.\/[^']+)'/gm)) queue.push(m[1]); + } + } + continue; + } + const fns = new Map(); + for (const st of sf.getStatements()) { + if (Node.isFunctionDeclaration(st)) fns.set(st.getName(), st); + else if (!Node.isImportDeclaration(st) && !Node.isInterfaceDeclaration(st)) { + failures.push(`${file}: a top-level statement that is neither an import, the host interface nor a moved function: ${st.getText().slice(0, 60)}`); + } + } + modules.set(file, { sf, fns, claimed: new Set() }); + } + + const marker = new RegExp(`^\\{\\s*// ${MARK.replace(/[[\]]/g, '\\$&')} The body moved to \`(sql-driver-[a-z0-9-]+\\.ts)\``); + const edits = []; + for (const m of headCls.getMethods()) { + const body = m.getBody(); + if (!body) continue; + const hit = marker.exec(body.getText()); + if (!hit) continue; + const name = m.getName(); + const mod = modules.get(hit[1]); + const fn = mod && mod.fns.get(name); + if (!fn) { failures.push(`${name}: its stub names ${hit[1]}, which has no function ${name}`); continue; } + mod.claimed.add(name); + const base = baseCls.getMethod(name); + if (!base || !base.getBody()) { failures.push(`${name}: no method of that name with a body at base`); continue; } + const fnParams = fn.getParameters().filter((p) => p.getName() !== 'this').map((p) => p.getText()); + const baseParams = base.getParameters().map((p) => p.getText()); + const same = { + body: fn.getBody().getText() === base.getBody().getText(), + params: JSON.stringify(fnParams) === JSON.stringify(baseParams), + returnType: (fn.getReturnTypeNode()?.getText() ?? null) === (base.getReturnTypeNode()?.getText() ?? null), + async: fn.isAsync() === base.isAsync(), + typeParameters: fn.getTypeParameters().length === 0 && base.getTypeParameters().length === 0, + }; + for (const [what, ok] of Object.entries(same)) if (!ok) failures.push(`${name}: the generated function's ${what} is not the base method's`); + moved.push({ + name, module: hit[1], bodySha: sha256(fn.getBody().getText()), baseBodySha: sha256(base.getBody().getText()), + lines: base.getBody().getEndLineNumber() - base.getBody().getStartLineNumber() + 1, ...same, + }); + edits.push({ start: body.getStart(), end: body.getEnd(), text: fn.getBody().getText() }); + if (fn.isAsync()) edits.push({ start: m.getNameNode().getStart(), end: m.getNameNode().getStart(), text: 'async ' }); + } + for (const [file, mod] of modules) { + for (const name of mod.fns.keys()) if (!mod.claimed.has(name)) failures.push(`${file}: function ${name} has no stub on the class`); + } + const generatedSpecifier = (spec) => { + const hit = /^\.\/(sql-driver-[a-z0-9-]+)\.js$/.exec(spec ?? ''); + return Boolean(hit && (modules.has(`${hit[1]}.ts`) || (leaf && `${hit[1]}.ts` === leaf.file))); + }; + for (const st of headSf.getStatements()) { + const drop = (Node.isImportDeclaration(st) && generatedSpecifier(st.getModuleSpecifierValue())) || + (Node.isExportDeclaration(st) && (st.getModuleSpecifier() + ? generatedSpecifier(st.getModuleSpecifierValue()) + : st.getFullText().includes(MARK))); + if (drop) edits.push({ start: st.getFullStart(), end: st.getEnd(), text: '' }); + } + edits.sort((a, b) => b.start - a.start || b.end - a.end); + let rebuilt = headText; + for (const e of edits) rebuilt = rebuilt.slice(0, e.start) + e.text + rebuilt.slice(e.end); + + if (leaf) { + // Splice the leaf back, in the order the base interleaves it by NAME. + const restSf = parse(tm, HOST_PATH, rebuilt); + const rest = restSf.getStatements(); + const queue = [...leaf.statements]; + const baseStmts = baseSf.getStatements(); + let j = 0; + let assembled = rest.length > 0 ? rebuilt.slice(0, rest[0].getFullStart()) : ''; + for (const b of baseStmts) { + const names = declaredNames(tm, b); + const key = names ? names.join(',') : null; + if (queue.length > 0 && key !== null && queue[0].names.join(',') === key) { + const l = queue.shift(); + assembled += l.text; + const same = l.text === b.getFullText(); + if (!same) failures.push(`${leaf.file}: ${key} is not its base text (leading comments included)`); + movedTop.push({ + name: key, lines: b.getEndLineNumber() - b.getStartLineNumber(true) + 1, + sha: sha256(l.text), baseSha: sha256(b.getFullText()), same, exported: Node.isExportable(b) && b.isExported(), + }); + continue; + } + if (j >= rest.length) { failures.push(`undoing the split runs out of head statements at base ${key ?? b.getKindName()}`); break; } + assembled += rest[j].getFullText(); + j += 1; + } + if (queue.length > 0) failures.push(`${leaf.file}: ${queue.map((q) => q.names.join(',')).join('; ')} not placed — out of base order or not in the base`); + if (rest.length > 0) assembled += rebuilt.slice(rest[rest.length - 1].getEnd()); + rebuilt = assembled; + } + + if (rebuilt !== baseText) { + const a = rebuilt.split('\n'); + const b = baseText.split('\n'); + let i = 0; + while (i < Math.max(a.length, b.length) && a[i] === b[i]) i += 1; + failures.push(`undoing the split does not rebuild the base sql-driver.ts: first difference at line ${i + 1}` + + `\n rebuilt: ${JSON.stringify(a[i] ?? '')}\n base: ${JSON.stringify(b[i] ?? '')}`); + } + if (moved.length === 0 && movedTop.length === 0) failures.push('no moved method or declaration found: a proof over nothing is not a proof'); + return { failures, moved, movedTop, linesBefore: lineCount(baseText), linesAfter: lineCount(headText) }; +} + +/** Lines as `wc -l` counts them: newline characters. */ +function lineCount(text) { + return text.split('\n').length - 1; +} + +// ── Proof 2: the declaration-file bar (ruling 6348891160 item 1) ───────────── + +/** + * The ruling's one normalisation, verbatim: sort the members of each run of + * string-literal union members in place. Nothing else is normalised. + */ +const NORMALISER_ONE_LINER = + 'node -e \'const s=require("fs").readFileSync(process.argv[1],"utf8");process.stdout.write(s.replace(/"[^"\\n]*"(?:\\s*\\|\\s*"[^"\\n]*")+/g,r=>r.split(/\\s*\\|\\s*/).sort().join(" | ")))\' FILE | sha256sum'; + +function normaliseUnionOrder(text) { + return text.replace(/"[^"\n]*"(?:\s*\|\s*"[^"\n]*")+/g, (r) => r.split(/\s*\|\s*/).sort().join(' | ')); +} + +/** Export names of a bundled `.d.ts`: every `export { … }` list, plus `export Name`. */ +function dtsExportNames(text) { + const names = new Set(); + for (const m of text.matchAll(/^export\s*(?:type\s*)?\{([^}]*)\}/gm)) { + for (const part of m[1].split(',')) { + const t = part.trim().replace(/^type\s+/, ''); + if (!t) continue; + const as = /\bas\s+([\w$]+)$/.exec(t); + names.add(as ? as[1] : t); + } + } + for (const m of text.matchAll(/^export\s+(?:declare\s+)?(?:abstract\s+)?(?:class|interface|type|function|const|let|var|enum|namespace)\s+([\w$]+)/gm)) names.add(m[1]); + return [...names].sort(); +} + +/** One positive control: the first `private` member made public. */ +function injectVisibilityChange(text) { + const at = text.search(/^ {4}private /m); + return at < 0 ? null : text.slice(0, at) + ' ' + text.slice(at + ' private '.length); +} + +/** + * The number of lines deleted plus lines inserted between two line arrays — + * what `diff` prints as `<` and `>` lines (Myers' O((N+M)D) edit distance, so + * a handful of changed lines in a 9,000-line file costs nothing). + */ +function lineEditDistance(a, b) { + const n = a.length; + const m = b.length; + const max = n + m; + const off = max + 1; + const v = new Int32Array(2 * max + 3); + for (let d = 0; d <= max; d += 1) { + for (let k = -d; k <= d; k += 2) { + let x = (k === -d || (k !== d && v[off + k - 1] < v[off + k + 1])) ? v[off + k + 1] : v[off + k - 1] + 1; + let y = x - k; + while (x < n && y < m && a[x] === b[y]) { x += 1; y += 1; } + v[off + k] = x; + if (x >= n && y >= m) return d; + } + } + return max; +} + +/** + * The bar on one declaration file pair. Returns `{ failures, reading }`. + * + * `rawDiffLines` counts the lines that differ IN PLACE when both files have the + * same line count (one per swapped union, as the ruling reads it), and the + * lines `diff` would print (deleted plus inserted) when the counts differ; + * `rawDiffMethod` names which. Before, a differing line count read as 0. + */ +function dtsBarFor(file, before, after) { + const failures = []; + const nb = normaliseUnionOrder(before); + const na = normaliseUnionOrder(after); + const bl = before.split('\n'); + const al = after.split('\n'); + let rawDiffLines = 0; + const rawDiffMethod = bl.length === al.length ? 'in-place' : 'diff'; + if (bl.length !== al.length) { + rawDiffLines = lineEditDistance(bl, al); + failures.push(`${file}: ${bl.length} lines at base, ${al.length} at head — a line added or removed is a declaration change, outside the ruling`); + } else { + for (let i = 0; i < bl.length; i += 1) { + if (bl[i] === al[i]) continue; + rawDiffLines += 1; + if (normaliseUnionOrder(bl[i]) !== normaliseUnionOrder(al[i])) { + failures.push(`${file}:${i + 1} differs other than by union member order:\n base: ${bl[i].trim()}\n head: ${al[i].trim()}`); + } + } + } + if (nb !== na) failures.push(`${file}: normalised base and head differ`); + const eb = dtsExportNames(before); + const ea = dtsExportNames(after); + if (JSON.stringify(eb) !== JSON.stringify(ea)) failures.push(`${file}: the export list differs (${eb.length} at base, ${ea.length} at head)`); + const control = injectVisibilityChange(after); + const controlSha = control === null ? null : sha256(normaliseUnionOrder(control)); + if (control === null) failures.push(`${file}: no private member to make public — the positive control could not run (INCONCLUSIVE)`); + else if (controlSha === sha256(nb)) failures.push(`${file}: the positive control normalises to the base hash — the normaliser hides a visibility change (INCONCLUSIVE)`); + return { + failures, + reading: { + file, + rawBase: sha256(before), rawHead: sha256(after), rawDiffLines, rawDiffMethod, + normalisedBase: sha256(nb), normalisedHead: sha256(na), + exportsBase: eb.length, exportsHead: ea.length, + positiveControl: controlSha, + }, + }; +} + +function dtsBar(beforeDir, afterDir) { + const failures = []; + const readings = []; + for (const file of ['index.d.ts', 'index.d.mts']) { + const b = join(beforeDir, file); + const a = join(afterDir, file); + if (!existsSync(b) || !existsSync(a)) refuse(`--dts: ${file} missing under ${existsSync(b) ? afterDir : beforeDir}`); + const r = dtsBarFor(file, readFileSync(b, 'utf8'), readFileSync(a, 'utf8')); + failures.push(...r.failures); + readings.push(r.reading); + } + return { failures, readings }; +} + +// ── Reports ────────────────────────────────────────────────────────────────── + +function planReport(planned) { + const out = []; + const bySection = new Map(); + for (const r of planned.records) { + const key = r.section ?? '(before the first banner)'; + if (!bySection.has(key)) bySection.set(key, []); + bySection.get(key).push(r); + } + let movedMethods = 0; + let movedLines = 0; + let movedSaved = 0; + const reasonCounts = new Map(); + for (const [section, rs] of bySection) { + const movers = rs.filter((r) => r.reasons.length === 0); + const lines = movers.reduce((n, r) => n + r.bodyLines, 0); + const saved = movers.reduce((n, r) => n + r.bodyLines - r.stubLines, 0); + movedMethods += movers.length; + movedLines += lines; + movedSaved += saved; + out.push(`§ ${section} -> ${rs[0].module ?? '(stays)'}: ${movers.length}/${rs.length} methods move ` + + `(${lines} body lines out, ${lines - saved} stub lines in)`); + for (const r of rs) { + if (r.reasons.length === 0) out.push(` move ${r.name} (body ${r.bodyLines} -> stub ${r.stubLines})`); + else out.push(` stay ${r.name} (${r.lineCount}) — ${r.reasons.join('; ')}`); + for (const reason of r.reasons) { + const code = reason.split(':')[0]; + reasonCounts.set(code, (reasonCounts.get(code) ?? 0) + 1); + } + } + } + out.push(''); + out.push(`total: ${movedMethods}/${planned.records.length} methods move into ${planned.modules.size} module(s): ` + + `${movedLines} body lines out, ${movedLines - movedSaved} stub lines in, plus ${2 * planned.modules.size} import lines`); + out.push(`stay reasons (a method may carry several): ${[...reasonCounts].sort((a, b) => b[1] - a[1]).map(([c, n]) => `${c} ${n}`).join(', ')}`); + const top = planned.top; + out.push(''); + out.push(`§ top-level declarations ahead of the class -> ${LEAF_MODULE}: ${top.movers.length}/${top.records.length} move`); + const topReasons = new Map(); + for (const r of top.records) { + if (r.reasons.length === 0) out.push(` move ${r.label} (${r.lines})${r.exported ? ' [exported, re-exported]' : ''}`); + else out.push(` stay ${r.label} (${r.lines}) — ${r.reasons.join('; ')}`); + for (const reason of r.reasons) { + const code = reason.split(':')[0]; + topReasons.set(code, (topReasons.get(code) ?? 0) + 1); + } + } + const topLines = top.movers.reduce((n, r) => n + r.lines, 0); + out.push(`top-level total: ${top.movers.length}/${top.records.length} declarations move (${topLines} lines with their docblocks); ` + + `stay reasons: ${[...topReasons].sort((a, b) => b[1] - a[1]).map(([c, n]) => `${c} ${n}`).join(', ') || 'none'}`); + out.push(`lever (a): ${planned.hostExportsForSections.size} staying name(s) exported for the section modules` + + `${planned.hostExportsForSections.size ? ` (${[...planned.hostExportsForSections.keys()].sort().join(', ')})` : ''}; ` + + `${planned.leafExports.length} leaf name(s) exported beside the base exports`); + return out.join('\n'); +} + +function treeHash(files) { + const h = createHash('sha256'); + for (const [name, text] of [...files].sort(([a], [b]) => (a < b ? -1 : 1))) h.update(`${name}\0${sha256(text)}\n`); + return h.digest('hex'); +} + +function printVerify(v) { + for (const f of v.failures) console.error(` ✗ ${f}`); + const identical = v.moved.filter((m) => m.body && m.params && m.returnType && m.async).length; + const identicalTop = v.movedTop.filter((m) => m.same).length; + console.log(`byte-identity: ${identical}/${v.moved.length} moved bodies identical to base (body, parameters, return type, async); ` + + `${identicalTop}/${v.movedTop.length} moved top-level declarations identical to base (leading comments included); ` + + `sql-driver.ts ${v.linesBefore} -> ${v.linesAfter} lines; undoing the split ${v.failures.some((f) => f.startsWith('undoing')) ? 'does NOT' : 'does'} rebuild the base byte-for-byte`); +} + +function gitShow(ref, path) { + return execFileSync('git', ['show', `${ref}:${path}`], { cwd: ROOT, encoding: 'utf8', maxBuffer: 1 << 28 }); +} + +/** A sibling of `sql-driver.ts` by basename, or null when there is none. */ +function readSibling(basename) { + const p = join(SRC_DIR, basename); + return existsSync(p) ? readFileSync(p, 'utf8') : null; +} + +function generatedModulesOnDisk() { + const out = new Map(); + for (const file of [LEAF_MODULE, ...Object.values(SECTION_MODULES)]) { + if (!file) continue; + const p = join(SRC_DIR, file); + if (existsSync(p)) out.set(file, readFileSync(p, 'utf8')); + } + return out; +} + +// ── Entry ──────────────────────────────────────────────────────────────────── + +const USAGE = [ + 'usage: node scripts/codemod-split-sql-driver.mjs [--write] plan (or apply) the split of an UNSPLIT sql-driver.ts', + ' node scripts/codemod-split-sql-driver.mjs --verify --base REF re-prove a split on disk against sql-driver.ts at REF', + ' node scripts/codemod-split-sql-driver.mjs --dts BEFORE_DIST AFTER_DIST the declaration-file bar on two built dists', + ' node scripts/codemod-split-sql-driver.mjs --self-test', + ' node scripts/codemod-split-sql-driver.mjs --help', +].join('\n'); + +async function main(argv) { + // Answered before any guard: `--help` reads no tree, so the "already split" + // refusal below must never be what a reader asking for usage gets. + if (argv.includes('--help') || argv.includes('-h')) { + console.log(USAGE); + return 0; + } + if (argv.includes('--self-test')) { + await selfTest(); + if (process.exitCode === 1) return 1; + if (!selfTestReachedVerdict) { + console.error('\n✗ codemod-split-sql-driver self-test: selfTest() returned without reaching its verdict,\n' + + 'so no success line was printed. Exiting 0 here would report a self-test that never\n' + + 'finished as a self-test that passed.'); + return 1; + } + return 0; + } + if (argv.includes('--dts')) { + const i = argv.indexOf('--dts'); + const [before, after] = [argv[i + 1], argv[i + 2]]; + if (!before || !after) refuse('--dts needs '); + const { failures, readings } = dtsBar(before, after); + console.log(`normaliser (ruling 6348891160 item 2, verbatim): ${NORMALISER_ONE_LINER}`); + for (const r of readings) console.log(JSON.stringify(r)); + for (const f of failures) console.error(` ✗ ${f}`); + console.log(failures.length === 0 ? '✓ declaration-file bar: PASS' : `✗ declaration-file bar: FAIL (${failures.length})`); + return failures.length === 0 ? 0 : 1; + } + const tm = await loadTsMorph(); + if (argv.includes('--verify')) { + const i = argv.indexOf('--base'); + const ref = i >= 0 ? argv[i + 1] : null; + if (!ref) refuse('--verify needs --base '); + const v = verify(tm, gitShow(ref, `${SRC_REL}/${HOST_BASENAME}`), readFileSync(HOST_PATH, 'utf8'), generatedModulesOnDisk(), { readSibling }); + printVerify(v); + return v.failures.length === 0 ? 0 : 1; + } + const input = readFileSync(HOST_PATH, 'utf8'); + if (input.includes(MARK)) refuse(`${HOST_BASENAME} already carries ${MARK} stubs: run the script on an unsplit file (re-run on the latest main, never on its own output)`); + const planned = plan(tm, input); + console.log(planReport(planned)); + const out = generate(tm, input, planned); + const files = new Map([[HOST_BASENAME, out.host], ...out.modules]); + console.log(`output tree sha256: ${treeHash(files)} (input sha256 ${sha256(input)})`); + const v = verify(tm, input, out.host, out.modules, { readSibling }); + printVerify(v); + if (v.failures.length > 0) { + console.error('✗ the generated split fails byte-identity: nothing written. Report the fork; never hand-edit the split.'); + return 1; + } + if (!argv.includes('--write')) { + console.log('plan only: nothing written (pass --write to apply).'); + return 0; + } + for (const [file] of out.modules) { + const p = join(SRC_DIR, file); + if (existsSync(p) && !readFileSync(p, 'utf8').includes(MARK)) refuse(`${file} exists and was not written by this script`); + } + writeFileSync(HOST_PATH, out.host); + for (const [file, text] of out.modules) writeFileSync(join(SRC_DIR, file), text); + console.log(`✓ wrote ${HOST_BASENAME} and ${out.modules.size} module(s) under ${SRC_REL}`); + return 0; +} + +// ── Self-test ──────────────────────────────────────────────────────────────── +// +// The proofs above are this script's whole value, and a proof that cannot fail +// proves nothing, so the self-test feeds each one an input it must REFUSE. The +// roster pins battery NAMES with a floor each, the roster's own size is pinned, +// and the verdict sets a flag the dispatch reads (AGENTS.md, "Writing a +// `--self-test`"), so a battery that stops running, or a `return` above the +// verdict, cannot read as a pass. + +let selfTestReachedVerdict = false; + +const SELF_TEST_BATTERIES = Object.freeze({ + 'declaration bar': 7, + 'stay rules': 12, + 'top-level moves': 11, + 'byte-identity proof': 10, + 'refusals': 5, +}); +const SELF_TEST_BATTERY_FLOOR = 5; + +const FIXTURE_SECTIONS = Object.freeze({ 'Lifecycle': 'sql-driver-lifecycle.ts', 'Kept': null }); +const FIXTURE_NEEDLES = Object.freeze({ 'PINNED_TEXT': 'a fixture reader', 'needleInMethod(': 'a fixture reader' }); +const FIXTURE_CLASS_NEEDLES = Object.freeze({ 'classReaderText(': 'a fixture reader that also reads the leaf' }); +const FIXTURE_PINS = Object.freeze({ pinnedMethod: 'a fixture reader' }); +const fixturePlan = (tm, text) => plan(tm, text, + { sections: FIXTURE_SECTIONS, pins: FIXTURE_PINS, needles: FIXTURE_NEEDLES, classNeedles: FIXTURE_CLASS_NEEDLES }); + +function fixtureSource(extraMethods = '') { + const pad = Array.from({ length: 12 }, (_, i) => ` const p${i} = ${i};`).join('\n'); + return `import type { Knex } from 'knex'; +import { helperImported } from './other.js'; +import { seamHelper } from './sql-driver-seam.js'; + +/** A value the leaf takes, read by a moved body. */ +const LOCAL_VALUE = 1; +type LocalType = { n: number }; +export interface ExportedShape { a: number } + +// A reader takes this one out of the file by path. +const PINNED = 'PINNED_TEXT'; +function dependsOnPinned(): string { + return PINNED; +} +function namesClass(d: SqlDriver): SqlDriver { + return d; +} +const VIA_SEAM = seamHelper(); +export const DERIVED = LOCAL_VALUE + 1; +function classReaderText(n: number): number { + return n; +} + +export class SqlDriver { + private knex: any = null; + protected logger: { warn(m: string): void } = console; + private count = 0; + private readonly fn = (x: string): string => x; + private static readonly SECRET = 1; + + // =================================== + // Lifecycle + // =================================== + + /** Docblock that stays. */ + protected async movable(object: string, trx?: Knex.Transaction): Promise { +${pad} + const a = helperImported(object) as ExportedShape | string; + this.logger.warn(String(a)); + return this.other(String(a)) + String(trx ? this.count : 0); + } + + protected other(x: string): string { + return x; + } + + protected writes(): void { +${pad} + this.count = 1; + } + + protected asValue(): string[] { +${pad} + return ['a'].map(this.other); + } + + protected readsLocal(): number { +${pad} + return LOCAL_VALUE + dependsOnPinned().length; + } + + protected namesLocalType(): LocalType | null { +${pad} + return null; + } + + public inferred() { +${pad} + return 1; + } + + protected scoped(object: string): string { +${pad} + return this.getBuilder(object); + } + + protected getBuilder(object: string): string { + return object; + } + + protected defaulted(x: string = 'a'): string { +${pad} + return x; + } + + protected callsField(): string { +${pad} + return this.fn('a'); + } + + protected readsStatic(): number { +${pad} + return SqlDriver.SECRET; + } + + protected pinnedMethod(): number { +${pad} + return 3; + } + + protected carriesNeedle(): number { +${pad} + return needleInMethod(1); + } + + protected carriesClassNeedle(): number { +${pad} + return classReaderText(1); + } +${extraMethods} + // =================================== + // Kept + // =================================== + + protected keptSection(): number { +${pad} + return 2 + VIA_SEAM + namesClass(this).count; + } +} + +function needleInMethod(n: number): number { + return n; +} +`; +} + +async function selfTest() { + const tm = await loadTsMorph(); + const batterySeen = new Map(); + let battery = null; + let failed = 0; + let cases = 0; + const open = (name) => { battery = name; }; + const check = (label, ok, detail = '') => { + batterySeen.set(battery, (batterySeen.get(battery) ?? 0) + 1); + cases += 1; + if (ok) console.log(` ✓ [${battery}] ${label}`); + else { failed += 1; console.error(` ✗ [${battery}] ${label}${detail ? ` — ${detail}` : ''}`); } + }; + const refused = (fn) => { try { fn(); return false; } catch (e) { return e instanceof CodemodRefusal; } }; + + open('declaration bar'); + { + const base = 'export declare class A {\n private x;\n t: "b" | "a";\n}\nexport { A };\n'; + const swapped = base.replace('"b" | "a"', '"a" | "b"'); + check('a union-member swap passes, and is counted as one raw diff line', + (() => { const r = dtsBarFor('f', base, swapped); return r.failures.length === 0 && r.reading.rawDiffLines === 1; })()); + check('the normaliser sorts union members and nothing else', normaliseUnionOrder('x: "b" | "a"; y: B | A') === 'x: "a" | "b"; y: B | A'); + check('a retyped member fails', dtsBarFor('f', base, base.replace('"b" | "a"', '"b" | "c"')).failures.length > 0); + check('a private member made public fails (the positive control the bar runs)', + dtsBarFor('f', base, base.replace(' private x;', ' x;')).failures.length > 0); + check('an added line fails', dtsBarFor('f', base, base.replace('}\n', ' y: number;\n}\n')).failures.length > 0); + check('a line-count change is counted as diff counts it, never as 0', + (() => { const r = dtsBarFor('f', base, base.replace('}\n', ' y: number;\n}\n')).reading; return r.rawDiffLines === 1 && r.rawDiffMethod === 'diff'; })() + && lineEditDistance(['a', 'b', 'c'], ['a', 'x', 'c', 'd']) === 3); + check('a changed export list fails', dtsBarFor('f', base, base.replace('export { A };', 'export { A as B };')).failures.length > 0); + } + + open('stay rules'); + { + const planned = fixturePlan(tm, fixtureSource()); + const why = (name) => (planned.records.find((r) => r.name === name)?.reasons ?? ['']).join('; '); + const stays = (name, code) => check(`${name} stays (${code})`, why(name).split('; ').some((r) => r.startsWith(code)), why(name)); + const moves = (name, because) => check(`${name} moves (${because})`, why(name) === '', why(name)); + moves('movable', 'the canonical body'); + moves('readsLocal', 'lever (a)/(b): it imports the leaf value and the staying function'); + moves('namesLocalType', 'a non-exported type it names moves to the leaf'); + moves('scoped', 'the widened chokepoint gate reads the module'); + stays('other', 'no-yield'); + stays('writes', 'this-write'); + stays('asValue', 'method-as-value'); + stays('inferred', 'inferred-return-type'); + stays('defaulted', 'parameter-shape'); + stays('readsStatic', 'non-public-static'); + check('a pinned method and a method carrying a needle stay (path-reader)', + why('pinnedMethod').startsWith('path-reader') && why('carriesNeedle').startsWith('path-reader'), + `${why('pinnedMethod')} | ${why('carriesNeedle')}`); + check('a method carrying a class-scoped needle stays (path-reader): its reader reads no section module', + why('carriesClassNeedle').startsWith('path-reader'), why('carriesClassNeedle')); + } + + open('top-level moves'); + { + const input = fixtureSource(); + const planned = fixturePlan(tm, input); + const top = (label) => planned.top.records.find((r) => r.label === label); + const why = (label) => (top(label)?.reasons ?? ['']).join('; '); + check('a value, a local type and an exported interface move', ['LOCAL_VALUE', 'LocalType', 'ExportedShape', 'DERIVED'] + .every((n) => why(n) === ''), ['LOCAL_VALUE', 'LocalType', 'ExportedShape', 'DERIVED'].map(why).join(' | ')); + check('a declaration carrying a reader\'s needle stays (path-reader)', why('PINNED').startsWith('path-reader'), why('PINNED')); + check('a declaration carrying a class-scoped needle moves: its reader reads the leaf', why('classReaderText') === '', why('classReaderText')); + check('a declaration naming a stayer stays (needs-stayer)', why('dependsOnPinned').startsWith('needs-stayer'), why('dependsOnPinned')); + check('a declaration naming the class stays (class-reference)', why('namesClass').startsWith('class-reference'), why('namesClass')); + check('a declaration using a sql-driver-* import stays (seam-import)', why('VIA_SEAM').startsWith('seam-import'), why('VIA_SEAM')); + check('a declaration after the class is not in the region', !top('needleInMethod')); + const out = generate(tm, input, planned); + const leaf = out.modules.get(LEAF_MODULE) ?? ''; + check('the leaf header is line comments, never a docblock the bundled .d.ts would publish', + leaf.includes(MARK) && !leaf.slice(0, leaf.indexOf('\n\n', leaf.indexOf(MARK))).includes('/*')); + check('the leaf imports neither sql-driver.ts nor a seam module', leaf.length > 0 && !/from '\.\/sql-driver/.test(leaf), leaf.slice(0, 200)); + check('sql-driver.ts re-exports the exported movers by name, the interface as a type', + /export \{ DERIVED, type ExportedShape \} from '\.\/sql-driver-top-level\.js';/.test(out.host)); + check('lever (a): the staying function a moved body reads is exported for its module, and the module imports it', + /export \{ dependsOnPinned \};/.test(out.host) && + /import \{ dependsOnPinned \} from '\.\/sql-driver\.js';/.test(out.modules.get('sql-driver-lifecycle.ts') ?? '')); + } + + open('byte-identity proof'); + { + const input = fixtureSource(); + const planned = fixturePlan(tm, input); + const out = generate(tm, input, planned); + const again = generate(tm, input, fixturePlan(tm, input)); + const v = verify(tm, input, out.host, out.modules); + check('the generated split proves against its base', v.failures.length === 0 && v.moved.length === 4 && v.movedTop.length === 5, + [...v.failures, `moved: ${v.moved.map((m) => m.name).join()}`, `top: ${v.movedTop.map((m) => m.name).join()}`].join(' | ')); + check('two runs on one input write byte-identical trees', + treeHash(new Map([['h', out.host], ...out.modules])) === treeHash(new Map([['h', again.host], ...again.modules]))); + const mod = out.modules.get('sql-driver-lifecycle.ts'); + const edited = new Map([...out.modules, ['sql-driver-lifecycle.ts', mod.replace('const p3 = 3;', 'const p3 = 4;')]]); + check('one edited character in a moved body fails', verify(tm, input, out.host, edited).failures.length > 0); + check('an edited stub signature fails', + verify(tm, input, out.host.replace('trx?: Knex.Transaction): Promise', 'trx?: Knex.Transaction): Promise'), out.modules).failures.length > 0); + check('an extra top-level statement in a module fails', + verify(tm, input, out.host, new Map([...out.modules, ['sql-driver-lifecycle.ts', `${mod}\nexport const extra = 1;\n`]])).failures.length > 0); + check('a proof over no moved method fails', verify(tm, input, input, new Map()).failures.length > 0); + const leaf = out.modules.get(LEAF_MODULE); + const leafWith = (text) => new Map([...out.modules, [LEAF_MODULE, text]]); + check('one edited character in a moved declaration fails', + verify(tm, input, out.host, leafWith(leaf.replace('const LOCAL_VALUE = 1;', 'const LOCAL_VALUE = 2;'))).failures.length > 0); + check('an edited comment above a moved declaration fails', + verify(tm, input, out.host, leafWith(leaf.replace('/** A value the leaf takes', '/** A value the leaf holds'))).failures.length > 0); + check('a leaf that imports sql-driver.ts back fails', + verify(tm, input, out.host, leafWith(leaf.replace('\nconst LOCAL_VALUE', "\nimport { SqlDriver } from './sql-driver.js';\nconst LOCAL_VALUE"))).failures.length > 0); + const swapped = leaf.replace('type LocalType = { n: number };\nexport interface ExportedShape { a: number }', + 'export interface ExportedShape { a: number }\ntype LocalType = { n: number };'); + check('two moved declarations out of base order fail', swapped !== leaf && verify(tm, input, out.host, leafWith(swapped)).failures.length > 0); + } + + open('refusals'); + { + const unknownBanner = fixtureSource('\n // ── Unnamed section ──────────\n\n protected z(): number {\n return 1;\n }\n'); + check('a banner the section table does not name refuses', refused(() => fixturePlan(tm, unknownBanner))); + check('a pin naming what the file lacks refuses', refused(() => plan(tm, fixtureSource(), { sections: FIXTURE_SECTIONS, pins: { gone: 'x' }, needles: {}, classNeedles: {} }))); + check('a class-scoped needle the file no longer carries refuses', + refused(() => plan(tm, fixtureSource(), { sections: FIXTURE_SECTIONS, pins: {}, needles: {}, classNeedles: { 'gone text': 'x' } }))); + check('a needle the file no longer carries refuses', refused(() => plan(tm, fixtureSource(), { sections: FIXTURE_SECTIONS, pins: {}, needles: { 'gone text': 'x' }, classNeedles: {} }))); + // `--help` reads no tree: it answers before the "already split" guard, so + // it exits 0 whether sql-driver.ts on disk is split (as on a branch) or not. + check('--help is answered, never refused, whatever sql-driver.ts holds', (await main(['--help']).catch(() => 2)) === 0); + } + + const declared = Object.keys(SELF_TEST_BATTERIES); + if (declared.length < SELF_TEST_BATTERY_FLOOR) { + failed += 1; + console.error(`✗ self-test floor: ${declared.length} batteries declared, below the pinned ${SELF_TEST_BATTERY_FLOOR}`); + } + for (const [name, count] of batterySeen) { + if (!declared.includes(name)) { failed += 1; console.error(`✗ self-test floor: battery "${name}" ran ${count} case(s) but is not declared`); } + } + for (const name of declared) { + const count = batterySeen.get(name) ?? 0; + if (count < SELF_TEST_BATTERIES[name]) { + failed += 1; + console.error(`✗ self-test floor: battery "${name}" ran ${count} case(s), below its pinned ${SELF_TEST_BATTERIES[name]}`); + } + } + if (failed > 0) { + console.error(`\n✗ codemod-split-sql-driver self-test: ${failed} failure(s) (cases and floor).`); + process.exitCode = 1; + selfTestReachedVerdict = true; + return; + } + console.log(`\n✓ codemod-split-sql-driver self-test: ${cases} cases in ${declared.length} batteries pass.`); + selfTestReachedVerdict = true; +} + +if (isEntrypoint(import.meta.url)) { + main(process.argv.slice(2)).then( + (code) => process.exit(code), + (error) => { + if (error instanceof CodemodRefusal) { + console.error(`✗ codemod-split-sql-driver: ${error.message}`); + process.exit(2); + } + console.error(error); + process.exit(2); + }, + ); +}