From c358b41710d78ab0b4abe575771199e35ec42f7e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 11:24:44 +0000 Subject: [PATCH 1/8] refactor(objectql): type the six #4918 query-options erasure sites in engine.ts Five drop the any-assertion (the literals already satisfy EngineQueryOptions); the dangling-reference audit hand-over names EngineQueryOptions, since the published port spells its options Record. Claude-Session: https://claude.ai/code/session_01Utp6CXX2qjqm2uXbJA8Jcv Co-authored-by: Claude --- packages/objectql/src/engine.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 64b4db60cbe..90fb59f19de 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -2313,7 +2313,7 @@ export class ObjectQL implements IObjectQLEngine { ...q, fields: ['id', field], context: execCtx, - } as any), + }), { where: prefix ? { [field]: { $startsWith: prefix } } : undefined, pageSize: AUTONUMBER_SEED_PAGE_SIZE, @@ -3154,7 +3154,7 @@ export class ObjectQL implements IObjectQLEngine { where: { id }, fields: ['id'], context: ObjectQL.referenceCheckContext(context), - } as any); + }); return !!row; } catch { return null; @@ -3300,7 +3300,7 @@ export class ObjectQL implements IObjectQLEngine { if (!readable.has(String(parentId))) return null; // 2. The write's own elevated, tenant-kept read, for a header read 1 returned. try { - const row = await this.findOne(rel.master, { where: { id: parentId }, context: ObjectQL.referenceCheckContext(context) } as any); + const row = await this.findOne(rel.master, { where: { id: parentId }, context: ObjectQL.referenceCheckContext(context) }); // `null` stays `null` — the fail-CLOSED signal (#4889) is the ABSENCE of // the binding, and materialising a row we do not have would destroy it. return row == null ? null : this.materializeParentHeader(rel.master, row as Record); @@ -3367,7 +3367,7 @@ export class ObjectQL implements IObjectQLEngine { const rows = await this.find(rel.master, { where: { id: { $in: [...readable] } }, context: ObjectQL.referenceCheckContext(context), - } as any) as Array>; + }) as Array>; // [#6457] One declared-field lookup for the whole batch, then one shallow // copy per header. A master the registry does not know leaves `fields` // undefined and every header passes through untouched — see @@ -3513,7 +3513,10 @@ export class ObjectQL implements IObjectQLEngine { return auditDanglingReferences( { objects: () => this._registry.getAllObjects() as unknown as AuditableObject[], - find: (object, opts) => this.find(object, opts as any) as Promise>>, + // The port's options are the audit's own `{ fields, limit, context }` + // read; the published port spells them `Record`, so + // the hand-over names the engine contract they are read under. + find: (object, opts) => this.find(object, opts as EngineQueryOptions) as Promise>>, probe: (target, id, organization) => this.referenceExists( target, id, organization == null || unionScope ? undefined : ({ tenantId: organization } as ExecutionContext), ), @@ -9479,7 +9482,7 @@ export class ObjectQL implements IObjectQLEngine { // on-delete behaviour, BEHAVIOUR follows the declaration; the // identity of this probe stays system, unconditionally. Do not make // this line conditional on `behavior`. - { where: probeWhere, context: ObjectQL.referenceCheckContext(context) } as any, + { where: probeWhere, context: ObjectQL.referenceCheckContext(context) }, ); } catch (error) { // [#8895] Discriminate by error TYPE — this probe IS the referential From 162cb326687dcc74e0f38e27ff22b34fdbfcae87 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 11:25:51 +0000 Subject: [PATCH 2/8] refactor(objectql): name the keyset walk's where as the engine's where slot in seedAutonumber Claude-Session: https://claude.ai/code/session_01Utp6CXX2qjqm2uXbJA8Jcv Co-authored-by: Claude --- packages/objectql/src/engine.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 90fb59f19de..7a112856a5c 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -2310,7 +2310,9 @@ export class ObjectQL implements IObjectQLEngine { // from 0", i.e. duplicate autonumbers. const walk = keysetWalk>( (q: KeysetPageQuery) => this.find(object, { - ...q, + // The walk spells its `where` `unknown` (this scan's filter AND-ed + // with the seek predicate); it is read as the engine's `where` slot. + ...(q as Omit & { where?: EngineQueryOptions['where'] }), fields: ['id', field], context: execCtx, }), From 6937d0a556bc71dbf30f2265adc8949ed0ed18d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 11:37:49 +0000 Subject: [PATCH 3/8] feat(scripts): the engine codemod's fourth pass moves named methods onto the split class, with an undo proof The first pass runs only on an unsplit engine.ts. --members A,B moves the named ObjectQL methods as the first pass would (same stay rules, stub, facet member and docblock rule) into their section's written facet module, growing its header, host interface, imports, host factory and the statics route by what the bodies read. --members --verify --base REF undoes the moves from the head and requires the base engine.ts and modules byte for byte. The six PATH_READER_PINS rows retire: their #4918 sites are typed. Claude-Session: https://claude.ai/code/session_01Utp6CXX2qjqm2uXbJA8Jcv Co-authored-by: Claude --- scripts/codemod-split-engine.mjs | 845 ++++++++++++++++++++++++++++--- 1 file changed, 782 insertions(+), 63 deletions(-) diff --git a/scripts/codemod-split-engine.mjs b/scripts/codemod-split-engine.mjs index 7e1e722cba8..990ac00702a 100644 --- a/scripts/codemod-split-engine.mjs +++ b/scripts/codemod-split-engine.mjs @@ -234,21 +234,11 @@ const HOSTS_CLASS = 'FacetHost'; * nothing the file declares stops the script (exit 2), so the table cannot rot * into names nobody reads. */ -const QUERY_OPTIONS_GUARD = 'eslint query-options/no-any-erasure grandfathering and scripts/check-query-options-erasure-ratchet.mjs (baseline keyed by path, never grows)'; const PATH_READER_PINS = Object.freeze({ - // The #4918 query-options erasure guard grandfathers its pre-existing sites - // BY FILE: an `ignores` entry in eslint.config.mjs (QUERY_OPTIONS_UNSWEPT) - // plus a per-file count in scripts/query-options-erasure-baseline.json, a list - // that may only shrink. A moved body takes its `opts as any` site to a file - // the list does not name, so `pnpm lint` and `check:query-options-erasure` - // red (measured: the six methods below, one site each; the last four once - // ruling 6624345766 dropped the no-yield rule and let them move). - seedAutonumber: QUERY_OPTIONS_GUARD, - inspectDanglingReferences: QUERY_OPTIONS_GUARD, - probeReferencingRows: QUERY_OPTIONS_GUARD, - referenceExists: QUERY_OPTIONS_GUARD, - resolveMasterDetailParent: QUERY_OPTIONS_GUARD, - resolveMasterDetailParents: QUERY_OPTIONS_GUARD, + // Empty since #30055: the six rows the #4918 query-options erasure guard + // held here (its per-file baseline names `engine.ts`, so a moved `as any` + // site would have red `pnpm lint`) retired once their sites were typed, and + // the fourth pass (`--members`) moved the six bodies. }); /** @@ -874,14 +864,7 @@ function hostsTexts(facets) { ' static {', ...sorted.flatMap((f) => [ ` ${hostVarOf(f.moduleFile)}.install((engine) => ({`, - ...[...f.members.keys()].sort().flatMap((member) => { - if (f.members.get(member) === 'getter') { - return [` get ${member}() { return engine.${member}; },`, - ...(f.written.has(member) ? [` set ${member}(v) { engine.${member} = v; },`] : [])]; - } - if (BOUND_HOST_METHODS.has(member)) return [` get ${member}() { return engine.${member}.bind(engine); },`]; - return [` ${member}: (...a) => engine.${member}(...a),`]; - }), + ...hostInstallLines(f.members, f.written), ' }));', ]), ' }', @@ -889,6 +872,18 @@ function hostsTexts(facets) { return { module, block }; } +/** The member lines of one facet's host factory, in its install call: sorted, a getter's setter after it. */ +function hostInstallLines(members, written) { + return [...members.keys()].sort().flatMap((member) => { + if (members.get(member) === 'getter') { + return [` get ${member}() { return engine.${member}; },`, + ...(written.has(member) ? [` set ${member}(v) { engine.${member} = v; },`] : [])]; + } + if (BOUND_HOST_METHODS.has(member)) return [` get ${member}() { return engine.${member}.bind(engine); },`]; + return [` ${member}: (...a) => engine.${member}(...a),`]; + }); +} + /** 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) { @@ -1043,23 +1038,14 @@ function hostUsesOfMovers(tm, ctx, top, methodRecords) { } /** - * Read `text` (an `engine.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. + * The class's statics: scope, and what a forwarder in `ObjectQLStatics` would + * be (a method forwards as a function; a field or accessor as a getter). */ -function plan(tm, text, { - anchors = SECTION_ANCHORS, pins = PATH_READER_PINS, needles = PATH_READER_NEEDLES, classNeedles = CLASS_PATH_READER_NEEDLES, -} = {}) { - const sf = parse(tm, HOST_PATH, text); - const cls = sf.getClass(CLASS_NAME); - if (!cls) refuse(`no class ${CLASS_NAME} in ${HOST_BASENAME}`); - const sectionOf = readSections(cls, anchors); - // The class's statics: scope, and what a forwarder in `ObjectQLStatics` - // would be (a method forwards as a function; a field or accessor as a getter). +function classStaticsOf(tm, cls) { + const { Node } = tm; const statics = new Map(); for (const m of cls.getMembers()) { if (typeof m.isStatic === 'function' && m.isStatic() && typeof m.getName === 'function') { - const { Node } = tm; const prev = statics.get(m.getName()); const kind = Node.isMethodDeclaration(m) ? 'method' : Node.isSetAccessorDeclaration(m) ? (prev ? prev.kind : 'setter') : Node.isGetAccessorDeclaration(m) ? 'getter' : 'field'; @@ -1069,7 +1055,22 @@ function plan(tm, text, { }); } } - const ctx = { text, sf, cls, members: instanceMembers(tm, cls), scope: moduleScope(tm, sf), statics, pins, needles, classNeedles }; + return statics; +} + +/** + * Read `text` (an `engine.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, { + anchors = SECTION_ANCHORS, pins = PATH_READER_PINS, needles = PATH_READER_NEEDLES, classNeedles = CLASS_PATH_READER_NEEDLES, +} = {}) { + const sf = parse(tm, HOST_PATH, text); + const cls = sf.getClass(CLASS_NAME); + if (!cls) refuse(`no class ${CLASS_NAME} in ${HOST_BASENAME}`); + const sectionOf = readSections(cls, anchors); + const ctx = { text, sf, cls, members: instanceMembers(tm, cls), scope: moduleScope(tm, sf), statics: classStaticsOf(tm, cls), pins, needles, classNeedles }; for (const n of [STATICS_NAME, STATICS_HOST_NAME, ...SECTION_ANCHORS.filter((row) => row[2]).map((row) => facetNameOf(row[2]))]) { if (ctx.scope.bindings.has(n)) refuse(`${HOST_BASENAME} already binds ${n}, a name this script generates`); } @@ -1233,24 +1234,7 @@ function importLines(scope, users, { needsHost = false, moduleRefs = new Map(), } const out = []; const specifiers = [...bySpecifier.keys()].sort((a, b) => scope.importOrder.get(a) - scope.importOrder.get(b)); - for (const specifier of specifiers) { - const entries = [...bySpecifier.get(specifier).entries()].sort(byName); - const named = (value) => entries - .filter(([, e]) => e.form === 'named' && e.value === value) - .map(([local, e]) => (e.imported === local ? local : `${e.imported} as ${local}`)); - const def = entries.find(([, e]) => e.form === 'default'); - const ns = entries.find(([, e]) => e.form === 'namespace'); - const valueNamed = named(true); - const defValue = def && def[1].value ? def[0] : null; - if (defValue || valueNamed.length > 0) { - const parts = [defValue, valueNamed.length > 0 ? `{ ${valueNamed.join(', ')} }` : null].filter(Boolean); - out.push(`import ${parts.join(', ')} from '${specifier}';`); - } - if (def && !def[1].value) out.push(`import type ${def[0]} from '${specifier}';`); - if (ns) out.push(`import ${ns[1].value ? '' : 'type '}* as ${ns[0]} from '${specifier}';`); - const typeNamed = named(false); - if (typeNamed.length > 0) out.push(`import type { ${typeNamed.join(', ')} } from '${specifier}';`); - } + for (const specifier of specifiers) out.push(...specifierImportLines(specifier, bySpecifier.get(specifier))); const fromLeaf = new Map(); const fromHost = new Map(); for (const [n, use] of moduleRefs) (leafNames.has(n) ? fromLeaf : fromHost).set(n, use); @@ -1266,6 +1250,32 @@ function importLines(scope, users, { needsHost = false, moduleRefs = new Map(), return out; } +/** + * The import lines of one package specifier in a facet module, from its + * bindings (local -> { form, imported, value }): the value statement, then a + * type-only default, a namespace, then the type-only names, each list sorted. + */ +function specifierImportLines(specifier, bindings) { + const out = []; + const entries = [...bindings.entries()].sort(byName); + const named = (value) => entries + .filter(([, e]) => e.form === 'named' && e.value === value) + .map(([local, e]) => (e.imported === local ? local : `${e.imported} as ${local}`)); + const def = entries.find(([, e]) => e.form === 'default'); + const ns = entries.find(([, e]) => e.form === 'namespace'); + const valueNamed = named(true); + const defValue = def && def[1].value ? def[0] : null; + if (defValue || valueNamed.length > 0) { + const parts = [defValue, valueNamed.length > 0 ? `{ ${valueNamed.join(', ')} }` : null].filter(Boolean); + out.push(`import ${parts.join(', ')} from '${specifier}';`); + } + if (def && !def[1].value) out.push(`import type ${def[0]} from '${specifier}';`); + if (ns) out.push(`import ${ns[1].value ? '' : 'type '}* as ${ns[0]} from '${specifier}';`); + const typeNamed = named(false); + if (typeNamed.length > 0) out.push(`import type { ${typeNamed.join(', ')} } from '${specifier}';`); + return out; +} + /** `text` wrapped into ` * ` comment lines no wider than `width`. */ function wrapComment(words, width = 80) { const lines = []; @@ -1303,13 +1313,11 @@ function moduleText(moduleFile, title, movers, scope, leafNames, routed = false) } } const readsHostValues = [...moduleRefs].some(([n, use]) => use.value && !leafNames.has(n)); - const names = movers.map((r) => `\`${r.name}\`${r === movers[movers.length - 1] ? '' : ','}`); const out = []; out.push('// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.'); out.push(''); out.push('/**'); - out.push(...wrapComment([MARK, 'The', `"${title}"`, 'section', 'of', '`ObjectQL`:', 'the', 'bodies', 'of', - ...names, 'moved', 'out', 'of', '`engine.ts`', 'whole', 'by', `\`${SCRIPT}\`.`])); + out.push(...moduleHeaderLines(title, movers.map((r) => r.name))); out.push(' *'); out.push(` * A MOVE, not a redesign. {@link ${facet}} is a stateless facet of \`ObjectQL\``); out.push(' * (ruling 6624345766): each static method of it is the body of the `ObjectQL`'); @@ -1370,19 +1378,35 @@ function moduleText(moduleFile, title, movers, scope, leafNames, routed = false) out.push(`/** The bodies of the "${title}" section of \`ObjectQL\`: a stateless facet, called by the class's stubs. */`); out.push(`export class ${facet} {`); movers.forEach((r, i) => { - 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()}` : ''; if (i > 0) out.push(''); - const typeParams = m.getTypeParameters().length > 0 ? `<${m.getTypeParameters().map((t) => t.getText()).join(', ')}>` : ''; - out.push(` ${r.doc ?? `${placeholderDoc(r.name)}\n `}static ${m.isAsync() ? 'async ' : ''}${r.name}${typeParams}(${params.join(', ')})${ret} ${m.getBody().getText()}`); + out.push(facetMethodText(r, hostName)); }); out.push('}'); out.push(''); return out.join('\n'); } +/** The opening lines of a facet module's header docblock: its section's title and the names of its bodies. */ +function moduleHeaderLines(title, names) { + const listed = names.map((n, i) => `\`${n}\`${i === names.length - 1 ? '' : ','}`); + return wrapComment([MARK, 'The', `"${title}"`, 'section', 'of', '`ObjectQL`:', 'the', 'bodies', 'of', + ...listed, 'moved', 'out', 'of', '`engine.ts`', 'whole', 'by', `\`${SCRIPT}\`.`]); +} + +/** + * One facet member, as its module holds it: the method's docblock (a private + * one) or the placeholder, `static`, and the method's own parameters, return + * type and body text, with `this` a `Pick` of the host when the body reads it. + */ +function facetMethodText(r, hostName) { + 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()}` : ''; + const typeParams = m.getTypeParameters().length > 0 ? `<${m.getTypeParameters().map((t) => t.getText()).join(', ')}>` : ''; + return ` ${r.doc ?? `${placeholderDoc(r.name)}\n `}static ${m.isAsync() ? 'async ' : ''}${r.name}${typeParams}(${params.join(', ')})${ret} ${m.getBody().getText()}`; +} + /** * The leaf: the moved top-level statements, each its base text exactly (its * leading comments and blank lines included), in base order, after the @@ -3060,6 +3084,698 @@ async function mainHandDocs(tm, argv) { return 0; } +// ── The fourth pass: named methods onto the split class (#30055) ───────────── +// +// The first pass refuses an `engine.ts` that already carries its stubs: it is +// run on an unsplit file, never on its own output, and `main` has been split +// since #29986. A method a stay rule held then (a path pin since retired, a +// return type since spelled) is moved by THIS pass instead, on the split file: +// `--members a,b` names the methods, and each goes exactly as the first pass +// would have taken it. The same stay rules apply, and any reason refuses: the +// pass names what it moves and never decides it. The stub, the facet member and +// the docblock rule are the first pass's, and the member goes into the facet +// module of its section that the first pass wrote, in class order. That +// module's header, host interface and import lines grow by what the moved +// bodies read, and so do the class's statics route and that facet's host +// factory. Nothing else moves, and the pass never starts a facet, reroutes a +// module or widens a generated export list: each refuses (exit 2), and is a +// decision someone writes here. +// +// The proof (`--members --verify --base REF` re-runs it) undoes the moves from +// the head alone, as the first pass's does: each moved body, docblock and +// `async` goes back onto its stub; each receiving module loses the moved +// members, the host members and import bindings only they read, and its header +// their names; the statics route and the host factory lose what only they +// read. The result must be the base `engine.ts` and modules byte for byte (the +// base's import bindings only moved bodies read pruned from it, as the first +// pass prunes them), every other generated module unchanged, and each moved +// member its base method's text exactly, by a stay-rule reading of the base. + +const MEMBERS_CARD = '#30055'; + +/** A first-pass stub's marker line: its module, whether its docblock moved, whether it hands a host. */ +const STUB_MARK_LINE = new RegExp( + `^ *// ${MARK.replace(/[[\]]/g, '\\$&')} The body moved to \`(engine-[a-z0-9-]+\\.ts)\`(, ${DOC_MOVED})?( \\(see \`[A-Za-z]+\`\\))?\\.$`, 'm'); + +const lineStartOf = (text, pos) => text.lastIndexOf('\n', pos - 1) + 1; + +/** The 1-based line at which `a` and `b` first differ. */ +function firstDiffLine(a, b) { + let i = 0; + while (i < a.length && i < b.length && a[i] === b[i]) i += 1; + return a.slice(0, i).split('\n').length; +} + +/** Apply non-overlapping `{ start, end, text }` edits; ties apply in the order given. */ +function applyOrderedEdits(text, edits) { + const sorted = edits.map((e, i) => ({ ...e, i })).sort((a, b) => b.start - a.start || b.end - a.end || b.i - a.i); + let out = text; + let floor = Infinity; + for (const e of sorted) { + if (e.end > floor) refuse('overlapping edits'); + out = out.slice(0, e.start) + e.text + out.slice(e.end); + floor = e.start; + } + return out; +} + +/** The analysis context of an `engine.ts` the first pass has split: `plan`'s, without its unsplit-file guards. */ +function membersContext(tm, text, { 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}`); + return { text, sf, cls, members: instanceMembers(tm, cls), scope: moduleScope(tm, sf), statics: classStaticsOf(tm, cls), pins, needles, classNeedles }; +} + +const isGeneratedStub = (m) => Boolean(m.getBody() && STUB_MARK_LINE.test(m.getBody().getText())); + +/** The header docblock's opening lines of a facet module (`moduleHeaderLines`), as a region. */ +function headerRegionOf(text) { + const open = text.indexOf('\n/**\n'); + const end = text.indexOf('\n *\n * A MOVE, not a redesign.', open); + return open < 0 || end < 0 ? null : { start: open + 5, end }; +} + +/** + * The names a facet module's header lists, read back; null unless the header + * is `moduleHeaderLines` of them. The list is the header's own, which a hand + * edit to the module may have left behind its members: this pass adds and + * removes its names in it and leaves the rest as it stands. + */ +function headerNamesOf(text, title) { + const region = headerRegionOf(text); + if (!region) return null; + const prose = text.slice(region.start, region.end).split('\n').map((l) => l.replace(/^ \*( |$)/, '')).join(' '); + const i = prose.indexOf(' the bodies of '); + const j = prose.lastIndexOf(' moved out of '); + if (i < 0 || j < i) return null; + const names = [...prose.slice(i, j).matchAll(/`([^`]+)`/g)].map((m) => m[1]); + return moduleHeaderLines(title, names).join('\n') === text.slice(region.start, region.end) ? { region, names } : null; +} + +/** A facet module's host interface: its member lines, each `[readonly ]m: Class['m'];`, in order. */ +function interfaceRegionOf(text, hostName) { + const head = `\nexport interface ${hostName} {\n`; + const i = text.indexOf(head); + if (i < 0) return null; + const start = i + head.length; + const end = text.indexOf('\n}', start); + const members = new Map(); + let classType = null; + for (const line of text.slice(start, end).split('\n')) { + const m = /^ {2}(readonly )?([A-Za-z_$][\w$]*): ([A-Za-z]+)\['([A-Za-z_$][\w$]*)'\];$/.exec(line); + if (!m || m[2] !== m[4] || (classType && classType !== m[3])) return null; + classType = m[3]; + members.set(m[2], { line, readonly: Boolean(m[1]) }); + } + const names = [...members.keys()]; + if (names.join() !== [...names].sort().join()) return null; + return { start, end, members, classType }; +} + +/** One facet's host factory in the class's install block (`hostInstallLines`), parsed back; null when it is not that shape. */ +function installRegionOf(text, moduleFile) { + const head = ` ${hostVarOf(moduleFile)}.install((engine) => ({\n`; + const i = text.indexOf(head); + if (i < 0 || text.indexOf(head, i + 1) >= 0) return null; + const start = i + head.length; + const end = text.indexOf('\n }));', start); + if (end < 0) return null; + const members = new Map(); + const written = new Set(); + for (const line of text.slice(start, end).split('\n')) { + let m; + if ((m = /^ {6}get ([\w$]+)\(\) \{ return engine\.\1; \},$/.exec(line))) members.set(m[1], 'getter'); + else if ((m = /^ {6}set ([\w$]+)\(v\) \{ engine\.\1 = v; \},$/.exec(line))) written.add(m[1]); + else if ((m = /^ {6}get ([\w$]+)\(\) \{ return engine\.\1\.bind\(engine\); \},$/.exec(line))) members.set(m[1], 'method'); + else if ((m = /^ {6}([\w$]+): \(\.\.\.a\) => engine\.\1\(\.\.\.a\),$/.exec(line))) members.set(m[1], 'method'); + else return null; + } + if (hostInstallLines(members, written).join('\n') !== text.slice(start, end)) return null; + return { start, end, members, written }; +} + +/** The statics route (`staticsTexts`) in `engine.ts`, parsed back: its kinds and both regions; null when absent or not that shape. */ +function staticsRegionOf(text) { + const head = `export interface ${STATICS_HOST_NAME} {\n`; + const i = text.indexOf(head); + if (i < 0) return null; + const end = text.indexOf('\n}', i); + const kinds = new Map(); + for (const line of text.slice(i + head.length, end).split('\n')) { + const m = new RegExp(`^ {2}readonly ([\\w$]+): \\(typeof ${CLASS_NAME}\\)\\['([\\w$]+)'\\];$`).exec(line); + if (!m || m[1] !== m[2]) return null; + kinds.set(m[1], text.includes(` ${m[1]}: { value: (...args: Parameters)`) ? 'method' : 'getter'); + } + const t = staticsTexts(kinds); + const a = text.indexOf(t.ahead); + const b = text.indexOf(t.block); + if (a < 0 || b < 0 || text.indexOf(t.ahead, a + 1) >= 0 || text.indexOf(t.block, b + 1) >= 0) return null; + return { kinds, ahead: { start: a, end: a + t.ahead.length }, block: { start: b, end: b + t.block.length } }; +} + +/** The facet class of a module and its members' texts, each from its line start; null unless they are joined by one blank line. */ +function facetMembersOf(tm, moduleFile, text) { + const sf = parse(tm, join(SRC_DIR, moduleFile), text); + const facet = sf.getClass(facetNameOf(moduleFile)); + if (!facet) return null; + const open = `export class ${facetNameOf(moduleFile)} {\n`; + const bodyStart = text.indexOf(open); + if (bodyStart < 0) return null; + const start = bodyStart + open.length; + const end = facet.getEnd() - 2; + const members = facet.getMembers().map((m) => ({ name: m.getName(), node: m, text: text.slice(lineStartOf(text, m.getStart(true)), m.getEnd()) })); + if (members.length === 0 || members.map((m) => m.text).join('\n\n') !== text.slice(start, end)) return null; + return { start, end, members }; +} + +/** The `Pick` keys a facet member's `this` names, or [] when it takes no host. */ +function picksOf(member) { + const p = member.node.getParameters()[0]; + if (!p || p.getName() !== 'this') return []; + const m = /^this: Pick<[A-Za-z]+, (.+)>$/.exec(p.getText()); + return m ? m[1].split(' | ').map((q) => q.slice(1, -1)) : ['']; +} + +/** A module's import declarations, each with its bindings (local -> { form, imported, value }). */ +function importDeclsOf(tm, moduleFile, text) { + const ts = tm.ts; + const sf = parseSourceFile(join(SRC_DIR, moduleFile), text); + return sf.statements.filter((st) => ts.isImportDeclaration(st)).map((st) => { + const clause = st.importClause; + const declType = Boolean(clause && clause.isTypeOnly); + const bindings = new Map(); + if (clause && clause.name) bindings.set(clause.name.text, { form: 'default', value: !declType }); + const nb = clause && clause.namedBindings; + if (nb && ts.isNamespaceImport(nb)) bindings.set(nb.name.text, { form: 'namespace', value: !declType }); + if (nb && ts.isNamedImports(nb)) { + for (const e of nb.elements) bindings.set(e.name.text, { form: 'named', imported: (e.propertyName ?? e.name).text, value: !declType && !e.isTypeOnly }); + } + return { specifier: st.moduleSpecifier.text, start: st.getStart(sf), end: st.getEnd(), bindings }; + }); +} + +/** The lines one specifier's bindings render to in a facet module: the leaf and `engine.ts` by `importPair`, a package by `specifierImportLines`. */ +function specifierLines(specifier, bindings) { + if (specifier === leafSpecifier() || specifier === './engine.js') { + if ([...bindings].some(([local, e]) => e.form !== 'named' || e.imported !== local)) return null; + return importPair(specifier, new Map([...bindings].map(([n, e]) => [n, { value: e.value }]))); + } + return specifierImportLines(specifier, bindings); +} + +/** + * Drop the import bindings nothing in a facet module reads, re-rendering each + * specifier's lines as the first pass writes them (`specifierLines`), so a + * pruned leaf list wraps as `namedStatement` wraps it. + */ +function pruneModuleImports(tm, moduleFile, text) { + const used = usedImportNames(tm, text); + const decls = importDeclsOf(tm, moduleFile, text); + const edits = []; + for (const specifier of new Set(decls.map((d) => d.specifier))) { + const run = decls.filter((d) => d.specifier === specifier); + const have = new Map(); + for (const d of run) for (const [local, e] of d.bindings) have.set(local, e); + const kept = new Map([...have].filter(([local]) => used.has(local))); + if (kept.size === have.size) continue; + const start = run[0].start; + if (kept.size === 0) { + const end = text[run.at(-1).end] === '\n' ? run.at(-1).end + 1 : run.at(-1).end; + edits.push({ start, end, text: '' }); + } else { + const lines = specifierLines(specifier, kept); + if (!lines) refuse(`${moduleFile}: ${specifier} is imported in a form this pass does not write`); + edits.push({ start, end: run.at(-1).end, text: lines.join('\n') }); + } + } + return applyOrderedEdits(text, edits); +} + +/** + * Decide the moves `names` asks for on a split `engine.ts`. Refuses (exit 2) + * on a name the class does not declare as one method, a method already a stub, + * one any stay rule of the first pass holds, and on any shape this pass does + * not grow a written module by (see the section above). Pure. + */ +function planMembers(tm, text, moduleTexts, names, { anchors = SECTION_ANCHORS, pins = PATH_READER_PINS, needles = PATH_READER_NEEDLES, classNeedles = CLASS_PATH_READER_NEEDLES } = {}) { + if (!names || names.length === 0) refuse('--members needs a comma-separated list of ObjectQL method names'); + if (new Set(names).size !== names.length) refuse(`--members names a method twice: ${names.join(', ')}`); + const ctx = membersContext(tm, text, { pins, needles, classNeedles }); + const methodNames = new Set(ctx.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 sectionOf = readSections(ctx.cls, anchors); + const records = []; + for (const name of names) { + const found = ctx.cls.getMethods().filter((m) => m.getName() === name); + if (found.length !== 1) refuse(`--members names ${name}, which ${CLASS_NAME} declares as ${found.length === 0 ? 'no method' : 'more than one method'}`); + const m = found[0]; + if (isGeneratedStub(m)) refuse(`${name} is already moved: its body is a ${MARK} stub`); + const read = readMethod(tm, ctx, m); + const section = sectionOf(m); + if (!section) read.reasons.unshift('no-section'); + else if (section[2] === null) read.reasons.unshift('section-kept'); + else for (const p of m.getParameters()) if (p.getName() === facetNameOf(section[2])) read.reasons.push(`parameter-shadows: ${p.getName()}`); + if (read.reasons.length > 0) { + refuse(`${name} stays on the class, as the first pass would keep it: ${read.reasons.map((r) => `${r} (${STAY_REASONS[r.split(':')[0]] ?? r})`).join('; ')}`); + } + const docStart = m.getStart(true); + const doc = m.getScope() === 'private' && docStart < m.getStart() ? text.slice(docStart, m.getStart()) : null; + records.push({ name, node: m, module: section[2], section: section[1], doc, ...read }); + } + records.sort((a, b) => a.node.getStart() - b.node.getStart()); + const modules = new Map(); + for (const r of records) modules.set(r.module, [...(modules.get(r.module) ?? []), r]); + for (const [file, movers] of modules) { + const mod = moduleTexts.get(file); + if (mod == null || !mod.includes(MARK)) refuse(`${file} is not on disk as the first pass wrote it: this pass adds to a written facet, never starts one`); + const routed = new RegExp(`^import \\{[^}]*\\b${STATICS_NAME} as ${CLASS_NAME}\\b[^}]*\\} from '\\./engine\\.js';$`, 'm').test(mod); + const hasHost = interfaceRegionOf(mod, hostNameOf(file)) !== null; + const typeImportsClass = new RegExp(`^import type \\{[^}]*\\b${CLASS_NAME}\\b[^}]*\\} from '\\./engine\\.js';$`, 'm').test(mod); + for (const r of movers) { + if (r.host.size > 0 && !hasHost) refuse(`${r.name} reads the class, and ${file} has no host: this pass does not give a facet one`); + if (r.host.size > 0 && r.node.getParameters().some((p) => p.getName() === hostVarOf(file))) { + refuse(`${r.name}: a parameter is named ${hostVarOf(file)}, which its stub reads as the facet's host`); + } + if (r.classStatics.size > 0 && !routed) refuse(`${r.name} reads statics of ${CLASS_NAME}, and ${file} does not take the statics route: this pass does not reroute a module`); + if (routed) { + const blockers = [...r.routeBlockers, ...(r.classOther ? ['static-route-conflict'] : [])]; + if (blockers.length > 0) refuse(`${r.name} stays on the class, as the first pass would keep it in a routed module: ${blockers.join('; ')}`); + } + for (const [n, use] of r.moduleRefs) { + if (n === CLASS_NAME && (routed || (!use.value && typeImportsClass))) continue; + refuse(`${r.name} reads ${n}, a top-level name of ${HOST_BASENAME}: this pass does not widen the generated export lists`); + } + for (const local of r.imports.keys()) { + const b = ctx.scope.bindings.get(local); + if (!b || b.kind !== 'import') refuse(`import binding ${local} not found`); + } + } + } + return { ctx, records, modules }; +} + +/** Apply a `planMembers` plan. Returns `{ host, modules, pruned }` (`modules`: the receiving modules' new texts). Pure. */ +function generateMembers(tm, text, moduleTexts, planned) { + const { ctx, modules } = planned; + const edits = []; + const newStatics = new Map(); + for (const [file, movers] of modules) { + for (const r of movers) { + const body = r.node.getBody(); + const lineStart = lineStartOf(text, body.getEnd() - 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({ node: r.node, name: r.name, usesHost: r.host.size > 0, docMoved: Boolean(r.doc) }, file, closeIndent) }); + const asyncKw = r.node.getModifiers().find((x) => x.getKind() === tm.SyntaxKind.AsyncKeyword); + if (asyncKw) edits.push({ start: asyncKw.getStart(), end: r.node.getNameNode().getStart(), text: '' }); + if (r.doc) edits.push({ start: r.node.getStart(true), end: r.node.getStart(), text: '' }); + for (const [k, kind] of r.classStatics) newStatics.set(k, kind); + } + const members = new Map(); + const written = new Set(); + for (const r of movers) for (const [k, kind] of r.host) members.set(k, kind); + for (const r of movers) for (const k of r.writes) written.add(k); + if (members.size === 0) continue; + const inst = installRegionOf(text, file); + if (!inst) refuse(`${file}: its host factory in ${HOST_BASENAME}'s install block is not as the first pass wrote it`); + const merged = new Map(inst.members); + for (const [k, kind] of members) { + if (merged.has(k) && merged.get(k) !== kind) refuse(`${file}: host member ${k} is a ${merged.get(k)} there and a ${kind} here`); + merged.set(k, kind); + } + const mergedWritten = new Set([...inst.written, ...written]); + const lines = hostInstallLines(merged, mergedWritten).join('\n'); + if (lines !== text.slice(inst.start, inst.end)) edits.push({ start: inst.start, end: inst.end, text: lines }); + } + if (newStatics.size > 0) { + const region = staticsRegionOf(text); + if (!region) refuse(`${HOST_BASENAME}: its statics route is not as the first pass wrote it`); + const merged = new Map(region.kinds); + for (const [k, kind] of newStatics) merged.set(k, kind); + if (merged.size > region.kinds.size) { + const t = staticsTexts(merged); + edits.push({ start: region.ahead.start, end: region.ahead.end, text: t.ahead }); + edits.push({ start: region.block.start, end: region.block.end, text: t.block }); + } + } + const out = applyOrderedEdits(text, edits); + const pruned = pruneImports(tm, out, usedImportNames(tm, out)); + + const moduleOut = new Map(); + for (const [file, movers] of modules) { + const mod = moduleTexts.get(file); + const title = movers[0].section; + const modEdits = []; + // The members, in class order among the ones the module holds. + const facet = facetMembersOf(tm, file, mod); + if (!facet) refuse(`${file}: its facet class is not as the first pass wrote it`); + const classPos = (name) => { + const ms = ctx.cls.getMethods().filter((m) => m.getName() === name); + if (ms.length !== 1) refuse(`${file}: facet member ${name} has ${ms.length} methods of that name on the class`); + return ms[0].getStart(); + }; + const held = facet.members.map((m) => ({ ...m, at: classPos(m.name) })); + const hostName = hostNameOf(file); + const groups = new Map(); + for (const r of movers) { + const next = held.find((m) => m.at > r.node.getStart()); + const key = next ? next.name : ''; + groups.set(key, [...(groups.get(key) ?? []), facetMethodText(r, hostName)]); + } + for (const [key, texts] of groups) { + if (key) { + const at = lineStartOf(mod, held.find((m) => m.name === key).node.getStart(true)); + modEdits.push({ start: at, end: at, text: texts.map((t) => `${t}\n\n`).join('') }); + } else { + const at = held.at(-1).node.getEnd(); + modEdits.push({ start: at, end: at, text: texts.map((t) => `\n\n${t}`).join('') }); + } + } + // The header's list gains the names, each before the first listed name declared below it on the class. + const header = headerNamesOf(mod, title); + if (!header) refuse(`${file}: its header is not as the first pass wrote it`); + const listed = [...header.names]; + for (const r of movers) { + const k = listed.findIndex((n) => classPos(n) > r.node.getStart()); + listed.splice(k < 0 ? listed.length : k, 0, r.name); + } + modEdits.push({ start: header.region.start, end: header.region.end, text: moduleHeaderLines(title, listed).join('\n') }); + // The host interface gains what the moved bodies read. + const reads = new Map(); + const writes = new Set(); + for (const r of movers) for (const [k, kind] of r.host) reads.set(k, kind); + for (const r of movers) for (const k of r.writes) writes.add(k); + if (reads.size > 0) { + const iface = interfaceRegionOf(mod, hostName); + if (!iface) refuse(`${file}: its host interface is not as the first pass wrote it`); + const lines = new Map([...iface.members].map(([k, v]) => [k, v.line])); + for (const [k, kind] of reads) { + const readonly = kind === 'getter' && !writes.has(k); + const prev = iface.members.get(k); + if (prev && prev.readonly && !readonly) refuse(`${file}: host member ${k} is read-only there, and a moved body ${kind === 'getter' ? 'assigns' : 'calls'} it`); + if (!prev) lines.set(k, ` ${readonly ? 'readonly ' : ''}${k}: ${iface.classType}['${k}'];`); + } + modEdits.push({ start: iface.start, end: iface.end, text: [...lines.keys()].sort().map((k) => lines.get(k)).join('\n') }); + } + // The import lines gain the bindings the moved bodies read, by specifier. + const decls = importDeclsOf(tm, file, mod); + const adds = new Map(); // specifier -> Map(local -> binding) + for (const r of movers) { + for (const [local, use] of r.imports) { + const b = ctx.scope.bindings.get(local); + const slot = adds.get(b.specifier) ?? new Map(); + const prev = slot.get(local); + slot.set(local, { form: b.form, imported: b.imported, value: Boolean(prev && prev.value) || (use.value && !b.typeOnly) }); + adds.set(b.specifier, slot); + } + } + const tail = decls.findIndex((d) => d.specifier === leafSpecifier() || d.specifier === './engine.js'); + const inserts = new Map(); // offset -> [{ order, text }] + for (const [specifier, slot] of adds) { + const run = decls.map((d, i) => ({ d, i })).filter(({ d }) => d.specifier === specifier); + if (run.length > 0) { + if (run.at(-1).i - run[0].i !== run.length - 1) refuse(`${file}: the imports of ${specifier} are not one run`); + const have = new Map(); + for (const { d } of run) for (const [local, e] of d.bindings) have.set(local, e); + const before = specifierLines(specifier, have); + const span = mod.slice(run[0].d.start, run.at(-1).d.end); + if (!before || before.join('\n') !== span) refuse(`${file}: the imports of ${specifier} are not as the first pass wrote them`); + const merged = new Map(have); + for (const [local, e] of slot) { + const prev = merged.get(local); + if (prev && (prev.form !== e.form || prev.imported !== e.imported)) refuse(`${file}: ${local} is bound differently there`); + merged.set(local, { ...e, value: Boolean(prev && prev.value) || e.value }); + } + modEdits.push({ start: run[0].d.start, end: run.at(-1).d.end, text: specifierLines(specifier, merged).join('\n') }); + continue; + } + for (const [local] of slot) if (decls.some((d) => d.bindings.has(local))) refuse(`${file}: ${local} is bound there from another specifier`); + const lines = specifierLines(specifier, slot); + if (!lines) refuse(`${file}: ${specifier} would be imported in a form this pass does not write`); + const rank = ctx.scope.importOrder.get(specifier); + let at; + let text = `${lines.join('\n')}\n`; + if (specifier === leafSpecifier()) { + const engineDecl = decls.find((d) => d.specifier === './engine.js'); + at = engineDecl ? lineStartOf(mod, engineDecl.start) : null; + } else { + const next = decls.slice(0, tail < 0 ? decls.length : tail).find((d) => { + const o = ctx.scope.importOrder.get(d.specifier); + return o !== undefined && o > rank; + }); + at = next ? lineStartOf(mod, next.start) : tail >= 0 ? lineStartOf(mod, decls[tail].start) : null; + } + if (at === null) { at = decls.at(-1).end; text = `\n${lines.join('\n')}`; } + inserts.set(at, [...(inserts.get(at) ?? []), { order: specifier === leafSpecifier() ? Infinity : rank, text }]); + } + for (const [at, list] of inserts) { + list.sort((a, b) => a.order - b.order); + modEdits.push({ start: at, end: at, text: list.map((x) => x.text).join('') }); + } + moduleOut.set(file, applyOrderedEdits(mod, modEdits)); + } + return { host: pruned.text, modules: moduleOut, pruned: pruned.removed }; +} + +/** + * Undo this pass's moves out of one receiving module, from its head text + * alone: drop the named members and their names from the header's list, keep + * the host members the rest still `Pick`, and prune the imports nothing reads. + * Checks the head on the way (`fail`): the header lists them, the interface is the + * union of the `Pick`s, and no import binding is unread. Returns the text, or + * null when the head is not a module this pass can read. + */ +function undoMembersModule(tm, file, text, names, title, fail) { + const facet = facetMembersOf(tm, file, text); + const header = headerNamesOf(text, title); + if (!facet || !header) { fail(`${file}: not a facet module as the first pass writes one`); return null; } + for (const n of names) if (!header.names.includes(n)) fail(`${file}: its header does not list ${n}`); + const union = (ms) => new Set(ms.flatMap((m) => picksOf(m))); + const iface = interfaceRegionOf(text, hostNameOf(file)); + const headUnion = union(facet.members); + if ((iface ? [...iface.members.keys()].join() : '') !== [...headUnion].sort().join()) fail(`${file}: its host interface is not exactly the members its bodies' Picks name`); + const unread = pruneImports(tm, text, usedImportNames(tm, text)).removed; + if (unread.length > 0) fail(`${file}: imports what nothing in it reads: ${unread.join(', ')}`); + const keep = facet.members.filter((m) => !names.includes(m.name)); + if (keep.length === 0) { fail(`${file}: every member is one this pass moved (it never starts a facet)`); return null; } + const edits = [ + { start: facet.start, end: facet.end, text: keep.map((m) => m.text).join('\n\n') }, + { start: header.region.start, end: header.region.end, text: moduleHeaderLines(title, header.names.filter((n) => !names.includes(n))).join('\n') }, + ]; + if (iface) { + const kept = union(keep); + edits.push({ start: iface.start, end: iface.end, text: [...iface.members].filter(([k]) => kept.has(k)).map(([, v]) => v.line).join('\n') }); + } + return pruneModuleImports(tm, file, applyOrderedEdits(text, edits)); +} + +/** The statics `ObjectQL.x` each routed module's text reads. */ +function routedStaticReads(texts) { + const reads = new Set(); + const routedImport = new RegExp(`^import \\{[^}]*\\b${STATICS_NAME} as ${CLASS_NAME}\\b`, 'm'); + for (const t of texts) { + if (!t || !routedImport.test(t)) continue; + for (const m of t.matchAll(new RegExp(`\\b${CLASS_NAME}\\.([A-Za-z_$][\\w$]*)`, 'g'))) reads.add(m[1]); + } + return reads; +} + +/** + * Prove this pass's moves against their base, from the texts alone (it + * shares no state with `generateMembers`). `baseModules` / `headModules`: the + * first pass's generated modules (leaf and hosts module included) at each + * side, by file. Returns `{ failures, moved, pruned, linesBefore, linesAfter }`. + */ +function verifyMembers(tm, baseText, headText, baseModules, headModules, { + anchors = SECTION_ANCHORS, pins = PATH_READER_PINS, needles = PATH_READER_NEEDLES, classNeedles = CLASS_PATH_READER_NEEDLES, +} = {}) { + const failures = []; + const fail = (msg) => failures.push(msg); + const moved = []; + const base = membersContext(tm, baseText, { pins, needles, classNeedles }); + const headCls = parse(tm, HOST_PATH, headText).getClass(CLASS_NAME); + const done = (pruned = []) => ({ failures, moved: moved.map((mv) => mv.name), pruned, linesBefore: lineCount(baseText), linesAfter: lineCount(headText) }); + if (!headCls) { fail(`no class ${CLASS_NAME} at head`); return done(); } + const key = (m) => `${m.isStatic() ? 'static ' : ''}${m.getName()}`; + const baseMethods = new Map(base.cls.getMethods().map((m) => [key(m), m])); + const headMethods = new Map(headCls.getMethods().map((m) => [key(m), m])); + if ([...baseMethods.keys()].join() !== [...headMethods.keys()].join()) fail(`${CLASS_NAME} does not declare the same methods in the same order at base and head`); + for (const [k, hm] of headMethods) { + const bm = baseMethods.get(k); + if (!bm) continue; + if (isGeneratedStub(bm) && !isGeneratedStub(hm)) fail(`${k}: a stub at base is a body at head`); + if (!isGeneratedStub(hm) || isGeneratedStub(bm)) continue; + const m = STUB_MARK_LINE.exec(hm.getBody().getText()); + moved.push({ name: hm.getName(), hm, bm, file: m[1], docMoved: Boolean(m[2]), usesHost: Boolean(m[3]) }); + } + if (moved.length === 0) fail('nothing moved: the head carries no stub its base did not'); + const sectionOf = readSections(base.cls, anchors); + for (const mv of moved) { + const { name, hm, bm, file } = mv; + const section = sectionOf(bm); + if (!section || section[2] !== file) { fail(`${name}: its stub names ${file}, and its section's module is ${section?.[2] ?? 'none'}`); continue; } + mv.title = section[1]; + const read = readMethod(tm, base, bm); + if (read.reasons.length > 0) fail(`${name}: a stay rule holds it at base (${read.reasons.join('; ')})`); + if ((read.host.size > 0) !== mv.usesHost) fail(`${name}: its stub ${mv.usesHost ? 'hands' : 'hands no'} host, and its base body reads ${read.host.size > 0 ? 'the class' : 'no class member'}`); + const text = headModules.get(file); + const facet = text && text.includes(MARK) ? facetMembersOf(tm, file, text) : null; + const fm = facet?.members.find((m) => m.name === name); + if (!fm) { fail(`${name}: ${file} at head holds no facet member ${name}`); continue; } + mv.fm = fm.node; + mv.moduleText = text; + const body = hm.getBody(); + const closeIndent = headText.slice(lineStartOf(headText, body.getEnd() - 1), body.getEnd() - 1); + if (body.getText() !== stubBody({ node: hm, name, usesHost: mv.usesHost, docMoved: mv.docMoved }, file, closeIndent)) fail(`${name}: the stub is not the first pass's stub`); + if (hm.isAsync()) fail(`${name}: the stub kept \`async\``); + const baseDoc = bm.getScope() === 'private' && bm.getStart(true) < bm.getStart() ? baseText.slice(bm.getStart(true), bm.getStart()) : null; + if (mv.docMoved !== (baseDoc !== null)) fail(`${name}: its stub says its docblock ${mv.docMoved ? 'moved' : 'stayed'}, and the base method is ${baseDoc === null ? 'not a private one with a docblock' : 'a private one with a docblock'}`); + if (fm.text !== facetMethodText({ name, node: bm, host: read.host, doc: baseDoc }, hostNameOf(file))) { + fail(`${name}: the facet member is not its base method's text (docblock, parameters, return type, body)`); + } + } + + // The modules: the receiving ones undo to their base; every other one is its base. + const touched = new Map(); + for (const mv of moved) if (mv.fm) touched.set(mv.file, [...(touched.get(mv.file) ?? []), mv]); + const undone = new Map(); + for (const file of new Set([...baseModules.keys(), ...headModules.keys()])) { + const b = baseModules.get(file) ?? null; + const h = headModules.get(file) ?? null; + if (!touched.has(file)) { + if (b !== h) fail(`${file}: changed, and no member this pass moved is in it`); + continue; + } + if (b === null) { fail(`${file}: absent at base (this pass never starts a facet)`); continue; } + const mvs = touched.get(file); + const u = undoMembersModule(tm, file, h, mvs.map((mv) => mv.name), mvs[0].title, fail); + if (u === null) continue; + undone.set(file, u); + if (u !== b) fail(`undoing the moves out of ${file} does not rebuild its base (first difference at line ${firstDiffLine(u, b)})`); + } + + // engine.ts: each body, docblock and `async` back; the route and the host factories lose what only moved bodies read. + const edits = []; + for (const mv of moved) { + if (!mv.fm) continue; + const body = mv.hm.getBody(); + edits.push({ start: body.getStart(), end: body.getEnd(), text: mv.fm.getBody().getText() }); + if (mv.fm.isAsync()) edits.push({ start: mv.hm.getNameNode().getStart(), end: mv.hm.getNameNode().getStart(), text: 'async ' }); + if (mv.docMoved) edits.push({ start: mv.hm.getStart(), end: mv.hm.getStart(), text: mv.moduleText.slice(mv.fm.getStart(true), mv.fm.getStart()) }); + } + const sections = [...headModules.keys()].filter((f) => f !== LEAF_MODULE && f !== HOSTS_MODULE); + const statics = staticsRegionOf(headText); + if (statics) { + const readAtHead = routedStaticReads(sections.map((f) => headModules.get(f))); + for (const k of statics.kinds.keys()) if (!readAtHead.has(k)) fail(`${STATICS_NAME} carries ${k}, which no routed facet body reads`); + const readUndone = routedStaticReads(sections.map((f) => undone.get(f) ?? headModules.get(f))); + const kept = new Map([...statics.kinds].filter(([k]) => readUndone.has(k))); + if (kept.size !== statics.kinds.size) { + const t = staticsTexts(kept); + edits.push({ start: statics.ahead.start, end: statics.ahead.end, text: t.ahead }); + edits.push({ start: statics.block.start, end: statics.block.end, text: t.block }); + } + } else if (headText.includes(`export interface ${STATICS_HOST_NAME} {`)) { + fail(`${HOST_BASENAME}: its statics route is not as the first pass writes it`); + } + for (const file of touched.keys()) { + const head = headModules.get(file); + const iface = interfaceRegionOf(head, hostNameOf(file)); + if (!iface) continue; + const inst = installRegionOf(headText, file); + if (!inst) { fail(`${file}: its host factory in ${HOST_BASENAME} is not as the first pass writes it`); continue; } + for (const [k, v] of iface.members) { + const kind = inst.members.get(k); + if (!kind || v.readonly !== (kind === 'getter' && !inst.written.has(k))) fail(`${file}: host member ${k} is not the same member in its interface and its factory`); + } + if (inst.members.size !== iface.members.size) fail(`${file}: its host factory forwards members its interface does not declare`); + const u = undone.get(file); + const keptIface = u ? interfaceRegionOf(u, hostNameOf(file)) : null; + if (!keptIface) continue; + const kept = new Map([...inst.members].filter(([k]) => keptIface.members.has(k))); + if (kept.size !== inst.members.size) { + edits.push({ start: inst.start, end: inst.end, text: hostInstallLines(kept, new Set([...inst.written].filter((k) => kept.has(k)))).join('\n') }); + } + } + let undoneHost = null; + try { + undoneHost = applyOrderedEdits(headText, edits); + } catch (e) { + if (!(e instanceof CodemodRefusal)) throw e; + fail(`${HOST_BASENAME}: the undo's edits overlap`); + } + const prune = pruneImports(tm, baseText, usedImportNames(tm, headText)); + if (undoneHost !== null && undoneHost !== prune.text) { + fail(`undoing the moves does not rebuild the base ${HOST_BASENAME} (first difference at line ${firstDiffLine(undoneHost, prune.text)})`); + } + const movedText = moved.map((mv) => mv.bm.getText()).join('\n'); + for (const n of prune.removed) { + if (!new RegExp(`\\b${n.replace(/\$/g, '\\$')}\\b`).test(movedText)) fail(`${HOST_BASENAME} lost the import binding ${n}, which no moved body reads`); + } + return done(prune.removed); +} + +function planReportMembers(planned) { + const out = [`# codemod-split-engine --members (${MEMBERS_CARD}): named methods onto the split class`, '']; + for (const [file, movers] of planned.modules) { + out.push(`- ${file}: ${movers.map((r) => `${r.name} (${r.bodyLines ?? r.node.getBody().getEndLineNumber() - r.node.getBody().getStartLineNumber() + 1} body lines${r.doc ? ', its docblock with it' : ''})`).join(', ')}`); + } + return out.join('\n'); +} + +function printVerifyMembers(v) { + for (const f of v.failures) console.error(` ✗ ${f}`); + console.log(`undo proof: ${v.moved.length} moved method(s) (${v.moved.join(', ') || 'none'}); engine.ts ${v.linesBefore} -> ${v.linesAfter} lines; ` + + `undoing the moves ${v.failures.length > 0 ? 'does NOT' : 'does'} rebuild the base engine.ts and its modules byte-for-byte ` + + `once the ${v.pruned.length} import binding(s) only moved bodies read are pruned from the base (${v.pruned.join(', ') || 'none'})`); +} + +function generatedModulesAt(ref) { + const out = new Map(); + for (const file of [LEAF_MODULE, HOSTS_MODULE, ...SECTION_ANCHORS.map((row) => row[2])]) { + if (!file) continue; + try { out.set(file, gitShow(ref, `${SRC_REL}/${file}`)); } catch { /* absent at the ref */ } + } + return out; +} + +async function mainMembers(tm, argv) { + if (argv.includes('--verify')) { + const i = argv.indexOf('--base'); + const ref = i >= 0 ? argv[i + 1] : null; + if (!ref) refuse('--members --verify needs --base '); + const v = verifyMembers(tm, gitShow(ref, `${SRC_REL}/${HOST_BASENAME}`), readFileSync(HOST_PATH, 'utf8'), generatedModulesAt(ref), generatedModulesOnDisk()); + printVerifyMembers(v); + return v.failures.length === 0 ? 0 : 1; + } + const j = argv.indexOf('--members'); + const value = argv[j + 1]; + const names = value && !value.startsWith('--') ? value.split(',').map((s) => s.trim()).filter(Boolean) : []; + const input = readFileSync(HOST_PATH, 'utf8'); + const moduleTexts = generatedModulesOnDisk(); + const planned = planMembers(tm, input, moduleTexts, names); + console.log(planReportMembers(planned)); + const out = generateMembers(tm, input, moduleTexts, planned); + const headModules = new Map([...moduleTexts, ...out.modules]); + console.log(`output tree sha256: ${treeHash(new Map([[HOST_BASENAME, out.host], ...headModules]))} (input sha256 ${sha256(input)})`); + const v = verifyMembers(tm, input, out.host, moduleTexts, headModules); + printVerifyMembers(v); + if (v.failures.length > 0) { + console.error('✗ the generated moves fail the undo proof: nothing written. Report the fork; never hand-edit the moves.'); + return 1; + } + if (!argv.includes('--write')) { + console.log('plan only: nothing written (pass --write to apply).'); + return 0; + } + 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; +} + // ── Entry ──────────────────────────────────────────────────────────────────── const USAGE = [ @@ -3069,6 +3785,8 @@ const USAGE = [ ' node scripts/codemod-split-engine.mjs --scoped --verify --base REF re-prove the second pass on disk against engine.ts at REF', ' node scripts/codemod-split-engine.mjs --hand-docs [--modules A.ts,B.ts] [--write] plan (or apply) the third pass: the hand series\' private docblocks', ' node scripts/codemod-split-engine.mjs --hand-docs --verify --base REF re-prove the third pass on disk against engine.ts and its hand modules at REF', + ' node scripts/codemod-split-engine.mjs --members A,B [--write] plan (or apply) the fourth pass: move the named ObjectQL methods onto the split class', + ' node scripts/codemod-split-engine.mjs --members --verify --base REF re-prove the fourth pass on disk against engine.ts and its modules at REF', ' node scripts/codemod-split-engine.mjs --self-test', ' node scripts/codemod-split-engine.mjs --help', ].join('\n'); @@ -3094,6 +3812,7 @@ async function main(argv) { const tm = await loadTsMorph(); if (argv.includes('--scoped')) return mainScoped(tm, argv); if (argv.includes('--hand-docs')) return mainHandDocs(tm, argv); + if (argv.includes('--members')) return mainMembers(tm, argv); if (argv.includes('--verify')) { const i = argv.indexOf('--base'); const ref = i >= 0 ? argv[i + 1] : null; From ec4b138b77c662eb9928bdf3b138fe05293af5e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 11:38:38 +0000 Subject: [PATCH 4/8] fix(scripts): the fourth pass keeps engine.ts's leaf import wrapped as the first pass wrote it Claude-Session: https://claude.ai/code/session_01Utp6CXX2qjqm2uXbJA8Jcv Co-authored-by: Claude --- scripts/codemod-split-engine.mjs | 37 +++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/scripts/codemod-split-engine.mjs b/scripts/codemod-split-engine.mjs index 990ac00702a..f903fa09d63 100644 --- a/scripts/codemod-split-engine.mjs +++ b/scripts/codemod-split-engine.mjs @@ -3139,6 +3139,35 @@ function applyOrderedEdits(text, edits) { return out; } +/** + * `pruneImports` for this pass's `engine.ts`, with one difference: the first + * pass's import of the leaf is a `namedStatement`, so a binding dropped from it + * leaves it wrapped as `namedStatement` wraps it (`pruneImports` would rewrite + * a wrapped list one binding per line). Pure. Returns `{ text, removed }`. + */ +function pruneHostImports(tm, text, used) { + const ts = tm.ts; + const sf = parseSourceFile(HOST_PATH, text); + const edits = []; + const removed = []; + for (const st of sf.statements) { + if (!ts.isImportDeclaration(st) || st.moduleSpecifier.text !== leafSpecifier()) continue; + const clause = st.importClause; + const nb = clause && clause.namedBindings; + if (!clause || clause.name || !nb || !ts.isNamedImports(nb) || nb.elements.some((e) => e.propertyName || e.isTypeOnly)) continue; + const names = nb.elements.map((e) => e.name.text); + const kept = names.filter((n) => used.has(n)); + if (kept.length === names.length) continue; + removed.push(...names.filter((n) => !used.has(n))); + const end = text[st.getEnd()] === '\n' ? st.getEnd() + 1 : st.getEnd(); + edits.push(kept.length === 0 + ? { start: st.getStart(sf), end, text: '' } + : { start: st.getStart(sf), end: st.getEnd(), text: namedStatement(clause.isTypeOnly ? 'import type' : 'import', kept, ` from '${leafSpecifier()}';`) }); + } + const rest = pruneImports(tm, applyOrderedEdits(text, edits), used); + return { text: rest.text, removed: [...removed, ...rest.removed].sort() }; +} + /** The analysis context of an `engine.ts` the first pass has split: `plan`'s, without its unsplit-file guards. */ function membersContext(tm, text, { pins = PATH_READER_PINS, needles = PATH_READER_NEEDLES, classNeedles = CLASS_PATH_READER_NEEDLES } = {}) { const sf = parse(tm, HOST_PATH, text); @@ -3421,7 +3450,7 @@ function generateMembers(tm, text, moduleTexts, planned) { } } const out = applyOrderedEdits(text, edits); - const pruned = pruneImports(tm, out, usedImportNames(tm, out)); + const pruned = pruneHostImports(tm, out, usedImportNames(tm, out)); const moduleOut = new Map(); for (const [file, movers] of modules) { @@ -3706,7 +3735,7 @@ function verifyMembers(tm, baseText, headText, baseModules, headModules, { if (!(e instanceof CodemodRefusal)) throw e; fail(`${HOST_BASENAME}: the undo's edits overlap`); } - const prune = pruneImports(tm, baseText, usedImportNames(tm, headText)); + const prune = pruneHostImports(tm, baseText, usedImportNames(tm, headText)); if (undoneHost !== null && undoneHost !== prune.text) { fail(`undoing the moves does not rebuild the base ${HOST_BASENAME} (first difference at line ${firstDiffLine(undoneHost, prune.text)})`); } @@ -3736,7 +3765,9 @@ function generatedModulesAt(ref) { const out = new Map(); for (const file of [LEAF_MODULE, HOSTS_MODULE, ...SECTION_ANCHORS.map((row) => row[2])]) { if (!file) continue; - try { out.set(file, gitShow(ref, `${SRC_REL}/${file}`)); } catch { /* absent at the ref */ } + try { + out.set(file, execFileSync('git', ['show', `${ref}:${SRC_REL}/${file}`], { cwd: ROOT, encoding: 'utf8', maxBuffer: 1 << 28, stdio: ['ignore', 'pipe', 'ignore'] })); + } catch { /* absent at the ref */ } } return out; } From baf1a536e80aba2915d752dc3ab6bea47b7c63ec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 11:43:50 +0000 Subject: [PATCH 5/8] refactor(objectql): move the six formerly erasure-pinned ObjectQL bodies out of engine.ts with the codemod's fourth pass node scripts/codemod-split-engine.mjs --members seedAutonumber,referenceExists,resolveMasterDetailParent,resolveMasterDetailParents,inspectDanglingReferences,probeReferencingRows --write The six #30019 no-body exceptions leave the facade registry; engine.ts leaves the #4918 erasure baseline (0 sites); the referenceExists read-invention entry follows its body to engine-references-and-master-detail.ts. Claude-Session: https://claude.ai/code/session_01Utp6CXX2qjqm2uXbJA8Jcv Co-authored-by: Claude --- .../objectql/src/engine-cascade-planning.ts | 116 +++- .../src/engine-defaults-and-autonumbers.ts | 164 +++++- .../engine-references-and-master-detail.ts | 254 ++++++++- packages/objectql/src/engine.ts | 530 ++---------------- .../durability-read-invention.baseline.json | 2 +- scripts/facade-delegation-registry.json | 42 -- scripts/query-options-erasure-baseline.json | 1 - 7 files changed, 558 insertions(+), 551 deletions(-) diff --git a/packages/objectql/src/engine-cascade-planning.ts b/packages/objectql/src/engine-cascade-planning.ts index 4e6bba4625f..961fa139598 100644 --- a/packages/objectql/src/engine-cascade-planning.ts +++ b/packages/objectql/src/engine-cascade-planning.ts @@ -6,9 +6,9 @@ * `storedReferenceIncludes`, `remainderAfterMemberRemoval`, * `referenceCheckContext`, `dependentCountIsDisclosable`, * `cascadeDeleteRelations`, `cascadeRelationBehavior`, - * `fileReferenceCheckElevation`, `referenceProbeFilterOfSet`, - * `probeReferencingRowsOfSet`, `walkReferencingRelations`, - * `isTenantColumnSetNull`, `outlivesOrganization`, + * `fileReferenceCheckElevation`, `probeReferencingRows`, + * `referenceProbeFilterOfSet`, `probeReferencingRowsOfSet`, + * `walkReferencingRelations`, `isTenantColumnSetNull`, `outlivesOrganization`, * `keepsOrganizationReference`, `countReferencingRowsOutside`, * `organizationCountContext`, `organizationCountIsDisclosable`, * `refuseOrganizationDeleteWhileOwned`, `organizationOwnedRowCounts` moved out @@ -658,6 +658,116 @@ export class CascadePlanningFacet { this.recordReferenceCheckElevation(object, id, childName, fieldName, context); } + /** + * The dependents probe of {@link cascadeDeleteRelations}: the rows of + * `childName` whose `fieldName` references `id`, read under SYSTEM identity + * and exactly narrowed. [#22305] Both phases read through it, so the set the + * collection walk gathers and the rows the deleting walk acts on are one + * reading of the same relation. + */ + static async probeReferencingRows(this: Pick, childName: string, fieldName: string, fdef: any, id: string | number | ReadonlyArray, probeWhere: Record, context: ExecutionContext | undefined): Promise { + let dependents: any[]; + try { + dependents = await this.find( + childName, + // [#12166] (maintainer ruling 2026-08-26, option A) SYSTEM identity, + // unconditionally. This probe is the platform's own referential- + // integrity read, not a query the caller asked for, and running it + // as the caller made "delete permission" silently mean "delete + + // read on EVERY referencing table": a caller with full delete rights + // on `object` but no read grant on `childName` got a blanket 403 + // from the security middleware — whether or not a reference existed, + // and with `childName` EMPTY. Measured on a real deployment across + // 17 role×object pairs, with the A/B control that granting read-only + // on the referencing object (touching NOTHING about delete rights) + // turned the identical delete into a 200. + // + // Referential-integrity actions are engine responsibility executed + // under system identity on every mainstream platform — the RDBMS FK + // baseline, Salesforce (lookup clearing / cascade delete documented + // as bypassing sharing), Dataverse, ServiceNow, Odoo. Caller + // identity here was the outlier. + // + // The elevation is `sudo()`-SHAPED (`{ ...context, isSystem: true }`), + // never a bare `{ isSystem: true }` — the same posture + // `recomputeSummaries` holds one axis over. Three reasons, and each + // one is a defect if dropped: + // - the open transaction handle, `tenantId` and `timezone` must + // survive, or this probe leaves the caller's transaction and + // stops being TENANT-scoped — a bare system context would widen + // the probe across the tenant wall, which is the opposite of + // what this card relaxes; + // - `userId` survives, and that IS the audit ledger's + // "triggered-by" half (ruling constraint 3): the record carries + // triggered-by = the deleting operator and executed-as = system. + // `read-audit.ts` states the same property of `sudo()`; + // - it is a NARROW elevation: nothing else about the delete path + // changes identity (ruling constraint 1). The `set_null` UPDATE + // and the `cascade` DELETE below still run as the caller, byte + // for byte, so this relaxes the reference CHECK and not the + // caller's own authority over the dependent rows. + // + // [Constraint 4] If the spec later declares per-relationship + // on-delete behaviour, BEHAVIOUR follows the declaration; the + // identity of this probe stays system, unconditionally. Do not make + // this line conditional on `behavior`. + { where: probeWhere, context: ObjectQL.referenceCheckContext(context) }, + ); + } catch (error) { + // [#8895] Discriminate by error TYPE — this probe IS the referential + // guard, so an empty answer is only truthful for the one failure that + // really means "no dependents". + // + // The bare `catch { continue }` this replaces reached `continue` on + // ANY probe failure, and every consequence of that is silent: the + // walk's `restrict` branch never fires, so a delete the integrity + // rules say must be REFUSED is allowed through; `set_null`/`cascade` + // never run, so child rows that should have been nulled or removed + // are left orphaned; nothing is logged and the caller is told the + // delete succeeded. Fail-OPEN on an integrity guard — the read did + // not happen and the answer "there are none" was invented for it + // (ADR-0110 D3: "the probe found nothing" and "the probe could not + // run" are different facts with opposite meanings here). + // + // Benign: the child object is registered but its TABLE was never + // provisioned (schema sync not run yet). It cannot hold a row that + // references anything, so zero dependents IS the truth and skipping + // the relation is correct. Asked through the shared + // `isMissingTableError` predicate (`@objectstack/metadata/errors`, + // #4825) — the same call `seedAutonumber` and `resolveFileReferences` + // make — never a hand-rolled code test. + // + // Everything else (connection drop, timeout, permission denial, a + // query error, a missing COLUMN on a provisioned table) means the + // dependents may well exist and simply were not seen. It propagates: + // the delete fails loudly and nothing is written, which is the same + // disposition the maintainer's 2026-08-15 ruling gives this family — + // unprovisioned is truthful emptiness, everything else must surface. + // A guard that could not be EVALUATED must not silently pass. + // + // No new response field and no new error code: the caller receives + // the probe's own failure, envelope intact. + if (isMissingTableError(error, childName)) return []; + throw error; + } + // [#9362] The multi-value pushdown in the probe filter can be a SUPERSET + // (the substring reading, on a backend without the declaration), so the + // exact answer is taken here, on the rows themselves. Everything the walk + // does with them — the `restrict` count in the 409 envelope, the `cascade` + // recursion, the `set_null` write — and the set the collection walk + // gathers read this answer, so narrowing anywhere later would leave one of + // them acting on a row that never referenced this record. + // [#27004] Over a set of ids (`probeReferencingRowsOfSet`), a row is kept + // when it holds any of them. + if (declaredMultiValued(fdef) && dependents) { + const wanted: ReadonlyArray = Array.isArray(id) ? id : [id as string | number]; + dependents = dependents.filter((row) => + wanted.some((w) => ObjectQL.storedReferenceIncludes(row?.[fieldName], w)), + ); + } + return dependents ?? []; + } + /** * [#27004] {@link referenceProbeFilter} for a SET of records: the rows that * reference any of `ids`. A single-valued reference asks `$in`; a diff --git a/packages/objectql/src/engine-defaults-and-autonumbers.ts b/packages/objectql/src/engine-defaults-and-autonumbers.ts index e008bdb076d..ff4a1f00349 100644 --- a/packages/objectql/src/engine-defaults-and-autonumbers.ts +++ b/packages/objectql/src/engine-defaults-and-autonumbers.ts @@ -5,8 +5,9 @@ * the bodies of `resolveNowDefault`, `normalizeExpressionDefault`, * `resolveOptionDefault`, `applyFieldDefaults`, `defaultFieldEntries`, * `applyAutonumbers`, `adoptExplicitAutonumber`, `createWithAutonumberResync`, - * `isIssuedAutonumberCollision`, `warnAutonumberReadersUnserved` moved out of - * `engine.ts` whole by `scripts/codemod-split-engine.mjs`. + * `isIssuedAutonumberCollision`, `warnAutonumberReadersUnserved`, + * `seedAutonumber` moved out of `engine.ts` whole by + * `scripts/codemod-split-engine.mjs`. * * A MOVE, not a redesign. {@link DefaultsAndAutonumbersFacet} is a stateless facet of `ObjectQL` * (ruling 6624345766): each static method of it is the body of the `ObjectQL` @@ -32,14 +33,17 @@ */ import { isCurrentUserDefaultToken, isNowDefaultToken, missingFieldValues, parseAutonumberFormat, renderAutonumber, resolveAutonumberFormat } from '@objectstack/spec/data'; -import { isUniqueViolationError, uniqueViolationColumn } from '@objectstack/types'; +import type { EngineQueryOptions } from '@objectstack/spec/data'; +import { isUniqueViolationError, keysetWalk, uniqueViolationColumn } from '@objectstack/types'; +import type { KeysetPageQuery } from '@objectstack/types'; import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { isMissingTableError } from '@objectstack/metadata/errors'; import { envelopeDriverWriteRefusal } from './not-null-refusal-error.js'; import { ExpressionEngine } from '@objectstack/formula'; import type { EvalPermissions } from '@objectstack/formula'; import { - AUTONUMBER_COLLISION_ATTEMPTS, declaredMultiValued, isExpressionDefault, readStoredAutonumberCounter, - recomputeAutonumberReaders, + AUTONUMBER_COLLISION_ATTEMPTS, AUTONUMBER_SEED_PAGE_SIZE, declaredMultiValued, isExpressionDefault, + readStoredAutonumberCounter, recomputeAutonumberReaders, } from './engine-top-level.js'; import type { AutonumberReaders, FormulaPlanEntry, IssuedAutonumber } from './engine-top-level.js'; import type { ObjectQL } from './engine.js'; @@ -57,6 +61,7 @@ export interface DefaultsAndAutonumbersHost { readonly autonumberCounters: ObjectQL['autonumberCounters']; readonly autonumberReadersUnservedReported: ObjectQL['autonumberReadersUnservedReported']; defaultFieldEntries: ObjectQL['defaultFieldEntries']; + find: ObjectQL['find']; getSchema: ObjectQL['getSchema']; isIssuedAutonumberCollision: ObjectQL['isIssuedAutonumberCollision']; readonly logger: ObjectQL['logger']; @@ -854,4 +859,153 @@ export class DefaultsAndAutonumbersFacet { { object, fields, driver: driverName }, ); } + + /** + * Seed the autonumber counter from the current max in store, scoped to + * `prefix`. With a non-empty prefix (date/field formats) only rows in the + * same scope count. + * + * # Locating the counter inside a stored value (#6468) + * + * `renderAutonumber` composes `prefix + zero-padded(seq) + suffix`, and + * `suffix` is a DECLARED return value: every token after the `{0..0}` slot + * renders behind the counter (`{000}-{YYYY}` → `001-2026`). So the counter is + * NOT "the digits at the end of the string" — reading it that way took the + * year for the counter and seeded `2026` against a true counter of `1`, which + * jumps the next issued number to `2027-2026` and burns the band in between. + * + * Both the rendered `prefix` and the rendered `suffix` therefore come in from + * the caller (they are `renderAutonumber`'s own output — this method does not + * re-derive any format understanding of its own, and neither does the SQL + * driver's `scanMaxNumericTail`, which is handed the same two strings): + * + * - **Either one declared ⇒ the slot is ANCHORED**: the counter is the digit + * run at the START of what follows the prefix, after removing the declared + * suffix when this row carries it. That half is spec's + * `readAutonumberCounter` — the declared inverse of `renderAutonumber`, + * which the SQL driver's scan calls too, so one edit moves both sides + * (#6560). + * - **Neither declared ⇒ UNANCHORED**: the legacy reading is kept — the LAST + * digit run of the whole value. A format with no `{0..0}` slot renders a + * bare trailing counter, and values predating any format have no anchor to + * read from, so this stays exactly as it was. The SQL driver's legacy + * reading of this case differs on purpose, which is why it stays per-side. + * + * The two together are {@link readStoredAutonumberCounter}, module-level + * rather than inline, because #6806's resync must read an exempt writer's + * supplied value by exactly these rules. + * + * The suffix is *stripped when it matches*, never *required* to match: a + * dynamic suffix renders differently per row (`{000}-{YYYY}` is `-2025` on + * last year's rows) while the counter scope is the rendered PREFIX — here `''` + * — so those rows share this very counter and must still be counted. Skipping + * them would seed BELOW the real max, which is the duplicate-record-number + * harm #6249 fixed on the scan side. Reading the leading digit run gets them + * right regardless; the strip only adds precision when a suffix begins with a + * digit (`{0000}{YYYY}`, whose values are ambiguous by construction — the + * compile lint nudges authors to a delimiter). + * + * # Why this walks every row in the scope (#6249) + * + * The seed used to be one `find` with `limit: 5000`, no `orderBy` and no + * filter: the max of an ARBITRARY 5000-row window (on SQL, typically the + * oldest 5000 rows), which for any object past that size — or any scope + * whose rows sit outside the window because other scopes filled it — seeds + * BELOW the real MAX. The counter then issues numbers from an already-taken + * band, and on a `unique` record-number field that is a duplicate business + * identifier: a value written wrong, which no retry and no restart repairs + * (the same class of harm as the read-outage half fixed in #5979/#6114). + * + * The scan is therefore complete rather than windowed, in the shape the + * SQL driver's own seeding already uses (`scanMaxNumericTail` pushes + * `like 'prefix%'` down with NO limit). Two deliberate choices: + * + * - **The numeric max is computed here, never delegated to an ORDER BY or + * an aggregate `max`.** Both of those rank the stored value as TEXT, and + * lexicographic order equals numeric order only when every value in the + * scope is zero-padded to one fixed width — which the format language + * does not guarantee (`{0}` pads to nothing, and any width OVERFLOWS + * once the counter passes it, putting `CASE-99999` above `CASE-100000`). + * The empty-prefix legacy path, which reads the LAST digit run of the + * whole value, has no lexicographic reading at all. Parsing every value + * keeps one code path correct for every format instead of a fast path + * guarded by assumptions a format author can silently break. + * - **Seek pagination, not `offset`** — `keysetWalk`'s own rationale + * (#4363): an offset walk cannot promise it visited every row, and a + * row it skips is exactly a number this seed must not miss. + * + * `prefix` is pushed down as `$startsWith` so a date/`{field}` scope reads + * its own rows instead of paging through every other scope's. The JS-side + * `startsWith` re-check below is kept as the authority: a driver whose + * matching is LOOSER (a case-insensitive `LIKE`) must not be able to inflate + * the max with another scope's rows. + */ + static async seedAutonumber(this: Pick, object: string, field: string, prefix: string, suffix: string, execCtx?: ExecutionContext): Promise { + try { + // Canonical `fields`, not the wire spelling `select` — this call sat on + // the exact #4371 silent drop (the projection never applied; the scan + // worked only because an unprojected row still carries `field`), and the + // catch below would have swallowed the guard's rejection into "seed + // from 0", i.e. duplicate autonumbers. + const walk = keysetWalk>( + (q: KeysetPageQuery) => this.find(object, { + // The walk spells its `where` `unknown` (this scan's filter AND-ed + // with the seek predicate); it is read as the engine's `where` slot. + ...(q as Omit & { where?: EngineQueryOptions['where'] }), + fields: ['id', field], + context: execCtx, + }), + { + where: prefix ? { [field]: { $startsWith: prefix } } : undefined, + pageSize: AUTONUMBER_SEED_PAGE_SIZE, + }, + ); + let max = 0; + for await (const page of walk.pages()) { + for (const r of page) { + const v = r?.[field]; + if (v == null) continue; + // The reading itself lives in `readStoredAutonumberCounter` (the + // section above describes it) because #6806's adopt-on-exempt-write + // resync must read a supplied value by the SAME rules this scan reads + // a stored one — two copies of it would drift into two different + // answers for one row, which is a duplicate record number. Its + // anchored half is spec's `readAutonumberCounter`, the one the SQL + // driver's own scan calls (#6560). + const counter = readStoredAutonumberCounter(String(v), prefix, suffix); + if (counter != null) max = Math.max(max, counter); + } + } + // The walk is unbounded (no `max`), so truncation here means the scan + // could not COMPLETE: a row carried no `id` to seek past, or the reader + // never applied the seek predicate. Either way rows were left unread, and + // the max over what was read is a floor, not the max. Answering with it + // is the "seed below the real MAX" defect this method was fixed for, so + // it fails loudly instead — the same disposition #6114 gave the read + // outage: allocate nothing, write nothing. + if (walk.truncated) { + throw new Error( + `Cannot seed the autonumber counter for "${object}.${field}": the seeding scan ` + + `could not visit every stored row (it stopped after ${walk.scanned} rows without ` + + `reaching the end). Seeding from a partial scan would issue record numbers that ` + + `collide with existing ones, so no number was allocated.`, + ); + } + return max; + } catch (error) { + // [#5979] Discriminate by error TYPE. Seeding from 0 is the truth for + // exactly ONE failure reason — the table has not been provisioned, so + // there are genuinely no rows and number 1 collides with nothing. + if (isMissingTableError(error, object)) return 0; + // Every other failure (connection drop, timeout, permission denial, + // query error) means the rows may well exist and simply were not seen. + // Answering 0 there restarts the sequence at 1 against a table already + // holding N rows and issues autonumbers that COLLIDE with existing ones + // — a value written wrong, which no retry and no restart repairs. So the + // read failure propagates and the caller allocates nothing: the write + // fails loudly instead of succeeding with a forged business identifier. + // This is the hazard the #4371 comment above the read already named. + throw error; + } + } } diff --git a/packages/objectql/src/engine-references-and-master-detail.ts b/packages/objectql/src/engine-references-and-master-detail.ts index 77913761e38..898b8c27db1 100644 --- a/packages/objectql/src/engine-references-and-master-detail.ts +++ b/packages/objectql/src/engine-references-and-master-detail.ts @@ -5,10 +5,12 @@ * "Realtime, i18n, home amounts, validation messages, references, master-detail" * section of `ObjectQL`: the bodies of `setRealtimeService`, `setI18nService`, * `setHomeLocalizationResolver`, `homeAmountOrganizationOf`, - * `previewHomeAmounts`, `projectHomeAmountsOntoPatch`, - * `masterDetailHeaderExists`, `materializeParentHeader`, - * `masterDeclaredFields`, `inspectCascadeDelete`, `getCascadeDeleteObjects` - * moved out of `engine.ts` whole by `scripts/codemod-split-engine.mjs`. + * `previewHomeAmounts`, `projectHomeAmountsOntoPatch`, `referenceExists`, + * `masterDetailHeaderExists`, `resolveMasterDetailParent`, + * `resolveMasterDetailParents`, `materializeParentHeader`, + * `masterDeclaredFields`, `inspectDanglingReferences`, `inspectCascadeDelete`, + * `getCascadeDeleteObjects` moved out of `engine.ts` whole by + * `scripts/codemod-split-engine.mjs`. * * A MOVE, not a redesign. {@link ReferencesAndMasterDetailFacet} is a stateless facet of `ObjectQL` * (ruling 6624345766): each static method of it is the body of the `ObjectQL` @@ -39,12 +41,19 @@ * this module. */ +import type { EngineQueryOptions } from '@objectstack/spec/data'; import type { IRealtimeService } from '@objectstack/spec/contracts'; +import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel'; import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { postureUsesUnionScope } from '@objectstack/spec/security'; import { homeAmountsTouched, planHomeAmounts, projectHomeAmounts } from './home-amount-projection.js'; import type { HomeAmountPlanEntry, HomeAmountReads, HomeAmountServing, HomeLocalizationResolver } from './home-amount-projection.js'; +import { resolveMasterDetailRelation } from './master-detail.js'; import { materializeDeclaredFields } from './declared-fields.js'; import { collectCascadeDeleteObjects } from './engine-delete-cascade.js'; +import { auditDanglingReferences } from './integrity/dangling-reference-audit.js'; +import type { AuditableObject, DanglingReferenceAuditOptions, DanglingReferenceReport } from './integrity/dangling-reference-audit.js'; +import { masterIdOf } from './engine-top-level.js'; import { ObjectQLStatics as ObjectQL } from './engine.js'; import type { ObjectQL as ObjectQLClass } from './engine.js'; @@ -59,14 +68,18 @@ export interface ReferencesAndMasterDetailHost { readonly _registry: ObjectQLClass['_registry']; cascadeRelationBehavior: ObjectQLClass['cascadeRelationBehavior']; collectCascadeDeleteSet: ObjectQLClass['collectCascadeDeleteSet']; + find: ObjectQLClass['find']; + findOne: ObjectQLClass['findOne']; homeAmountServing: ObjectQLClass['homeAmountServing']; homeLocalizationResolver: ObjectQLClass['homeLocalizationResolver']; i18nService: ObjectQLClass['i18nService']; readonly logger: ObjectQLClass['logger']; masterDeclaredFields: ObjectQLClass['masterDeclaredFields']; masterDetailHeadersCallerReads: ObjectQLClass['masterDetailHeadersCallerReads']; + materializeParentHeader: ObjectQLClass['materializeParentHeader']; realtimeService: ObjectQLClass['realtimeService']; referenceExists: ObjectQLClass['referenceExists']; + resolveEnginePosture: ObjectQLClass['resolveEnginePosture']; resolveObjectName: ObjectQLClass['resolveObjectName']; } @@ -154,6 +167,42 @@ export class ReferencesAndMasterDetailFacet { })); } + /** + * Does `id` name a row in `target`? `false` only when the probe RAN and found + * nothing; `null` when it could not run at all (see the fail-open note on + * {@link assertReferencesResolve}). + * + * `context` is the CALLER's execution context. The probe runs under + * {@link ObjectQL.referenceCheckContext} — the same `sudo()`-shaped elevation + * the pre-delete reference check uses, so the two reference checks share one + * spelling: RLS/FLS bypassed, the caller's `tenantId` (and, under the `group` + * posture, its membership set) forwarded to the driver by + * `buildDriverOptions`. A row outside the caller's tenant scope is therefore + * `false` here, exactly like a row that exists nowhere (#19808). + * + * Without a `context` the elevation is the bare `{ isSystem: true }` and the + * probe spans every organization. {@link inspectDanglingReferences}, which + * has no caller, passes the SCANNED row's own organization as the context's + * `tenantId` instead (#19837), and no context only for a row that carries + * none or under a union (`group`) posture — so outside `group` the audit + * reports a stored reference into another organization, the class this guard + * now refuses. + */ + static async referenceExists(this: Pick, target: string, id: unknown, context?: ExecutionContext): Promise { + try { + const resolved = this.resolveObjectName(target); + if (!this._registry.getObject(resolved)) return null; + const row = await this.findOne(resolved, { + where: { id }, + fields: ['id'], + context: ObjectQL.referenceCheckContext(context), + }); + return !!row; + } catch { + return null; + } + } + /** * [#22519] The write's reference check for a `master_detail` field: does `id` * name a header THIS CALLER can read? The write's two header reads, as @@ -180,6 +229,183 @@ export class ReferencesAndMasterDetailFacet { return this.referenceExists(target, id, context); } + /** + * [#4889] The master-detail header a detail row's `parent`-scoped + * `readonlyWhen` reads, or `null` when this write cannot resolve one. + * + * `readonlyWhen: parent.status == 'paid'` is a documented **server** + * guarantee (ADR-0124 D1 puts enforcement here; the client grid is + * courtesy), but the strip is a pure function over the payload and the prior + * row — it has no driver and cannot fetch a header. So the engine resolves it + * and passes it in. + * + * The header id comes from the payload first, then the prior row: a write that + * REPOINTS the detail at another master must be judged against the master it + * is landing on, not the one it is leaving. + * + * ## Two reads: may the caller see the header, then what it holds (#22519) + * + * A write of a detail whose header the caller cannot read answers exactly + * what a write naming a header that exists nowhere answers — for every + * detail, `controlled_by_parent` or not, on insert, update (a repoint + * included) and the bulk paths. The caller's right to write the detail does + * NOT settle its right to read the header: ADR-0055 §3 gates a detail write + * on its header (master edit, which implies read) only for a + * `controlled_by_parent` detail, and a detail with a sharing model of its own + * meets no header check on write. So the header is read twice, each read + * answering one question, in the shape the preview reads it in + * ({@link resolvePreviewParents}): + * + * 1. ⛔ MAY THIS CALLER SEE THE HEADER — the read door, under the caller's + * own context ({@link masterDetailHeadersCallerReads}). A header it does + * not return binds as a missing header binds: `null`, so a parent-scoped + * `requiredWhen` / `readonlyWhen` meets an unbound `parent`, and the + * reference check answers `reference_not_found` for it + * ({@link masterDetailHeaderExists}). + * 2. WHAT THE HEADER HOLDS — the write's own ELEVATED read below, for a + * header read 1 returned only. The lock is a data-integrity property of + * the header's state, so a header the caller can read is judged on its + * STORED values, a column field-level security hides from or masks for + * this caller included: author-defined integrity rules run on true data. + * The preview's per-column masking is the preview's own, not the write's. + * + * A SYSTEM caller (seed replay, boot) asks no read door: read 2 alone, as + * before. + * + * ## The elevation keeps the tenant wall (#19837) + * + * Read 2's argument is about the VALUES of a header the caller may see, never + * about TENANCY, so the + * read runs under {@link ObjectQL.referenceCheckContext} — the `sudo()`-shaped + * `{ ...context, isSystem: true }` both reference checks share — and never a + * bare `{ isSystem: true }`. The bare spelling carried no `tenantId`, so the + * header was found in ANY organization and a `parent.*` predicate was judged + * against another organization's row: an org-bound caller naming that row's + * id learned one bit of it per write (`requiredWhen` answered `required` for a + * `locked` header and `reference_not_found` for an `open` one; `readonlyWhen` + * dropped or kept the field). With the caller's context spread first, + * `buildDriverOptions` forwards its `tenantId` (and, under the `group` + * posture, its membership set as `tenantIds`), a header stamped with an + * organization outside that scope — another organization, or under `group` + * one outside the caller's membership set — is not found, and it binds + * exactly as a header that exists nowhere does — absent. The tenancy exemptions stay where they are decided: + * a `tenancy.enabled: false` or federated master gets no `tenantId`. A + * header with no organization is no tenant's (ADR-0131 D1): the driver's + * scope carries no `IS NULL` arm, so a scoped read does not find it. A system + * caller with no `tenantId` (seed replay, boot) still reads unscoped. + * + * `null` on any failure — no relation, no id, header not returned by the + * caller's read door, header gone or outside the caller's tenant scope, read + * threw. It is NOT read as "unlocked": an + * unresolved binding leaves `parent` unbound, and `isReadonlyWhenLocked` + * treats a predicate that needs it as LOCKED. + * + * [#6457] A header that IS resolved is handed over TOTAL over the MASTER + * object's declared fields — see {@link materializeParentHeader} for why that + * had to happen here and nowhere else, and for the verdict table it moves. + */ + static async resolveMasterDetailParent(this: Pick, schema: any, data: Record | null | undefined, priorRow: Record | null | undefined, context: ExecutionContext | undefined): Promise | null> { + const rel = resolveMasterDetailRelation(schema); + if (!rel) return null; + // [#24358] ⛔ A header in the stored-metadata-body family is never read, + // under either authority. Read 2 below is a whole-row read the family's + // projection never reaches, so a `parent.*` predicate would be judged over + // the stored body and its content hash unprojected: one bit of material the + // family's own door withholds, per write. `parent` stays unbound, the + // fail-closed signal (#4889) an unreadable header already gives, so the + // verdict says nothing about the row. + if (isStoredMetadataBodyObject(rel.master)) return null; + const parentId = masterIdOf(rel.fk, data, priorRow); + if (parentId == null) return null; + // [#22519] 1. The caller's read door: a header it does not return binds as + // a missing header binds. + const readable = await this.masterDetailHeadersCallerReads(rel.master, [parentId], context); + if (!readable.has(String(parentId))) return null; + // 2. The write's own elevated, tenant-kept read, for a header read 1 returned. + try { + const row = await this.findOne(rel.master, { where: { id: parentId }, context: ObjectQL.referenceCheckContext(context) }); + // `null` stays `null` — the fail-CLOSED signal (#4889) is the ABSENCE of + // the binding, and materialising a row we do not have would destroy it. + return row == null ? null : this.materializeParentHeader(rel.master, row as Record); + } catch (err) { + this.logger?.warn?.('readonlyWhen parent lookup failed — parent stays unbound', { + object: rel.master, id: parentId, error: err, + }); + return null; + } + } + + /** + * Bulk counterpart of {@link resolveMasterDetailParent}: one read for the + * whole matched set, then a per-row lookup for + * `stripReadonlyWhenFieldsMulti`. A bulk update of N details under M masters + * costs ONE extra query, not N — the same "read the match set once" discipline + * the #3106 prior-row fetch follows. + * + * [#4977] Serves three callers now, unchanged: the bulk `readonlyWhen` strip, + * the bulk `requiredWhen` evaluation, and the INSERT path — where `data` is + * passed as `null` and each inserted row supplies its own FK, so + * `masterIdOf(fk, null, row)` reads `row[fk]` and the batch costs one header + * read for the whole `insert()` call. + * + * [#6457] Every header this resolves is materialised over the MASTER's + * declared fields, exactly as the single-id twin does — the declared-field + * table is read ONCE for the batch, not per row. A row this map has no entry + * for still answers `null` (unbound, fail-CLOSED for `readonlyWhen`). + * + * [#19837] Read under the same tenant-keeping elevation as the single-id twin, + * for the same reason: a header outside the caller's tenant scope gets no + * entry here, so it binds as absent rather than lending its fields to a + * `parent.*` predicate. + * + * [#22519] The same two reads as the single-id twin, each once for the batch: + * the caller's read door ({@link masterDetailHeadersCallerReads}), then the + * elevated read for the headers it returned only. A header the read door does + * not return gets no entry, so it binds as a missing header binds. + */ + static async resolveMasterDetailParents(this: Pick, schema: any, data: Record | null | undefined, priorRows: ReadonlyArray> | null | undefined, context: ExecutionContext | undefined): Promise<(row: Record | undefined) => Record | null> { + const unbound = () => null; + const rel = resolveMasterDetailRelation(schema); + if (!rel) return unbound; + // [#24358] ⛔ The single-id twin's family guard, for the batch: a + // stored-metadata-body header is never read, and every row binds unbound. + if (isStoredMetadataBodyObject(rel.master)) return unbound; + const ids = new Set(); + for (const row of priorRows ?? []) { + const id = masterIdOf(rel.fk, data, row); + if (id != null) ids.add(String(id)); + } + if (ids.size === 0) return unbound; + // [#22519] 1. The caller's read door, once for the batch. + const readable = await this.masterDetailHeadersCallerReads(rel.master, [...ids], context); + if (readable.size === 0) return unbound; + const byId = new Map>(); + try { + // 2. The write's own elevated, tenant-kept read, for the headers read 1 returned. + const rows = await this.find(rel.master, { + where: { id: { $in: [...readable] } }, + context: ObjectQL.referenceCheckContext(context), + }) as Array>; + // [#6457] One declared-field lookup for the whole batch, then one shallow + // copy per header. A master the registry does not know leaves `fields` + // undefined and every header passes through untouched — see + // {@link materializeParentHeader}. + const masterFields = this.masterDeclaredFields(rel.master); + for (const row of Array.isArray(rows) ? rows : []) { + if (row?.id != null) byId.set(String(row.id), materializeDeclaredFields({ ...row }, masterFields)); + } + } catch (err) { + this.logger?.warn?.('readonlyWhen parent lookup failed — parent stays unbound', { + object: rel.master, error: err, + }); + return unbound; + } + return (row) => { + const id = masterIdOf(rel.fk, data, row); + return id == null ? null : (byId.get(String(id)) ?? null); + }; + } + /** * [#6457] Make a resolved master-detail header TOTAL over the MASTER * object's declared fields, so a `parent.` predicate is evaluable @@ -247,6 +473,26 @@ export class ReferencesAndMasterDetailFacet { return fields && typeof fields === 'object' ? fields : undefined; } + /** The body of `ObjectQL#inspectDanglingReferences`; the method's docblock there is its contract. */ + static async inspectDanglingReferences(this: Pick, options?: DanglingReferenceAuditOptions): Promise { + // Read once per run, live — the posture IN FORCE, by the engine's one reader. + const unionScope = postureUsesUnionScope(this.resolveEnginePosture()); + return auditDanglingReferences( + { + objects: () => this._registry.getAllObjects() as unknown as AuditableObject[], + // The port's options are the audit's own `{ fields, limit, context }` + // read; the published port spells them `Record`, so + // the hand-over names the engine contract they are read under. + find: (object, opts) => this.find(object, opts as EngineQueryOptions) as Promise>>, + probe: (target, id, organization) => this.referenceExists( + target, id, organization == null || unionScope ? undefined : ({ tenantId: organization } as ExecutionContext), + ), + warn: (msg, meta) => this.logger?.warn?.(msg, meta as any), + }, + options, + ); + } + /** The body of `ObjectQL#inspectCascadeDelete`; the method's docblock there is its contract. */ static async inspectCascadeDelete(this: Pick, object: string, id: string | number): Promise> { const resolved = this.resolveObjectName(object); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 23aafca6cfe..baa2912e80d 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -46,7 +46,6 @@ import { // Seek pagination for the walks that must read EVERY row — the autonumber seed // scan is one (#6249). Shared with `summary-backfill` rather than re-rolled: // the cursor merge is the part that is easy to get subtly wrong. -import { keysetWalk, type KeysetPageQuery } from '@objectstack/types'; // [#25522 cut 1] The validation-message localisation helpers; the methods of the // same names below hand each body the members it reads (`ValidationMessageHost`). import { negotiatedMessageLocale, objectDisplayLabel, validationMessageContext } from './engine-validation-messages.js'; @@ -119,7 +118,6 @@ import { buildDriverOptions, driverReadScope, driverWriteTarget, engineLayer0Ver // methods of the same names below hand each body the members it reads (`MigrationFlagsHost`). import { announceOpenMigrationGates, isOrganizationOwnershipCertified, isValueShapesMigrationVerified, noteAdmittedValueShapeViolation, readFileReferencesFlagRow, readMigrationFlagVerified, supplyTenantKeyCertifiedResolver, wasDatastoreCreatedFromEmpty } from './engine-migration-flags.js'; import { ExecutionContext, ExecutionContextSchema, type InstalledPackageSource } from '@objectstack/spec/kernel'; -import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel'; // [#27884] The stored-row read gate, registered on the family's tables below. import { STORED_METADATA_TYPE_READ_GATE_OBJECTS, storedMetadataTypeReadGate } from './stored-metadata-type-read-gate.js'; // Imported from spec directly rather than through `@objectstack/core`'s @@ -182,7 +180,6 @@ import { resolveAllowDriverConnectFailure } from '@objectstack/types'; // `code === '42P01'` test here is load-bearing, not stylistic: a second // vocabulary of "benign driver error" is the exact debt that module exists to // retire, and `check:durability-log-level` exempts only this declared name. -import { isMissingTableError } from '@objectstack/metadata/errors'; // [#25522 cut 17] The registration door's forward conversion, the // nested-plugin registration and its navigation resolution; the methods of the // same names below hand each body the members it reads @@ -292,7 +289,6 @@ import { resolveMasterDetailRelation } from './master-detail.js'; // [#6457] The master-detail header a `parent`-scoped predicate reads is made // total over the MASTER's declared fields before it leaves this engine — the // same helper every other server seam materialises with (#1871/#4649/#4953). -import { materializeDeclaredFields } from './declared-fields.js'; // [#21571] The declared column set — the read verbs' default projection, their // explicit-projection filter and the write path's undeclared-key door, one list. import { @@ -359,23 +355,21 @@ import { bindHookNotice } from './hook-notice.js'; import { lowerRelationConditions, resolveSearchQueryableFields, resolveWhereTokens } from './engine-query-lowering.js'; import { auditDanglingReferences, - type AuditableObject, type DanglingReferenceAuditOptions, type DanglingReferenceReport, } from './integrity/dangling-reference-audit.js'; // [#29986 codemod] The top-level declarations that moved whole to `engine-top-level.ts`, as this file still reads them. import { - AUTONUMBER_SEED_PAGE_SIZE, DISPATCHABLE_HOOK_EVENTS, ENGINE_DELETE_OPTION_KEYS, ENGINE_FIND_OPTION_KEYS, - ENGINE_QUERY_SLOTS, ENGINE_UPDATE_OPTION_KEYS, ENGINE_WHERE_SLOTS, ENGINE_WIRE_ONLY_SLOTS, - SECURITY_SERVICE_SOURCE_SLOT, WRITE_PRECONDITION_OPTION, applyFormulaPlan, applyStoredExpressionsToPatch, - assertDispatchableHookEvent, assertHookScopeNotSelfCancelling, assertOrderByIsMaterializable, - assertProjectionHasNoDottedPaths, assertValidHookExcludeObjects, assertValidHookObject, - assertValidUnscopedMultiWriteFlag, autonumberFieldNames, callerKeySets, callerSuppliedRow, computeStoredExpressions, - declaredDatetimeLowering, declaredMultiValued, droppedFieldEvents, foldEngineOptionAliases, formulaPlanEntry, - handBackWithheldInsertReadonly, hookMatchesObject, hydrateWriteFormulas, insertStaticReadonlySubject, - lowerWhereFilterArray, masterIdOf, mergeReadContext, organizationPredicateDeleteRefusal, - packageBodyCollectionRefusal, packageBodyObjectAndAppOwnerStampRefusal, packageBodySeedDataRefusal, - planFormulaProjection, planStoredExpressions, readStoredAutonumberCounter, recomputeAutonumberReaders, + DISPATCHABLE_HOOK_EVENTS, ENGINE_DELETE_OPTION_KEYS, ENGINE_FIND_OPTION_KEYS, ENGINE_QUERY_SLOTS, + ENGINE_UPDATE_OPTION_KEYS, ENGINE_WHERE_SLOTS, ENGINE_WIRE_ONLY_SLOTS, SECURITY_SERVICE_SOURCE_SLOT, + WRITE_PRECONDITION_OPTION, applyFormulaPlan, applyStoredExpressionsToPatch, assertDispatchableHookEvent, + assertHookScopeNotSelfCancelling, assertOrderByIsMaterializable, assertProjectionHasNoDottedPaths, + assertValidHookExcludeObjects, assertValidHookObject, assertValidUnscopedMultiWriteFlag, autonumberFieldNames, + callerKeySets, callerSuppliedRow, computeStoredExpressions, declaredDatetimeLowering, droppedFieldEvents, + foldEngineOptionAliases, formulaPlanEntry, handBackWithheldInsertReadonly, hookMatchesObject, hydrateWriteFormulas, + insertStaticReadonlySubject, lowerWhereFilterArray, masterIdOf, mergeReadContext, + organizationPredicateDeleteRefusal, packageBodyCollectionRefusal, packageBodyObjectAndAppOwnerStampRefusal, + packageBodySeedDataRefusal, planFormulaProjection, planStoredExpressions, recomputeAutonumberReaders, rejectUnknownEngineOptions, replacedStoredExpressionFields, rowsWithoutFormulaWidening, settleBulkStoredExpressions, shouldSkipStateMachine, staticReadonlyStoredView, storedExpressionsReading, stripComputedWriteFields, undeclaredWriteFieldErrors, updateStaticReadonlySubject, withFileOwnerKey, withholdInsertReadonlyFromHooks, @@ -469,6 +463,7 @@ export interface ObjectQLStaticsHost { readonly mediaFieldPresence: (typeof ObjectQL)['mediaFieldPresence']; readonly organizationCountContext: (typeof ObjectQL)['organizationCountContext']; readonly outlivesOrganization: (typeof ObjectQL)['outlivesOrganization']; + readonly referenceCheckContext: (typeof ObjectQL)['referenceCheckContext']; readonly remainderAfterMemberRemoval: (typeof ObjectQL)['remainderAfterMemberRemoval']; readonly storedReferenceIncludes: (typeof ObjectQL)['storedReferenceIncludes']; } @@ -2215,159 +2210,15 @@ export class ObjectQL implements IObjectQLEngine { return DefaultsAndAutonumbersFacet.warnAutonumberReadersUnserved.call(defaultsAndAutonumbersHost.of(this), object, plan, driver); } - /** - * Seed the autonumber counter from the current max in store, scoped to - * `prefix`. With a non-empty prefix (date/field formats) only rows in the - * same scope count. - * - * # Locating the counter inside a stored value (#6468) - * - * `renderAutonumber` composes `prefix + zero-padded(seq) + suffix`, and - * `suffix` is a DECLARED return value: every token after the `{0..0}` slot - * renders behind the counter (`{000}-{YYYY}` → `001-2026`). So the counter is - * NOT "the digits at the end of the string" — reading it that way took the - * year for the counter and seeded `2026` against a true counter of `1`, which - * jumps the next issued number to `2027-2026` and burns the band in between. - * - * Both the rendered `prefix` and the rendered `suffix` therefore come in from - * the caller (they are `renderAutonumber`'s own output — this method does not - * re-derive any format understanding of its own, and neither does the SQL - * driver's `scanMaxNumericTail`, which is handed the same two strings): - * - * - **Either one declared ⇒ the slot is ANCHORED**: the counter is the digit - * run at the START of what follows the prefix, after removing the declared - * suffix when this row carries it. That half is spec's - * `readAutonumberCounter` — the declared inverse of `renderAutonumber`, - * which the SQL driver's scan calls too, so one edit moves both sides - * (#6560). - * - **Neither declared ⇒ UNANCHORED**: the legacy reading is kept — the LAST - * digit run of the whole value. A format with no `{0..0}` slot renders a - * bare trailing counter, and values predating any format have no anchor to - * read from, so this stays exactly as it was. The SQL driver's legacy - * reading of this case differs on purpose, which is why it stays per-side. - * - * The two together are {@link readStoredAutonumberCounter}, module-level - * rather than inline, because #6806's resync must read an exempt writer's - * supplied value by exactly these rules. - * - * The suffix is *stripped when it matches*, never *required* to match: a - * dynamic suffix renders differently per row (`{000}-{YYYY}` is `-2025` on - * last year's rows) while the counter scope is the rendered PREFIX — here `''` - * — so those rows share this very counter and must still be counted. Skipping - * them would seed BELOW the real max, which is the duplicate-record-number - * harm #6249 fixed on the scan side. Reading the leading digit run gets them - * right regardless; the strip only adds precision when a suffix begins with a - * digit (`{0000}{YYYY}`, whose values are ambiguous by construction — the - * compile lint nudges authors to a delimiter). - * - * # Why this walks every row in the scope (#6249) - * - * The seed used to be one `find` with `limit: 5000`, no `orderBy` and no - * filter: the max of an ARBITRARY 5000-row window (on SQL, typically the - * oldest 5000 rows), which for any object past that size — or any scope - * whose rows sit outside the window because other scopes filled it — seeds - * BELOW the real MAX. The counter then issues numbers from an already-taken - * band, and on a `unique` record-number field that is a duplicate business - * identifier: a value written wrong, which no retry and no restart repairs - * (the same class of harm as the read-outage half fixed in #5979/#6114). - * - * The scan is therefore complete rather than windowed, in the shape the - * SQL driver's own seeding already uses (`scanMaxNumericTail` pushes - * `like 'prefix%'` down with NO limit). Two deliberate choices: - * - * - **The numeric max is computed here, never delegated to an ORDER BY or - * an aggregate `max`.** Both of those rank the stored value as TEXT, and - * lexicographic order equals numeric order only when every value in the - * scope is zero-padded to one fixed width — which the format language - * does not guarantee (`{0}` pads to nothing, and any width OVERFLOWS - * once the counter passes it, putting `CASE-99999` above `CASE-100000`). - * The empty-prefix legacy path, which reads the LAST digit run of the - * whole value, has no lexicographic reading at all. Parsing every value - * keeps one code path correct for every format instead of a fast path - * guarded by assumptions a format author can silently break. - * - **Seek pagination, not `offset`** — `keysetWalk`'s own rationale - * (#4363): an offset walk cannot promise it visited every row, and a - * row it skips is exactly a number this seed must not miss. - * - * `prefix` is pushed down as `$startsWith` so a date/`{field}` scope reads - * its own rows instead of paging through every other scope's. The JS-side - * `startsWith` re-check below is kept as the authority: a driver whose - * matching is LOOSER (a case-insensitive `LIKE`) must not be able to inflate - * the max with another scope's rows. - */ - private async seedAutonumber( + private seedAutonumber( object: string, field: string, prefix: string, suffix: string, execCtx?: ExecutionContext, ): Promise { - try { - // Canonical `fields`, not the wire spelling `select` — this call sat on - // the exact #4371 silent drop (the projection never applied; the scan - // worked only because an unprojected row still carries `field`), and the - // catch below would have swallowed the guard's rejection into "seed - // from 0", i.e. duplicate autonumbers. - const walk = keysetWalk>( - (q: KeysetPageQuery) => this.find(object, { - // The walk spells its `where` `unknown` (this scan's filter AND-ed - // with the seek predicate); it is read as the engine's `where` slot. - ...(q as Omit & { where?: EngineQueryOptions['where'] }), - fields: ['id', field], - context: execCtx, - }), - { - where: prefix ? { [field]: { $startsWith: prefix } } : undefined, - pageSize: AUTONUMBER_SEED_PAGE_SIZE, - }, - ); - let max = 0; - for await (const page of walk.pages()) { - for (const r of page) { - const v = r?.[field]; - if (v == null) continue; - // The reading itself lives in `readStoredAutonumberCounter` (the - // section above describes it) because #6806's adopt-on-exempt-write - // resync must read a supplied value by the SAME rules this scan reads - // a stored one — two copies of it would drift into two different - // answers for one row, which is a duplicate record number. Its - // anchored half is spec's `readAutonumberCounter`, the one the SQL - // driver's own scan calls (#6560). - const counter = readStoredAutonumberCounter(String(v), prefix, suffix); - if (counter != null) max = Math.max(max, counter); - } - } - // The walk is unbounded (no `max`), so truncation here means the scan - // could not COMPLETE: a row carried no `id` to seek past, or the reader - // never applied the seek predicate. Either way rows were left unread, and - // the max over what was read is a floor, not the max. Answering with it - // is the "seed below the real MAX" defect this method was fixed for, so - // it fails loudly instead — the same disposition #6114 gave the read - // outage: allocate nothing, write nothing. - if (walk.truncated) { - throw new Error( - `Cannot seed the autonumber counter for "${object}.${field}": the seeding scan ` + - `could not visit every stored row (it stopped after ${walk.scanned} rows without ` + - `reaching the end). Seeding from a partial scan would issue record numbers that ` + - `collide with existing ones, so no number was allocated.`, - ); - } - return max; - } catch (error) { - // [#5979] Discriminate by error TYPE. Seeding from 0 is the truth for - // exactly ONE failure reason — the table has not been provisioned, so - // there are genuinely no rows and number 1 collides with nothing. - if (isMissingTableError(error, object)) return 0; - // Every other failure (connection drop, timeout, permission denial, - // query error) means the rows may well exist and simply were not seen. - // Answering 0 there restarts the sequence at 1 against a table already - // holding N rows and issues autonumbers that COLLIDE with existing ones - // — a value written wrong, which no retry and no restart repairs. So the - // read failure propagates and the caller allocates nothing: the write - // fails loudly instead of succeeding with a forged business identifier. - // This is the hazard the #4371 comment above the read already named. - throw error; - } + // [#29986 codemod] The body moved to `engine-defaults-and-autonumbers.ts`, its docblock with it (see `DefaultsAndAutonumbersHost`). + return DefaultsAndAutonumbersFacet.seedAutonumber.call(defaultsAndAutonumbersHost.of(this), object, field, prefix, suffix, execCtx); } /** @@ -3146,40 +2997,9 @@ export class ObjectQL implements IObjectQLEngine { return assertReferencesResolve.call(host, schema, data, supplied, context, msgCtx, storedRows); } - /** - * Does `id` name a row in `target`? `false` only when the probe RAN and found - * nothing; `null` when it could not run at all (see the fail-open note on - * {@link assertReferencesResolve}). - * - * `context` is the CALLER's execution context. The probe runs under - * {@link ObjectQL.referenceCheckContext} — the same `sudo()`-shaped elevation - * the pre-delete reference check uses, so the two reference checks share one - * spelling: RLS/FLS bypassed, the caller's `tenantId` (and, under the `group` - * posture, its membership set) forwarded to the driver by - * `buildDriverOptions`. A row outside the caller's tenant scope is therefore - * `false` here, exactly like a row that exists nowhere (#19808). - * - * Without a `context` the elevation is the bare `{ isSystem: true }` and the - * probe spans every organization. {@link inspectDanglingReferences}, which - * has no caller, passes the SCANNED row's own organization as the context's - * `tenantId` instead (#19837), and no context only for a row that carries - * none or under a union (`group`) posture — so outside `group` the audit - * reports a stored reference into another organization, the class this guard - * now refuses. - */ - private async referenceExists(target: string, id: unknown, context?: ExecutionContext): Promise { - try { - const resolved = this.resolveObjectName(target); - if (!this._registry.getObject(resolved)) return null; - const row = await this.findOne(resolved, { - where: { id }, - fields: ['id'], - context: ObjectQL.referenceCheckContext(context), - }); - return !!row; - } catch { - return null; - } + private referenceExists(target: string, id: unknown, context?: ExecutionContext): Promise { + // [#29986 codemod] The body moved to `engine-references-and-master-detail.ts`, its docblock with it (see `ReferencesAndMasterDetailHost`). + return ReferencesAndMasterDetailFacet.referenceExists.call(referencesAndMasterDetailHost.of(this), target, id, context); } private masterDetailHeaderExists( @@ -3222,191 +3042,24 @@ export class ObjectQL implements IObjectQLEngine { return settleMasterDetailLanding(args); } - /** - * [#4889] The master-detail header a detail row's `parent`-scoped - * `readonlyWhen` reads, or `null` when this write cannot resolve one. - * - * `readonlyWhen: parent.status == 'paid'` is a documented **server** - * guarantee (ADR-0124 D1 puts enforcement here; the client grid is - * courtesy), but the strip is a pure function over the payload and the prior - * row — it has no driver and cannot fetch a header. So the engine resolves it - * and passes it in. - * - * The header id comes from the payload first, then the prior row: a write that - * REPOINTS the detail at another master must be judged against the master it - * is landing on, not the one it is leaving. - * - * ## Two reads: may the caller see the header, then what it holds (#22519) - * - * A write of a detail whose header the caller cannot read answers exactly - * what a write naming a header that exists nowhere answers — for every - * detail, `controlled_by_parent` or not, on insert, update (a repoint - * included) and the bulk paths. The caller's right to write the detail does - * NOT settle its right to read the header: ADR-0055 §3 gates a detail write - * on its header (master edit, which implies read) only for a - * `controlled_by_parent` detail, and a detail with a sharing model of its own - * meets no header check on write. So the header is read twice, each read - * answering one question, in the shape the preview reads it in - * ({@link resolvePreviewParents}): - * - * 1. ⛔ MAY THIS CALLER SEE THE HEADER — the read door, under the caller's - * own context ({@link masterDetailHeadersCallerReads}). A header it does - * not return binds as a missing header binds: `null`, so a parent-scoped - * `requiredWhen` / `readonlyWhen` meets an unbound `parent`, and the - * reference check answers `reference_not_found` for it - * ({@link masterDetailHeaderExists}). - * 2. WHAT THE HEADER HOLDS — the write's own ELEVATED read below, for a - * header read 1 returned only. The lock is a data-integrity property of - * the header's state, so a header the caller can read is judged on its - * STORED values, a column field-level security hides from or masks for - * this caller included: author-defined integrity rules run on true data. - * The preview's per-column masking is the preview's own, not the write's. - * - * A SYSTEM caller (seed replay, boot) asks no read door: read 2 alone, as - * before. - * - * ## The elevation keeps the tenant wall (#19837) - * - * Read 2's argument is about the VALUES of a header the caller may see, never - * about TENANCY, so the - * read runs under {@link ObjectQL.referenceCheckContext} — the `sudo()`-shaped - * `{ ...context, isSystem: true }` both reference checks share — and never a - * bare `{ isSystem: true }`. The bare spelling carried no `tenantId`, so the - * header was found in ANY organization and a `parent.*` predicate was judged - * against another organization's row: an org-bound caller naming that row's - * id learned one bit of it per write (`requiredWhen` answered `required` for a - * `locked` header and `reference_not_found` for an `open` one; `readonlyWhen` - * dropped or kept the field). With the caller's context spread first, - * `buildDriverOptions` forwards its `tenantId` (and, under the `group` - * posture, its membership set as `tenantIds`), a header stamped with an - * organization outside that scope — another organization, or under `group` - * one outside the caller's membership set — is not found, and it binds - * exactly as a header that exists nowhere does — absent. The tenancy exemptions stay where they are decided: - * a `tenancy.enabled: false` or federated master gets no `tenantId`. A - * header with no organization is no tenant's (ADR-0131 D1): the driver's - * scope carries no `IS NULL` arm, so a scoped read does not find it. A system - * caller with no `tenantId` (seed replay, boot) still reads unscoped. - * - * `null` on any failure — no relation, no id, header not returned by the - * caller's read door, header gone or outside the caller's tenant scope, read - * threw. It is NOT read as "unlocked": an - * unresolved binding leaves `parent` unbound, and `isReadonlyWhenLocked` - * treats a predicate that needs it as LOCKED. - * - * [#6457] A header that IS resolved is handed over TOTAL over the MASTER - * object's declared fields — see {@link materializeParentHeader} for why that - * had to happen here and nowhere else, and for the verdict table it moves. - */ - private async resolveMasterDetailParent( + private resolveMasterDetailParent( schema: any, data: Record | null | undefined, priorRow: Record | null | undefined, context: ExecutionContext | undefined, ): Promise | null> { - const rel = resolveMasterDetailRelation(schema); - if (!rel) return null; - // [#24358] ⛔ A header in the stored-metadata-body family is never read, - // under either authority. Read 2 below is a whole-row read the family's - // projection never reaches, so a `parent.*` predicate would be judged over - // the stored body and its content hash unprojected: one bit of material the - // family's own door withholds, per write. `parent` stays unbound, the - // fail-closed signal (#4889) an unreadable header already gives, so the - // verdict says nothing about the row. - if (isStoredMetadataBodyObject(rel.master)) return null; - const parentId = masterIdOf(rel.fk, data, priorRow); - if (parentId == null) return null; - // [#22519] 1. The caller's read door: a header it does not return binds as - // a missing header binds. - const readable = await this.masterDetailHeadersCallerReads(rel.master, [parentId], context); - if (!readable.has(String(parentId))) return null; - // 2. The write's own elevated, tenant-kept read, for a header read 1 returned. - try { - const row = await this.findOne(rel.master, { where: { id: parentId }, context: ObjectQL.referenceCheckContext(context) }); - // `null` stays `null` — the fail-CLOSED signal (#4889) is the ABSENCE of - // the binding, and materialising a row we do not have would destroy it. - return row == null ? null : this.materializeParentHeader(rel.master, row as Record); - } catch (err) { - this.logger?.warn?.('readonlyWhen parent lookup failed — parent stays unbound', { - object: rel.master, id: parentId, error: err, - }); - return null; - } + // [#29986 codemod] The body moved to `engine-references-and-master-detail.ts`, its docblock with it (see `ReferencesAndMasterDetailHost`). + return ReferencesAndMasterDetailFacet.resolveMasterDetailParent.call(referencesAndMasterDetailHost.of(this), schema, data, priorRow, context); } - /** - * Bulk counterpart of {@link resolveMasterDetailParent}: one read for the - * whole matched set, then a per-row lookup for - * `stripReadonlyWhenFieldsMulti`. A bulk update of N details under M masters - * costs ONE extra query, not N — the same "read the match set once" discipline - * the #3106 prior-row fetch follows. - * - * [#4977] Serves three callers now, unchanged: the bulk `readonlyWhen` strip, - * the bulk `requiredWhen` evaluation, and the INSERT path — where `data` is - * passed as `null` and each inserted row supplies its own FK, so - * `masterIdOf(fk, null, row)` reads `row[fk]` and the batch costs one header - * read for the whole `insert()` call. - * - * [#6457] Every header this resolves is materialised over the MASTER's - * declared fields, exactly as the single-id twin does — the declared-field - * table is read ONCE for the batch, not per row. A row this map has no entry - * for still answers `null` (unbound, fail-CLOSED for `readonlyWhen`). - * - * [#19837] Read under the same tenant-keeping elevation as the single-id twin, - * for the same reason: a header outside the caller's tenant scope gets no - * entry here, so it binds as absent rather than lending its fields to a - * `parent.*` predicate. - * - * [#22519] The same two reads as the single-id twin, each once for the batch: - * the caller's read door ({@link masterDetailHeadersCallerReads}), then the - * elevated read for the headers it returned only. A header the read door does - * not return gets no entry, so it binds as a missing header binds. - */ - private async resolveMasterDetailParents( + private resolveMasterDetailParents( schema: any, data: Record | null | undefined, priorRows: ReadonlyArray> | null | undefined, context: ExecutionContext | undefined, ): Promise<(row: Record | undefined) => Record | null> { - const unbound = () => null; - const rel = resolveMasterDetailRelation(schema); - if (!rel) return unbound; - // [#24358] ⛔ The single-id twin's family guard, for the batch: a - // stored-metadata-body header is never read, and every row binds unbound. - if (isStoredMetadataBodyObject(rel.master)) return unbound; - const ids = new Set(); - for (const row of priorRows ?? []) { - const id = masterIdOf(rel.fk, data, row); - if (id != null) ids.add(String(id)); - } - if (ids.size === 0) return unbound; - // [#22519] 1. The caller's read door, once for the batch. - const readable = await this.masterDetailHeadersCallerReads(rel.master, [...ids], context); - if (readable.size === 0) return unbound; - const byId = new Map>(); - try { - // 2. The write's own elevated, tenant-kept read, for the headers read 1 returned. - const rows = await this.find(rel.master, { - where: { id: { $in: [...readable] } }, - context: ObjectQL.referenceCheckContext(context), - }) as Array>; - // [#6457] One declared-field lookup for the whole batch, then one shallow - // copy per header. A master the registry does not know leaves `fields` - // undefined and every header passes through untouched — see - // {@link materializeParentHeader}. - const masterFields = this.masterDeclaredFields(rel.master); - for (const row of Array.isArray(rows) ? rows : []) { - if (row?.id != null) byId.set(String(row.id), materializeDeclaredFields({ ...row }, masterFields)); - } - } catch (err) { - this.logger?.warn?.('readonlyWhen parent lookup failed — parent stays unbound', { - object: rel.master, error: err, - }); - return unbound; - } - return (row) => { - const id = masterIdOf(rel.fk, data, row); - return id == null ? null : (byId.get(String(id)) ?? null); - }; + // [#29986 codemod] The body moved to `engine-references-and-master-detail.ts`, its docblock with it (see `ReferencesAndMasterDetailHost`). + return ReferencesAndMasterDetailFacet.resolveMasterDetailParents.call(referencesAndMasterDetailHost.of(this), schema, data, priorRows, context); } private async resolvePreviewParents( @@ -3526,25 +3179,11 @@ export class ObjectQL implements IObjectQLEngine { * budget never reached (#5718), `unreadableObjects` for what the datasource * refused, and `aborted` for a run called off (#4747). */ - async inspectDanglingReferences( + inspectDanglingReferences( options?: DanglingReferenceAuditOptions, ): Promise { - // Read once per run, live — the posture IN FORCE, by the engine's one reader. - const unionScope = postureUsesUnionScope(this.resolveEnginePosture()); - return auditDanglingReferences( - { - objects: () => this._registry.getAllObjects() as unknown as AuditableObject[], - // The port's options are the audit's own `{ fields, limit, context }` - // read; the published port spells them `Record`, so - // the hand-over names the engine contract they are read under. - find: (object, opts) => this.find(object, opts as EngineQueryOptions) as Promise>>, - probe: (target, id, organization) => this.referenceExists( - target, id, organization == null || unionScope ? undefined : ({ tenantId: organization } as ExecutionContext), - ), - warn: (msg, meta) => this.logger?.warn?.(msg, meta as any), - }, - options, - ); + // [#29986 codemod] The body moved to `engine-references-and-master-detail.ts` (see `ReferencesAndMasterDetailHost`). + return ReferencesAndMasterDetailFacet.inspectDanglingReferences.call(referencesAndMasterDetailHost.of(this), options); } /** @@ -9443,14 +9082,7 @@ export class ObjectQL implements IObjectQLEngine { return CascadePlanningFacet.fileReferenceCheckElevation.call(cascadePlanningHost.of(this), set, object, id, childName, fieldName, context); } - /** - * The dependents probe of {@link cascadeDeleteRelations}: the rows of - * `childName` whose `fieldName` references `id`, read under SYSTEM identity - * and exactly narrowed. [#22305] Both phases read through it, so the set the - * collection walk gathers and the rows the deleting walk acts on are one - * reading of the same relation. - */ - private async probeReferencingRows( + private probeReferencingRows( childName: string, fieldName: string, fdef: any, @@ -9458,106 +9090,8 @@ export class ObjectQL implements IObjectQLEngine { probeWhere: Record, context: ExecutionContext | undefined, ): Promise { - let dependents: any[]; - try { - dependents = await this.find( - childName, - // [#12166] (maintainer ruling 2026-08-26, option A) SYSTEM identity, - // unconditionally. This probe is the platform's own referential- - // integrity read, not a query the caller asked for, and running it - // as the caller made "delete permission" silently mean "delete + - // read on EVERY referencing table": a caller with full delete rights - // on `object` but no read grant on `childName` got a blanket 403 - // from the security middleware — whether or not a reference existed, - // and with `childName` EMPTY. Measured on a real deployment across - // 17 role×object pairs, with the A/B control that granting read-only - // on the referencing object (touching NOTHING about delete rights) - // turned the identical delete into a 200. - // - // Referential-integrity actions are engine responsibility executed - // under system identity on every mainstream platform — the RDBMS FK - // baseline, Salesforce (lookup clearing / cascade delete documented - // as bypassing sharing), Dataverse, ServiceNow, Odoo. Caller - // identity here was the outlier. - // - // The elevation is `sudo()`-SHAPED (`{ ...context, isSystem: true }`), - // never a bare `{ isSystem: true }` — the same posture - // `recomputeSummaries` holds one axis over. Three reasons, and each - // one is a defect if dropped: - // - the open transaction handle, `tenantId` and `timezone` must - // survive, or this probe leaves the caller's transaction and - // stops being TENANT-scoped — a bare system context would widen - // the probe across the tenant wall, which is the opposite of - // what this card relaxes; - // - `userId` survives, and that IS the audit ledger's - // "triggered-by" half (ruling constraint 3): the record carries - // triggered-by = the deleting operator and executed-as = system. - // `read-audit.ts` states the same property of `sudo()`; - // - it is a NARROW elevation: nothing else about the delete path - // changes identity (ruling constraint 1). The `set_null` UPDATE - // and the `cascade` DELETE below still run as the caller, byte - // for byte, so this relaxes the reference CHECK and not the - // caller's own authority over the dependent rows. - // - // [Constraint 4] If the spec later declares per-relationship - // on-delete behaviour, BEHAVIOUR follows the declaration; the - // identity of this probe stays system, unconditionally. Do not make - // this line conditional on `behavior`. - { where: probeWhere, context: ObjectQL.referenceCheckContext(context) }, - ); - } catch (error) { - // [#8895] Discriminate by error TYPE — this probe IS the referential - // guard, so an empty answer is only truthful for the one failure that - // really means "no dependents". - // - // The bare `catch { continue }` this replaces reached `continue` on - // ANY probe failure, and every consequence of that is silent: the - // walk's `restrict` branch never fires, so a delete the integrity - // rules say must be REFUSED is allowed through; `set_null`/`cascade` - // never run, so child rows that should have been nulled or removed - // are left orphaned; nothing is logged and the caller is told the - // delete succeeded. Fail-OPEN on an integrity guard — the read did - // not happen and the answer "there are none" was invented for it - // (ADR-0110 D3: "the probe found nothing" and "the probe could not - // run" are different facts with opposite meanings here). - // - // Benign: the child object is registered but its TABLE was never - // provisioned (schema sync not run yet). It cannot hold a row that - // references anything, so zero dependents IS the truth and skipping - // the relation is correct. Asked through the shared - // `isMissingTableError` predicate (`@objectstack/metadata/errors`, - // #4825) — the same call `seedAutonumber` and `resolveFileReferences` - // make — never a hand-rolled code test. - // - // Everything else (connection drop, timeout, permission denial, a - // query error, a missing COLUMN on a provisioned table) means the - // dependents may well exist and simply were not seen. It propagates: - // the delete fails loudly and nothing is written, which is the same - // disposition the maintainer's 2026-08-15 ruling gives this family — - // unprovisioned is truthful emptiness, everything else must surface. - // A guard that could not be EVALUATED must not silently pass. - // - // No new response field and no new error code: the caller receives - // the probe's own failure, envelope intact. - if (isMissingTableError(error, childName)) return []; - throw error; - } - // [#9362] The multi-value pushdown in the probe filter can be a SUPERSET - // (the substring reading, on a backend without the declaration), so the - // exact answer is taken here, on the rows themselves. Everything the walk - // does with them — the `restrict` count in the 409 envelope, the `cascade` - // recursion, the `set_null` write — and the set the collection walk - // gathers read this answer, so narrowing anywhere later would leave one of - // them acting on a row that never referenced this record. - // [#27004] Over a set of ids (`probeReferencingRowsOfSet`), a row is kept - // when it holds any of them. - if (declaredMultiValued(fdef) && dependents) { - const wanted: ReadonlyArray = Array.isArray(id) ? id : [id as string | number]; - dependents = dependents.filter((row) => - wanted.some((w) => ObjectQL.storedReferenceIncludes(row?.[fieldName], w)), - ); - } - return dependents ?? []; + // [#29986 codemod] The body moved to `engine-cascade-planning.ts`, its docblock with it (see `CascadePlanningHost`). + return CascadePlanningFacet.probeReferencingRows.call(cascadePlanningHost.of(this), childName, fieldName, fdef, id, probeWhere, context); } private referenceProbeFilterOfSet( @@ -10852,6 +10386,7 @@ export class ObjectQL implements IObjectQLEngine { mediaFieldPresence: { get: () => ObjectQL.mediaFieldPresence }, organizationCountContext: { value: (...args: Parameters) => ObjectQL.organizationCountContext(...args) }, outlivesOrganization: { value: (...args: Parameters) => ObjectQL.outlivesOrganization(...args) }, + referenceCheckContext: { value: (...args: Parameters) => ObjectQL.referenceCheckContext(...args) }, remainderAfterMemberRemoval: { value: (...args: Parameters) => ObjectQL.remainderAfterMemberRemoval(...args) }, storedReferenceIncludes: { value: (...args: Parameters) => ObjectQL.storedReferenceIncludes(...args) }, }); @@ -10910,6 +10445,7 @@ export class ObjectQL implements IObjectQLEngine { get autonumberCounters() { return engine.autonumberCounters; }, get autonumberReadersUnservedReported() { return engine.autonumberReadersUnservedReported; }, defaultFieldEntries: (...a) => engine.defaultFieldEntries(...a), + find: (...a) => engine.find(...a), getSchema: (...a) => engine.getSchema(...a), isIssuedAutonumberCollision: (...a) => engine.isIssuedAutonumberCollision(...a), get logger() { return engine.logger; }, @@ -11024,6 +10560,8 @@ export class ObjectQL implements IObjectQLEngine { get _registry() { return engine._registry; }, cascadeRelationBehavior: (...a) => engine.cascadeRelationBehavior(...a), collectCascadeDeleteSet: (...a) => engine.collectCascadeDeleteSet(...a), + find: (...a) => engine.find(...a), + findOne: (...a) => engine.findOne(...a), homeAmountServing: (...a) => engine.homeAmountServing(...a), get homeLocalizationResolver() { return engine.homeLocalizationResolver; }, set homeLocalizationResolver(v) { engine.homeLocalizationResolver = v; }, @@ -11032,9 +10570,11 @@ export class ObjectQL implements IObjectQLEngine { get logger() { return engine.logger; }, masterDeclaredFields: (...a) => engine.masterDeclaredFields(...a), masterDetailHeadersCallerReads: (...a) => engine.masterDetailHeadersCallerReads(...a), + materializeParentHeader: (...a) => engine.materializeParentHeader(...a), get realtimeService() { return engine.realtimeService; }, set realtimeService(v) { engine.realtimeService = v; }, referenceExists: (...a) => engine.referenceExists(...a), + resolveEnginePosture: (...a) => engine.resolveEnginePosture(...a), resolveObjectName: (...a) => engine.resolveObjectName(...a), })); registrationHost.install((engine) => ({ diff --git a/scripts/durability-read-invention.baseline.json b/scripts/durability-read-invention.baseline.json index 5c18ee5788a..32f10eef15a 100644 --- a/scripts/durability-read-invention.baseline.json +++ b/scripts/durability-read-invention.baseline.json @@ -46,7 +46,7 @@ ], "entries": [ { - "file": "packages/objectql/src/engine.ts", + "file": "packages/objectql/src/engine-references-and-master-detail.ts", "fn": "referenceExists", "verdict": "reviewed-legitimate", "invents": "return null (catch has no log and no error-type discrimination)", diff --git a/scripts/facade-delegation-registry.json b/scripts/facade-delegation-registry.json index 0ac73758dd6..181fb781225 100644 --- a/scripts/facade-delegation-registry.json +++ b/scripts/facade-delegation-registry.json @@ -347,48 +347,6 @@ "card": "#30054", "reason": "inferred-return-type (#29986 report: stays on the class until the return type is spelled)" }, - { - "file": "packages/objectql/src/engine.ts", - "class": "ObjectQL", - "member": "seedAutonumber", - "card": "#30055", - "reason": "query-options erasure pin, baseline keyed by path (#29986 report: moves once the #4918 sites are typed)" - }, - { - "file": "packages/objectql/src/engine.ts", - "class": "ObjectQL", - "member": "probeReferencingRows", - "card": "#30055", - "reason": "query-options erasure pin, baseline keyed by path (#29986 report: moves once the #4918 sites are typed)" - }, - { - "file": "packages/objectql/src/engine.ts", - "class": "ObjectQL", - "member": "resolveMasterDetailParent", - "card": "#30055", - "reason": "query-options erasure pin, baseline keyed by path (#29986 report: moves once the #4918 sites are typed)" - }, - { - "file": "packages/objectql/src/engine.ts", - "class": "ObjectQL", - "member": "resolveMasterDetailParents", - "card": "#30055", - "reason": "query-options erasure pin, baseline keyed by path (#29986 report: moves once the #4918 sites are typed)" - }, - { - "file": "packages/objectql/src/engine.ts", - "class": "ObjectQL", - "member": "inspectDanglingReferences", - "card": "#30055", - "reason": "query-options erasure pin, baseline keyed by path (#29986 report: moves once the #4918 sites are typed)" - }, - { - "file": "packages/objectql/src/engine.ts", - "class": "ObjectQL", - "member": "referenceExists", - "card": "#30055", - "reason": "query-options erasure pin, baseline keyed by path (#29986 report: moves once the #4918 sites are typed)" - }, { "file": "packages/objectql/src/engine.ts", "class": "ObjectQL", diff --git a/scripts/query-options-erasure-baseline.json b/scripts/query-options-erasure-baseline.json index 5f84b5cde0f..f6bc8cdeb27 100644 --- a/scripts/query-options-erasure-baseline.json +++ b/scripts/query-options-erasure-baseline.json @@ -35,7 +35,6 @@ "packages/cloud-connection/src/marketplace-install-local-plugin.ts": 1, "packages/core/src/security/resolve-authz-context.ts": 1, "packages/metadata-protocol/src/seed-loader.ts": 3, - "packages/objectql/src/engine.ts": 8, "packages/plugins/plugin-approvals/src/approval-service.ts": 10, "packages/plugins/plugin-approvals/src/approver-org-scope.ts": 2, "packages/plugins/plugin-approvals/src/lifecycle-hooks.ts": 4, From 205b86d961157f84b037a9c537dd05e605637135 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 11:46:49 +0000 Subject: [PATCH 6/8] test(scripts): the engine codemod's self-test pins the fourth pass (18 cases) Claude-Session: https://claude.ai/code/session_01Utp6CXX2qjqm2uXbJA8Jcv Co-authored-by: Claude --- scripts/codemod-split-engine.mjs | 96 ++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/scripts/codemod-split-engine.mjs b/scripts/codemod-split-engine.mjs index f903fa09d63..f7ab3b0b050 100644 --- a/scripts/codemod-split-engine.mjs +++ b/scripts/codemod-split-engine.mjs @@ -19,6 +19,10 @@ * # the third pass (#30053): plan (or apply) the hand series' private docblocks * node scripts/codemod-split-engine.mjs --hand-docs --verify --base * # re-prove the third pass on disk against engine.ts and its hand modules at + * node scripts/codemod-split-engine.mjs --members [--write] + * # the fourth pass (#30055): plan (or apply) moving the named methods onto the split class + * node scripts/codemod-split-engine.mjs --members --verify --base + * # re-prove the fourth pass on disk against engine.ts and its modules at * node scripts/codemod-split-engine.mjs --self-test * node scripts/codemod-split-engine.mjs --help # this usage, on any tree (split or not) * @@ -148,6 +152,14 @@ * the class. Its section below (`HAND_STUB_MARK`) states the rule, the slices * (`--modules`) and the proof, the undo of every move. * + * ## The fourth pass: named methods onto the split class (#30055) + * + * The first pass runs on an unsplit file only, so a method a stay rule held + * when `main` was split (a path pin since retired, a return type since + * spelled) moves by `--members`: on the split file, as the first pass would + * have moved it, into the facet module it wrote. Its section below + * (`MEMBERS_CARD`) states what it grows, what it refuses and the undo proof. + * * ts-morph is a root devDependency, resolved from the root manifest, as the * pilot takes it. * @@ -3917,6 +3929,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'refusals': 6, 'scoped classes (second pass)': 16, 'hand-series docblocks (third pass)': 19, + 'named methods onto the split class (fourth pass)': 18, }); const SELF_TEST_BATTERY_FLOOR = 5; @@ -4094,6 +4107,32 @@ function needleInMethod(n: number): number { `; } +/** + * The fourth pass's fixture: the first pass's, with two methods a pin held + * when it split the file (`FOURTH_FIXTURE_PINS`), one private that reads a + * new host member, a new private static and an import only it reads, one + * protected that names a type only it imports. + */ +const FOURTH_FIXTURE_PINS = Object.freeze({ ...FIXTURE_PINS, pinnedHost: 'a fixture reader', pinnedTyped: 'a fixture reader' }); +function fourthFixtureSource() { + const pad = Array.from({ length: 12 }, (_, i) => ` const p${i} = ${i};`).join('\n'); + const extra = ` + /** A pinned private docblock: the fourth pass moves it with the body. */ + private async pinnedHost(object: string): Promise { +${pad} + return this.other(object) + String(this.count) + String(this.knex) + helperImported(object) + String(ObjectQL.SECRET) + String(ObjectQL.bump) + fourthOnly(); + } + + /** A pinned protected docblock: it stays on the class. */ + protected pinnedTyped(x: FourthShape): number { +${pad} + return x.n; + } +`; + return fixtureSource(extra).replace("import { seamHelper } from './engine-seam.js';\n", + "import { seamHelper } from './engine-seam.js';\nimport { fourthOnly } from './fourth-only.js';\nimport type { FourthShape } from './fourth-shape.js';\n"); +} + const FIXTURE_SCOPED_CLASSES = Object.freeze([ ['ObjectRepository', 'engine-object-repository.ts', 'Repository'], ['ScopedContext', 'engine-scoped-context.ts', 'Context'], @@ -4552,6 +4591,63 @@ async function selfTest() { refused(() => planHandDocs(tm, input, mods, { modules: ['engine-gone.ts'] }))); } + open('named methods onto the split class (fourth pass)'); + { + const opts = { anchors: FIXTURE_ANCHORS, pins: FIXTURE_PINS, needles: FIXTURE_NEEDLES, classNeedles: FIXTURE_CLASS_NEEDLES }; + const source = fourthFixtureSource(); + const split = generate(tm, source, plan(tm, source, { ...opts, pins: FOURTH_FIXTURE_PINS })); + const input = split.host; + const mods = split.modules; + const names = ['pinnedTyped', 'pinnedHost']; + check('the first pass keeps the pinned methods on the class (path-reader)', + input.includes(' private async pinnedHost(object: string): Promise {\n const p0') && input.includes(' protected pinnedTyped(x: FourthShape): number {\n const p0')); + const planned = planMembers(tm, input, mods, names, opts); + check('the plan takes both into their section\'s written module, in class order', + [...planned.modules.keys()].join() === 'engine-lifecycle.ts' && planned.records.map((r) => r.name).join() === 'pinnedHost,pinnedTyped', + planned.records.map((r) => `${r.name}->${r.module}`).join()); + const out = generateMembers(tm, input, mods, planned); + const again = generateMembers(tm, input, mods, planMembers(tm, input, mods, names, opts)); + const head = new Map([...mods, ...out.modules]); + const v = verifyMembers(tm, input, out.host, mods, head, opts); + check('the generated moves prove against their base', v.failures.length === 0 && v.moved.length === 2, v.failures.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 lifecycle = out.modules.get('engine-lifecycle.ts') ?? ''; + check('each stub is the first pass\'s stub, `async` dropped', + out.host.includes(` private pinnedHost(object: string): Promise {\n // ${MARK} The body moved to \`engine-lifecycle.ts\`, ${DOC_MOVED} (see \`LifecycleHost\`).\n return LifecycleFacet.pinnedHost.call(lifecycleHost.of(this), object);\n }`) && + out.host.includes(` protected pinnedTyped(x: FourthShape): number {\n // ${MARK} The body moved to \`engine-lifecycle.ts\`.\n return LifecycleFacet.pinnedTyped(x);\n }`), + out.host.slice(out.host.indexOf('pinnedHost(') - 80, out.host.indexOf('pinnedHost(') + 300)); + check('a private docblock moves with its body; a protected one stays on the class', + lifecycle.includes(' /** A pinned private docblock: the fourth pass moves it with the body. */\n static async pinnedHost(') && !out.host.includes('A pinned private docblock') && + out.host.includes(' /** A pinned protected docblock: it stays on the class. */\n protected pinnedTyped(') && lifecycle.includes(` ${placeholderDoc('pinnedTyped')}\n static pinnedTyped(`)); + const listed = headerNamesOf(lifecycle, 'Lifecycle')?.names ?? []; + check('the header lists both, in class order after the members declared above them', + listed.indexOf('pinnedHost') > listed.indexOf('generic') && listed.indexOf('pinnedTyped') === listed.indexOf('pinnedHost') + 1, listed.join()); + check('a host member only a moved body reads joins the interface and the factory', + lifecycle.includes(" readonly knex: ObjectQLClass['knex'];") && out.host.includes(' get knex() { return engine.knex; },') && !input.includes('get knex()')); + check('a private static only a moved body reads joins the statics route', + out.host.includes(" readonly bump: (typeof ObjectQL)['bump'];") && out.host.includes(' bump: { get: () => ObjectQL.bump },') && !input.includes("['bump']")); + check('the module imports what the moved members read, ahead of the leaf; engine.ts drops what only the bodies read and keeps the type a stub still names, and the proof names the drops', + /import \{ fourthOnly \} from '\.\/fourth-only\.js';\nimport type \{ FourthShape \} from '\.\/fourth-shape\.js';\n(import[^\n]*\n)*import[^\n]*'\.\/engine-top-level\.js'/.test(lifecycle) && + out.pruned.join() === 'fourthOnly,helperImported' && v.pruned.join() === 'fourthOnly,helperImported' && + out.host.includes("import type { FourthShape } from './fourth-shape.js';"), `${out.pruned} | ${v.pruned}`); + check('a name the class does not declare refuses', refused(() => planMembers(tm, input, mods, ['gone'], opts))); + check('a method already moved refuses', refused(() => planMembers(tm, input, mods, ['movable'], opts))); + check('a method a stay rule holds refuses (inferred-return-type, and a pin that still stands)', + refused(() => planMembers(tm, input, mods, ['inferred'], opts)) && refused(() => planMembers(tm, input, mods, ['pinnedMethod'], opts))); + const fails = (host, edited) => verifyMembers(tm, input, host, mods, new Map([...head, ...edited]), opts).failures.length > 0; + check('one edited character in a moved body fails', fails(out.host, [['engine-lifecycle.ts', lifecycle.replace('return x.n;', 'return x.n + 1;')]])); + check('a host member no body reads, in the interface and the factory, fails', fails( + out.host.replace(' get knex() { return engine.knex; },', ' get knex() { return engine.knex; },\n get zed() { return engine.zed; },'), + [['engine-lifecycle.ts', lifecycle.replace(" readonly knex: ObjectQLClass['knex'];", " readonly knex: ObjectQLClass['knex'];\n readonly zed: ObjectQLClass['zed'];")]])); + check('an import nothing in the module reads fails', fails(out.host, [['engine-lifecycle.ts', lifecycle.replace("import { fourthOnly } from './fourth-only.js';", "import { fourthOnly, unread } from './fourth-only.js';")]])); + check('a stub that kept `async` fails', fails(out.host.replace(' private pinnedHost(object: string)', ' private async pinnedHost(object: string)'), [])); + check('an edit elsewhere in engine.ts, or in a module no moved member is in, fails', + fails(out.host.replace(' return 2 + VIA_SEAM', ' return 3 + VIA_SEAM'), []) && + fails(out.host, [[LEAF_MODULE, (mods.get(LEAF_MODULE) ?? '').replace('const LOCAL_VALUE = 1;', 'const LOCAL_VALUE = 2;')]])); + check('a head that moved nothing fails', verifyMembers(tm, input, input, mods, mods, opts).failures.some((f) => f.startsWith('nothing moved'))); + } + const declared = Object.keys(SELF_TEST_BATTERIES); if (declared.length < SELF_TEST_BATTERY_FLOOR) { failed += 1; From 3554ad9a10c6e2396c07aff6f1480d71f2b9d804 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 11:47:37 +0000 Subject: [PATCH 7/8] chore(changeset): objectql patch note for the six typed and moved ObjectQL bodies Claude-Session: https://claude.ai/code/session_01Utp6CXX2qjqm2uXbJA8Jcv Co-authored-by: Claude --- .changeset/30055-erasure-pinned-bodies.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/30055-erasure-pinned-bodies.md diff --git a/.changeset/30055-erasure-pinned-bodies.md b/.changeset/30055-erasure-pinned-bodies.md new file mode 100644 index 00000000000..74ef534dbc8 --- /dev/null +++ b/.changeset/30055-erasure-pinned-bodies.md @@ -0,0 +1,12 @@ +--- +'@objectstack/objectql': patch +--- + +refactor(objectql): the six `ObjectQL` methods the #4918 query-options erasure pin kept in `engine.ts` are typed and move into their facet modules; no behaviour or public declaration changes + +Clause-②: no +Metadata-protocol: none + +- The six internal reads in `seedAutonumber`, `referenceExists`, `resolveMasterDetailParent`, `resolveMasterDetailParents`, `inspectDanglingReferences` and `probeReferencingRows` no longer erase their query options to `any`: `tsc` now checks each one against `EngineQueryOptions`, and `engine.ts` leaves the erasure baseline with zero sites. +- A new fourth pass of the `engine.ts` codemod, `--members`, moves the six bodies the way the first pass moves a body, into the facet modules it already wrote, and proves the move by undoing it. `engine.ts` goes from 11,771 to 11,316 lines. +- Nothing to do on upgrade: the exported names, the public `.d.ts` declarations and what every method does are the same. From 293b12ffec15abd89ec4f36b9fbf7b995e0ec481 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 11:47:50 +0000 Subject: [PATCH 8/8] chore(changeset): say which two sites name EngineQueryOptions by assertion Claude-Session: https://claude.ai/code/session_01Utp6CXX2qjqm2uXbJA8Jcv Co-authored-by: Claude --- .changeset/30055-erasure-pinned-bodies.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/30055-erasure-pinned-bodies.md b/.changeset/30055-erasure-pinned-bodies.md index 74ef534dbc8..fa0801d8fc7 100644 --- a/.changeset/30055-erasure-pinned-bodies.md +++ b/.changeset/30055-erasure-pinned-bodies.md @@ -7,6 +7,6 @@ refactor(objectql): the six `ObjectQL` methods the #4918 query-options erasure p Clause-②: no Metadata-protocol: none -- The six internal reads in `seedAutonumber`, `referenceExists`, `resolveMasterDetailParent`, `resolveMasterDetailParents`, `inspectDanglingReferences` and `probeReferencingRows` no longer erase their query options to `any`: `tsc` now checks each one against `EngineQueryOptions`, and `engine.ts` leaves the erasure baseline with zero sites. +- The six internal reads in `seedAutonumber`, `referenceExists`, `resolveMasterDetailParent`, `resolveMasterDetailParents`, `inspectDanglingReferences` and `probeReferencingRows` no longer erase their query options to `any`. Four are plain literals `tsc` now checks against `EngineQueryOptions`; the keyset walk's `where` and the dangling-reference audit's port options are named as `EngineQueryOptions` slots instead. `engine.ts` leaves the erasure baseline with zero sites. - A new fourth pass of the `engine.ts` codemod, `--members`, moves the six bodies the way the first pass moves a body, into the facet modules it already wrote, and proves the move by undoing it. `engine.ts` goes from 11,771 to 11,316 lines. - Nothing to do on upgrade: the exported names, the public `.d.ts` declarations and what every method does are the same.