Skip to content

Commit 881af5d

Browse files
refactor(metadata-protocol): move the bodies of 22 private members out of protocol.ts into eight themed modules (#25467 cut 33) (#27540)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuDs1YApZetPpXnckKDrvuBk)_ Part of #25467 Clause-②: no Metadata-protocol: none Ruling-ref: 6270097840 ## 维护者速读 - 改了什么:把 `protocol.ts` 里 22 个私有方法的方法体原样搬进 8 个按主题划分的新文件(存储行绑定、显式降级、overlay 索引、视图容器判断、包提交记录、包作用域、`/meta` 铸造拒绝、SDUI 清单),类上的方法、签名和文档注释都保留,只剩一行调用。 - 为什么改:#25467 拆分超大文件 `protocol.ts` 的第 33 刀。单个成员一刀只能减 6 到 10 行,这次按派发令一次搬完所有通过筛查的私有成员;筛完后私有成员这条线已经搬尽。 - 风险与回滚:低。22 个方法体逐 token 相同(2,061 个 token,差异 0),发布的 `.d.ts` 与 `main` 原始字节完全相同,包测试与 `main` 逐条比对 0 丢失 0 变化,只多了 4 条新登记行带来的用例;回滚就是还原这一个 PR。 - 先看:`protocol.ts` 里带 `[#25467 cut 33]` 标记的 22 个桩方法,以及 `protocol.bracketed-refusal-opener-absence.test.ts` 里新增的 4 行 `PRODUCERS` 登记。 - 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 src/protocol.bracketed-refusal-opener-absence.test.ts src/protocol.register-item-call-sites.test.ts`,预期全部通过。 - 需要你做的:无,席位落地。 ## What changes Cut 33 of the `protocol.ts` split (#24586 ruling A, recorded in `6270097840`). Cuts 1–32 landed; cut 32 is #27406. The claim is `6489357157`. The dispatch asked for a batch: start from cut 31's alternative (`6465513012`, `storedRowKnownDatasources`), screen every remaining private member with a live body on fresh `main`, and move every one that passes in one PR under 3,000 lines, grouped by theme. 22 `ObjectStackProtocolImplementation` members move unedited into 8 new modules under `packages/metadata-protocol/src/` (#23050 shape B): | Module | Members (base line, body lines, AST tokens) | Host | |---|---|---| | `protocol-stored-row-bindings.ts` (113 lines) | `storedRowKnownDatasources` (`:6555`, 6, 102), `overlayLockLayerAt` (`:10478`, 13, 138), `resolveOverlayPackageBinding` (`:19272`, 11, 65) | `StoredRowBindingsHost` | | `protocol-loud-degradations.ts` (69) | `throwMetadataServiceUnavailable` (`:9407`, 7, 29), `warnClosureReadUnavailable` (`:23514`, 9, 36) | `LoudDegradationsHost`; the first reads no member | | `protocol-overlay-index.ts` (95) | `ensureOverlayIndex` (`:8017`, 37, 160), `ensureOverlayMembersBackfilled` (`:8072`, 3, 28) | `OverlayIndexHost`, with getter and setter for the two one-shot flags | | `protocol-registry-view-predicates.ts` (122) | `expandStoredViewContainers` (`:10277`, 7, 76), `runtimeViewContainerObject` (`:18209`, 4, 53), `copiesOwnShippedViewContainer` (`:18241`, 2, 32), `isAnotherPackagesObject` (`:18295`, 6, 59), `registryTakesStoredRows` (`:18647`, 1, 13), `overlaidShippedContainerViewNames` (`:20018`, 6, 82) | `RegistryViewPredicatesHost`; `runtimeViewContainerObject` reads no member | | `protocol-package-commit-record.ts` (137) | `persistPackageCommitRow` (`:26369`, 2, 24), `recordPackageCommit` (`:26397`, 71, 342) | `PackageCommitRecordHost` | | `protocol-package-scope.ts` (150) | `uninstalledNamedBaseRefusal` (`:16236`, 28, 175), `isWritablePackage` (`:16577`, 1, 12), `shouldDropStorage` (`:19325`, 6, 53), `resolveAppPackageIdKey` (`:10582`, 38, 323) | `PackageScopeHost`; `shouldDropStorage` reads no member | | `protocol-unmintable-meta-type.ts` (62) | `refuseUnmintableMetaType` (`:19495`, 17, 82) | `UnmintableMetaTypeHost` | | `protocol-sdui-manifest.ts` (77) | `resolveSduiManifest` (`:6750`, 12, 66), `reportPageRequiresAbsentAtLoad` (`:28937`, 13, 111) | `SduiManifestHost`; the second reads no member | Base lines are `protocol.ts` at `main` `f7188b562b`, which is byte-identical to the claim's base `c5d4634174` for this file. 300 body lines and 2,061 AST tokens in total. - **The class keeps every member**: name, modifiers, signature, docblock and position. Each body is now one call into its module, marked `// [#25467 cut 33] The body moved to ...`. - **Shape B, as cuts 20–32.** A body that reads `this` builds one `const host: ThisParameterType<typeof fn> = {...}` and returns `fn.call(host, ...)`. Methods are passed as closures over `this`, fields as getters, and the two written flags (`overlayIndexEnsured`, `overlayMembersBackfilled`) as getter plus setter. The host type is the module's one named context; there is no second copy. The 4 bodies with no `this` call the plain function directly, as cut 26 did. The generic `resolveAppPackageIdKey` is called as `(resolveAppPackageIdKey<R>).call(host, ...)`. - **Every caller still calls the class method.** No call site changes and no member changes visibility. - `protocol.ts` goes from 29,508 to 29,346 lines. The PR is +982 / −312 over 10 files, 1,294 changed lines. The card stays open for later cuts. ### Screened and rejected Every private, non-static class member with a live body on fresh `main` was screened. A member had to pass four checks: no private-static read, no `this.constructor` read, no by-path reader of its body, and an unchanged read-seam census (`--list` included). Each candidate was also run through the package suite and the hand gates on a trial tree. These 40 stay: | Reason | Members | |---|---| | Reads a private static (7) | `isSealedManagedItem` (`registryAllowsOverlay`), `refusePackagedBaseRemoval` (`mergesOverlayAtRead`), `recordOptimisticConflictAudit` (`optimisticConflictAuditEntry`), `applyObjectRegistryMutation` (`overlayPackageMismatchError`; also the register-item and write-through pins, and the ADR-0029 seam), `reconcileViewExpansions` (`registeredViewExpansions`; also its pins), `promoteDraftForPublish` (4 statics; also the view-expansion pin), `readRevertableCommit` (`organizationScopedWriteRefusal`) | | `protocol.lookup-artifact-item-call-sites` pin keys `lookupArtifactItem(` sites by member in `protocol.ts` (11) | `packagedArtifactBase`, `readCodeLayerForCarryForward`, `readFlattenedMetaItems` (also view-expansion), `readMetaItem`, `isArtifactBacked`, `isNestedArtifactField`, `shippedViewContainerOf`, `runtimeViewContainerPackage`, `isShippedByAnotherPackage`, `expandRuntimeViewContainer`, `viewContainerNameCollisionRefusal` | | Other by-path pins on `protocol.ts` (11) | `emitMetadataMutation` (recovery-doors counts `this.emitMetadataMutation(` and its private declaration), `applyRemoteMetadataMutation` (recovery-doors body scan, view-expansion), `getOverlayRepo` (view-expansion: `new SysMetadataRepository(` must sit in it), `hydrateOverlayIntoRegistry` (lookup, register-item, view-expansion, hydrate-canonical-type), `hydrateExpandedViewItems` (view-expansion), `applyRegistryWriteThrough` (view-expansion, write-through, hydrate), `restoreArtifactRegistryView` (register-item, view-expansion), `runPublishSideEffects` (view-expansion, recovery-doors), `ensureObjectStorage` and `dropObjectStorage` (the write-through-spelling pin counts `if (type !== 'object') return;` in `protocol.ts`), `anotherPackageShips` (the register-item pin regex-matches its body; measured red on the trial tree) | | Moves the read-seam census (8) | `findEnvironmentRows` (89 → 73), `findEnvironmentRow` (→ 82), `findServedOverlayRow` (→ 86), `storedFlowBindingAgrees` (→ 88), `foldStoredCollection` (→ 88), `rethrowUnlessMetadataStoreUnprovisioned` (exit 1, a 70-line `--list` diff: it is the benign-error discriminator), `unmanagedImportCollisions` (→ 88), `rollBackUnmanagedImport` (→ 88) | | Other (3) | `probeRecord` (the query-options-erasure ratchet reds: a new erasure site in a new file; it is also an engine-double seam), `packagelessAddress` (census 90 → 86), `metaTypeNamespaceExists` (census 90 → 89) | The census readings in the first rows were taken against base `c5d4634174` (89); the last two against `main` (90). **The private series is spent.** After this cut no private member with a live body passes the screen. The 57 public members were not screened; inferred-return publics stay, per the seat's shape call. ## Tests One test file changes, and it is the path-reader migration the ruling requires: `protocol.bracketed-refusal-opener-absence.test.ts` keeps a per-file floor of `new Error(` producers (`PRODUCERS`). Five `new Error(` sites move, so four new modules get their row, under a `[#25467 cut 33]` comment, after `'protocol-packaged-base-refusal.ts': 1,`: ``` 'protocol-package-scope.ts': 2, 'protocol-unmintable-meta-type.ts': 1, 'protocol-loud-degradations.ts': 1, 'protocol-overlay-index.ts': 1, ``` Each row adds one generated case, so the suite grows by 4 (see Verification). No other test changes. The moved members are driven end to end by the existing suites (package install and uninstall, the `/meta` save and publish doors, view-container expansion, overlay hydration, SDUI page loads); all pass at both ends. ## Acceptance notes - **Two top-level helpers gain `export` in place.** `rowMatchesOverlayWhere` (read by `overlayLockLayerAt`) and `metadataStoreUnavailableError` (read by `throwMetadataServiceUnavailable`) are `protocol.ts`'s own non-exported functions. Each gains the `export` keyword with nothing else in its statement changed, as `CommitPlanItem` did in cut 32. Both keep readers in `protocol.ts` and stay there. `index.ts` names its re-exports one by one and names neither, so the published surface does not move (the `.d.ts` is raw-identical, below). - **Imports that lost their last reader are removed from `protocol.ts`**: `isUsableSduiManifest`, `findPageRequiresAbsentFromManifest`, `isWritablePackageShared` (with its `[#7560]` comment), `ensureMetadataOverlayIndexes`, `driverCanRunSql` and `resolveDriverExec` (the whole `driver-exec` import), and `isAppPackageIdKey` (with its `[#23183]` comment). The two comments travel with the import into `protocol-package-scope.ts`. `SDUI_MANIFEST_SERVICE` and `backfillOverlayBundleMembers` stay: docblocks still `@link` them. - **One read-seam `--list` line changes file, with the same verdict and count.** `recordPackageCommit`'s `catch` that "guards persistPackageCommitRow() → loud (error)" now sits in `protocol-package-commit-record.ts`. The census stays at 90. - **`check-adr-anchors` counts move, and the gate passes at both ends.** Citations 60,579 → 60,582 and files 8,558 → 8,561, because the moved bodies' ADR citations now also appear in the new modules. Decision-letter citations on the anchored surface go 1,910 → 1,909: one moved letter citation left `protocol.ts` for a module outside the anchored set. `check-adr-symbol-anchors` output is identical. - **The cut 25 docblock-offset trap did not fire.** The `.d.ts` is raw-identical to `main`. - **History.** The branch holds the move commit `a04a2c73cc` on `c5d4634174`, one merge of `main` `f7188b562b` (19 commits, none in `packages/metadata-protocol/`), and `044b4d97de`. That commit adds `refuseUnmintableMetaType` and `overlaidShippedContainerViewNames`, which had been held back and passed every check on a re-measure against `main`, and it adds the `PRODUCERS` rows. The whole move was regenerated by script on that commit, so the two late members follow the same recipe. The first commit's subject says 21 members and seven modules; it moved 20. - **Branch name.** `claude/project-thread-06v1io` is the harness-pinned branch for this thread. It names no issue, so the claim comment is the identity record. - **Serial with in-flight PRs.** At `pr_create`, of the 25 open PRs one edits `protocol.ts`: #27535 (permission-set name uniqueness, +57/−3 in `protocol.ts`). `git merge-tree --write-tree` of this head with its head `4cd524bf0b` is clean, and its added lines call none of the moved members. #26517 (Version Packages) touches only `CHANGELOG.md` and `package.json` in this package. `main` moved after the merge (to `aef631b80b`); the one new commit in this package only adds `protocol.draft-publish-verdict.test.ts`, and `git merge-tree` with it is clean, so `main` was not merged again. - **Liveness ledger follow-up, carried forward** (`packages/spec/**`, outside this card): `manifest.json`'s `dependencies` row → `protocol.ts#resolveWritePackageScope` (cut 19) and `seed.json` → `protocol.ts#applySeedBodies` (cut 14). No row points at this cut's members. - No changeset: `skip-changeset`, per ruling `6348891160`. ## Verification ### Proof of move-only The compare reads the TypeScript compiler's AST leaf tokens, with JSDoc skipped, over each member's parameters, return type and body, `protocol.ts` at `main` against the head module: ``` overlayLockLayerAt 138 tokens differ 0 text equal resolveOverlayPackageBinding 65 tokens differ 0 text equal storedRowKnownDatasources 102 tokens differ 0 text equal throwMetadataServiceUnavailable 29 tokens differ 0 text equal warnClosureReadUnavailable 36 tokens differ 0 text equal ensureOverlayIndex 160 tokens differ 0 text equal ensureOverlayMembersBackfilled 28 tokens differ 0 text equal expandStoredViewContainers 76 tokens differ 0 text equal runtimeViewContainerObject 53 tokens differ 0 text equal copiesOwnShippedViewContainer 32 tokens differ 0 text equal isAnotherPackagesObject 59 tokens differ 0 text equal registryTakesStoredRows 13 tokens differ 0 text equal overlaidShippedContainerViewNames 82 tokens differ 0 text equal persistPackageCommitRow 24 tokens differ 0 text equal recordPackageCommit 342 tokens differ 0 text equal isWritablePackage 12 tokens differ 0 text equal uninstalledNamedBaseRefusal 175 tokens differ 0 text equal shouldDropStorage 53 tokens differ 0 text equal resolveAppPackageIdKey 323 tokens differ 0 text equal refuseUnmintableMetaType 82 tokens differ 0 text equal resolveSduiManifest 66 tokens differ 0 text equal reportPageRequiresAbsentAtLoad 111 tokens differ 0 text equal TOTAL 2061 differ 0 ``` "text equal" is the whitespace-collapsed source text, comments included. Negative controls, one token changed in an in-memory copy of the head module, each `differ 1`: `registryTakesStoredRows` `===` → `!==`; `isWritablePackage` `packageId` → `pkg`; `overlaidShippedContainerViewNames` `ownPackageId` → `pkg`; `refuseUnmintableMetaType` `400` → `409`. - **Class side.** 304 members at both ends, same order. The 282 that did not move are byte-identical, leading comments included; the 22 stubs keep their leading trivia and signature byte-identical. - **Top level.** 266 → 271 statements; 259 byte-identical. The rest: the 8 module imports (each with a one-line `[#25467 cut 33]` comment), the two helpers with `export` added, and the removed imports listed above. - `tsc --noEmit` in `packages/metadata-protocol`: exit 0. `pnpm exec eslint --no-inline-config packages/metadata-protocol`: exit 0, no output. ### The published `.d.ts` (ruling `6348891160`) Built with `pnpm --filter @objectstack/metadata-protocol build` against one dependency tree (a full `turbo run build`). The base and `main` legs swapped the package `src/` tree-only and restored it from `HEAD` under a trap; afterwards `git status --porcelain` was empty. | Reading | Base `c5d4634174` | Move commit `a04a2c73cc` | `main` `f7188b562b` | Final head `044b4d97de` | |---|---|---|---|---| | raw sha256 `dist/index.d.ts` = `dist/index.d.cts` | `4cb92b2e20135dd78d07fd2800b1d2a050a432b923cd511d5b50f3ed6de29a58` (784,960 bytes) | same as base | `08235c712877a28a3cdf7c36d96d18fc72b246c8533eae22feb64aaae7af12ea` (785,044 bytes) | same as `main` | | normalised sha256 | `a4dcc2306638c29983bceaf7258df71a29a3020a3ec26904c9830a0cecd0c70e` | same as base | `6faa3395b5c4ccdc2e24a47a359fa8a2f35bd1aaa2f3df821ea164c316a11192` | same as `main` | | raw-diff lines | | 0 against base | | 0 against `main` | The merged tree `2c6ff0ae` gives the `main` hashes too; the `.d.ts` moved between base and `main` only through the merged `main` commits. Bundles change as expected: `index.js` `e5abe0490663649f` → `d80799c603dc52be`, `index.cjs` `1c36338d33aa16a5` → `c8cbc5fc5a540a2d` (sha256, 16). The ESM and CJS export tables are the same 78 names (`e47ac7c753bcb8be`) at both ends; none of the moved functions or the two exported helpers appears. Normaliser, verbatim: ``` node -e 'const s=require("fs").readFileSync(process.argv[1],"utf8");process.stdout.write(s.replace(/"[^"\n]*"(?:\s*\|\s*"[^"\n]*")+/g,r=>r.split(/\s*\|\s*/).sort().join(" | ")))' dist/index.d.ts | sha256sum ``` **Positive control.** On the final head `.d.ts`, `private storedRowKnownDatasources;` was made public (a 2-line raw diff). Raw goes to `66b4b1c26cdbfa9b…`, normalised to `b248e476c8ff57fb…`, both different from the bar. So the bar can see a change on a moved member. ### Gates and suites <details><summary>Package suite</summary> `packages/metadata-protocol`, vitest `--maxWorkers=2`, JSON reporter, under the shared verify lock. - Base `c5d4634174` and move commit `a04a2c73cc`: both 21,800 tests (21,774 passed, 0 failed, 26 skipped), 283 files; per test 0 lost, 0 added, 0 changed. - `main` `f7188b562b` (src swapped tree-only, restored under a trap): 21,800 tests (21,774 passed, 0 failed, 26 skipped), 283 files. - Final head `044b4d97de`: 21,804 tests (21,778 passed, 0 failed, 26 skipped), 283 files. Per test name and status over 21,787 distinct names against `main`: 0 lost, 0 changed, 4 added, all four the new `PRODUCERS` rows. </details> <details><summary>Hand gates, main and head</summary> All 13 exit 0 at both `main` `f7188b562b` and the final head. - Identical output: `check-adr-symbol-anchors`, `check-system-context-census`, `check-tenant-audit-census`, `check-query-options-erasure-ratchet` (50 unswept non-test sites in 15 files, none new), `check-filter-alias-parity`, `check-durability-degradation-log-level` (`90 read seam(s)`), the spec liveness gate, `check-platform-checklist`. - `check-durability-degradation-log-level --list`: identical once `[@:][0-9]+` is stripped, except the one `recordPackageCommit` line that changes file (above). - `check-adr-anchors`: counts as noted in Acceptance notes. - `check-startup-registry`: scanned files 3,693 → 3,702. `check-nul-bytes`: 21,998 → 22,006 files. `check-engine-double-contract`: the `updateData`/`deleteData` seams keep their entries at new line numbers; `productionFiles` 3,887 → 3,896. Each count is the new modules plus build output in the head tree, as in cuts 20–32. - `check-platform-checklist --changed-from origin/main`: OK, 32 anchors judged, 0 lost. - `check-governed-merges --branch claude/project-thread-06v1io`: 0 of 10 paths in the register (13 surfaces), NOT governed; 1,294 changed lines, under the 3,000-line bar. </details> <details><summary>Derived gates</summary> `dispatch-gates --commands --repo objectstack-ai/objectstack` on `044b4d97de` derives 52 gates on 10 changed paths; re-derived against `main` `aef631b80b`, the list is unchanged. All 52 exit 0 after a full `turbo run build`. `dispatch-gates --ran` reports 52 derived, 52 run, 0 NOT-MEASURED, 0 UNRUN, and exits 0. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgVeV5GgzaH9Ny16aSx --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgVeV5GgzaH9Ny16aSx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d4ff1a0 commit 881af5d

10 files changed

Lines changed: 982 additions & 312 deletions
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25467 cut 33] Two degradations said out loud, from
5+
* `ObjectStackProtocolImplementation`, moved out of `protocol.ts` whole:
6+
*
7+
* - `throwMetadataServiceUnavailable` — a degraded MetadataService read
8+
* turned into the same 503 the overlay half of the reads throws;
9+
* - `warnClosureReadUnavailable` — why the batch closure degraded, once per
10+
* process.
11+
*
12+
* A MOVE, not a redesign. Each function below is the body of the
13+
* `ObjectStackProtocolImplementation` method of the same name as it stood,
14+
* re-indented one level and otherwise unedited.
15+
*
16+
* ## How the moved bodies reach the class
17+
*
18+
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
19+
* header carries the full reasoning), as `protocol-batch-data.ts` applies it.
20+
* The class keeps each method — the published `dist/*.d.ts` lists its name,
21+
* docblock and declared signature — and its body is one literal of exactly
22+
* the {@link LoudDegradationsHost} members it reads, plus a `.call`; a body
23+
* that reads no member is called as a plain function. The warned-once flag is
24+
* handed over as a getter and a setter, so the read and the write reach the
25+
* class field as it stands at call time. Every caller keeps calling the class
26+
* methods.
27+
*
28+
* What the bodies read from elsewhere, they import from where `protocol.ts`
29+
* imports it; `metadataStoreUnavailableError` is `protocol.ts`'s own and is imported from there. Nothing re-exports this module, so the published surface does
30+
* not move.
31+
*/
32+
33+
import type { ObjectStackProtocolImplementation } from './protocol.js';
34+
import { metadataStoreUnavailableError } from './protocol.js';
35+
36+
/**
37+
* The `ObjectStackProtocolImplementation` members the functions below read —
38+
* every one of them, and no other; each function's `this` is the `Pick` of
39+
* the members it reads. Each is typed AS the class member (an indexed access
40+
* on the class), so a change to a member's signature on the class is a change
41+
* here too, and naming a member the class does not have is a compile error.
42+
*/
43+
export interface LoudDegradationsHost {
44+
closureReadWarned: ObjectStackProtocolImplementation['closureReadWarned'];
45+
}
46+
47+
/** The body of `ObjectStackProtocolImplementation#throwMetadataServiceUnavailable`; the method's docblock there is its contract. */
48+
export function throwMetadataServiceUnavailable(errors: string[]): never {
49+
throw metadataStoreUnavailableError(
50+
new Error(
51+
`The metadata service could not read every loader: ${
52+
errors.length > 0 ? errors.join('; ') : 'no loader detail reported'
53+
}`,
54+
),
55+
);
56+
}
57+
58+
/** The body of `ObjectStackProtocolImplementation#warnClosureReadUnavailable`; the method's docblock there is its contract. */
59+
export function warnClosureReadUnavailable(this: Pick<LoudDegradationsHost, 'closureReadWarned'>, type: string, name: string): void {
60+
if (this.closureReadWarned) return;
61+
this.closureReadWarned = true;
62+
console.warn(
63+
`[Protocol] publishPackageDrafts: this overlay repository declares no 'get', so the batch's own `
64+
+ `pending drafts cannot be read (first reached at ${type}/${name}). Author-time validation falls `
65+
+ `back to the LIVE declarations only, without this batch's own drafts in the closure, so a draft that references a `
66+
+ `sibling drafted in the SAME batch may be refused as unresolved. Nothing is published unchecked: `
67+
+ `the gate still runs, with strictly less resolution context.`,
68+
);
69+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25467 cut 33] `sys_metadata`'s overlay-uniqueness indexes and the
5+
* bundle-member backfill they need first, each run once per process, from
6+
* `ObjectStackProtocolImplementation`, moved out of `protocol.ts` whole:
7+
* `ensureOverlayIndex` and `ensureOverlayMembersBackfilled`.
8+
*
9+
* A MOVE, not a redesign. Each function below is the body of the
10+
* `ObjectStackProtocolImplementation` method of the same name as it stood,
11+
* re-indented one level and otherwise unedited.
12+
*
13+
* ## How the moved bodies reach the class
14+
*
15+
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
16+
* header carries the full reasoning), as `protocol-batch-data.ts` applies it.
17+
* The class keeps each method — the published `dist/*.d.ts` lists its name,
18+
* docblock and declared signature — and its body is one literal of exactly
19+
* the {@link OverlayIndexHost} members it reads, plus a `.call`; a body that
20+
* reads no member is called as a plain function. The `engine` field and the
21+
* two run-once flags are handed over as getters (and the flags as setters),
22+
* so each read and write reaches the class field as it stands at call time;
23+
* the call to `ensureOverlayMembersBackfilled` is a closure, so it still
24+
* reaches the class method. Every caller keeps calling the class methods.
25+
*
26+
* What the bodies read from elsewhere, they import from where `protocol.ts`
27+
* imports it. Nothing re-exports this module, so the published surface does
28+
* not move.
29+
*/
30+
31+
import { driverCanRunSql, resolveDriverExec } from './migrations/driver-exec.js';
32+
import { backfillOverlayBundleMembers, ensureMetadataOverlayIndexes } from './migrations/overlay-index.js';
33+
import type { ObjectStackProtocolImplementation } from './protocol.js';
34+
35+
/**
36+
* The `ObjectStackProtocolImplementation` members the functions below read —
37+
* every one of them, and no other; each function's `this` is the `Pick` of
38+
* the members it reads. Each is typed AS the class member (an indexed access
39+
* on the class), so a change to a member's signature on the class is a change
40+
* here too, and naming a member the class does not have is a compile error.
41+
*/
42+
export interface OverlayIndexHost {
43+
readonly engine: ObjectStackProtocolImplementation['engine'];
44+
readonly ensureOverlayMembersBackfilled: ObjectStackProtocolImplementation['ensureOverlayMembersBackfilled'];
45+
overlayIndexEnsured: ObjectStackProtocolImplementation['overlayIndexEnsured'];
46+
overlayMembersBackfilled: ObjectStackProtocolImplementation['overlayMembersBackfilled'];
47+
}
48+
49+
/** The body of `ObjectStackProtocolImplementation#ensureOverlayIndex`. */
50+
export async function ensureOverlayIndex(this: Pick<OverlayIndexHost, 'engine' | 'ensureOverlayMembersBackfilled' | 'overlayIndexEnsured'>): Promise<void> {
51+
if (this.overlayIndexEnsured) return;
52+
this.overlayIndexEnsured = true;
53+
// [#22877] Rows stored before the member column existed get their
54+
// body's member before the first write can address them.
55+
await this.ensureOverlayMembersBackfilled();
56+
try {
57+
const engineAny = this.engine as any;
58+
let driver: any = engineAny?.driver ?? engineAny?.getDriver?.();
59+
if (!driver && engineAny?.drivers instanceof Map) {
60+
for (const candidate of engineAny.drivers.values()) {
61+
if (driverCanRunSql(candidate)) {
62+
driver = candidate;
63+
break;
64+
}
65+
}
66+
}
67+
if (!driver) return;
68+
const resolved = resolveDriverExec(driver);
69+
// The refusal stays INSIDE the seam rather than becoming an early
70+
// return: a driver taken from `engineAny?.driver` is not
71+
// capability-checked above, and when it offers neither surface it is
72+
// the migration that classifies and reports the failure — an early
73+
// return here would put that back into the silent `catch` below.
74+
const exec = async (sql: string): Promise<void> => {
75+
if (!resolved) throw new Error('driver has neither raw nor execute');
76+
await resolved(sql);
77+
};
78+
// `console` satisfies the logger surface structurally; this class
79+
// carries no injected logger, and its own diagnostics go to
80+
// `console.warn` throughout (see `emitMetadataMutation`).
81+
await ensureMetadataOverlayIndexes(exec, console);
82+
} catch {
83+
// A boot must never fail over an index. Note this arm is now only
84+
// reachable for driver RESOLUTION failures: every DDL failure past
85+
// this point is classified and reported by the migration itself,
86+
// instead of vanishing into an empty catch (#6418).
87+
}
88+
}
89+
90+
/** The body of `ObjectStackProtocolImplementation#ensureOverlayMembersBackfilled`. */
91+
export async function ensureOverlayMembersBackfilled(this: Pick<OverlayIndexHost, 'engine' | 'overlayMembersBackfilled'>): Promise<void> {
92+
if (this.overlayMembersBackfilled) return;
93+
this.overlayMembersBackfilled = true;
94+
await backfillOverlayBundleMembers(this.engine as any, console);
95+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25467 cut 33] The ADR-0067 commit row that groups a turn's published
5+
* artifacts, from `ObjectStackProtocolImplementation`, moved out of
6+
* `protocol.ts` whole: `recordPackageCommit` and the named INSERT it calls,
7+
* `persistPackageCommitRow` (named so the durability log-level rule can
8+
* declare it).
9+
*
10+
* A MOVE, not a redesign. Each function below is the body of the
11+
* `ObjectStackProtocolImplementation` method of the same name as it stood,
12+
* re-indented one level and otherwise unedited.
13+
*
14+
* ## How the moved bodies reach the class
15+
*
16+
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
17+
* header carries the full reasoning), as `protocol-batch-data.ts` applies it.
18+
* The class keeps each method — the published `dist/*.d.ts` lists its name,
19+
* docblock and declared signature — and its body is one literal of exactly
20+
* the {@link PackageCommitRecordHost} members it reads, plus a `.call`; a
21+
* body that reads no member is called as a plain function. The `engine` field
22+
* and the noted-once flag are handed over as getters (and the flag as a
23+
* setter), so each read and write reaches the class field as it stands at
24+
* call time; the call to `persistPackageCommitRow` is a closure, so it still
25+
* reaches the class method. Every caller keeps calling the class methods.
26+
*
27+
* What the bodies read from elsewhere, they import from where `protocol.ts`
28+
* imports it. Nothing re-exports this module, so the published surface does
29+
* not move.
30+
*/
31+
32+
import { isMissingTableError } from '@objectstack/metadata/errors';
33+
import type { CommitPlanItem, ObjectStackProtocolImplementation } from './protocol.js';
34+
35+
/**
36+
* The `ObjectStackProtocolImplementation` members the functions below read —
37+
* every one of them, and no other; each function's `this` is the `Pick` of
38+
* the members it reads. Each is typed AS the class member (an indexed access
39+
* on the class), so a change to a member's signature on the class is a change
40+
* here too, and naming a member the class does not have is a compile error.
41+
*/
42+
export interface PackageCommitRecordHost {
43+
commitStoreUnprovisionedNoted: ObjectStackProtocolImplementation['commitStoreUnprovisionedNoted'];
44+
readonly engine: ObjectStackProtocolImplementation['engine'];
45+
readonly persistPackageCommitRow: ObjectStackProtocolImplementation['persistPackageCommitRow'];
46+
}
47+
48+
/** The body of `ObjectStackProtocolImplementation#persistPackageCommitRow`; the method's docblock there is its contract. */
49+
export async function persistPackageCommitRow(this: Pick<PackageCommitRecordHost, 'engine'>, row: Record<string, unknown>): Promise<void> {
50+
// [#21911] The explicit system opt-in — see recordMetadataAudit.
51+
await this.engine.insert('sys_metadata_commit', row, { context: { isSystem: true } });
52+
}
53+
54+
/** The body of `ObjectStackProtocolImplementation#recordPackageCommit`; the method's docblock there is its contract. */
55+
export async function recordPackageCommit(this: Pick<PackageCommitRecordHost, 'commitStoreUnprovisionedNoted' | 'persistPackageCommitRow'>, args: {
56+
packageId: string;
57+
operation: 'apply' | 'revert';
58+
message?: string;
59+
actor?: string;
60+
aiModel?: string;
61+
parentCommitId?: string;
62+
items: CommitPlanItem[];
63+
eventSeqStart?: number;
64+
eventSeqEnd?: number;
65+
}): Promise<{ commitId: string } | null> {
66+
try {
67+
const commitId = 'cmt_' + (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
68+
? crypto.randomUUID()
69+
: `${args.eventSeqEnd ?? 0}-${args.items.length}-${args.packageId}`);
70+
await this.persistPackageCommitRow({
71+
id: commitId,
72+
package_id: args.packageId,
73+
operation: args.operation,
74+
...(args.message ? { message: args.message } : {}),
75+
...(args.actor ? { actor: args.actor } : {}),
76+
...(args.aiModel ? { ai_model: args.aiModel } : {}),
77+
...(args.parentCommitId ? { parent_commit_id: args.parentCommitId } : {}),
78+
...(args.eventSeqStart !== undefined ? { event_seq_start: args.eventSeqStart } : {}),
79+
...(args.eventSeqEnd !== undefined ? { event_seq_end: args.eventSeqEnd } : {}),
80+
items: JSON.stringify(args.items),
81+
item_count: args.items.length,
82+
created_at: new Date().toISOString(),
83+
});
84+
return { commitId };
85+
} catch (error) {
86+
// [#9066] The publish keeps its outcome — `null` still comes back
87+
// and the caller still reports success — but the silence is over.
88+
//
89+
// BENIGN: `sys_metadata_commit` was never provisioned. That is a
90+
// real deployment state (a first boot, or an env kernel composed
91+
// without the commit log — the exact case
92+
// `@objectstack/metadata`'s plugin note records), and it is a
93+
// CONFIGURATION fact, not a store that broke: it is the same on
94+
// every publish and it is fixed in one place. Stated once per
95+
// instance, at `info`, per this card's ruling that an unprovisioned
96+
// commit store stays silent or informational.
97+
//
98+
// EVERYTHING ELSE — a connection drop, a timeout, a permission
99+
// denial, schema drift on that one table — is a write that was
100+
// supposed to land and did not, while the publish it describes DID.
101+
// `error`, per AGENTS.md "Degradation log levels", and the line
102+
// owes the two things that rule requires: the CONSEQUENCE (this
103+
// turn is not revertible, and the system will keep looking healthy)
104+
// and the FIX (repair write access to the commit store).
105+
//
106+
// NOT rethrown, and not surfaced to the caller: the artifacts are
107+
// already live and unwinding them over a missing history row would
108+
// be strictly worse than losing the row. Telling the CALLER that
109+
// the turn is unrevertible is a separate question (a response-field
110+
// change the #8896 ruling forbids for this family) and deliberately
111+
// NOT decided here.
112+
if (isMissingTableError(error, 'sys_metadata_commit')) {
113+
if (!this.commitStoreUnprovisionedNoted) {
114+
this.commitStoreUnprovisionedNoted = true;
115+
console.info(
116+
'[Protocol] sys_metadata_commit is not provisioned — publishes and reverts '
117+
+ 'succeed but record no ADR-0067 commit row, so no turn can be reverted '
118+
+ '(the revert plan of every turn is dropped). Fix: provision the commit '
119+
+ 'store — it is registered alongside sys_metadata_history by '
120+
+ "@objectstack/metadata's plugin; run schema sync. Said once per protocol instance.",
121+
);
122+
}
123+
return null;
124+
}
125+
const reason = (error as { message?: string } | undefined)?.message ?? String(error);
126+
console.error(
127+
`[Protocol] sys_metadata_commit write FAILED for package '${args.packageId}' `
128+
+ `(${args.operation}, ${args.items.length} item(s)): ${reason}. `
129+
+ 'The publish itself SUCCEEDED and reports success, so nothing looks broken — but '
130+
+ "this turn's revert plan was not persisted: the turn is absent from the package "
131+
+ 'commit timeline and can never be reverted, and every further publish loses its '
132+
+ 'own the same way until this is repaired. Fix: restore write access to '
133+
+ 'sys_metadata_commit (connectivity, permissions, or schema drift on that table).',
134+
);
135+
return null;
136+
}
137+
}

0 commit comments

Comments
 (0)