From 939ace024557b977b594162f15ae41597cd7d91c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:53:28 +0000 Subject: [PATCH 01/20] feat(scripts): codemod that splits SqlDriver sections out of sql-driver.ts and proves the move A deterministic ts-morph codemod: per section banner of SqlDriver, the bodies of every method that can move under the hand series' cut pattern go to sql-driver-
.ts unedited, the class keeps one-line host stubs, and the script proves byte-identity (undoing the split rebuilds the base file byte-for-byte) and, on two built dists, the declaration-file bar of ruling 6348891160 item 1. ts-morph resolves through packages/cli's closure; the root adds no dependency. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2gLghEqeDKTbDTC7Zd --- scripts/codemod-split-sql-driver.mjs | 1334 ++++++++++++++++++++++++++ 1 file changed, 1334 insertions(+) create mode 100644 scripts/codemod-split-sql-driver.mjs diff --git a/scripts/codemod-split-sql-driver.mjs b/scripts/codemod-split-sql-driver.mjs new file mode 100644 index 00000000000..0c5881423a6 --- /dev/null +++ b/scripts/codemod-split-sql-driver.mjs @@ -0,0 +1,1334 @@ +#!/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 + * + * ## 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, a field or accessor as a GETTER that reads it + * when the body reads it. + * - The module imports `sql-driver.ts` for types only, so the two form no + * evaluation cycle. + * + * ⛔ 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; the plan + * prints, per section, every method that stayed and why. The two reasons that + * are tables rather than rules are `PATH_READER_PINS` (a member whose body a + * reader extracts from `sql-driver.ts` by path) and the tenant-chokepoint + * spellings (`check-tenant-chokepoint` scans `sql-driver.ts` alone). + * + * ## 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; and putting every body back + * (and `async` back on its stub, and the generated imports out) rebuilds + * the base `sql-driver.ts` byte-for-byte, so nothing else in it moved. + * 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 resolved through `@objectstack/cli`'s dependency closure + * (`packages/cli/package.json` declares `ts-morph`), the way other root + * scripts resolve a tool through the package that declares it; the root adds + * no dependency for 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). + */ + +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'; + +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', +}); + +/** + * Members whose body a reader takes out of `sql-driver.ts` BY PATH — moving the + * body would leave that reader reading a stub. Each row names the reader. A row + * naming a method the class no longer has stops the script (exit 2), so the + * table cannot rot into names nobody reads. + */ +const PATH_READER_PINS = Object.freeze({ +}); + +/** + * The spellings `scripts/check-tenant-chokepoint.mjs` keys on. It scans + * `sql-driver.ts` (and the two subclasses) and no seam module, so a body that + * carries one stays where the gate reads it: its counts (read doors, tenant + * predicates, D8 arms) must not move. + */ +const CHOKEPOINT_SPELLINGS = Object.freeze([ + 'getBuilder', + 'applyTenantScope', + 'resolveTenantField', + 'computeTenantField', + 'computeAndRecordTenantField', + 'tenantField', + "'organization_id'", + '"organization_id"', +]); + +/** 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)', + 'tenant-chokepoint': 'its body carries a spelling check-tenant-chokepoint reads in sql-driver.ts only', + 'path-reader': 'a reader extracts its body from sql-driver.ts by path (PATH_READER_PINS)', + '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)', + 'module-value': 'it reads a value declared in sql-driver.ts itself (a value import would make an evaluation cycle)', + 'module-local-type': 'it names a type sql-driver.ts declares without exporting it', + '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)', +}); + +// ── Loading ts-morph ───────────────────────────────────────────────────────── + +let tsMorph = null; + +/** ts-morph through `@objectstack/cli`'s closure; a missing install is exit 2, never a stack trace. */ +async function loadTsMorph() { + if (tsMorph) return tsMorph; + const manifest = join(ROOT, 'packages', 'cli', '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. */ +function parse(tm, fileName, text) { + const project = new tm.Project({ + useInMemoryFileSystem: true, + compilerOptions: { target: tm.ts.ScriptTarget.ES2020, strict: true, noLib: true, allowJs: false }, + }); + return project.createSourceFile(fileName, text, { overwrite: true }); +} + +/** 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 -> { specifier, importDecl, value } + const localTypes = new Set(); // exported sql-driver.ts types it names + + 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, localTypes }; } + 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, localTypes }; + + // 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 bodyText = body.getText(); + for (const spelling of CHOKEPOINT_SPELLINGS) if (bodyText.includes(spelling)) add('tenant-chokepoint', spelling); + if (Object.prototype.hasOwnProperty.call(ctx.pins, name)) add('path-reader', ctx.pins[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; + const declName = decl.name && ts.isIdentifier(decl.name) ? decl.name.text : node.getText(); + const isTypeDecl = ts.isInterfaceDeclaration(decl) || ts.isTypeAliasDeclaration(decl); + if (!typePos || !(isTypeDecl || ts.isClassDeclaration(decl) || ts.isEnumDeclaration(decl))) { + add('module-value', declName); + } else if (!ctx.scope.exported.has(declName)) { + add('module-local-type', declName); + } else { + localTypes.add(declName); + } + } + } + } + + 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, localTypes }; +} + +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`; +} + +/** + * Read `text` (a `sql-driver.ts`) and decide, per method, where its body goes. + * Pure: the same text gives the same plan. + */ +function plan(tm, text, { sections = SECTION_MODULES, pins = PATH_READER_PINS } = {}) { + 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 ctx = { text, sf, members: instanceMembers(tm, cls), scope: moduleScope(tm, sf) }; + const methodNames = new Set(cls.getMethods().map((m) => m.getName())); + const stalePins = Object.keys(pins).filter((n) => !methodNames.has(n)); + if (stalePins.length > 0) refuse(`PATH_READER_PINS names method(s) the class no longer has: ${stalePins.join(', ')}`); + + 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, pins }, 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(['SqlDriver', hostNameOf(moduleFile), 'ThisParameterType', 'Pick']); + for (const r of movers) { + for (const local of r.imports.keys()) taken.add(local); + for (const t of r.localTypes) taken.add(t); + } + for (const r of movers) { + if (taken.has(r.name)) { r.reasons.push('name-collision'); changed = true; } + } + } + } + return { sf, cls, ctx, banners, records, modules: groupMovers(records) }; +} + +/** 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)}`; + +/** The import lines a module needs, in sql-driver.ts's own import order. */ +function importLines(scope, movers, needsHost) { + const bySpecifier = new Map(); + for (const r of movers) { + 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(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + 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 localTypes = [...new Set(movers.flatMap((r) => [...r.localTypes]))].sort(); + const fromHost = [...(needsHost ? ['SqlDriver'] : []), ...localTypes.filter((t) => t !== 'SqlDriver')]; + if (fromHost.length > 0) out.push(`import type { ${fromHost.join(', ')} } from './sql-driver.js';`); + 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) { + 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 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, so a subclass override or an instance spy'); + out.push(' * is still the one reached; fields and accessors go as GETTERS, read when the'); + out.push(' * body reads them, as they were on the class.'); + out.push(' *'); + 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.'); + } else { + out.push(' * No body reads a `SqlDriver` member, so each method calls its function'); + out.push(' * directly. `index.ts` re-exports nothing from this module.'); + } + out.push(' */'); + out.push(''); + out.push(...importLines(scope, movers, needsHost)); + 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'); +} + +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) { + out.push(r.host.get(member) === 'getter' + ? `${ind} get ${member}() { return ${readerName(member)}(); },` + : `${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. Pure. + */ +function generate(tm, text, planned) { + const { sf, ctx, modules } = planned; + const edits = []; + const importBlock = []; + 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: '' }); + } + } + 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(); + for (const [moduleFile, movers] of modules) { + moduleTexts.set(moduleFile, moduleText(moduleFile, movers[0].section, movers, ctx.scope)); + } + 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, and undoing + * the split rebuilds the base `sql-driver.ts` byte-for-byte. + * + * Returns `{ failures, moved, linesBefore, linesAfter }`; no failure = proved. + */ +function verify(tm, baseText, headText, moduleTexts) { + const { Node, SyntaxKind } = tm; + const failures = []; + const moved = []; + 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); + if (!baseCls || !headCls) return { failures: [`no class ${CLASS_NAME} at base or head`], moved, linesBefore: 0, linesAfter: 0 }; + + const modules = new Map(); + for (const [file, text] of moduleTexts) { + if (!text.includes(MARK)) continue; + const sf = parse(tm, join(SRC_DIR, file), text); + 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`); + } + for (const imp of headSf.getImportDeclarations()) { + const hit = /^\.\/(sql-driver-[a-z0-9-]+)\.js$/.exec(imp.getModuleSpecifierValue()); + if (hit && modules.has(`${hit[1]}.ts`)) edits.push({ start: imp.getFullStart(), end: imp.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 (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) failures.push('no moved method found: a proof over nothing is not a proof'); + return { failures, moved, linesBefore: baseText.split('\n').length, linesAfter: headText.split('\n').length }; +} + +// ── 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 bar on one declaration file pair. Returns `{ failures, reading }`. */ +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; + if (bl.length !== al.length) { + 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, + 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(', ')}`); + 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; + console.log(`byte-identity: ${identical}/${v.moved.length} moved bodies identical to base (body, parameters, return type, async); ` + + `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 }); +} + +function generatedModulesOnDisk() { + const out = new Map(); + for (const file of 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 ──────────────────────────────────────────────────────────────────── + +async function main(argv) { + 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()); + 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); + 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': 6, + 'stay rules': 9, + 'byte-identity proof': 6, + 'refusals': 2, +}); +const SELF_TEST_BATTERY_FLOOR = 4; + +const FIXTURE_SECTIONS = Object.freeze({ 'Lifecycle': 'sql-driver-lifecycle.ts', 'Kept': null }); + +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'; + +const LOCAL_VALUE = 1; +type LocalType = { n: number }; +export interface ExportedShape { a: number } + +export class SqlDriver { + private knex: any = null; + protected logger: { warn(m: string): void } = console; + private count = 0; + private readonly fn = (x: string): string => x; + + // =================================== + // 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; + } + + 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'); + } +${extraMethods} + // =================================== + // Kept + // =================================== + + protected keptSection(): number { +${pad} + return 2; + } +} +`; +} + +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 changed export list fails', dtsBarFor('f', base, base.replace('export { A };', 'export { A as B };')).failures.length > 0); + } + + open('stay rules'); + { + const planned = plan(tm, fixtureSource(), { sections: FIXTURE_SECTIONS, pins: {} }); + 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)); + check('movable moves', why('movable') === '', why('movable')); + stays('other', 'no-yield'); + stays('writes', 'this-write'); + stays('asValue', 'method-as-value'); + stays('readsLocal', 'module-value'); + stays('namesLocalType', 'module-local-type'); + stays('inferred', 'inferred-return-type'); + stays('scoped', 'tenant-chokepoint'); + stays('defaulted', 'parameter-shape'); + } + + open('byte-identity proof'); + { + const input = fixtureSource(); + const planned = plan(tm, input, { sections: FIXTURE_SECTIONS, pins: {} }); + const out = generate(tm, input, planned); + const again = generate(tm, input, plan(tm, input, { sections: FIXTURE_SECTIONS, pins: {} })); + const v = verify(tm, input, out.host, out.modules); + check('the generated split proves against its base', v.failures.length === 0 && v.moved.map((m) => m.name).join() === 'movable', + [...v.failures, `moved: ${v.moved.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([['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([['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); + } + + 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(() => plan(tm, unknownBanner, { sections: FIXTURE_SECTIONS, pins: {} }))); + check('a pin naming a method the class lacks refuses', refused(() => plan(tm, fixtureSource(), { sections: FIXTURE_SECTIONS, pins: { gone: 'x' } }))); + } + + 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); + }, + ); +} From 391768627d6b5e45efb2e88208c3cfa623189cfc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:56:09 +0000 Subject: [PATCH 02/20] refactor(driver-sql): generated split of sql-driver.ts by scripts/codemod-split-sql-driver.mjs Output of `node scripts/codemod-split-sql-driver.mjs --write` on sql-driver.ts at dbca3ef034, unedited: 20 method bodies in 10 sections move to section modules, the class keeps host stubs. Byte-identity proved by the script (undoing the split rebuilds the base file byte-for-byte). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2gLghEqeDKTbDTC7Zd --- .../driver-sql/src/sql-driver-aggregation.ts | 29 ++ .../src/sql-driver-bulk-operations.ts | 60 +++ .../src/sql-driver-column-creation.ts | 88 +++++ .../drivers/driver-sql/src/sql-driver-crud.ts | 25 ++ .../src/sql-driver-database-helpers.ts | 54 +++ .../src/sql-driver-field-mapping.ts | 85 +++++ .../src/sql-driver-filter-helpers.ts | 95 +++++ .../driver-sql/src/sql-driver-lifecycle.ts | 65 ++++ .../src/sql-driver-managed-drift.ts | 91 +++++ .../src/sql-driver-sequence-semantics.ts | 50 +++ packages/drivers/driver-sql/src/sql-driver.ts | 348 ++++++------------ 11 files changed, 762 insertions(+), 228 deletions(-) create mode 100644 packages/drivers/driver-sql/src/sql-driver-aggregation.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-bulk-operations.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-column-creation.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-crud.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-database-helpers.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-field-mapping.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-lifecycle.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-managed-drift.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-sequence-semantics.ts 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..ac5b6135fc5 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-aggregation.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The "Aggregation" section of `SqlDriver`: the bodies of + * `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. + * + * No body reads a `SqlDriver` member, so each method calls its function + * directly. `index.ts` re-exports nothing from this module. + */ + + +/** 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..27cdf1937ec --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-bulk-operations.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The "Bulk & Batch Operations" section of `SqlDriver`: the + * bodies of `bulkUpdate` 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, 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 BulkOperationsHost { + readonly knex: SqlDriver['knex']; + update: SqlDriver['update']; +} + +/** 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 })); + } 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..d2196d25669 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-column-creation.ts @@ -0,0 +1,88 @@ +// 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, 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-crud.ts b/packages/drivers/driver-sql/src/sql-driver-crud.ts new file mode 100644 index 00000000000..4550548b7c8 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-crud.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The "CRUD — IDataDriver core" section of `SqlDriver`: the + * bodies of `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. + * + * No body reads a `SqlDriver` member, so each method calls its function + * directly. `index.ts` re-exports nothing from this module. + */ + +import { createHash } from 'node:crypto'; + +/** 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..22b0e2f2b1b --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-database-helpers.ts @@ -0,0 +1,54 @@ +// 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, 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-field-mapping.ts b/packages/drivers/driver-sql/src/sql-driver-field-mapping.ts new file mode 100644 index 00000000000..545f4602cb5 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-field-mapping.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The "Field mapping" section of `SqlDriver`: the bodies of + * `mapSortField`, `orderKeysFor` 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, 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 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; + } 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..f36373543c7 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The "Filter helpers" section of `SqlDriver`: the bodies of + * `tableNameForBuilder`, `toDateOnly`, `sqliteCanonicalDatetimeSql`, + * `sqliteNonTemporalTextSql`, `sqliteCanonicalTimeSql`, `presentReadColumns` + * 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, 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 { temporalStorageForm } from '@objectstack/core'; +import type { SqlDriver, ReadPresentationKind } 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 { + presentReadValue: SqlDriver['presentReadValue']; +} + +/** 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#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#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; + } 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..bb6058788ea --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-lifecycle.ts @@ -0,0 +1,65 @@ +// 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, 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..d99d80b2c25 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-managed-drift.ts @@ -0,0 +1,91 @@ +// 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`, `buildRebuiltColumn`, + * `dropIndexIfExists` 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, 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 { Knex } from 'knex'; +import type { SqlDriver, IntrospectedColumn } 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']; +} + +/** 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#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; + } 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..ef4d3bb7047 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-sequence-semantics.ts @@ -0,0 +1,50 @@ +// 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` 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, 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 { 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 SequenceSemanticsHost { + readonly knex: SqlDriver['knex']; +} + +/** 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()); + } diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 89b6a55f6ad..d7ad677f5c0 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -220,6 +220,26 @@ 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] 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 their arguments. +import { 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 { defineSequencesTable } 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 { bulkUpdate } from './sql-driver-bulk-operations.js'; +// [#29520 codemod] Aggregation: the methods of the same names below hand the bodies their arguments. +import { foldEmptyAggregateAnswers } from './sql-driver-aggregation.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 } from './sql-driver-managed-drift.js'; +// [#29520 codemod] Filter helpers: the methods of the same names below hand the bodies what they read (`FilterHelpersHost`). +import { presentReadColumns, 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 { mapSortField, orderKeysFor } from './sql-driver-field-mapping.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'; /** * [#20768] The async scope of a driver's own PRE-DDL question: the ADR-0104 @@ -5804,29 +5824,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); } /** @@ -6705,9 +6713,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 ────────────── @@ -6765,15 +6772,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); } /** @@ -8831,25 +8835,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 { @@ -9686,14 +9679,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); } /** @@ -11992,11 +11979,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(); } /** @@ -12423,18 +12407,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); } /** @@ -12606,30 +12580,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); } /** @@ -13107,9 +13068,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); } /** @@ -13169,10 +13129,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); } /** @@ -13286,11 +13244,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); } /** @@ -13346,11 +13301,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); } /** @@ -13382,15 +13334,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); } /** @@ -13555,14 +13500,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); } /** @@ -15152,9 +15094,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); } /** @@ -15239,42 +15180,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); } /** @@ -16244,25 +16158,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); } /** @@ -16486,22 +16392,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); } /** @@ -16632,21 +16529,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); } /** @@ -17286,19 +17181,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() { From ba014dfcb68e399e069f561a1141d4b815cf2e93 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:11:18 +0000 Subject: [PATCH 03/20] fix(scripts): route the codemod's parses through ts-parse.mjs's checked parse check:parse-guard flagged the raw createSourceFile: a parse whose errors nobody read. The text now passes parseSourceFile first, which refuses a source with parse errors, and ts-morph reads the vouched-for text. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2gLghEqeDKTbDTC7Zd --- scripts/codemod-split-sql-driver.mjs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/scripts/codemod-split-sql-driver.mjs b/scripts/codemod-split-sql-driver.mjs index 0c5881423a6..e84f459e6ae 100644 --- a/scripts/codemod-split-sql-driver.mjs +++ b/scripts/codemod-split-sql-driver.mjs @@ -75,7 +75,8 @@ * * 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). + * prerequisite, bad arguments) · 3 an input does not parse (`ts-parse.mjs`'s + * refusal, which names the parse errors). */ import { createHash } from 'node:crypto'; @@ -86,6 +87,7 @@ 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'; @@ -225,13 +227,20 @@ async function loadTsMorph() { } } -/** One in-memory project per parse: no file system, no lib, no build output. */ +/** + * 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 }, }); - return project.createSourceFile(fileName, text, { overwrite: true }); + 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. */ From 3c33663badda910b243c36697a6bb86c4e15ae4d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:34:16 +0000 Subject: [PATCH 04/20] feat(scripts): the codemod applies ruling 6591753301's widened cut pattern Lever (a): a section module imports values from sql-driver.ts; a staying top-level name a moved body reads is exported by one generated export list. Lever (b): the top-level declarations ahead of the class move whole, with their comments, into the leaf sql-driver-top-level.ts, which never imports sql-driver.ts or a seam module; sql-driver.ts imports back what it reads and re-exports by name what it exported. The tenant-chokepoint stay rule goes (the gate now reads the seam modules); the host hands getBuilder as a bound reference. Path readers are kept whole by PATH_READER_PINS and the new PATH_READER_NEEDLES. The byte-identity proof splices the leaf back by name and rebuilds the base byte-for-byte. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2gLghEqeDKTbDTC7Zd --- .../driver-sql/src/sql-driver-aggregation.ts | 29 - .../src/sql-driver-bulk-operations.ts | 60 -- .../src/sql-driver-column-creation.ts | 88 -- .../drivers/driver-sql/src/sql-driver-crud.ts | 25 - .../src/sql-driver-database-helpers.ts | 54 -- .../src/sql-driver-field-mapping.ts | 85 -- .../src/sql-driver-filter-helpers.ts | 95 --- .../driver-sql/src/sql-driver-lifecycle.ts | 65 -- .../src/sql-driver-managed-drift.ts | 91 -- .../src/sql-driver-sequence-semantics.ts | 50 -- packages/drivers/driver-sql/src/sql-driver.ts | 348 +++++--- scripts/codemod-split-sql-driver.mjs | 796 +++++++++++++++--- 12 files changed, 910 insertions(+), 876 deletions(-) delete mode 100644 packages/drivers/driver-sql/src/sql-driver-aggregation.ts delete mode 100644 packages/drivers/driver-sql/src/sql-driver-bulk-operations.ts delete mode 100644 packages/drivers/driver-sql/src/sql-driver-column-creation.ts delete mode 100644 packages/drivers/driver-sql/src/sql-driver-crud.ts delete mode 100644 packages/drivers/driver-sql/src/sql-driver-database-helpers.ts delete mode 100644 packages/drivers/driver-sql/src/sql-driver-field-mapping.ts delete mode 100644 packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts delete mode 100644 packages/drivers/driver-sql/src/sql-driver-lifecycle.ts delete mode 100644 packages/drivers/driver-sql/src/sql-driver-managed-drift.ts delete mode 100644 packages/drivers/driver-sql/src/sql-driver-sequence-semantics.ts diff --git a/packages/drivers/driver-sql/src/sql-driver-aggregation.ts b/packages/drivers/driver-sql/src/sql-driver-aggregation.ts deleted file mode 100644 index ac5b6135fc5..00000000000 --- a/packages/drivers/driver-sql/src/sql-driver-aggregation.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#29520 codemod] The "Aggregation" section of `SqlDriver`: the bodies of - * `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. - * - * No body reads a `SqlDriver` member, so each method calls its function - * directly. `index.ts` re-exports nothing from this module. - */ - - -/** 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 deleted file mode 100644 index 27cdf1937ec..00000000000 --- a/packages/drivers/driver-sql/src/sql-driver-bulk-operations.ts +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#29520 codemod] The "Bulk & Batch Operations" section of `SqlDriver`: the - * bodies of `bulkUpdate` 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, 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 BulkOperationsHost { - readonly knex: SqlDriver['knex']; - update: SqlDriver['update']; -} - -/** 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 })); - } diff --git a/packages/drivers/driver-sql/src/sql-driver-column-creation.ts b/packages/drivers/driver-sql/src/sql-driver-column-creation.ts deleted file mode 100644 index d2196d25669..00000000000 --- a/packages/drivers/driver-sql/src/sql-driver-column-creation.ts +++ /dev/null @@ -1,88 +0,0 @@ -// 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, 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-crud.ts b/packages/drivers/driver-sql/src/sql-driver-crud.ts deleted file mode 100644 index 4550548b7c8..00000000000 --- a/packages/drivers/driver-sql/src/sql-driver-crud.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#29520 codemod] The "CRUD — IDataDriver core" section of `SqlDriver`: the - * bodies of `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. - * - * No body reads a `SqlDriver` member, so each method calls its function - * directly. `index.ts` re-exports nothing from this module. - */ - -import { createHash } from 'node:crypto'; - -/** 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 deleted file mode 100644 index 22b0e2f2b1b..00000000000 --- a/packages/drivers/driver-sql/src/sql-driver-database-helpers.ts +++ /dev/null @@ -1,54 +0,0 @@ -// 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, 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-field-mapping.ts b/packages/drivers/driver-sql/src/sql-driver-field-mapping.ts deleted file mode 100644 index 545f4602cb5..00000000000 --- a/packages/drivers/driver-sql/src/sql-driver-field-mapping.ts +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#29520 codemod] The "Field mapping" section of `SqlDriver`: the bodies of - * `mapSortField`, `orderKeysFor` 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, 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 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; - } diff --git a/packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts b/packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts deleted file mode 100644 index f36373543c7..00000000000 --- a/packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#29520 codemod] The "Filter helpers" section of `SqlDriver`: the bodies of - * `tableNameForBuilder`, `toDateOnly`, `sqliteCanonicalDatetimeSql`, - * `sqliteNonTemporalTextSql`, `sqliteCanonicalTimeSql`, `presentReadColumns` - * 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, 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 { temporalStorageForm } from '@objectstack/core'; -import type { SqlDriver, ReadPresentationKind } 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 { - presentReadValue: SqlDriver['presentReadValue']; -} - -/** 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#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#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; - } diff --git a/packages/drivers/driver-sql/src/sql-driver-lifecycle.ts b/packages/drivers/driver-sql/src/sql-driver-lifecycle.ts deleted file mode 100644 index bb6058788ea..00000000000 --- a/packages/drivers/driver-sql/src/sql-driver-lifecycle.ts +++ /dev/null @@ -1,65 +0,0 @@ -// 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, 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 deleted file mode 100644 index d99d80b2c25..00000000000 --- a/packages/drivers/driver-sql/src/sql-driver-managed-drift.ts +++ /dev/null @@ -1,91 +0,0 @@ -// 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`, `buildRebuiltColumn`, - * `dropIndexIfExists` 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, 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 { Knex } from 'knex'; -import type { SqlDriver, IntrospectedColumn } 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']; -} - -/** 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#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; - } diff --git a/packages/drivers/driver-sql/src/sql-driver-sequence-semantics.ts b/packages/drivers/driver-sql/src/sql-driver-sequence-semantics.ts deleted file mode 100644 index ef4d3bb7047..00000000000 --- a/packages/drivers/driver-sql/src/sql-driver-sequence-semantics.ts +++ /dev/null @@ -1,50 +0,0 @@ -// 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` 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, 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 { 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 SequenceSemanticsHost { - readonly knex: SqlDriver['knex']; -} - -/** 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()); - } diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index d7ad677f5c0..89b6a55f6ad 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -220,26 +220,6 @@ 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] 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 their arguments. -import { 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 { defineSequencesTable } 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 { bulkUpdate } from './sql-driver-bulk-operations.js'; -// [#29520 codemod] Aggregation: the methods of the same names below hand the bodies their arguments. -import { foldEmptyAggregateAnswers } from './sql-driver-aggregation.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 } from './sql-driver-managed-drift.js'; -// [#29520 codemod] Filter helpers: the methods of the same names below hand the bodies what they read (`FilterHelpersHost`). -import { presentReadColumns, 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 { mapSortField, orderKeysFor } from './sql-driver-field-mapping.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'; /** * [#20768] The async scope of a driver's own PRE-DDL question: the ADR-0104 @@ -5824,17 +5804,29 @@ export class SqlDriver implements IDataDriver { // Lifecycle // =================================== - 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); + 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; + } } /** @@ -6713,8 +6705,9 @@ 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 { - // [#29520 codemod] The body moved to `sql-driver-crud.ts`. - return sequenceKeyHash(object, tenantId, field, scope); + return createHash('sha256') + .update(`${object}\u001f${tenantId}\u001f${field}\u001f${scope}`) + .digest('hex'); } // ── The sequence semantics a second face shares, stated once ────────────── @@ -6772,12 +6765,15 @@ export class SqlDriver implements IDataDriver { * and sends that, so both faces create byte-for-byte the same table. */ protected defineSequencesTable(t: Knex.CreateTableBuilder): void { - // [#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); + 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()); } /** @@ -8835,14 +8831,25 @@ 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`. - 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), + 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; }; - return bulkUpdate.call(host, object, updates, options); + + // 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 })); } async bulkDelete(object: string, ids: Array, options?: DriverOptions): Promise { @@ -9679,8 +9686,14 @@ export class SqlDriver implements IDataDriver { * `AGGREGATION_CASES` (spec `data/aggregation-conformance.ts`). */ protected foldEmptyAggregateAnswers(rows: any, identities: Map): any { - // [#29520 codemod] The body moved to `sql-driver-aggregation.ts`. - return foldEmptyAggregateAnswers(rows, identities); + 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; } /** @@ -11979,8 +11992,11 @@ export class SqlDriver implements IDataDriver { /** True only when running under `NODE_ENV=production` — auto-DDL is force-disabled there. */ protected isProductionEnv(): boolean { - // [#29520 codemod] The body moved to `sql-driver-managed-drift.ts`. - return isProductionEnv(); + try { + return (process.env.NODE_ENV ?? '').toLowerCase() === 'production'; + } catch { + return false; + } } /** @@ -12407,8 +12423,18 @@ 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 { - // [#29520 codemod] The body moved to `sql-driver-managed-drift.ts`. - return buildRebuiltColumn(t, c); + 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); } /** @@ -12580,17 +12606,30 @@ export class SqlDriver implements IDataDriver { * * Returns true when something was actually 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); + 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; } /** @@ -13068,8 +13107,9 @@ export class SqlDriver implements IDataDriver { * `initObjects`). Returns null when the builder is not table-scoped yet. */ protected tableNameForBuilder(builder: any): string | null { - // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts`. - return tableNameForBuilder(builder); + const t = builder?._single?.table; + if (typeof t === 'string') return t; + return null; } /** @@ -13129,8 +13169,10 @@ export class SqlDriver implements IDataDriver { * mirror direction, and `sql-driver-11389-date-tz-skew.test.ts` pins it. */ protected toDateOnly(value: any): any { - // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts`. - return toDateOnly(value); + // [#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'); } /** @@ -13244,8 +13286,11 @@ export class SqlDriver implements IDataDriver { * becoming "no value". */ protected sqliteCanonicalDatetimeSql(columnSql: string): string { - // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts`. - return sqliteCanonicalDatetimeSql(columnSql); + 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)` + ); } /** @@ -13301,8 +13346,11 @@ export class SqlDriver implements IDataDriver { * callers bind accordingly. */ protected sqliteNonTemporalTextSql(columnSql: string): string { - // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts`. - return sqliteNonTemporalTextSql(columnSql); + return ( + `(typeof(${columnSql}) not in ('integer','real') ` + + `and instr(${columnSql}, '-') = 0 and instr(${columnSql}, ':') = 0 ` + + `and julianday(${columnSql}) is not null)` + ); } /** @@ -13334,8 +13382,15 @@ export class SqlDriver implements IDataDriver { * {@link SQLITE_TIME_EXPR_REFS} times; callers bind accordingly. */ protected sqliteCanonicalTimeSql(columnSql: string): string { - // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts`. - return sqliteCanonicalTimeSql(columnSql); + 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)` + ); } /** @@ -13500,11 +13555,14 @@ export class SqlDriver implements IDataDriver { * Rows are mutated in place, as `formatOutput` does. */ protected presentReadColumns(rows: any, columns: Map): any { - // [#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); + 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; } /** @@ -15094,8 +15152,9 @@ export class SqlDriver implements IDataDriver { // ── Field mapping ─────────────────────────────────────────────────────────── protected mapSortField(field: string): string { - // [#29520 codemod] The body moved to `sql-driver-field-mapping.ts`. - return mapSortField(field); + if (field === 'createdAt') return 'created_at'; + if (field === 'updatedAt') return 'updated_at'; + return field; } /** @@ -15180,15 +15239,42 @@ export class SqlDriver implements IDataDriver { query: DriverQuery, opts?: { singleRowLookup?: boolean }, ): Array<{ field: string; direction: 'asc' | 'desc' }> { - // [#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); + 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; } /** @@ -16158,17 +16244,25 @@ 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 decorateHashShadowDuplicate( + protected async decorateHashShadowDuplicate( tableName: string, values: Record, error: unknown, ): Promise { - // [#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); + 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, + }); } /** @@ -16392,13 +16486,22 @@ 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 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); + 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; + } } /** @@ -16529,19 +16632,21 @@ 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 rethrowWithRowSizeExplanation( + private async rethrowWithRowSizeExplanation( tableName: string, fields: Record | undefined, keyedColumns: ReadonlyMap, addedColumns: string[] | null, e: any, ): Promise { - // [#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); + 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, + }); } /** @@ -17181,16 +17286,19 @@ export class SqlDriver implements IDataDriver { * each connection itself and the target is not ours to read. */ protected sqliteFilename(): string | null { - // [#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); + 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; } protected async ensureDatabaseExists() { diff --git a/scripts/codemod-split-sql-driver.mjs b/scripts/codemod-split-sql-driver.mjs index e84f459e6ae..e4bf9c460d6 100644 --- a/scripts/codemod-split-sql-driver.mjs +++ b/scripts/codemod-split-sql-driver.mjs @@ -29,10 +29,31 @@ * `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, a field or accessor as a GETTER that reads it - * when the body reads it. - * - The module imports `sql-driver.ts` for types only, so the two form no - * evaluation cycle. + * 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, @@ -42,18 +63,23 @@ * * 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; the plan - * prints, per section, every method that stayed and why. The two reasons that - * are tables rather than rules are `PATH_READER_PINS` (a member whose body a - * reader extracts from `sql-driver.ts` by path) and the tenant-chokepoint - * spellings (`check-tenant-chokepoint` scans `sql-driver.ts` alone). + * `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) and `PATH_READER_NEEDLES` (literal text a reader + * requires to find there). * * ## 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; and putting every body back - * (and `async` back on its stub, and the generated imports out) rebuilds - * the base `sql-driver.ts` byte-for-byte, so nothing else in it moved. + * 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 @@ -139,30 +165,64 @@ const SECTION_MODULES = Object.freeze({ }); /** - * Members whose body a reader takes out of `sql-driver.ts` BY PATH — moving the - * body would leave that reader reading a stub. Each row names the reader. A row - * naming a method the class no longer has stops the script (exit 2), so the - * table cannot rot into names nobody reads. + * 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. + * + * The first three are `sql-driver-compile-refusal-seam.test.ts`'s enumeration: + * it parses `sql-driver.ts` 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. A builder moved to a + * module it does not read drops out of that set. + */ +const PATH_READER_NEEDLES = Object.freeze({ + 'unsupportedFilterError(': 'sql-driver-compile-refusal-seam.test.ts enumerates its callers', + 'withheldFilterError(': 'sql-driver-compile-refusal-seam.test.ts enumerates its callers', + '.INVALID_FILTER': 'sql-driver-compile-refusal-seam.test.ts enumerates the INVALID_FILTER code writers', + "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', }); /** - * The spellings `scripts/check-tenant-chokepoint.mjs` keys on. It scans - * `sql-driver.ts` (and the two subclasses) and no seam module, so a body that - * carries one stays where the gate reads it: its counts (read doors, tenant - * predicates, D8 arms) must not move. + * 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 CHOKEPOINT_SPELLINGS = Object.freeze([ - 'getBuilder', - 'applyTenantScope', - 'resolveTenantField', - 'computeTenantField', - 'computeAndRecordTenantField', - 'tenantField', - "'organization_id'", - '"organization_id"', -]); +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 ' + @@ -184,8 +244,7 @@ const STAY_REASONS = Object.freeze({ '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)', - 'tenant-chokepoint': 'its body carries a spelling check-tenant-chokepoint reads in sql-driver.ts only', - 'path-reader': 'a reader extracts its body from sql-driver.ts by path (PATH_READER_PINS)', + 'path-reader': 'a reader takes its text out of sql-driver.ts by path (PATH_READER_PINS / PATH_READER_NEEDLES)', 'arguments': 'its body reads `arguments`', 'super': 'its body reads `super`', 'new-target': 'its body reads `new.target`', @@ -196,8 +255,8 @@ const STAY_REASONS = Object.freeze({ '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)', - 'module-value': 'it reads a value declared in sql-driver.ts itself (a value import would make an evaluation cycle)', - 'module-local-type': 'it names a type sql-driver.ts declares without exporting it', + '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', @@ -205,6 +264,16 @@ const STAY_REASONS = Object.freeze({ '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; @@ -408,8 +477,8 @@ function readMethod(tm, ctx, m) { if (!reasons.includes(tag)) reasons.push(tag); }; const host = new Map(); // member -> 'method' | 'getter' - const imports = new Map(); // local -> { specifier, importDecl, value } - const localTypes = new Set(); // exported sql-driver.ts types it names + 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'); @@ -418,7 +487,7 @@ function readMethod(tm, ctx, m) { 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, localTypes }; } + 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'); @@ -427,7 +496,7 @@ function readMethod(tm, ctx, m) { 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, localTypes }; + if (!body) return { reasons, host, imports, moduleRefs }; // A comment between the parentheses that no parameter's own text carries. const params = m.getParameters(); @@ -439,9 +508,9 @@ function readMethod(tm, ctx, m) { } if (/\/\/|\/\*/.test(between)) add('comment-in-parameters'); - const bodyText = body.getText(); - for (const spelling of CHOKEPOINT_SPELLINGS) if (bodyText.includes(spelling)) add('tenant-chokepoint', spelling); - if (Object.prototype.hasOwnProperty.call(ctx.pins, name)) add('path-reader', ctx.pins[name]); + const methodText = m.getText(); + for (const needle of Object.keys(ctx.needles)) 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; @@ -501,15 +570,22 @@ function readMethod(tm, ctx, m) { 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(); - const isTypeDecl = ts.isInterfaceDeclaration(decl) || ts.isTypeAliasDeclaration(decl); - if (!typePos || !(isTypeDecl || ts.isClassDeclaration(decl) || ts.isEnumDeclaration(decl))) { - add('module-value', declName); - } else if (!ctx.scope.exported.has(declName)) { - add('module-local-type', declName); - } else { - localTypes.add(declName); + 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 }); } } } @@ -523,7 +599,7 @@ function readMethod(tm, ctx, m) { const pn = p.getName(); if (pn === name || pn === 'host' || /^read[A-Z]/.test(pn)) add('parameter-shadows', pn); } - return { reasons, host, imports, localTypes }; + return { reasons, host, imports, moduleRefs }; } function isModuleScopeDecl(ts, decl) { @@ -549,11 +625,165 @@ function hostNameOf(moduleFile) { 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; +} + /** - * Read `text` (a `sql-driver.ts`) and decide, per method, where its body goes. - * Pure: the same text gives the same plan. + * 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 plan(tm, text, { sections = SECTION_MODULES, pins = PATH_READER_PINS } = {}) { +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 } = {}) { const sf = parse(tm, HOST_PATH, text); const cls = sf.getClass(CLASS_NAME); if (!cls) refuse(`no class ${CLASS_NAME} in ${HOST_BASENAME}`); @@ -564,10 +794,20 @@ function plan(tm, text, { sections = SECTION_MODULES, pins = PATH_READER_PINS } 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 ctx = { text, sf, members: instanceMembers(tm, cls), scope: moduleScope(tm, sf) }; + 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 }; const methodNames = new Set(cls.getMethods().map((m) => m.getName())); - const stalePins = Object.keys(pins).filter((n) => !methodNames.has(n)); - if (stalePins.length > 0) refuse(`PATH_READER_PINS names method(s) the class no longer has: ${stalePins.join(', ')}`); + 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 top = planTopLevel(tm, ctx); const records = []; const seen = new Set(); @@ -575,7 +815,7 @@ function plan(tm, text, { sections = SECTION_MODULES, pins = PATH_READER_PINS } const startLine = m.getStartLineNumber(true); let banner = null; for (const b of banners) if (b.line < startLine) banner = b; - const read = readMethod(tm, { ...ctx, pins }, m); + const read = readMethod(tm, ctx, m); const name = m.getName(); const firstOfName = !seen.has(name); seen.add(name); @@ -600,17 +840,40 @@ function plan(tm, text, { sections = SECTION_MODULES, pins = PATH_READER_PINS } changed = false; const byModule = groupMovers(records); for (const [moduleFile, movers] of byModule) { - const taken = new Set(['SqlDriver', hostNameOf(moduleFile), 'ThisParameterType', 'Pick']); + 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 t of r.localTypes) taken.add(t); + 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; } } } } - return { sf, cls, ctx, banners, records, modules: groupMovers(records) }; + 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. */ @@ -628,10 +891,46 @@ function groupMovers(records) { const readerName = (member) => `read${member[0].toUpperCase()}${member.slice(1)}`; -/** The import lines a module needs, in sql-driver.ts's own import order. */ -function importLines(scope, movers, needsHost) { +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 movers) { + 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`); @@ -644,7 +943,7 @@ function importLines(scope, movers, needsHost) { 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(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + 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}`)); @@ -661,9 +960,12 @@ function importLines(scope, movers, needsHost) { const typeNamed = named(false); if (typeNamed.length > 0) out.push(`import type { ${typeNamed.join(', ')} } from '${specifier}';`); } - const localTypes = [...new Set(movers.flatMap((r) => [...r.localTypes]))].sort(); - const fromHost = [...(needsHost ? ['SqlDriver'] : []), ...localTypes.filter((t) => t !== 'SqlDriver')]; - if (fromHost.length > 0) out.push(`import type { ${fromHost.join(', ')} } from './sql-driver.js';`); + 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; } @@ -687,11 +989,19 @@ function forwardList(m) { return m.getParameters().map((p) => (p.isRestParameter() ? `...${p.getName()}` : p.getName())); } -function moduleText(moduleFile, title, movers, scope) { +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.'); @@ -710,19 +1020,27 @@ function moduleText(moduleFile, title, movers, scope) { 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, so a subclass override or an instance spy'); - out.push(' * is still the one reached; fields and accessors go as GETTERS, read when the'); - out.push(' * body reads them, as they were on the class.'); - out.push(' *'); - 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(' * 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. `index.ts` re-exports nothing from this module.'); + 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)); + out.push(...importLines(scope, movers, { needsHost, moduleRefs, leafNames })); if (needsHost) { out.push(''); out.push('/**'); @@ -750,6 +1068,39 @@ function moduleText(moduleFile, title, movers, scope) { 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) { + const out = []; + out.push('// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.'); + out.push(''); + 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).'])); + 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(''); + 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); @@ -766,9 +1117,9 @@ function stubBody(r, moduleFile, closeIndent) { } out.push(`${ind}const host: ThisParameterType = {`); for (const member of members) { - out.push(r.host.get(member) === 'getter' - ? `${ind} get ${member}() { return ${readerName(member)}(); },` - : `${ind} ${member}: (...a) => this.${member}(...a),`); + 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}` : ''});`); @@ -779,12 +1130,28 @@ function stubBody(r, moduleFile, closeIndent) { /** * Apply a plan to `text`. Returns `{ host, modules }`: the new `sql-driver.ts` - * and a Map of module basename -> text. Pure. + * and a Map of module basename -> text (the leaf among them). Pure. */ function generate(tm, text, planned) { - const { sf, ctx, modules } = 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); @@ -802,6 +1169,14 @@ function generate(tm, text, planned) { 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('') }); @@ -814,8 +1189,10 @@ function generate(tm, text, planned) { 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)); + moduleTexts.set(moduleFile, moduleText(moduleFile, movers[0].section, movers, ctx.scope, leafNames)); } return { host: out, modules: moduleTexts }; } @@ -826,25 +1203,69 @@ 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, and undoing - * the split rebuilds the base `sql-driver.ts` byte-for-byte. + * `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. * - * Returns `{ failures, moved, linesBefore, linesAfter }`; no failure = proved. + * 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) { - const { Node, SyntaxKind } = tm; +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); - if (!baseCls || !headCls) return { failures: [`no class ${CLASS_NAME} at base or head`], moved, linesBefore: 0, linesAfter: 0 }; + 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', text.indexOf(MARK)) + ' */\n'.length; + 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); @@ -889,13 +1310,52 @@ function verify(tm, baseText, headText, moduleTexts) { 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`); } - for (const imp of headSf.getImportDeclarations()) { - const hit = /^\.\/(sql-driver-[a-z0-9-]+)\.js$/.exec(imp.getModuleSpecifierValue()); - if (hit && modules.has(`${hit[1]}.ts`)) edits.push({ start: imp.getFullStart(), end: imp.getEnd(), text: '' }); + 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'); @@ -904,8 +1364,13 @@ function verify(tm, baseText, headText, moduleTexts) { 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) failures.push('no moved method found: a proof over nothing is not a proof'); - return { failures, moved, linesBefore: baseText.split('\n').length, linesAfter: headText.split('\n').length }; + 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) ───────────── @@ -1031,6 +1496,24 @@ function planReport(planned) { 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'); } @@ -1043,7 +1526,9 @@ function treeHash(files) { 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`); } @@ -1051,9 +1536,15 @@ 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 Object.values(SECTION_MODULES)) { + 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')); @@ -1091,7 +1582,7 @@ async function main(argv) { 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()); + 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; } @@ -1102,7 +1593,7 @@ async function main(argv) { 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); + 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.'); @@ -1135,28 +1626,46 @@ let selfTestReachedVerdict = false; const SELF_TEST_BATTERIES = Object.freeze({ 'declaration bar': 6, - 'stay rules': 9, - 'byte-identity proof': 6, - 'refusals': 2, + 'stay rules': 11, + 'top-level moves': 9, + 'byte-identity proof': 10, + 'refusals': 3, }); -const SELF_TEST_BATTERY_FLOOR = 4; +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_PINS = Object.freeze({ pinnedMethod: 'a fixture reader' }); +const fixturePlan = (tm, text) => plan(tm, text, { sections: FIXTURE_SECTIONS, pins: FIXTURE_PINS, needles: FIXTURE_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; + 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 @@ -1186,7 +1695,7 @@ ${pad} protected readsLocal(): number { ${pad} - return LOCAL_VALUE; + return LOCAL_VALUE + dependsOnPinned().length; } protected namesLocalType(): LocalType | null { @@ -1217,6 +1726,21 @@ ${pad} ${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); + } ${extraMethods} // =================================== // Kept @@ -1224,9 +1748,13 @@ ${extraMethods} protected keptSection(): number { ${pad} - return 2; + return 2 + VIA_SEAM + namesClass(this).count; } } + +function needleInMethod(n: number): number { + return n; +} `; } @@ -1261,46 +1789,86 @@ async function selfTest() { open('stay rules'); { - const planned = plan(tm, fixtureSource(), { sections: FIXTURE_SECTIONS, pins: {} }); + 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)); - check('movable moves', why('movable') === '', why('movable')); + 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('readsLocal', 'module-value'); - stays('namesLocalType', 'module-local-type'); stays('inferred', 'inferred-return-type'); - stays('scoped', 'tenant-chokepoint'); 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')}`); + } + + 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 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 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 = plan(tm, input, { sections: FIXTURE_SECTIONS, pins: {} }); + const planned = fixturePlan(tm, input); const out = generate(tm, input, planned); - const again = generate(tm, input, plan(tm, input, { sections: FIXTURE_SECTIONS, pins: {} })); + 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.map((m) => m.name).join() === 'movable', - [...v.failures, `moved: ${v.moved.map((m) => m.name).join()}`].join(' | ')); + check('the generated split proves against its base', v.failures.length === 0 && v.moved.length === 4 && v.movedTop.length === 4, + [...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([['sql-driver-lifecycle.ts', mod.replace('const p3 = 3;', 'const p3 = 4;')]]); + 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([['sql-driver-lifecycle.ts', `${mod}\nexport const extra = 1;\n`]])).failures.length > 0); + 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(() => plan(tm, unknownBanner, { sections: FIXTURE_SECTIONS, pins: {} }))); - check('a pin naming a method the class lacks refuses', refused(() => plan(tm, fixtureSource(), { sections: FIXTURE_SECTIONS, pins: { gone: 'x' } }))); + 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: {} }))); + check('a needle the file no longer carries refuses', refused(() => plan(tm, fixtureSource(), { sections: FIXTURE_SECTIONS, pins: {}, needles: { 'gone text': 'x' } }))); } const declared = Object.keys(SELF_TEST_BATTERIES); From 39a5d9ed1a170a36af2f1b03bff7a406b33a23fe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:34:16 +0000 Subject: [PATCH 05/20] refactor(driver-sql): regenerate the sql-driver.ts split under the widened cut pattern Output of `node scripts/codemod-split-sql-driver.mjs --write` on sql-driver.ts at origin/main b391feb1d1 (input sha256 e73b1ce952e1), unedited: 56 method bodies move to 18 section modules, 97 of the 130 top-level declarations ahead of the class move to sql-driver-top-level.ts, and sql-driver.ts goes from 18333 to 13971 lines. Byte-identity proved by the script; output tree sha256 bee30883147c, the same on two runs. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2gLghEqeDKTbDTC7Zd --- .../driver-sql/src/sql-driver-aggregation.ts | 344 + .../src/sql-driver-bulk-operations.ts | 354 ++ .../src/sql-driver-column-creation.ts | 89 + .../drivers/driver-sql/src/sql-driver-crud.ts | 185 + .../src/sql-driver-database-helpers.ts | 55 + .../driver-sql/src/sql-driver-distinct.ts | 129 + .../src/sql-driver-field-mapping.ts | 95 + .../src/sql-driver-filter-helpers.ts | 329 + .../src/sql-driver-internal-helpers.ts | 126 + .../driver-sql/src/sql-driver-lifecycle.ts | 66 + .../src/sql-driver-managed-drift.ts | 131 + .../driver-sql/src/sql-driver-query-plan.ts | 121 + .../driver-sql/src/sql-driver-rotation.ts | 305 + .../src/sql-driver-schema-sync-section.ts | 55 + .../src/sql-driver-sequence-semantics.ts | 586 ++ .../src/sql-driver-sqlite-serialisation.ts | 531 ++ .../driver-sql/src/sql-driver-top-level.ts | 2419 +++++++ .../src/sql-driver-window-function-builder.ts | 80 + .../src/sql-driver-window-functions.ts | 150 + packages/drivers/driver-sql/src/sql-driver.ts | 5626 ++--------------- 20 files changed, 6782 insertions(+), 4994 deletions(-) create mode 100644 packages/drivers/driver-sql/src/sql-driver-aggregation.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-bulk-operations.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-column-creation.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-crud.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-database-helpers.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-distinct.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-field-mapping.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-internal-helpers.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-lifecycle.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-managed-drift.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-query-plan.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-rotation.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-schema-sync-section.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-sequence-semantics.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-sqlite-serialisation.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-top-level.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-window-function-builder.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-window-functions.ts 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-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..c6c76998996 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts @@ -0,0 +1,329 @@ +// 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. + * + * Values that stayed in `sql-driver.ts` are imported from it (lever (a) of + * ruling 6591753301); every use is inside a function body, so the import + * cycle is never read at evaluation time. `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, canonicalTimeOfDay, isEmptyFieldConstraint, + normalizeSqliteDatetimeOutput, presentAuditTimestampOutput, refusalSubtree, withheldFilterDiagnosticOf, +} from './sql-driver-top-level.js'; +import type { ReadPresentationKind } from './sql-driver-top-level.js'; +import { + assertCompilableComparand, assertDefinedComparands, emptyFieldConstraintError, filterArrayReachedDriverError, +} from './sql-driver.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..4122b83c4a0 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-rotation.ts @@ -0,0 +1,305 @@ +// 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`, `migrateMysqlDatetimeColumns`, + * `migrateMysqlTimeColumns` 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, MYSQL_DATETIME_PRECISION, NUMERIC_SCALAR_TYPES, declaredValueShapes, + isDeferredDdlLockWaitRefusal, isMultiValuedColumn, isNowDefaultValue, +} 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 isMysql: SqlDriver['isMysql']; + readonly jsonFields: SqlDriver['jsonFields']; + legacyMysqlTimeColumns: SqlDriver['legacyMysqlTimeColumns']; + legacyMysqlTimestampColumns: SqlDriver['legacyMysqlTimestampColumns']; + logDurabilityFailure: SqlDriver['logDurabilityFailure']; + readonly logger: SqlDriver['logger']; + readonly managedObjectFields: SqlDriver['managedObjectFields']; + readonly managedObjectIndexes: SqlDriver['managedObjectIndexes']; + readonly mediaFields: SqlDriver['mediaFields']; + nowColumnDefault: SqlDriver['nowColumnDefault']; + readonly numericFields: SqlDriver['numericFields']; + readonly numericValueFields: SqlDriver['numericValueFields']; + readonly physicalKeyIndexes: SqlDriver['physicalKeyIndexes']; + runWideningAlters: SqlDriver['runWideningAlters']; + 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 }; + } + +/** The body of `SqlDriver#migrateMysqlDatetimeColumns`; the method's docblock there is its contract. */ +export async function migrateMysqlDatetimeColumns(this: Pick, table: string, fields: Record): Promise { + if (!this.isMysql) return; + try { + const legacy = await this.legacyMysqlTimestampColumns(table, fields); + if (legacy.length === 0) return; + + // #9354: built as a list, then run as one unit, so the whole widening of a + // table shares the ONE pinned session the bounded lock wait needs. + const statements = legacy.map((col) => { + // The default is re-stated because MySQL drops a column's DEFAULT when + // MODIFY does not repeat it, and an audit column without + // `CURRENT_TIMESTAMP(3)` would start inserting NULL. + // + // A declared `defaultValue: 'NOW()'` column is the same case (#21241), + // and the TIME twin ({@link migrateMysqlTimeColumns}) already restates + // it. Measured on MySQL 8.0.46 before this line: a legacy + // `timestamp null default current_timestamp` NOW() column came out of + // the widening as `datetime(3)` with NO default, and an insert omitting + // it answered `null`. Both take the expression a fresh column gets, + // from {@link nowColumnDefault}, so the widening cannot spell a third. + const isAudit = (AUDIT_TIMESTAMP_COLUMNS as readonly string[]).includes(col.name); + const isNowDefault = isNowDefaultValue(fields[col.name]?.defaultValue); + const nullClause = col.nullable ? 'null' : 'not null'; + const defaultClause = isAudit || isNowDefault ? ` default ${this.nowColumnDefault('datetime').toString()}` : ''; + return { + sql: `alter table ?? modify column ?? datetime(${MYSQL_DATETIME_PRECISION}) ${nullClause}${defaultClause}`, + bindings: [table, col.name] as unknown[], + }; + }); + await this.runWideningAlters(table, statements); + this.logger.info?.( + `[sql-driver] widened MySQL TIMESTAMP → DATETIME(3) on ${table}: TIMESTAMP ends at 2038-01-19, ` + + `keeps no milliseconds and converts through the session timezone`, + { columns: legacy.map((c) => c.name) }, + ); + } catch (err) { + // #9354: the ONE failure that must not be swallowed. Everything else on + // this path keeps the policy the doc comment above describes; a metadata + // lock wait that the flush bounded is the operator's answer to a command + // they ran, and dropping it would report success for work not done. + if (isDeferredDdlLockWaitRefusal(err)) throw err; + this.logDurabilityFailure( + `[sql-driver] FAILED to widen MySQL datetime columns on ${table} — the DDL did not run and boot ` + + `continued: the columns are still legacy TIMESTAMP, so every write to them keeps truncating ` + + `milliseconds and keeps the 2038 ceiling, while reads return exactly what was stored and nothing ` + + `else reports the table as un-widened. Fix: identify the metadata-lock holder with ` + + '`SHOW PROCESSLIST` or `performance_schema.metadata_locks` (or resolve the error in this ' + + 'record\'s meta), end it, then re-run `os migrate apply` or restart — the widening is ' + + 're-detected from `information_schema` and is idempotent, so re-running is safe.', + { error: err instanceof Error ? err.message : String(err) }, + ); + } + } + +/** The body of `SqlDriver#migrateMysqlTimeColumns`; the method's docblock there is its contract. */ +export async function migrateMysqlTimeColumns(this: Pick, table: string, fields: Record): Promise { + if (!this.isMysql) return; + try { + const legacy = await this.legacyMysqlTimeColumns(table, fields); + if (legacy.length === 0) return; + + // #9354: the datetime twin's seam, for the same reason — this ALTER takes + // the same exclusive metadata lock and blocks on the same holder. + const statements = legacy.map((col) => { + // MODIFY drops a default it does not restate. A `defaultValue: 'NOW()'` + // column gets the canonical UTC expression default (`nowColumnDefault`); + // its legacy `current_timestamp()` default read the SESSION's zone, so + // dropping-and-replacing it is a fix, not collateral. + const isNowDefault = isNowDefaultValue(fields[col.name]?.defaultValue); + const defaultClause = isNowDefault ? ' default (cast(utc_timestamp(3) as time(3)))' : ''; + return { + sql: `alter table ?? modify column ?? time(3) ${col.nullable ? 'null' : 'not null'}${defaultClause}`, + bindings: [table, col.name] as unknown[], + }; + }); + await this.runWideningAlters(table, statements); + this.logger.info?.( + `[sql-driver] widened MySQL TIME → TIME(3) on ${table}: a zero-precision TIME rounds the ` + + `canonical milliseconds to the nearest second, changing the stored wall clock`, + { columns: legacy.map((c) => c.name) }, + ); + } catch (err) { + // #9354: the ONE failure that must not be swallowed. Everything else on + // this path keeps the policy the doc comment above describes; a metadata + // lock wait that the flush bounded is the operator's answer to a command + // they ran, and dropping it would report success for work not done. + if (isDeferredDdlLockWaitRefusal(err)) throw err; + this.logDurabilityFailure( + `[sql-driver] FAILED to widen MySQL time columns on ${table} — the DDL did not run and boot ` + + `continued: the columns are still zero-precision TIME, so every fractional-second write to them ` + + `keeps being ROUNDED to whole seconds — the stored wall clock is not the one that was asked for — ` + + `while reads return exactly what was stored and nothing else reports the table as un-widened. ` + + 'Fix: identify the metadata-lock holder with `SHOW PROCESSLIST` or ' + + '`performance_schema.metadata_locks` (or resolve the error in this record\'s meta), end it, then ' + + 're-run `os migrate apply` or restart — the widening is re-detected from `information_schema` ' + + 'and is idempotent, so re-running is safe.', + { error: err instanceof Error ? err.message : String(err) }, + ); + } + } 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..3de39a13f77 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-top-level.ts @@ -0,0 +1,2419 @@ +// 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 { AggregationFunction, NUMERIC_VALUE_TYPES, isAcceptedFilterComparand, isMultiValueField, isNowDefaultToken } from '@objectstack/spec/data'; +import type { 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 { temporalStorageForm } 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; + +/** + * [#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] 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; +} + +/** + * 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.'; + +/** + * [#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)}`, + }; +} + +/** + * [#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 LIST_COMPARAND_OPERATORS}. + */ +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] Operators for which an ARRAY is the legitimate comparand, so it is + * each MEMBER that must be individually compilable. + * + * Scoping the member scan to these three is what keeps a scalar operator that + * received an array 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. + */ +const LIST_COMPARAND_OPERATORS: ReadonlySet = new Set(['$in', '$nin', '$between']); + +/** + * [#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); +} + +/** 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'; +} + +/** [#5240] Is this field spec the zero-operator constraint refused above? */ +function isEmptyFieldConstraint(spec: unknown): boolean { + return isFilterNode(spec) && Object.keys(spec).length === 0; +} + +/** + * [#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.' + ); +} + +/** + * [#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, FilterVerdict, + LIKE_PATTERN_OPERATORS, LIST_COMPARAND_OPERATORS, RowWidthContribution, SCALAR_COMPARAND_OPERATORS, + SQL_AGGREGATE_FUNCTIONS, SQL_WINDOW_FUNCTIONS, SQL_WINDOW_SORT_DIRECTIONS, SUPPORTED_FILTER_OPERATORS_SENTENCE, + SqlAggregateLowering, TEXT_OPERATORS, TEXT_PATTERN_OPERATORS, WITHHELD_FILTER_AUTHOR_TEXT, + WITHHELD_FILTER_DIAGNOSTIC, WITHHELD_FILTER_LOGGED, WITHHELD_FILTER_SUBTREE, canonicalTimeOfDay, + canonicalUtcDatetime, declaresColumnNotNull, describeFilterOperand, insertedRowsNotReadBackError, + invalidWindowFunctionQueryError, isBindableComparand, isDeclaredFieldType, isDeferredDdlLockWaitRefusal, + isEmptyFieldConstraint, isRenderableTextComparand, isUpsertFenceRefusal, nonBooleanFlagWithheldMessage, + normalizeSqliteDatetimeOutput, nowAnchorOf, presentAuditTimestampOutput, reclaimBetterSqlite3, refusalSubtree, + refuseAggregateFunction, refuseCrossRowIdentityMerge, refuseDateBucketedGroupBy, + refuseDistinctAggregateWithoutField, refuseRejectedReferenceAlias, refuseUndeclaredFieldType, safeShapePreview, + uniqueProbeNotNullClause, unsupportedAggregationFilterError, +}; 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 89b6a55f6ad..09bc80ffe62 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -220,148 +220,74 @@ import { // Turso's remote transport, `driver-memory` and `driver-mongodb`. import { findMisplacedReadableIds, refuseReadableIdsOnce } from '@objectstack/core'; import { isComposedReadableIds, type ComposedReadableIds } from '@objectstack/spec/data'; - -/** - * [#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; -} +// [#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, + LIKE_PATTERN_OPERATORS, LIST_COMPARAND_OPERATORS, MYSQL_DATETIME_PRECISION, PRE_DDL_QUESTION_SCOPE, + SCALAR_COMPARAND_OPERATORS, SEQUENCES_TABLE, SUPPORTED_FILTER_OPERATORS_SENTENCE, TEXT_OPERATORS, + TEXT_PATTERN_OPERATORS, WITHHELD_FILTER_AUTHOR_TEXT, WITHHELD_FILTER_DIAGNOSTIC, WITHHELD_FILTER_SUBTREE, + canonicalTimeOfDay, canonicalUtcDatetime, declaresColumnNotNull, describeFilterOperand, fieldReferenceOf, + isBindableComparand, isDeclaredFieldType, isEmptyFieldConstraint, isFilterNode, isMultiValuedColumn, + isRenderableTextComparand, isUnresolvableColumnError, mysqlDatetimeLiteral, nonBooleanFlagWithheldMessage, + nowAnchorOf, refusalSubtree, refuseRejectedReferenceAlias, refuseUndeclaredFieldType, resolveSqliteAbsentFileTarget, + safeShapePreview, withheldFilterDiagnosticOf, +} from './sql-driver-top-level.js'; +import type { + AutoNumberReservation, ConflictTargetVerdict, FilterVerdict, 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, + declaredValueShapes, fieldReferenceOf, isFilterNode, isMultiValuedColumn, isNowDefaultValue, + isUnresolvableColumnError, lastIdentifierSegment, mysqlDatetimeLiteral, postgresTableNameCollisionError, + rawStatementFaultError, refuseUnbackedConflictTarget, refuseUpsertConflictOutsideWrittenOrganization, + resolveSqliteAbsentFileTarget, unquoteSqlIdentifier, withheldFilterDiagnosticOf, +} 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, migrateMysqlDatetimeColumns, migrateMysqlTimeColumns, 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'; +// [#29520 codemod] Exported for the section modules above, which import them (lever (a)); `index.ts` re-exports by name, so none reaches the package. +export { assertCompilableComparand, assertDefinedComparands, emptyFieldConstraintError, filterArrayReachedDriverError }; // GLOBAL_TENANT ('__global__') — the sentinel for the NULL-organization // ("platform") bucket — is defined ONCE in schema-drift.ts and imported here: @@ -424,362 +350,6 @@ export const JSON_COLUMN_TYPES = new Set([ */ 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. @@ -866,201 +436,6 @@ export function unsupportedFilterError(message: string): Error { 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 @@ -1125,867 +500,6 @@ export function withheldFilterError( 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. * @@ -2085,15 +599,6 @@ function retiredFilterOperatorError( ); } -/** - * 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. * @@ -2262,61 +767,6 @@ function nulLikePatternError( ); } -/** - * [#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 @@ -2448,156 +898,15 @@ function bareFieldReferenceError(field: string, ref: string, subtree?: unknown): `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 LIST_COMPARAND_OPERATORS}. - */ -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] Operators for which an ARRAY is the legitimate comparand, so it is - * each MEMBER that must be individually compilable. - * - * Scoping the member scan to these three is what keeps a scalar operator that - * received an array 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. - */ -const LIST_COMPARAND_OPERATORS: ReadonlySet = new Set(['$in', '$nin', '$between']); - -/** - * [#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); + `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.`, + ); } /** @@ -2906,70 +1215,6 @@ function jsonColumnOperatorError( 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. * @@ -3048,11 +1293,6 @@ function emptyFieldConstraintError(field: string, path: string, subtree?: unknow ); } -/** [#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. @@ -3208,23 +1448,6 @@ function nonBooleanNullComparandError( ); } -/** - * [#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 ─────────────────────────────────────────── /** @@ -3716,540 +1939,6 @@ function classifyFilterKey( 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}`); - } -} - export class SqlDriver implements IDataDriver { // IDataDriver metadata public readonly name: string = 'com.objectstack.driver.sql'; @@ -5804,29 +3493,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); } /** @@ -6453,89 +4130,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); } /** @@ -6591,42 +4205,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); } /** @@ -6705,9 +4298,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 ────────────── @@ -6765,15 +4357,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); } /** @@ -7219,16 +4808,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); } /** @@ -7311,13 +4896,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); } /** @@ -7608,43 +5192,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); } /** @@ -8078,7 +5643,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, @@ -8086,24 +5651,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); } /** @@ -8161,7 +5716,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[], @@ -8171,32 +5726,14 @@ 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 @@ -8206,381 +5743,36 @@ 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 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; + 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,163 +5803,28 @@ export class SqlDriver implements IDataDriver { * rows, each through `formatOutput()` for read-back parity with * {@link create}. The annotation used to be an explicit `Promise`, which * 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, - ); - } + * contract's type now, pinned by `sql-driver-doors-declared-types.test.ts`. + */ + 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); } /** @@ -8831,25 +5888,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 { @@ -8911,45 +5957,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 { @@ -8966,39 +5990,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); } /** @@ -9099,37 +6109,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); } // =================================== @@ -9348,276 +6337,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); } /** @@ -9686,14 +6430,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); } /** @@ -9853,89 +6591,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); } /** @@ -10093,108 +6760,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); } // =================================== @@ -10222,78 +6801,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); } // =================================== @@ -10340,19 +6866,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) ───────────────────────────────── @@ -10651,16 +7173,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); } /** @@ -10777,120 +7291,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); } /** @@ -11628,61 +8068,22 @@ export class SqlDriver implements IDataDriver { fn((sql, bindings) => trx.raw(sql, (bindings ?? []) as any))); } - protected async migrateMysqlDatetimeColumns( + protected migrateMysqlDatetimeColumns( table: string, fields: Record, ): Promise { - if (!this.isMysql) return; - try { - const legacy = await this.legacyMysqlTimestampColumns(table, fields); - if (legacy.length === 0) return; - - // #9354: built as a list, then run as one unit, so the whole widening of a - // table shares the ONE pinned session the bounded lock wait needs. - const statements = legacy.map((col) => { - // The default is re-stated because MySQL drops a column's DEFAULT when - // MODIFY does not repeat it, and an audit column without - // `CURRENT_TIMESTAMP(3)` would start inserting NULL. - // - // A declared `defaultValue: 'NOW()'` column is the same case (#21241), - // and the TIME twin ({@link migrateMysqlTimeColumns}) already restates - // it. Measured on MySQL 8.0.46 before this line: a legacy - // `timestamp null default current_timestamp` NOW() column came out of - // the widening as `datetime(3)` with NO default, and an insert omitting - // it answered `null`. Both take the expression a fresh column gets, - // from {@link nowColumnDefault}, so the widening cannot spell a third. - const isAudit = (AUDIT_TIMESTAMP_COLUMNS as readonly string[]).includes(col.name); - const isNowDefault = isNowDefaultValue(fields[col.name]?.defaultValue); - const nullClause = col.nullable ? 'null' : 'not null'; - const defaultClause = isAudit || isNowDefault ? ` default ${this.nowColumnDefault('datetime').toString()}` : ''; - return { - sql: `alter table ?? modify column ?? datetime(${MYSQL_DATETIME_PRECISION}) ${nullClause}${defaultClause}`, - bindings: [table, col.name] as unknown[], - }; - }); - await this.runWideningAlters(table, statements); - this.logger.info?.( - `[sql-driver] widened MySQL TIMESTAMP → DATETIME(3) on ${table}: TIMESTAMP ends at 2038-01-19, ` + - `keeps no milliseconds and converts through the session timezone`, - { columns: legacy.map((c) => c.name) }, - ); - } catch (err) { - // #9354: the ONE failure that must not be swallowed. Everything else on - // this path keeps the policy the doc comment above describes; a metadata - // lock wait that the flush bounded is the operator's answer to a command - // they ran, and dropping it would report success for work not done. - if (isDeferredDdlLockWaitRefusal(err)) throw err; - this.logDurabilityFailure( - `[sql-driver] FAILED to widen MySQL datetime columns on ${table} — the DDL did not run and boot ` + - `continued: the columns are still legacy TIMESTAMP, so every write to them keeps truncating ` + - `milliseconds and keeps the 2038 ceiling, while reads return exactly what was stored and nothing ` + - `else reports the table as un-widened. Fix: identify the metadata-lock holder with ` + - '`SHOW PROCESSLIST` or `performance_schema.metadata_locks` (or resolve the error in this ' + - 'record\'s meta), end it, then re-run `os migrate apply` or restart — the widening is ' + - 're-detected from `information_schema` and is idempotent, so re-running is safe.', - { error: err instanceof Error ? err.message : String(err) }, - ); - } + // [#29520 codemod] The body moved to `sql-driver-rotation.ts` (see `RotationHost`). + const readIsMysql = () => this.isMysql; + const readLogger = () => this.logger; + const host: ThisParameterType = { + get isMysql() { return readIsMysql(); }, + legacyMysqlTimestampColumns: (...a) => this.legacyMysqlTimestampColumns(...a), + logDurabilityFailure: (...a) => this.logDurabilityFailure(...a), + get logger() { return readLogger(); }, + nowColumnDefault: (...a) => this.nowColumnDefault(...a), + runWideningAlters: (...a) => this.runWideningAlters(...a), + }; + return migrateMysqlDatetimeColumns.call(host, table, fields); } /** @@ -11731,53 +8132,21 @@ export class SqlDriver implements IDataDriver { * as un-widened, so the level IS the signal. Same reasoning, same level, on * {@link migrateMysqlDatetimeColumns}. */ - protected async migrateMysqlTimeColumns( + protected migrateMysqlTimeColumns( table: string, fields: Record, ): Promise { - if (!this.isMysql) return; - try { - const legacy = await this.legacyMysqlTimeColumns(table, fields); - if (legacy.length === 0) return; - - // #9354: the datetime twin's seam, for the same reason — this ALTER takes - // the same exclusive metadata lock and blocks on the same holder. - const statements = legacy.map((col) => { - // MODIFY drops a default it does not restate. A `defaultValue: 'NOW()'` - // column gets the canonical UTC expression default (`nowColumnDefault`); - // its legacy `current_timestamp()` default read the SESSION's zone, so - // dropping-and-replacing it is a fix, not collateral. - const isNowDefault = isNowDefaultValue(fields[col.name]?.defaultValue); - const defaultClause = isNowDefault ? ' default (cast(utc_timestamp(3) as time(3)))' : ''; - return { - sql: `alter table ?? modify column ?? time(3) ${col.nullable ? 'null' : 'not null'}${defaultClause}`, - bindings: [table, col.name] as unknown[], - }; - }); - await this.runWideningAlters(table, statements); - this.logger.info?.( - `[sql-driver] widened MySQL TIME → TIME(3) on ${table}: a zero-precision TIME rounds the ` + - `canonical milliseconds to the nearest second, changing the stored wall clock`, - { columns: legacy.map((c) => c.name) }, - ); - } catch (err) { - // #9354: the ONE failure that must not be swallowed. Everything else on - // this path keeps the policy the doc comment above describes; a metadata - // lock wait that the flush bounded is the operator's answer to a command - // they ran, and dropping it would report success for work not done. - if (isDeferredDdlLockWaitRefusal(err)) throw err; - this.logDurabilityFailure( - `[sql-driver] FAILED to widen MySQL time columns on ${table} — the DDL did not run and boot ` + - `continued: the columns are still zero-precision TIME, so every fractional-second write to them ` + - `keeps being ROUNDED to whole seconds — the stored wall clock is not the one that was asked for — ` + - `while reads return exactly what was stored and nothing else reports the table as un-widened. ` + - 'Fix: identify the metadata-lock holder with `SHOW PROCESSLIST` or ' + - '`performance_schema.metadata_locks` (or resolve the error in this record\'s meta), end it, then ' + - 're-run `os migrate apply` or restart — the widening is re-detected from `information_schema` ' + - 'and is idempotent, so re-running is safe.', - { error: err instanceof Error ? err.message : String(err) }, - ); - } + // [#29520 codemod] The body moved to `sql-driver-rotation.ts` (see `RotationHost`). + const readIsMysql = () => this.isMysql; + const readLogger = () => this.logger; + const host: ThisParameterType = { + get isMysql() { return readIsMysql(); }, + legacyMysqlTimeColumns: (...a) => this.legacyMysqlTimeColumns(...a), + logDurabilityFailure: (...a) => this.logDurabilityFailure(...a), + get logger() { return readLogger(); }, + runWideningAlters: (...a) => this.runWideningAlters(...a), + }; + return migrateMysqlTimeColumns.call(host, table, fields); } // ── Deferred schema DDL (#3917) ──────────────────────────────────────────── @@ -11992,11 +8361,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(); } /** @@ -12121,28 +8487,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); } /** @@ -12423,18 +8778,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); } /** @@ -12606,30 +8951,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); } /** @@ -12649,21 +8981,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); } /** @@ -12943,36 +9273,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); } /** @@ -13015,13 +9320,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); } /** @@ -13061,42 +9364,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 ────────────────────────────────────────────────────────── @@ -13107,9 +9384,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); } /** @@ -13169,10 +9445,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); } /** @@ -13212,16 +9486,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); } /** @@ -13286,11 +9558,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); } /** @@ -13346,11 +9615,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); } /** @@ -13382,15 +9648,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); } /** @@ -13452,20 +9711,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); } /** @@ -13505,41 +9763,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); } /** @@ -13555,14 +9784,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); } /** @@ -13605,19 +9831,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); } /** @@ -14113,22 +10334,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); } /** @@ -14160,92 +10371,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); } /** @@ -15152,9 +11288,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); } /** @@ -15239,42 +11374,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); } /** @@ -15320,9 +11428,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 ───────────────────────────────────────────────── @@ -15336,41 +11443,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 ────────────────────────────────────────────────── @@ -16244,25 +12323,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); } /** @@ -16486,22 +12557,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); } /** @@ -16632,21 +12694,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); } /** @@ -17286,19 +13346,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() { @@ -17578,478 +13635,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 ───────────────────────────────────────────────── From 20f49a9da05e5901cda615d9e11d866255e7ded4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:34:16 +0000 Subject: [PATCH 06/20] feat(scripts): check-tenant-chokepoint reads every sql-driver-*.ts seam module SCAN_FILES widens to the non-test sql-driver-*.ts modules of driver-sql/src, listed from the directory (ruling 6591753301), and a module-level function is read as a class member is, under its own name, so a moved body is still a door. The gate prints its readings (bindings, read doors, tenant predicates, D8 arms) on one line. The watch hints declare each seam module by name; the self-test holds them to the listing both ways and gains a moved-bodies battery. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2gLghEqeDKTbDTC7Zd --- scripts/check-tenant-chokepoint.mjs | 149 ++++++++++++++++++++++++++-- 1 file changed, 142 insertions(+), 7 deletions(-) diff --git a/scripts/check-tenant-chokepoint.mjs b/scripts/check-tenant-chokepoint.mjs index 7d69ddee0f1..66de1bf7900 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,35 @@ import { isEntrypoint } from './invoked-as.mjs'; const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); -/** The `SqlDriver` family — every class that inherits this chokepoint. */ +/** The directory whose `sql-driver-*.ts` seam modules carry `SqlDriver`'s moved bodies. */ +const SEAM_DIR = join('packages', 'drivers', 'driver-sql', 'src'); + +/** + * Which basenames of {@link SEAM_DIR} 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(); +} + +/** + * 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(existsSync(join(ROOT, SEAM_DIR)) ? readdirSync(join(ROOT, SEAM_DIR)) : []).map((n) => join(SEAM_DIR, n)), ]; /** @@ -181,9 +213,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 +224,13 @@ 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. 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 until it is declared here (#29520). * * 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 +242,36 @@ 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-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 +647,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 +713,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 +725,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,6 +1051,50 @@ 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('/')); @@ -1056,7 +1179,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 +1198,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 +1284,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( From a5515ac0258eb79061f08c3440a26f2c2304d70e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:58:55 +0000 Subject: [PATCH 07/20] fix(scripts): the leaf's header is line comments, so the bundled .d.ts carries none of it The bundler keeps a JSDoc block that leads a module's first statement, and the leaf is bundled first, so its /** */ header was published as 14 lines the base dist/index.d.ts did not carry. The header is // lines now; the self-test holds that. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2gLghEqeDKTbDTC7Zd --- scripts/codemod-split-sql-driver.mjs | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/scripts/codemod-split-sql-driver.mjs b/scripts/codemod-split-sql-driver.mjs index e4bf9c460d6..731952be4b7 100644 --- a/scripts/codemod-split-sql-driver.mjs +++ b/scripts/codemod-split-sql-driver.mjs @@ -1075,21 +1075,23 @@ function moduleText(moduleFile, title, movers, scope, leafNames) { * 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('/**'); 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).'])); - 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(' */'); + '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'); @@ -1236,7 +1238,7 @@ function verify(tm, baseText, headText, moduleTexts, { readSibling = null } = {} 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', text.indexOf(MARK)) + ' */\n'.length; + const headerEnd = text.indexOf('\n\n', text.indexOf(MARK)) + 1; stmts.forEach((st, i) => { if (Node.isImportDeclaration(st)) { if (/^\.\/sql-driver/.test(st.getModuleSpecifierValue())) { @@ -1627,7 +1629,7 @@ let selfTestReachedVerdict = false; const SELF_TEST_BATTERIES = Object.freeze({ 'declaration bar': 6, 'stay rules': 11, - 'top-level moves': 9, + 'top-level moves': 10, 'byte-identity proof': 10, 'refusals': 3, }); @@ -1823,6 +1825,8 @@ async function selfTest() { 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)); From db0a766dfe78197e140be2dce9b9b96ec104ccd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:58:55 +0000 Subject: [PATCH 08/20] refactor(driver-sql): regenerate the split after the leaf header change Output of `node scripts/codemod-split-sql-driver.mjs --write` on sql-driver.ts at origin/main b391feb1d1 (input sha256 e73b1ce952e1), unedited. Only sql-driver-top-level.ts's header changes; output tree sha256 5187eba96f64, the same on two runs. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2gLghEqeDKTbDTC7Zd --- .../driver-sql/src/sql-driver-top-level.ts | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver-top-level.ts b/packages/drivers/driver-sql/src/sql-driver-top-level.ts index 3de39a13f77..2eaa07f5c62 100644 --- a/packages/drivers/driver-sql/src/sql-driver-top-level.ts +++ b/packages/drivers/driver-sql/src/sql-driver-top-level.ts @@ -1,17 +1,15 @@ // 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. - */ +// [#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 { AggregationFunction, NUMERIC_VALUE_TYPES, isAcceptedFilterComparand, isMultiValueField, isNowDefaultToken } from '@objectstack/spec/data'; import type { SchemaMode, FilterVerdict as SharedFilterVerdict, ValueShapeFieldDef } from '@objectstack/spec/data'; From 8b8f32d279e6ded0fe3f3c80c3af856b5b681e7d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 01:26:49 +0000 Subject: [PATCH 09/20] fix(scripts): keep the two widening-ALTER bodies where the durability gate resolves their log helper check-durability-degradation-log-level follows same-file helpers, so a moved catch whose only log is this.logDurabilityFailure(...) read as swallowed (measured: migrateMysqlDatetimeColumns and migrateMysqlTimeColumns red). PATH_READER_NEEDLES gains this.runWideningAlters( with that reader. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2gLghEqeDKTbDTC7Zd --- scripts/codemod-split-sql-driver.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/codemod-split-sql-driver.mjs b/scripts/codemod-split-sql-driver.mjs index 731952be4b7..ba8358d4575 100644 --- a/scripts/codemod-split-sql-driver.mjs +++ b/scripts/codemod-split-sql-driver.mjs @@ -210,6 +210,12 @@ const PATH_READER_NEEDLES = Object.freeze({ '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', }); /** From 1cc66c934986c57c4091cb4a0cfc4d9954e0987d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 01:26:49 +0000 Subject: [PATCH 10/20] refactor(driver-sql): regenerate the split with the durability-gate pin Output of `node scripts/codemod-split-sql-driver.mjs --write` on sql-driver.ts at origin/main b391feb1d1 (input sha256 e73b1ce952e1), unedited: 54 method bodies and 97 top-level declarations move; sql-driver.ts 18333 -> 14042 lines. Output tree sha256 f4dc8078d9b7, the same on two runs. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2gLghEqeDKTbDTC7Zd --- .../driver-sql/src/sql-driver-rotation.ts | 115 +-------------- packages/drivers/driver-sql/src/sql-driver.ts | 131 ++++++++++++++---- 2 files changed, 103 insertions(+), 143 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver-rotation.ts b/packages/drivers/driver-sql/src/sql-driver-rotation.ts index 4122b83c4a0..c56204c7d09 100644 --- a/packages/drivers/driver-sql/src/sql-driver-rotation.ts +++ b/packages/drivers/driver-sql/src/sql-driver-rotation.ts @@ -3,8 +3,7 @@ /** * [#29520 codemod] The "Data-lifecycle rotation (ADR-0057 P2)" section of * `SqlDriver`: the bodies of `computeTenantField`, - * `registerManagedObjectMetadata`, `migrateMysqlDatetimeColumns`, - * `migrateMysqlTimeColumns` moved out of `sql-driver.ts` whole by + * `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` @@ -30,8 +29,7 @@ import type { AutonumberToken } from '@objectstack/spec/data'; import { aggregandColumnClass } from '@objectstack/core'; import { StorageNameMapping } from '@objectstack/spec/system'; import { - AUDIT_TIMESTAMP_COLUMNS, MYSQL_DATETIME_PRECISION, NUMERIC_SCALAR_TYPES, declaredValueShapes, - isDeferredDdlLockWaitRefusal, isMultiValuedColumn, isNowDefaultValue, + AUDIT_TIMESTAMP_COLUMNS, NUMERIC_SCALAR_TYPES, declaredValueShapes, isMultiValuedColumn, } from './sql-driver-top-level.js'; import type { SqlDriver } from './sql-driver.js'; @@ -49,20 +47,13 @@ export interface RotationHost { readonly datetimeFields: SqlDriver['datetimeFields']; readonly fractionalNumericFields: SqlDriver['fractionalNumericFields']; isJsonField: SqlDriver['isJsonField']; - readonly isMysql: SqlDriver['isMysql']; readonly jsonFields: SqlDriver['jsonFields']; - legacyMysqlTimeColumns: SqlDriver['legacyMysqlTimeColumns']; - legacyMysqlTimestampColumns: SqlDriver['legacyMysqlTimestampColumns']; - logDurabilityFailure: SqlDriver['logDurabilityFailure']; - readonly logger: SqlDriver['logger']; readonly managedObjectFields: SqlDriver['managedObjectFields']; readonly managedObjectIndexes: SqlDriver['managedObjectIndexes']; readonly mediaFields: SqlDriver['mediaFields']; - nowColumnDefault: SqlDriver['nowColumnDefault']; readonly numericFields: SqlDriver['numericFields']; readonly numericValueFields: SqlDriver['numericValueFields']; readonly physicalKeyIndexes: SqlDriver['physicalKeyIndexes']; - runWideningAlters: SqlDriver['runWideningAlters']; readonly tenantFieldByTable: SqlDriver['tenantFieldByTable']; readonly timeFields: SqlDriver['timeFields']; readonly undeclaredAuditTimestampFields: SqlDriver['undeclaredAuditTimestampFields']; @@ -201,105 +192,3 @@ export function registerManagedObjectMetadata(this: Pick, table: string, fields: Record): Promise { - if (!this.isMysql) return; - try { - const legacy = await this.legacyMysqlTimestampColumns(table, fields); - if (legacy.length === 0) return; - - // #9354: built as a list, then run as one unit, so the whole widening of a - // table shares the ONE pinned session the bounded lock wait needs. - const statements = legacy.map((col) => { - // The default is re-stated because MySQL drops a column's DEFAULT when - // MODIFY does not repeat it, and an audit column without - // `CURRENT_TIMESTAMP(3)` would start inserting NULL. - // - // A declared `defaultValue: 'NOW()'` column is the same case (#21241), - // and the TIME twin ({@link migrateMysqlTimeColumns}) already restates - // it. Measured on MySQL 8.0.46 before this line: a legacy - // `timestamp null default current_timestamp` NOW() column came out of - // the widening as `datetime(3)` with NO default, and an insert omitting - // it answered `null`. Both take the expression a fresh column gets, - // from {@link nowColumnDefault}, so the widening cannot spell a third. - const isAudit = (AUDIT_TIMESTAMP_COLUMNS as readonly string[]).includes(col.name); - const isNowDefault = isNowDefaultValue(fields[col.name]?.defaultValue); - const nullClause = col.nullable ? 'null' : 'not null'; - const defaultClause = isAudit || isNowDefault ? ` default ${this.nowColumnDefault('datetime').toString()}` : ''; - return { - sql: `alter table ?? modify column ?? datetime(${MYSQL_DATETIME_PRECISION}) ${nullClause}${defaultClause}`, - bindings: [table, col.name] as unknown[], - }; - }); - await this.runWideningAlters(table, statements); - this.logger.info?.( - `[sql-driver] widened MySQL TIMESTAMP → DATETIME(3) on ${table}: TIMESTAMP ends at 2038-01-19, ` + - `keeps no milliseconds and converts through the session timezone`, - { columns: legacy.map((c) => c.name) }, - ); - } catch (err) { - // #9354: the ONE failure that must not be swallowed. Everything else on - // this path keeps the policy the doc comment above describes; a metadata - // lock wait that the flush bounded is the operator's answer to a command - // they ran, and dropping it would report success for work not done. - if (isDeferredDdlLockWaitRefusal(err)) throw err; - this.logDurabilityFailure( - `[sql-driver] FAILED to widen MySQL datetime columns on ${table} — the DDL did not run and boot ` + - `continued: the columns are still legacy TIMESTAMP, so every write to them keeps truncating ` + - `milliseconds and keeps the 2038 ceiling, while reads return exactly what was stored and nothing ` + - `else reports the table as un-widened. Fix: identify the metadata-lock holder with ` + - '`SHOW PROCESSLIST` or `performance_schema.metadata_locks` (or resolve the error in this ' + - 'record\'s meta), end it, then re-run `os migrate apply` or restart — the widening is ' + - 're-detected from `information_schema` and is idempotent, so re-running is safe.', - { error: err instanceof Error ? err.message : String(err) }, - ); - } - } - -/** The body of `SqlDriver#migrateMysqlTimeColumns`; the method's docblock there is its contract. */ -export async function migrateMysqlTimeColumns(this: Pick, table: string, fields: Record): Promise { - if (!this.isMysql) return; - try { - const legacy = await this.legacyMysqlTimeColumns(table, fields); - if (legacy.length === 0) return; - - // #9354: the datetime twin's seam, for the same reason — this ALTER takes - // the same exclusive metadata lock and blocks on the same holder. - const statements = legacy.map((col) => { - // MODIFY drops a default it does not restate. A `defaultValue: 'NOW()'` - // column gets the canonical UTC expression default (`nowColumnDefault`); - // its legacy `current_timestamp()` default read the SESSION's zone, so - // dropping-and-replacing it is a fix, not collateral. - const isNowDefault = isNowDefaultValue(fields[col.name]?.defaultValue); - const defaultClause = isNowDefault ? ' default (cast(utc_timestamp(3) as time(3)))' : ''; - return { - sql: `alter table ?? modify column ?? time(3) ${col.nullable ? 'null' : 'not null'}${defaultClause}`, - bindings: [table, col.name] as unknown[], - }; - }); - await this.runWideningAlters(table, statements); - this.logger.info?.( - `[sql-driver] widened MySQL TIME → TIME(3) on ${table}: a zero-precision TIME rounds the ` + - `canonical milliseconds to the nearest second, changing the stored wall clock`, - { columns: legacy.map((c) => c.name) }, - ); - } catch (err) { - // #9354: the ONE failure that must not be swallowed. Everything else on - // this path keeps the policy the doc comment above describes; a metadata - // lock wait that the flush bounded is the operator's answer to a command - // they ran, and dropping it would report success for work not done. - if (isDeferredDdlLockWaitRefusal(err)) throw err; - this.logDurabilityFailure( - `[sql-driver] FAILED to widen MySQL time columns on ${table} — the DDL did not run and boot ` + - `continued: the columns are still zero-precision TIME, so every fractional-second write to them ` + - `keeps being ROUNDED to whole seconds — the stored wall clock is not the one that was asked for — ` + - `while reads return exactly what was stored and nothing else reports the table as un-widened. ` + - 'Fix: identify the metadata-lock holder with `SHOW PROCESSLIST` or ' + - '`performance_schema.metadata_locks` (or resolve the error in this record\'s meta), end it, then ' + - 're-run `os migrate apply` or restart — the widening is re-detected from `information_schema` ' + - 'and is idempotent, so re-running is safe.', - { error: err instanceof Error ? err.message : String(err) }, - ); - } - } diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 09bc80ffe62..48b4a4133d2 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -227,10 +227,10 @@ import { SCALAR_COMPARAND_OPERATORS, SEQUENCES_TABLE, SUPPORTED_FILTER_OPERATORS_SENTENCE, TEXT_OPERATORS, TEXT_PATTERN_OPERATORS, WITHHELD_FILTER_AUTHOR_TEXT, WITHHELD_FILTER_DIAGNOSTIC, WITHHELD_FILTER_SUBTREE, canonicalTimeOfDay, canonicalUtcDatetime, declaresColumnNotNull, describeFilterOperand, fieldReferenceOf, - isBindableComparand, isDeclaredFieldType, isEmptyFieldConstraint, isFilterNode, isMultiValuedColumn, - isRenderableTextComparand, isUnresolvableColumnError, mysqlDatetimeLiteral, nonBooleanFlagWithheldMessage, - nowAnchorOf, refusalSubtree, refuseRejectedReferenceAlias, refuseUndeclaredFieldType, resolveSqliteAbsentFileTarget, - safeShapePreview, withheldFilterDiagnosticOf, + isBindableComparand, isDeclaredFieldType, isDeferredDdlLockWaitRefusal, isEmptyFieldConstraint, isFilterNode, + isMultiValuedColumn, isNowDefaultValue, isRenderableTextComparand, isUnresolvableColumnError, mysqlDatetimeLiteral, + nonBooleanFlagWithheldMessage, nowAnchorOf, refusalSubtree, refuseRejectedReferenceAlias, refuseUndeclaredFieldType, + resolveSqliteAbsentFileTarget, safeShapePreview, withheldFilterDiagnosticOf, } from './sql-driver-top-level.js'; import type { AutoNumberReservation, ConflictTargetVerdict, FilterVerdict, IntrospectedColumn, IntrospectedForeignKey, @@ -269,7 +269,7 @@ 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, migrateMysqlDatetimeColumns, migrateMysqlTimeColumns, registerManagedObjectMetadata } from './sql-driver-rotation.js'; +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`). @@ -8068,22 +8068,61 @@ export class SqlDriver implements IDataDriver { fn((sql, bindings) => trx.raw(sql, (bindings ?? []) as any))); } - protected migrateMysqlDatetimeColumns( + protected async migrateMysqlDatetimeColumns( table: string, fields: Record, ): Promise { - // [#29520 codemod] The body moved to `sql-driver-rotation.ts` (see `RotationHost`). - const readIsMysql = () => this.isMysql; - const readLogger = () => this.logger; - const host: ThisParameterType = { - get isMysql() { return readIsMysql(); }, - legacyMysqlTimestampColumns: (...a) => this.legacyMysqlTimestampColumns(...a), - logDurabilityFailure: (...a) => this.logDurabilityFailure(...a), - get logger() { return readLogger(); }, - nowColumnDefault: (...a) => this.nowColumnDefault(...a), - runWideningAlters: (...a) => this.runWideningAlters(...a), - }; - return migrateMysqlDatetimeColumns.call(host, table, fields); + if (!this.isMysql) return; + try { + const legacy = await this.legacyMysqlTimestampColumns(table, fields); + if (legacy.length === 0) return; + + // #9354: built as a list, then run as one unit, so the whole widening of a + // table shares the ONE pinned session the bounded lock wait needs. + const statements = legacy.map((col) => { + // The default is re-stated because MySQL drops a column's DEFAULT when + // MODIFY does not repeat it, and an audit column without + // `CURRENT_TIMESTAMP(3)` would start inserting NULL. + // + // A declared `defaultValue: 'NOW()'` column is the same case (#21241), + // and the TIME twin ({@link migrateMysqlTimeColumns}) already restates + // it. Measured on MySQL 8.0.46 before this line: a legacy + // `timestamp null default current_timestamp` NOW() column came out of + // the widening as `datetime(3)` with NO default, and an insert omitting + // it answered `null`. Both take the expression a fresh column gets, + // from {@link nowColumnDefault}, so the widening cannot spell a third. + const isAudit = (AUDIT_TIMESTAMP_COLUMNS as readonly string[]).includes(col.name); + const isNowDefault = isNowDefaultValue(fields[col.name]?.defaultValue); + const nullClause = col.nullable ? 'null' : 'not null'; + const defaultClause = isAudit || isNowDefault ? ` default ${this.nowColumnDefault('datetime').toString()}` : ''; + return { + sql: `alter table ?? modify column ?? datetime(${MYSQL_DATETIME_PRECISION}) ${nullClause}${defaultClause}`, + bindings: [table, col.name] as unknown[], + }; + }); + await this.runWideningAlters(table, statements); + this.logger.info?.( + `[sql-driver] widened MySQL TIMESTAMP → DATETIME(3) on ${table}: TIMESTAMP ends at 2038-01-19, ` + + `keeps no milliseconds and converts through the session timezone`, + { columns: legacy.map((c) => c.name) }, + ); + } catch (err) { + // #9354: the ONE failure that must not be swallowed. Everything else on + // this path keeps the policy the doc comment above describes; a metadata + // lock wait that the flush bounded is the operator's answer to a command + // they ran, and dropping it would report success for work not done. + if (isDeferredDdlLockWaitRefusal(err)) throw err; + this.logDurabilityFailure( + `[sql-driver] FAILED to widen MySQL datetime columns on ${table} — the DDL did not run and boot ` + + `continued: the columns are still legacy TIMESTAMP, so every write to them keeps truncating ` + + `milliseconds and keeps the 2038 ceiling, while reads return exactly what was stored and nothing ` + + `else reports the table as un-widened. Fix: identify the metadata-lock holder with ` + + '`SHOW PROCESSLIST` or `performance_schema.metadata_locks` (or resolve the error in this ' + + 'record\'s meta), end it, then re-run `os migrate apply` or restart — the widening is ' + + 're-detected from `information_schema` and is idempotent, so re-running is safe.', + { error: err instanceof Error ? err.message : String(err) }, + ); + } } /** @@ -8132,21 +8171,53 @@ export class SqlDriver implements IDataDriver { * as un-widened, so the level IS the signal. Same reasoning, same level, on * {@link migrateMysqlDatetimeColumns}. */ - protected migrateMysqlTimeColumns( + protected async migrateMysqlTimeColumns( table: string, fields: Record, ): Promise { - // [#29520 codemod] The body moved to `sql-driver-rotation.ts` (see `RotationHost`). - const readIsMysql = () => this.isMysql; - const readLogger = () => this.logger; - const host: ThisParameterType = { - get isMysql() { return readIsMysql(); }, - legacyMysqlTimeColumns: (...a) => this.legacyMysqlTimeColumns(...a), - logDurabilityFailure: (...a) => this.logDurabilityFailure(...a), - get logger() { return readLogger(); }, - runWideningAlters: (...a) => this.runWideningAlters(...a), - }; - return migrateMysqlTimeColumns.call(host, table, fields); + if (!this.isMysql) return; + try { + const legacy = await this.legacyMysqlTimeColumns(table, fields); + if (legacy.length === 0) return; + + // #9354: the datetime twin's seam, for the same reason — this ALTER takes + // the same exclusive metadata lock and blocks on the same holder. + const statements = legacy.map((col) => { + // MODIFY drops a default it does not restate. A `defaultValue: 'NOW()'` + // column gets the canonical UTC expression default (`nowColumnDefault`); + // its legacy `current_timestamp()` default read the SESSION's zone, so + // dropping-and-replacing it is a fix, not collateral. + const isNowDefault = isNowDefaultValue(fields[col.name]?.defaultValue); + const defaultClause = isNowDefault ? ' default (cast(utc_timestamp(3) as time(3)))' : ''; + return { + sql: `alter table ?? modify column ?? time(3) ${col.nullable ? 'null' : 'not null'}${defaultClause}`, + bindings: [table, col.name] as unknown[], + }; + }); + await this.runWideningAlters(table, statements); + this.logger.info?.( + `[sql-driver] widened MySQL TIME → TIME(3) on ${table}: a zero-precision TIME rounds the ` + + `canonical milliseconds to the nearest second, changing the stored wall clock`, + { columns: legacy.map((c) => c.name) }, + ); + } catch (err) { + // #9354: the ONE failure that must not be swallowed. Everything else on + // this path keeps the policy the doc comment above describes; a metadata + // lock wait that the flush bounded is the operator's answer to a command + // they ran, and dropping it would report success for work not done. + if (isDeferredDdlLockWaitRefusal(err)) throw err; + this.logDurabilityFailure( + `[sql-driver] FAILED to widen MySQL time columns on ${table} — the DDL did not run and boot ` + + `continued: the columns are still zero-precision TIME, so every fractional-second write to them ` + + `keeps being ROUNDED to whole seconds — the stored wall clock is not the one that was asked for — ` + + `while reads return exactly what was stored and nothing else reports the table as un-widened. ` + + 'Fix: identify the metadata-lock holder with `SHOW PROCESSLIST` or ' + + '`performance_schema.metadata_locks` (or resolve the error in this record\'s meta), end it, then ' + + 're-run `os migrate apply` or restart — the widening is re-detected from `information_schema` ' + + 'and is idempotent, so re-running is safe.', + { error: err instanceof Error ? err.message : String(err) }, + ); + } } // ── Deferred schema DDL (#3917) ──────────────────────────────────────────── From 5e2cef71b6ae1f496cedaab2f3105460777cee90 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 01:26:49 +0000 Subject: [PATCH 11/20] fix(scripts): list the chokepoint seam directory inside SCAN_FILES A SEAM_DIR constant read to bare-root-worklist as a new invisible bare-root population; the gate already declares every file it reads, one by one, in DECLARED_WATCH_HINTS, so the directory is spelled inline as the three driver files are. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2gLghEqeDKTbDTC7Zd --- scripts/check-tenant-chokepoint.mjs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/scripts/check-tenant-chokepoint.mjs b/scripts/check-tenant-chokepoint.mjs index 66de1bf7900..76aaf0f9e13 100644 --- a/scripts/check-tenant-chokepoint.mjs +++ b/scripts/check-tenant-chokepoint.mjs @@ -175,11 +175,8 @@ import { isEntrypoint } from './invoked-as.mjs'; const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); -/** The directory whose `sql-driver-*.ts` seam modules carry `SqlDriver`'s moved bodies. */ -const SEAM_DIR = join('packages', 'drivers', 'driver-sql', 'src'); - /** - * Which basenames of {@link SEAM_DIR} are seam modules this gate reads: + * 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. */ @@ -189,6 +186,11 @@ export function seamModulesOf(basenames) { .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, @@ -203,7 +205,8 @@ 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(existsSync(join(ROOT, SEAM_DIR)) ? readdirSync(join(ROOT, SEAM_DIR)) : []).map((n) => join(SEAM_DIR, n)), + ...seamModulesOf(listing(join('packages', 'drivers', 'driver-sql', 'src'))) + .map((n) => join('packages', 'drivers', 'driver-sql', 'src', n)), ]; /** From 94bd4c0e8389a74985bdd96b8416c4b535df9c00 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 02:12:47 +0000 Subject: [PATCH 12/20] refactor(driver-sql): regenerate the split on origin/main 2ea604223a Output of `node scripts/codemod-split-sql-driver.mjs --write` on sql-driver.ts at origin/main 2ea604223a (input sha256 9abf7867fd98; #29660 touched it), unedited: 54 method bodies move to 18 section modules and 96 of the 129 top-level declarations ahead of the class to sql-driver-top-level.ts; sql-driver.ts 18330 -> 14050 lines. Output tree sha256 ab8990700a0d, the same on two runs. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2gLghEqeDKTbDTC7Zd --- .../driver-sql/src/sql-driver-aggregation.ts | 344 ++ .../src/sql-driver-bulk-operations.ts | 354 ++ .../src/sql-driver-column-creation.ts | 89 + .../drivers/driver-sql/src/sql-driver-crud.ts | 185 + .../src/sql-driver-database-helpers.ts | 55 + .../driver-sql/src/sql-driver-distinct.ts | 129 + .../src/sql-driver-field-mapping.ts | 95 + .../src/sql-driver-filter-helpers.ts | 329 + .../src/sql-driver-internal-helpers.ts | 126 + .../driver-sql/src/sql-driver-lifecycle.ts | 66 + .../src/sql-driver-managed-drift.ts | 131 + .../driver-sql/src/sql-driver-query-plan.ts | 121 + .../driver-sql/src/sql-driver-rotation.ts | 194 + .../src/sql-driver-schema-sync-section.ts | 55 + .../src/sql-driver-sequence-semantics.ts | 586 ++ .../src/sql-driver-sqlite-serialisation.ts | 531 ++ .../driver-sql/src/sql-driver-top-level.ts | 2406 ++++++++ .../src/sql-driver-window-function-builder.ts | 80 + .../src/sql-driver-window-functions.ts | 150 + packages/drivers/driver-sql/src/sql-driver.ts | 5490 ++--------------- 20 files changed, 6631 insertions(+), 4885 deletions(-) create mode 100644 packages/drivers/driver-sql/src/sql-driver-aggregation.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-bulk-operations.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-column-creation.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-crud.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-database-helpers.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-distinct.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-field-mapping.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-internal-helpers.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-lifecycle.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-managed-drift.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-query-plan.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-rotation.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-schema-sync-section.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-sequence-semantics.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-sqlite-serialisation.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-top-level.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-window-function-builder.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-window-functions.ts 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-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..c6c76998996 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts @@ -0,0 +1,329 @@ +// 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. + * + * Values that stayed in `sql-driver.ts` are imported from it (lever (a) of + * ruling 6591753301); every use is inside a function body, so the import + * cycle is never read at evaluation time. `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, canonicalTimeOfDay, isEmptyFieldConstraint, + normalizeSqliteDatetimeOutput, presentAuditTimestampOutput, refusalSubtree, withheldFilterDiagnosticOf, +} from './sql-driver-top-level.js'; +import type { ReadPresentationKind } from './sql-driver-top-level.js'; +import { + assertCompilableComparand, assertDefinedComparands, emptyFieldConstraintError, filterArrayReachedDriverError, +} from './sql-driver.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..e98ea7c3a32 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-top-level.ts @@ -0,0 +1,2406 @@ +// 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 { AggregationFunction, NUMERIC_VALUE_TYPES, isAcceptedFilterComparand, isMultiValueField, isNowDefaultToken } from '@objectstack/spec/data'; +import type { 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 { temporalStorageForm } 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; + +/** + * [#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] 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; +} + +/** + * 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.'; + +/** + * [#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)}`, + }; +} + +/** + * [#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); +} + +/** 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'; +} + +/** [#5240] Is this field spec the zero-operator constraint refused above? */ +function isEmptyFieldConstraint(spec: unknown): boolean { + return isFilterNode(spec) && Object.keys(spec).length === 0; +} + +/** + * [#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.' + ); +} + +/** + * [#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, FilterVerdict, + LIKE_PATTERN_OPERATORS, RowWidthContribution, SCALAR_COMPARAND_OPERATORS, SQL_AGGREGATE_FUNCTIONS, + SQL_WINDOW_FUNCTIONS, SQL_WINDOW_SORT_DIRECTIONS, SUPPORTED_FILTER_OPERATORS_SENTENCE, SqlAggregateLowering, + TEXT_OPERATORS, TEXT_PATTERN_OPERATORS, WITHHELD_FILTER_AUTHOR_TEXT, WITHHELD_FILTER_DIAGNOSTIC, + WITHHELD_FILTER_LOGGED, WITHHELD_FILTER_SUBTREE, canonicalTimeOfDay, canonicalUtcDatetime, declaresColumnNotNull, + describeFilterOperand, insertedRowsNotReadBackError, invalidWindowFunctionQueryError, isBindableComparand, + isDeclaredFieldType, isDeferredDdlLockWaitRefusal, isEmptyFieldConstraint, isRenderableTextComparand, + isUpsertFenceRefusal, nonBooleanFlagWithheldMessage, normalizeSqliteDatetimeOutput, nowAnchorOf, + presentAuditTimestampOutput, reclaimBetterSqlite3, refusalSubtree, refuseAggregateFunction, + refuseCrossRowIdentityMerge, refuseDateBucketedGroupBy, refuseDistinctAggregateWithoutField, + refuseRejectedReferenceAlias, refuseUndeclaredFieldType, safeShapePreview, uniqueProbeNotNullClause, + unsupportedAggregationFilterError, +}; 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 46b65c0e893..8140deb0241 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -225,148 +225,74 @@ import { // Turso's remote transport, `driver-memory` and `driver-mongodb`. import { findMisplacedReadableIds, refuseReadableIdsOnce } from '@objectstack/core'; import { isComposedReadableIds, type ComposedReadableIds } from '@objectstack/spec/data'; - -/** - * [#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; -} +// [#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, + LIKE_PATTERN_OPERATORS, MYSQL_DATETIME_PRECISION, PRE_DDL_QUESTION_SCOPE, SCALAR_COMPARAND_OPERATORS, + SEQUENCES_TABLE, SUPPORTED_FILTER_OPERATORS_SENTENCE, TEXT_OPERATORS, TEXT_PATTERN_OPERATORS, + WITHHELD_FILTER_AUTHOR_TEXT, WITHHELD_FILTER_DIAGNOSTIC, WITHHELD_FILTER_SUBTREE, canonicalTimeOfDay, + canonicalUtcDatetime, declaresColumnNotNull, describeFilterOperand, fieldReferenceOf, isBindableComparand, + isDeclaredFieldType, isDeferredDdlLockWaitRefusal, isEmptyFieldConstraint, isFilterNode, isMultiValuedColumn, + isNowDefaultValue, isRenderableTextComparand, isUnresolvableColumnError, mysqlDatetimeLiteral, + nonBooleanFlagWithheldMessage, nowAnchorOf, refusalSubtree, refuseRejectedReferenceAlias, refuseUndeclaredFieldType, + resolveSqliteAbsentFileTarget, safeShapePreview, withheldFilterDiagnosticOf, +} from './sql-driver-top-level.js'; +import type { + AutoNumberReservation, ConflictTargetVerdict, FilterVerdict, 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, + declaredValueShapes, fieldReferenceOf, isFilterNode, isMultiValuedColumn, isNowDefaultValue, + isUnresolvableColumnError, lastIdentifierSegment, mysqlDatetimeLiteral, postgresTableNameCollisionError, + rawStatementFaultError, refuseUnbackedConflictTarget, refuseUpsertConflictOutsideWrittenOrganization, + resolveSqliteAbsentFileTarget, unquoteSqlIdentifier, withheldFilterDiagnosticOf, +} 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'; +// [#29520 codemod] Exported for the section modules above, which import them (lever (a)); `index.ts` re-exports by name, so none reaches the package. +export { assertCompilableComparand, assertDefinedComparands, emptyFieldConstraintError, filterArrayReachedDriverError }; // GLOBAL_TENANT ('__global__') — the sentinel for the NULL-organization // ("platform") bucket — is defined ONCE in schema-drift.ts and imported here: @@ -429,362 +355,6 @@ export const JSON_COLUMN_TYPES = new Set([ */ 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. @@ -871,201 +441,6 @@ export function unsupportedFilterError(message: string): Error { 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 @@ -1130,867 +505,6 @@ export function withheldFilterError( 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. * @@ -2090,15 +604,6 @@ function retiredFilterOperatorError( ); } -/** - * 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. * @@ -2260,66 +765,11 @@ function nulLikePatternError( `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)}`, - }; + `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, + ); } /** @@ -2464,136 +914,6 @@ function bareFieldReferenceError(field: string, ref: string, subtree?: unknown): ); } -/** - * [#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: @@ -2903,70 +1223,6 @@ function jsonColumnOperatorError( 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. * @@ -3045,11 +1301,6 @@ function emptyFieldConstraintError(field: string, path: string, subtree?: unknow ); } -/** [#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. @@ -3205,23 +1456,6 @@ function nonBooleanNullComparandError( ); } -/** - * [#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 ─────────────────────────────────────────── /** @@ -3713,540 +1947,6 @@ function classifyFilterKey( 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}`); - } -} - export class SqlDriver implements IDataDriver { // IDataDriver metadata public readonly name: string = 'com.objectstack.driver.sql'; @@ -5801,29 +3501,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); } /** @@ -6450,89 +4138,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); } /** @@ -6588,42 +4213,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); } /** @@ -6702,9 +4306,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 ────────────── @@ -6762,15 +4365,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); } /** @@ -7216,16 +4816,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); } /** @@ -7308,13 +4904,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); } /** @@ -7605,43 +5200,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); } /** @@ -8075,7 +5651,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, @@ -8083,24 +5659,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); } /** @@ -8158,7 +5724,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[], @@ -8168,416 +5734,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 { @@ -8610,161 +5813,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); } /** @@ -8828,25 +5896,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 { @@ -8908,45 +5965,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 { @@ -8963,39 +5998,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); } /** @@ -9096,37 +6117,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); } // =================================== @@ -9345,276 +6345,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); } /** @@ -9683,14 +6438,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); } /** @@ -9850,89 +6599,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); } /** @@ -10090,108 +6768,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); } // =================================== @@ -10219,78 +6809,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); } // =================================== @@ -10337,19 +6874,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) ───────────────────────────────── @@ -10648,16 +7181,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); } /** @@ -10774,120 +7299,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); } /** @@ -11989,11 +8440,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(); } /** @@ -12118,28 +8566,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); } /** @@ -12420,18 +8857,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); } /** @@ -12603,30 +9030,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); } /** @@ -12646,21 +9060,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); } /** @@ -12940,36 +9352,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); } /** @@ -13012,13 +9399,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); } /** @@ -13058,42 +9443,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 ────────────────────────────────────────────────────────── @@ -13104,9 +9463,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); } /** @@ -13166,10 +9524,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); } /** @@ -13209,16 +9565,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); } /** @@ -13283,11 +9637,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); } /** @@ -13343,11 +9694,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); } /** @@ -13379,15 +9727,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); } /** @@ -13449,20 +9790,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); } /** @@ -13502,41 +9842,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); } /** @@ -13552,14 +9863,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); } /** @@ -13602,19 +9910,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); } /** @@ -14110,22 +10413,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); } /** @@ -14157,92 +10450,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); } /** @@ -15149,9 +11367,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); } /** @@ -15236,42 +11453,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); } /** @@ -15317,9 +11507,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 ───────────────────────────────────────────────── @@ -15333,41 +11522,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 ────────────────────────────────────────────────── @@ -16241,25 +12402,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); } /** @@ -16483,22 +12636,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); } /** @@ -16629,21 +12773,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); } /** @@ -17283,19 +13425,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() { @@ -17575,478 +13714,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 ───────────────────────────────────────────────── From 2773c17c55a7229969bde32251e737890a595042 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 07:08:23 +0000 Subject: [PATCH 13/20] feat(scripts): the seam test reads the leaf, so its three needles bind methods only Ruling 6597555122 (round 3): sql-driver-compile-refusal-seam.test.ts adds sql-driver-top-level.ts to the sources it enumerates, in the form of hand cuts 13, 15 and 16. Its three needles leave PATH_READER_NEEDLES for a new CLASS_PATH_READER_NEEDLES table. A top-level declaration carrying one now moves to the leaf the test reads. A method carrying one still stays on the class, because its body would land in a section module the test does not read and drop SqlDriver.resolveWithheldFilterRefusal out of the pinned set. A stale class needle refuses, as a stale needle does. The self-test has 43 cases. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2gLghEqeDKTbDTC7Zd --- .../sql-driver-compile-refusal-seam.test.ts | 6 +- scripts/codemod-split-sql-driver.mjs | 78 ++++++++++++++----- 2 files changed, 63 insertions(+), 21 deletions(-) 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/scripts/codemod-split-sql-driver.mjs b/scripts/codemod-split-sql-driver.mjs index ba8358d4575..5489ca86b01 100644 --- a/scripts/codemod-split-sql-driver.mjs +++ b/scripts/codemod-split-sql-driver.mjs @@ -67,8 +67,9 @@ * 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) and `PATH_READER_NEEDLES` (literal text a reader - * requires to find there). + * `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) * @@ -190,16 +191,11 @@ const PATH_READER_PINS = Object.freeze({ * 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. * - * The first three are `sql-driver-compile-refusal-seam.test.ts`'s enumeration: - * it parses `sql-driver.ts` 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. A builder moved to a - * module it does not read drops out of that set. + * 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({ - 'unsupportedFilterError(': 'sql-driver-compile-refusal-seam.test.ts enumerates its callers', - 'withheldFilterError(': 'sql-driver-compile-refusal-seam.test.ts enumerates its callers', - '.INVALID_FILTER': 'sql-driver-compile-refusal-seam.test.ts enumerates the INVALID_FILTER code writers', "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', @@ -218,6 +214,28 @@ const PATH_READER_NEEDLES = Object.freeze({ '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 @@ -250,7 +268,7 @@ const STAY_REASONS = Object.freeze({ '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)', + '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`', @@ -516,6 +534,7 @@ function readMethod(tm, ctx, m) { 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); @@ -789,7 +808,9 @@ function hostUsesOfMovers(tm, ctx, top, methodRecords) { * 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 } = {}) { +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}`); @@ -806,12 +827,14 @@ function plan(tm, text, { sections = SECTION_MODULES, pins = PATH_READER_PINS, n 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 }; + 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); @@ -1634,17 +1657,19 @@ let selfTestReachedVerdict = false; const SELF_TEST_BATTERIES = Object.freeze({ 'declaration bar': 6, - 'stay rules': 11, - 'top-level moves': 10, + 'stay rules': 12, + 'top-level moves': 11, 'byte-identity proof': 10, - 'refusals': 3, + 'refusals': 4, }); 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 }); +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'); @@ -1667,6 +1692,9 @@ function namesClass(d: SqlDriver): SqlDriver { } const VIA_SEAM = seamHelper(); export const DERIVED = LOCAL_VALUE + 1; +function classReaderText(n: number): number { + return n; +} export class SqlDriver { private knex: any = null; @@ -1749,6 +1777,11 @@ ${pad} ${pad} return needleInMethod(1); } + + protected carriesClassNeedle(): number { +${pad} + return classReaderText(1); + } ${extraMethods} // =================================== // Kept @@ -1814,6 +1847,8 @@ async function selfTest() { 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'); @@ -1825,6 +1860,7 @@ async function selfTest() { 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')); @@ -1848,7 +1884,7 @@ async function selfTest() { 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 === 4, + 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]))); @@ -1877,8 +1913,10 @@ async function selfTest() { { 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: {} }))); - check('a needle the file no longer carries refuses', refused(() => plan(tm, fixtureSource(), { sections: FIXTURE_SECTIONS, pins: {}, needles: { 'gone text': 'x' } }))); + 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: {} }))); } const declared = Object.keys(SELF_TEST_BATTERIES); From d23e3c0d06c4f03d45af9859dd1cf149aed9a3b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 07:09:21 +0000 Subject: [PATCH 14/20] refactor(driver-sql): regenerate the split with the seam test reading the leaf The output of node scripts/codemod-split-sql-driver.mjs --write on origin/main 7a25b39cf9's unsplit sql-driver.ts, unedited. 127 of 129 top-level declarations now move to sql-driver-top-level.ts (96 before), and 54 of 254 methods move as before. sql-driver.ts goes 18,330 -> 12,456 lines. --verify: 54/54 bodies and 127/127 declarations are byte-identical to the base, and undoing the split rebuilds the base byte-for-byte. The output tree sha256 is f5d8722fa697b4530b9f76922c2b5d579f69048463e09cf190693793400b26e0. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2gLghEqeDKTbDTC7Zd --- .../src/sql-driver-filter-helpers.ts | 15 +- .../driver-sql/src/sql-driver-top-level.ts | 1623 ++++++++++++++++- packages/drivers/driver-sql/src/sql-driver.ts | 1622 +--------------- 3 files changed, 1628 insertions(+), 1632 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts b/packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts index c6c76998996..7ce8c07b717 100644 --- a/packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts +++ b/packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts @@ -23,23 +23,20 @@ * and accessors go as GETTERS, read when the body reads them, as they were on * the class. * - * Values that stayed in `sql-driver.ts` are imported from it (lever (a) of - * ruling 6591753301); every use is inside a function body, so the import - * cycle is never read at evaluation time. `index.ts` re-exports nothing from - * this module. + * 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, canonicalTimeOfDay, isEmptyFieldConstraint, - normalizeSqliteDatetimeOutput, presentAuditTimestampOutput, refusalSubtree, withheldFilterDiagnosticOf, + 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 { - assertCompilableComparand, assertDefinedComparands, emptyFieldConstraintError, filterArrayReachedDriverError, -} from './sql-driver.js'; import type { SqlDriver } from './sql-driver.js'; /** diff --git a/packages/drivers/driver-sql/src/sql-driver-top-level.ts b/packages/drivers/driver-sql/src/sql-driver-top-level.ts index e98ea7c3a32..5d0b4f62e39 100644 --- a/packages/drivers/driver-sql/src/sql-driver-top-level.ts +++ b/packages/drivers/driver-sql/src/sql-driver-top-level.ts @@ -11,10 +11,11 @@ // re-exports, by name, every declaration here it exported before, so every // importer of `./sql-driver.js` reads what it read. -import { AggregationFunction, NUMERIC_VALUE_TYPES, isAcceptedFilterComparand, isMultiValueField, isNowDefaultToken } from '@objectstack/spec/data'; -import type { SchemaMode, FilterVerdict as SharedFilterVerdict, ValueShapeFieldDef } from '@objectstack/spec/data'; +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 { temporalStorageForm } from '@objectstack/core'; +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'; @@ -519,6 +520,92 @@ export const SQLITE_TIME_EXPR_REFS = 8; */ 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? @@ -714,6 +801,70 @@ const WITHHELD_FILTER_SUBTREE = Symbol.for('objectstack.driver-sql.withheldFilte */ 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 @@ -1575,6 +1726,105 @@ function isUpsertFenceRefusal(error: unknown): boolean { 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 @@ -1584,6 +1834,174 @@ 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 @@ -1639,6 +2057,148 @@ function nowAnchorOf(value: unknown): NowAnchor | null { }; } +/** + * [#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 @@ -1769,6 +2329,315 @@ function isRenderableTextComparand(value: unknown): boolean { 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 { @@ -1833,11 +2702,244 @@ function describeFilterOperand(value: unknown): string { 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 @@ -1855,6 +2957,497 @@ function nonBooleanFlagWithheldMessage(op: '$null' | '$exists'): string { ); } +// ── [#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 @@ -2391,16 +3984,16 @@ function reclaimBetterSqlite3(db: BetterSqlite3Connection): void { // [#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, FilterVerdict, - LIKE_PATTERN_OPERATORS, RowWidthContribution, SCALAR_COMPARAND_OPERATORS, SQL_AGGREGATE_FUNCTIONS, - SQL_WINDOW_FUNCTIONS, SQL_WINDOW_SORT_DIRECTIONS, SUPPORTED_FILTER_OPERATORS_SENTENCE, SqlAggregateLowering, - TEXT_OPERATORS, TEXT_PATTERN_OPERATORS, WITHHELD_FILTER_AUTHOR_TEXT, WITHHELD_FILTER_DIAGNOSTIC, - WITHHELD_FILTER_LOGGED, WITHHELD_FILTER_SUBTREE, canonicalTimeOfDay, canonicalUtcDatetime, declaresColumnNotNull, - describeFilterOperand, insertedRowsNotReadBackError, invalidWindowFunctionQueryError, isBindableComparand, - isDeclaredFieldType, isDeferredDdlLockWaitRefusal, isEmptyFieldConstraint, isRenderableTextComparand, - isUpsertFenceRefusal, nonBooleanFlagWithheldMessage, normalizeSqliteDatetimeOutput, nowAnchorOf, - presentAuditTimestampOutput, reclaimBetterSqlite3, refusalSubtree, refuseAggregateFunction, - refuseCrossRowIdentityMerge, refuseDateBucketedGroupBy, refuseDistinctAggregateWithoutField, - refuseRejectedReferenceAlias, refuseUndeclaredFieldType, safeShapePreview, uniqueProbeNotNullClause, - unsupportedAggregationFilterError, + 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.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 8140deb0241..99e6ca8727e 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -228,18 +228,17 @@ import { isComposedReadableIds, type ComposedReadableIds } from '@objectstack/sp // [#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, - LIKE_PATTERN_OPERATORS, MYSQL_DATETIME_PRECISION, PRE_DDL_QUESTION_SCOPE, SCALAR_COMPARAND_OPERATORS, - SEQUENCES_TABLE, SUPPORTED_FILTER_OPERATORS_SENTENCE, TEXT_OPERATORS, TEXT_PATTERN_OPERATORS, - WITHHELD_FILTER_AUTHOR_TEXT, WITHHELD_FILTER_DIAGNOSTIC, WITHHELD_FILTER_SUBTREE, canonicalTimeOfDay, - canonicalUtcDatetime, declaresColumnNotNull, describeFilterOperand, fieldReferenceOf, isBindableComparand, - isDeclaredFieldType, isDeferredDdlLockWaitRefusal, isEmptyFieldConstraint, isFilterNode, isMultiValuedColumn, - isNowDefaultValue, isRenderableTextComparand, isUnresolvableColumnError, mysqlDatetimeLiteral, - nonBooleanFlagWithheldMessage, nowAnchorOf, refusalSubtree, refuseRejectedReferenceAlias, refuseUndeclaredFieldType, - resolveSqliteAbsentFileTarget, safeShapePreview, withheldFilterDiagnosticOf, + 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, FilterVerdict, IntrospectedColumn, IntrospectedForeignKey, - IntrospectedSchema, NowAnchor, ReadPresentationKind, RowWidthContribution, SqlAggregateLowering, SqlDriverConfig, + 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. @@ -250,10 +249,11 @@ export { 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, - declaredValueShapes, fieldReferenceOf, isFilterNode, isMultiValuedColumn, isNowDefaultValue, - isUnresolvableColumnError, lastIdentifierSegment, mysqlDatetimeLiteral, postgresTableNameCollisionError, - rawStatementFaultError, refuseUnbackedConflictTarget, refuseUpsertConflictOutsideWrittenOrganization, - resolveSqliteAbsentFileTarget, unquoteSqlIdentifier, withheldFilterDiagnosticOf, + 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'; @@ -291,8 +291,6 @@ import { decorateHashShadowDuplicate, rethrowWithRowSizeExplanation, schemaBytes 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'; -// [#29520 codemod] Exported for the section modules above, which import them (lever (a)); `index.ts` re-exports by name, so none reaches the package. -export { assertCompilableComparand, assertDefinedComparands, emptyFieldConstraintError, filterArrayReachedDriverError }; // GLOBAL_TENANT ('__global__') — the sentinel for the NULL-organization // ("platform") bucket — is defined ONCE in schema-drift.ts and imported here: @@ -355,1598 +353,6 @@ export const JSON_COLUMN_TYPES = new Set([ */ const MEDIA_ID_VARCHAR_CHARS = 2048; -/** - * [#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; -} - -/** - * [#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; -} - -/** - * [#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, - ); -} - -/** - * 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, - ); -} - -/** - * [#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.`, - ); -} - -/** - * [#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); -} - -/** - * [#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, - ); -} - -/** - * [#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, - ); -} - -// ── [#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'; -} - export class SqlDriver implements IDataDriver { // IDataDriver metadata public readonly name: string = 'com.objectstack.driver.sql'; From a92c230e41c34cc6c89635becf9da4dc0d1dc9ab Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 08:18:15 +0000 Subject: [PATCH 15/20] fix(spec): re-point the liveness anchor for unsupportedAggregationFilterError to the leaf The #29520 codemod moved the top-level declaration unsupportedAggregationFilterError from sql-driver.ts to the generated leaf sql-driver-top-level.ts. That left the query/aggregations.filter evidence anchor naming a symbol the cited file no longer has, which turned check-liveness.test.ts red in CI. Ruling 6597555122 point 3 says a reader that cites a file by path gains the new module in the same PR, so the one citation now names the leaf. Nothing else in the ledger changes. The other nine driver-sql anchors still name a symbol sql-driver.ts declares. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2gLghEqeDKTbDTC7Zd --- packages/spec/liveness/query.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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": { From 6cc535d2be2ff29540d3cbef33e250994f991861 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 08:44:58 +0000 Subject: [PATCH 16/20] test(driver-sql): the receiver-detach pin reads every module split out of sql-driver.ts Ruling 6597555122 point 3. Pin 3 of logger-receiver-detach.test.ts asserted that no detach-then-call shape survives in sql-driver.ts, and read only that file. The #29520 split moved 3 of the host's 21 log-channel calls into sql-driver-internal-helpers.ts, sql-driver-filter-helpers.ts and sql-driver-field-mapping.ts. Because the pin asserts an absence, it stayed green while it read less. The pin now lists the directory and reads every non-test sql-driver*.ts. A named list would rot whenever the codemod is regenerated on a newer main and moves a different set. A floor case fails if the listing finds nothing past the host. At base and at head the pin reads 55 log-channel calls (44 warn). Planting (this.logger.error ?? this.logger.warn)( in sql-driver-internal-helpers.ts turns it red. The old host-only pin stays green on that same planted tree. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2gLghEqeDKTbDTC7Zd --- .../src/logger-receiver-detach.test.ts | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) 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([]); }); }); From e2723ad825f78fdbef316c4bf4b706dbf881e461 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 08:44:59 +0000 Subject: [PATCH 17/20] fix(scripts): codemod --help answers on a split tree; --dts counts a line-count change Two defects the #29520 review found. --help was refused by the "already carries stubs" guard (exit 2) on any split tree. It is now answered before any guard and prints the usage. dtsBarFor reported rawDiffLines 0 whenever the two declaration files had different line counts. It now counts what diff prints (lines deleted plus inserted, a Myers edit distance) and names the method in rawDiffMethod. On round 3's dists it reads 19, the same as diff. When the counts are equal it still counts lines that differ in place, one per union swap. The self-test has 45 cases. A mutant of each fix is refused. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2gLghEqeDKTbDTC7Zd --- scripts/codemod-split-sql-driver.mjs | 61 ++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/scripts/codemod-split-sql-driver.mjs b/scripts/codemod-split-sql-driver.mjs index 5489ca86b01..394fc9d4302 100644 --- a/scripts/codemod-split-sql-driver.mjs +++ b/scripts/codemod-split-sql-driver.mjs @@ -13,6 +13,7 @@ * 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 * @@ -1438,7 +1439,37 @@ function injectVisibilityChange(text) { return at < 0 ? null : text.slice(0, at) + ' ' + text.slice(at + ' private '.length); } -/** The bar on one declaration file pair. Returns `{ failures, reading }`. */ +/** + * 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); @@ -1446,7 +1477,9 @@ function dtsBarFor(file, before, 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) { @@ -1469,7 +1502,7 @@ function dtsBarFor(file, before, after) { failures, reading: { file, - rawBase: sha256(before), rawHead: sha256(after), rawDiffLines, + rawBase: sha256(before), rawHead: sha256(after), rawDiffLines, rawDiffMethod, normalisedBase: sha256(nb), normalisedHead: sha256(na), exportsBase: eb.length, exportsHead: ea.length, positiveControl: controlSha, @@ -1585,7 +1618,21 @@ function generatedModulesOnDisk() { // ── 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; @@ -1656,11 +1703,11 @@ async function main(argv) { let selfTestReachedVerdict = false; const SELF_TEST_BATTERIES = Object.freeze({ - 'declaration bar': 6, + 'declaration bar': 7, 'stay rules': 12, 'top-level moves': 11, 'byte-identity proof': 10, - 'refusals': 4, + 'refusals': 5, }); const SELF_TEST_BATTERY_FLOOR = 5; @@ -1825,6 +1872,9 @@ async function selfTest() { 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); } @@ -1917,6 +1967,9 @@ async function selfTest() { 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); From 77e178eeffdc08d40b93b640d9a20168dbe442d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 09:47:05 +0000 Subject: [PATCH 18/20] chore(scripts): ADR-0053 anchor entries follow the read-boundary folds into their modules Ruling 6597555122 point 3, applied to the anchor registry. The #29520 split moved the code the sql-driver.ts ADR-0053 entry names out of that file: formatOutput's folds went to sql-driver-sqlite-serialisation.ts, presentReadValue's datetime and audit arms to sql-driver-filter-helpers.ts, and the fold helpers to sql-driver-top-level.ts. 18 of the host's 33 ADR-0053 citations left with them. check:adr-anchors stayed green on the 15 left behind while it guarded less. Each of the three modules now has its own ADR-0053 entry. Each entry's invariant names the code that moved there, in the shape #25521 cut 8 gave sql-driver-schema-sync.ts. The host entry stays: the stubs still live there, and so do 15 citations. Removing ADR-0053 from one module turns the gate red. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2gLghEqeDKTbDTC7Zd --- ...ers__driver-sql__src__sql-driver-filter-helpers.ts.json | 7 +++++++ ...river-sql__src__sql-driver-sqlite-serialisation.ts.json | 7 +++++++ ..._drivers__driver-sql__src__sql-driver-top-level.ts.json | 7 +++++++ 3 files changed, 21 insertions(+) create mode 100644 scripts/adr-anchors/packages__drivers__driver-sql__src__sql-driver-filter-helpers.ts.json create mode 100644 scripts/adr-anchors/packages__drivers__driver-sql__src__sql-driver-sqlite-serialisation.ts.json create mode 100644 scripts/adr-anchors/packages__drivers__driver-sql__src__sql-driver-top-level.ts.json 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)." +} From 5b19e9ac054641a04df7f846695baa4c5cca9c83 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 11:03:08 +0000 Subject: [PATCH 19/20] chore(scripts): the sql-driver codemod takes ts-morph from a root devDependency #29711 dropped ts-morph from @objectstack/cli, and no workspace package declares it any more. The codemod resolved ts-morph through the CLI's closure, so on a fresh `pnpm install --frozen-lockfile` its --verify and --self-test stopped at exit 2 and the ruled proofs could not be reproduced from a clean checkout. - package.json: ts-morph ^28.0.0 joins the root devDependencies, the way the other root scripts take typescript, yaml and semver. - pnpm-lock.yaml: written by `pnpm install --lockfile-only`. It adds ts-morph@28.0.0, @ts-morph/common@0.29.0 and code-block-writer@13.0.3. @ts-morph/common's minimatch resolves to the 10.2.6 already in the tree. - scripts/codemod-split-sql-driver.mjs: the loader resolves from the root manifest. The header says so, and also says which exit a tree with no install at all gets. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2gLghEqeDKTbDTC7Zd --- package.json | 1 + pnpm-lock.yaml | 25 +++++++++++++++++++++++++ scripts/codemod-split-sql-driver.mjs | 19 ++++++++++++------- 3 files changed, 38 insertions(+), 7 deletions(-) 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/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/codemod-split-sql-driver.mjs b/scripts/codemod-split-sql-driver.mjs index 394fc9d4302..0b4f38e87a8 100644 --- a/scripts/codemod-split-sql-driver.mjs +++ b/scripts/codemod-split-sql-driver.mjs @@ -96,15 +96,20 @@ * 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 resolved through `@objectstack/cli`'s dependency closure - * (`packages/cli/package.json` declares `ts-morph`), the way other root - * scripts resolve a tool through the package that declares it; the root adds - * no dependency for it. + * 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). + * 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'; @@ -303,10 +308,10 @@ const TOP_STAY_REASONS = Object.freeze({ let tsMorph = null; -/** ts-morph through `@objectstack/cli`'s closure; a missing install is exit 2, never a stack trace. */ +/** 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, 'packages', 'cli', 'package.json'); + const manifest = join(ROOT, 'package.json'); try { const resolved = createRequire(manifest).resolve('ts-morph'); const mod = await import(pathToFileURL(resolved).href); From 5f30cf9f2cb4725398af42ea3876f9e246715afc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:25:24 +0000 Subject: [PATCH 20/20] fix(scripts): check-tenant-chokepoint declares sql-driver-balanced-group.ts and names the fix when a module is undeclared The gate lists every non-test sql-driver-*.ts module from its directory. Its dispatch-gates declaration is a literal list. #29911 added sql-driver-balanced-group.ts, and the self-test went red with a message that did not say what to change. - DECLARED_WATCH_HINTS gains sql-driver-balanced-group.ts. - Both coupling asserts now name each missing or extra entry, and say to add it to or remove it from DECLARED_WATCH_HINTS. - The docblock records why the list stays literal. The extractor accepts a sql-driver-*.ts glob, but through hintCovers that glob covers 31 seam modules and 234 test files. A hint has no negation, so no spelling covers the modules and leaves the tests out. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2gLghEqeDKTbDTC7Zd --- scripts/check-tenant-chokepoint.mjs | 31 +++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/scripts/check-tenant-chokepoint.mjs b/scripts/check-tenant-chokepoint.mjs index 76aaf0f9e13..9a24ac68983 100644 --- a/scripts/check-tenant-chokepoint.mjs +++ b/scripts/check-tenant-chokepoint.mjs @@ -230,10 +230,14 @@ const SCAN_FILES = [ * 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. 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 until it is declared here (#29520). + * 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 @@ -247,6 +251,7 @@ const DECLARED_WATCH_HINTS = [ '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', @@ -1101,15 +1106,25 @@ function selfTest() { // ── 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('*')),