diff --git a/.changeset/30055-erasure-pinned-bodies.md b/.changeset/30055-erasure-pinned-bodies.md new file mode 100644 index 00000000000..fa0801d8fc7 --- /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`. 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. 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 e773f73b3c8..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,157 +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, { - ...q, - fields: ['id', field], - context: execCtx, - } as any), - { - 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); } /** @@ -3144,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), - } as any); - 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( @@ -3220,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) } as any); - // `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 any) 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( @@ -3524,22 +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[], - find: (object, opts) => this.find(object, opts as any) 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); } /** @@ -9438,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, @@ -9453,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) } as any, - ); - } 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( @@ -10847,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) }, }); @@ -10905,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; }, @@ -11019,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; }, @@ -11027,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/codemod-split-engine.mjs b/scripts/codemod-split-engine.mjs index 7e1e722cba8..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. * @@ -234,21 +246,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 +876,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 +884,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 +1050,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 +1067,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 +1246,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 +1262,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 +1325,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 +1390,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 +3096,729 @@ 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; +} + +/** + * `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); + 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 = pruneHostImports(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 = 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)})`); + } + 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, 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; +} + +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 +3828,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 +3855,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; @@ -3167,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; @@ -3344,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'], @@ -3802,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; 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,