Skip to content

Commit 10ba46b

Browse files
refactor(metadata-protocol): move the publish batch's seed apply and its pending-declaration collection out of protocol.ts into protocol-publish-batch.ts (#25467 cut 14) (#26014)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X7JKgiM29GUcitRvoGGj5BU)_ Part of #25467 Clause-②: no Metadata-protocol: none Ruling-ref: 6270097840 Cut 14 of the `protocol.ts` split (#24586 ruling A, recorded in `6270097840`). Cuts 1–13 landed as #25494, #25518, #25538, #25589, #25601, #25623, #25642, #25719, #25731, #25740, #25797, #25939 and #25972. The claim is `6410996628`; the scope is the cut 14 proposal of the cut 13 report, as the server seat approved it in its dispatch order, unnarrowed. Two `ObjectStackProtocolImplementation` members move unedited into a new module, `packages/metadata-protocol/src/protocol-publish-batch.ts` (#23050 shape B): the publish batch's seed apply `applySeedBodies` and the collection of the batch's own pending declarations `collectBatchPendingDeclarations`. `protocol.ts` loses 132 net lines (29,620 to 29,488). The cut is move-only, and the card stays open for later cuts. Branch `claude/project-thread-ybjm3h` is the harness-pinned branch for this thread, restarted from `main` `0479cd6a0a` after cut 13 merged. It names no issue, so the claim comment is the identity record. It holds the move commit `c2c08b1ae` and two merges of `main` (`4003f5015` with `05f7758f0`, `721669cb1` with `a4eae0649`). Neither merge touches `packages/metadata-protocol/`: the package's `src/` tree is `941b828d` at `0479cd6a0a`, at `a4eae0649` and at the base of this diff. **Serial with the file's in-flight PRs.** Measured just before this PR was opened: of the 20 open PRs, two touch `packages/metadata-protocol/src/`, and neither touches `protocol.ts` or the new module. #25989 edits `protocol.runtime-authoring-gate.test.ts` (behaviour, no by-path read) and #25986 edits `protocol-package-install.ts` and its test. ## What moves | Member | Base line | Visibility | Body lines | AST tokens (params + return + body) | |---|---|---|---|---| | `applySeedBodies` | `:23330` | private | 79 | 400 | | `collectBatchPendingDeclarations` | `:23526` | private | 71 | 258 | 150 body lines (the order's estimate was about 146). ## The shape (#23050 shape B, as cuts 1–13 apply it) - **The class keeps every member** with its name, docblock, signature and position. Each body is now one host literal plus a `.call`, marked `[#25467 cut 14]`. - **Typing.** `PublishBatchHost` types every member as the class member itself (an indexed access on `ObjectStackProtocolImplementation`). Each moved function types its `this` as the `Pick` of the host over exactly the members its body reads. Each stub annotates its literal as `ThisParameterType` of `typeof fn`. No member changes visibility. - **Members read, measured by AST walk** (property and element accesses on `this`, with `as` casts and parentheses stripped): - `applySeedBodies` reads `engine` (a live getter) and `getMetaItem` (a closure). **The `getMetaItem` read was not in the proposal.** The body spells it `(this as any).getMetaItem(...)` inside the loader's metadata adapter, so a `this.NAME` screen does not see it. Without it in the host, the adapter would call `undefined` at runtime while `tsc` stayed green (the `any` cast hides it). The closure calls `getMetaItem` on the protocol at call time, so a spy set on the instance is still the one the adapter reaches. - `collectBatchPendingDeclarations` reads `getOverlayRepo` and `warnClosureReadUnavailable`, both as closures. - **What stays.** `warnClosureReadUnavailable` stays in the class, as the order said: it writes the instance field `closureReadWarned`. Neither moved body reads a private-static member: the AST walk found no access on the `ObjectStackProtocolImplementation` identifier and none on `this.constructor`, in either body. - **Every other caller still calls the class method.** `runPublishSideEffects` and `publishPackageDrafts` call `applySeedBodies`; `publishPackageDrafts` calls `collectBatchPendingDeclarations`. ## Module-level declarations, by reader - **Two gain `export` in place, none moves.** `declaresClientRefusal` (read by `applySeedBodies`' catch) and `memberOption` (read by `collectBatchPendingDeclarations`). `canonicalMetaType`, `clientFacingFailureText` and `seedRequestValidationError` were already exported. None is re-exported from `index.ts`, so the package's export table does not change (below). - **Imports.** `CLOSURE_CONTEXT_KEY_BY_TYPE` loses its last reader in `protocol.ts` and leaves the `./runtime-authoring-gate.js` import; the new module imports it from there, beside the type `RuntimePendingDeclarations`. The body's two dynamic imports (`./seed-loader.js`, `@objectstack/spec/data`) move unchanged and resolve from the same directory. The one new statement in `protocol.ts` is the import of the new module. The new module's runtime import from `./protocol.js` is the cycle earlier modules already use (`protocol-batch-data.ts`, `protocol-drafts.ts`, `protocol-publish-backfill.ts` among them): it reads only function declarations, and only at call time. ## The published `.d.ts` The series bar is ruling `6348891160`, sorted-union identity, measured first. Base and head were built with `pnpm --filter @objectstack/metadata-protocol build` against one dependency tree: the closure built at head by a full `pnpm build` (119/119), then the package's `src/` swapped tree-only to `0479cd6a0a` for the base build and restored from `HEAD` under a trap (`git diff HEAD` empty, `git status` clean and both touched blobs equal to `HEAD`'s after). Measured twice: on the pre-merge tree (base `0479cd6a0a`, head `c2c08b1ae`) and after merging `main` (head `4003f5015`, closure rebuilt by a full `pnpm build`, 119/119). The readings below hold for both. - **Raw sha256**: `08914e65f936a2b031c3f3f215481a97f53d132eefd869e4f42d928947ebe173` at base and at head, on both `dist/index.d.ts` and `dist/index.d.cts`, 775,874 bytes each. **Raw identity holds**, so sorted-union identity holds too. Both members stay `private NAME;` lines. - **Normalised sha256**: `46ccd7e5c4db673652513d1801ec9c9f06bf34a0a7fb6ba97fb2531ef0cc544b` at both ends (80 string-literal union runs sorted; the cut 12 normaliser). - **The two builds differ where they should**: `index.js` hashes `46f350a33fcbf439…` at base and `a63f0a4b6b52b222…` at head; each function's `.call(host` site occurs 0 times in the base bundles and once in the head bundles (ESM and CJS alike). - **Positive control** on the head tree (on disk, restored from `HEAD` under a trap, hash equal to `HEAD`'s after): `private` dropped from `applySeedBodies` and the package rebuilt. Both `.d.ts` files move to `a0203ce8529250b7…` and print the member's full signature (`applySeedBodies(bodies: unknown[]): Promise<{ success: boolean; inserted: number; … }>`), so the bar can see a change on these members. - **Export tables.** The ESM and CJS bundles, loaded at runtime from the package, each export the same 78 names at base and head; the sorted list hashes to `9f18a0177ac736ed…` on all four. Neither moved function, the host type, `declaresClientRefusal`, `memberOption` nor `CLOSURE_CONTEXT_KEY_BY_TYPE` appears. No changeset: `skip-changeset`, per ruling `6348891160`. ## Proof of move-only **Bodies, by AST token** (the TypeScript compiler's AST leaf tokens, JSDoc skipped, over parameters less the added `this`, return type and body), base `protocol.ts` at `0479cd6a0a` against the head module: ``` applySeedBodies: base tokens 400, head tokens 400, differ 0, text-equal(ws-collapsed, comments incl.) true collectBatchPendingDeclarations: base tokens 258, head tokens 258, differ 0, text-equal(ws-collapsed, comments incl.) true TOTAL tokens=658 differ=0 members=2 CLASS members base=300 head=300 order-same=true byte-identical=298 stubs-with-identical-docblock+signature=2 other=[] TOP base=242 head=243 byte-identical=239 ``` **Negative control**, one token changed in an in-memory copy of the head module (`seeds.length === 0` to `seeds.length !== 0`, one occurrence): ``` MUTATED packages/metadata-protocol/src/protocol-publish-batch.ts "seeds.length === 0" -> "seeds.length !== 0" applySeedBodies: base tokens 400, head tokens 400, differ 1, text-equal(ws-collapsed, comments incl.) false collectBatchPendingDeclarations: base tokens 258, head tokens 258, differ 0, text-equal(ws-collapsed, comments incl.) true TOTAL tokens=658 differ=1 members=2 ``` - **Class side.** 300 members at base and at head, in the same order. The 298 that did not move are byte-identical to base, leading comments included. Each of the 2 stubs sits at its base position and keeps its docblock and signature byte-identical (everything before the body's opening brace). - **Top level of `protocol.ts`.** 239 of the 242 base statements are byte-identical, leading comments included. The other three: the `./runtime-authoring-gate.js` import losing `CLOSURE_CONTEXT_KEY_BY_TYPE`, and `memberOption` and `declaresClientRefusal` gaining `export`. Head adds one statement, the new module's import. - **Size.** +240 / −148 over 2 files, 388 changed lines (the order's estimate was 350–420). `protocol-publish-batch.ts` has 224 lines. ## Readers of the moved lines The seven test files the cut 13 report named were re-grepped by path for both member names. None reads `protocol.ts` by path (no `readFileSync`, no `protocol.ts` path literal); none names `collectBatchPendingDeclarations`. | File | Hits | Verdict | |---|---|---| | metadata-protocol `protocol.batch-verb-driver-text.test.ts` | 49 (comment); 315, 631, 661, 704 call `protocol.applySeedBodies(...)` | Behaviour through the class method, which the stub keeps. Passes at both ends. | | objectql `build-probes.test.ts` | 200, `vi.spyOn(protocol, 'applySeedBodies')` | The spy replaces the class method, which is what callers reach. Passes at head. | | objectql `protocol-publish-package-drafts.test.ts` | 418–512 spies; 518–571 direct calls with the real `SeedLoaderService` | Spies as above. The real-loader smoke test runs the moved body end to end, including the `getMetaItem` adapter read. Passes at head. | | objectql `publish-package-drafts-response-conformance.test.ts` | 283 (comment) | No edit owed. Passes at head. | | runtime `packages-publish-drafts-response-conformance.test.ts` | 215 (comment) | No edit owed. Passes at head. | | runtime `packages-seed-apply-disclosure.test.ts` | 8 (comment) | No edit owed. Passes at head. | | spec `api/protocol.test.ts` | 1291 (comment) | No edit owed (the comment names the producer, not its file). | - `docs/qa/platform-checklist/areas/platform-core.json:2880` anchors `packages/metadata-protocol/src/protocol.ts#applySeedBodies+`. The method keeps its name in `protocol.ts`; `check-platform-checklist --changed-from origin/main` judges it (see Verification). - Every other test or script that reads `protocol.ts` by path and names a symbol the bodies use (`protocol.hydrate-overlay-canonical-type`, `protocol.read-verb-canonical-fold`, `protocol.lookup-artifact-item-call-sites`, `protocol.register-item-call-sites`, `protocol.object-registry-write-through-spelling`, `protocol.view-expansion-reconcile-call-sites`) ran in the package suite and passes at both ends; `check-durability-degradation-log-level` ran as a hand gate (below). - **Bracketed-refusal pin** (`PRODUCERS`): the moved bodies construct no error (0 `new Error(`, 0 `new TypeError(`; `applySeedBodies` throws the `seedRequestValidationError(...)` it calls, which is built in `protocol.ts`). No row is owed, as the order said. - **ADR anchors and decision letters.** `check-adr-anchors` exits 0 at both ends: 86 anchored files, 58,982 citations, files 8,267 → 8,268, and **1,844 → 1,843** decision-letter citations. The one that leaves the anchored file is the `[ADR-0131 D6, §12]` comment in `applySeedBodies`' body (`protocol.ts` `ADR-0131 D6` 114 → 113). The `protocol.ts` anchor does not cover ADR-0131, and no entry paragraph describes the seed apply, so the new module gets no entry (disclosure only, as cuts 8, 12 and 13). - **Durability checker**: `check-durability-degradation-log-level` exits 0 at both ends with identical output, and its `--list` is identical once `[@:][0-9]+` is stripped (159 lines). - **Engine-double contract** (`check-engine-double-contract`): exits 0 at both ends. The `updateData`/`deleteData` seams keep their entries at new line numbers (14085 → 14086, 14110 → 14111). `productionFiles` is 3,710 at base and 3,712 at head: +1 is the new file, +1 is the gitignored `packages/spec/src/migrations/registry.ts`, which the spec build writes in the head tree and the clean base tree had not built. - **Identical output at both ends:** `check-adr-symbol-anchors`, `check-system-context-census`, `check-tenant-audit-census`, `check-query-options-erasure-ratchet`, `check-filter-alias-parity`. `check-nul-bytes` differs only in the scanned file count, +1. The base-end readings were taken in a clean detached worktree at `0479cd6a0a`. - **Governed surface.** `check-governed-merges --branch claude/project-thread-ybjm3h`: 0 of 2 paths hit the register, NOT governed; 388 changed lines, under the 3,000-line human-merge bar. ## Verification - `tsc --noEmit` in `packages/metadata-protocol` exits 0 before and after the merges. `pnpm exec eslint --no-inline-config packages/metadata-protocol` (the whole touched package; it has no `lint` script, these are the root `lint` script's flags) exits 0, no output. - **`packages/metadata-protocol` suite**, base and head, compared per test name and status (vitest `--maxWorkers=2`, JSON reporter, each run under the shared verify lock, `VERDICT command-exit 0` both legs), on the pre-merge tree (the merges changed no file of the package): - Base `0479cd6a0a`: 21,553 tests (21,534 passed, 0 failed, 19 skipped), 268 files. - Head `c2c08b1ae`: 21,553 tests (21,534 passed, 0 failed, 19 skipped), 268 files. - None lost, none added, none changed status. The base leg is the package `src/` swapped tree-only from `0479cd6a0a` (the new module removed from the tree) and restored from `HEAD` under a trap. - **Cross-package readers**, at head under the verify lock: objectql `build-probes`, `protocol-publish-package-drafts`, `publish-package-drafts-response-conformance`: 3 files, 43/43 passed, exit 0. runtime `packages-publish-drafts-response-conformance`, `packages-seed-apply-disclosure`: 2 files, 16/16 passed, exit 0. - `check-platform-checklist --changed-from origin/main` (merged tree): OK, 1 anchored file in the change set, 29 anchors judged, 0 lost. That includes `platform-core.json`'s `protocol.ts#applySeedBodies+`. - **Derived gates.** `dispatch-gates --commands --repo objectstack-ai/objectstack` derives 53 gates on 2 changed paths from the merged tree `721669cb1`, with no stale-tree warning; all 53 exit 0 after the full `pnpm build` (119/119). `dispatch-gates --ran`: 53 derived, 53 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. (Cut 13 derived 59: `.github/workflows/ci.yml` changed on `main` between the two cuts.) ## Acceptance notes - **A docblock that now reads narrower than the code.** `clientFacingFailureText`'s docblock (which ships in the `.d.ts`) says "`declaresClientRefusal` stays private on purpose: nothing outside this file needs the raw predicate". This cut exports it from `protocol.ts` because the moved catch reads it. Its purpose still holds at the package boundary: `index.ts` does not re-export it and the export table is unchanged. The sentence was left as it is, because editing it would change the `.d.ts` this series holds byte-identical. Rewording it to "outside this package" can ride a later cut that already moves `.d.ts` text, or a docs-only PR. - The proposal said `applySeedBodies` reads `engine` only. The AST walk with casts stripped found the `(this as any).getMetaItem` read too; the cut 15 screen below uses the same walk. ## Carry-forward Unchanged from cut 13: public members whose return type is inferred, the bodies kept by a reader (`probeRecord`, `getMetaItemCached`, `findReferencesToMeta`, `packagelessAddress`, `refuseUnmintableMetaType`, `metaTypeNamespaceExists`, `viewContainerNameCollisionRefusal` with `overlaidShippedContainerViewNames`, `reportPageRequiresAbsentAtLoad`, the 7 `applyRegistryWriteThrough` callers, `readCodeLayerForCarryForward`), and the private-static readers (`refusePackagedBaseRemoval`, `packagedBaseRefusal`, `runPublishSideEffects`, `promoteDraftForPublish`, `publishPackageDrafts`, `discardPackageDrafts`) stay in `protocol.ts`. Added this cut: `warnClosureReadUnavailable`, which writes an instance field. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016SP1spj613tyGCgxoD1An6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7bbc0b9 commit 10ba46b

2 files changed

Lines changed: 240 additions & 148 deletions

File tree

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25467 cut 14] Two helpers of `ObjectStackProtocolImplementation`'s publish
5+
* batch: the seed apply that loads a package's seed bodies after the batch
6+
* promotes (`applySeedBodies`) and the collection of the batch's own pending
7+
* declarations the runtime-authoring gate judges a draft against
8+
* (`collectBatchPendingDeclarations`), moved out of `protocol.ts` whole.
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: the same reads, in the same
13+
* order, answering with the same receipts and the same degradations.
14+
*
15+
* ## How the moved bodies reach the class
16+
*
17+
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
18+
* header carries the full reasoning), as `protocol-batch-data.ts` applies it.
19+
* The class keeps every method — the published `dist/*.d.ts` lists each one's
20+
* name, docblock and declared signature — and each body is one literal of
21+
* exactly the {@link PublishBatchHost} members that method reads, plus a
22+
* `.call`. The engine is handed over as a getter, so the body reads the
23+
* protocol's engine at call time; a method is handed over as a closure that
24+
* calls it on the protocol at call time, so a test double or spy set on the
25+
* instance is still the one the body reaches. Every other reader of these two
26+
* methods keeps calling the class method.
27+
*
28+
* `applySeedBodies` reads `getMetaItem` through an `any` cast inside the
29+
* loader's metadata adapter, so the host carries it although no `this.X`
30+
* spelling names it. `warnClosureReadUnavailable` stays in the class: it
31+
* writes the instance's `closureReadWarned` flag.
32+
*
33+
* What the bodies read of `protocol.ts`'s own module-level declarations they
34+
* import from it; what they import from elsewhere, they import from where
35+
* `protocol.ts` imported it. Nothing re-exports this module, so the published
36+
* surface does not move.
37+
*/
38+
39+
import type { ObjectStackProtocolImplementation } from './protocol.js';
40+
import {
41+
canonicalMetaType,
42+
clientFacingFailureText,
43+
declaresClientRefusal,
44+
memberOption,
45+
seedRequestValidationError,
46+
} from './protocol.js';
47+
import { CLOSURE_CONTEXT_KEY_BY_TYPE, type RuntimePendingDeclarations } from './runtime-authoring-gate.js';
48+
49+
/**
50+
* The `ObjectStackProtocolImplementation` members the functions below read —
51+
* every one of them, and no other; each function's `this` is the `Pick` of the
52+
* members IT reads. Each is typed AS the class member (an indexed access on
53+
* the class), so a change to a member's signature on the class is a change
54+
* here too, and naming a member the class does not have is a compile error.
55+
*/
56+
export interface PublishBatchHost {
57+
readonly engine: ObjectStackProtocolImplementation['engine'];
58+
getMetaItem: ObjectStackProtocolImplementation['getMetaItem'];
59+
getOverlayRepo: ObjectStackProtocolImplementation['getOverlayRepo'];
60+
warnClosureReadUnavailable: ObjectStackProtocolImplementation['warnClosureReadUnavailable'];
61+
}
62+
63+
/** The body of `ObjectStackProtocolImplementation#applySeedBodies`; the method's docblock there is its contract. */
64+
export async function applySeedBodies(
65+
this: Pick<PublishBatchHost, 'engine' | 'getMetaItem'>,
66+
bodies: unknown[],
67+
): Promise<{
68+
success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[];
69+
issues?: Array<{ path: string; message: string; code?: string | undefined }>;
70+
}> {
71+
try {
72+
const seeds = bodies.filter(
73+
(b: any) => b && typeof b.object === 'string' && Array.isArray(b.records),
74+
);
75+
if (seeds.length === 0) {
76+
return { success: false, inserted: 0, updated: 0, error: 'seed apply: no readable seed bodies' };
77+
}
78+
const { SeedLoaderService } = await import('./seed-loader.js');
79+
const { SeedLoaderRequestSchema } = await import('@objectstack/spec/data');
80+
// The loader only needs `getObject` from IMetadataService (dependency
81+
// graph + field introspection); satisfy it from the protocol's own
82+
// metadata reads so no kernel service lookup is required.
83+
const metadataAdapter = {
84+
getObject: async (name: string) => {
85+
const wrapper: any = await (this as any).getMetaItem({ type: 'object', name });
86+
return wrapper?.item ?? wrapper ?? null;
87+
},
88+
};
89+
const loader = new SeedLoaderService(
90+
this.engine as any,
91+
metadataAdapter as any,
92+
console as any,
93+
);
94+
// [#8333 · P9] `safeParse`, not `parse` — see {@link
95+
// seedRequestValidationError}. A malformed seed body used to reach
96+
// the catch below as a raw `ZodError`, which declares no `status`
97+
// and would therefore have been withheld along with the driver text
98+
// this card is about. Declaring the refusal at its own producer is
99+
// what keeps the author's feedback AND the positive list intact.
100+
const parsedRequest = SeedLoaderRequestSchema.safeParse({
101+
seeds,
102+
config: {
103+
defaultMode: 'upsert',
104+
multiPass: true,
105+
// [ADR-0131 D6, §12] No caller organization: a seed dataset
106+
// names the organization it populates itself, and the
107+
// loader derives the owner only under `single` (D9).
108+
},
109+
});
110+
if (!parsedRequest.success) throw seedRequestValidationError(parsedRequest.error.issues);
111+
const r = await loader.load(parsedRequest.data);
112+
return {
113+
success: r.success,
114+
inserted: r.summary.totalInserted,
115+
updated: r.summary.totalUpdated,
116+
...(r.errors?.length ? { errors: r.errors } : {}),
117+
};
118+
} catch (e: any) {
119+
// [#8333 · P9] `seedApplied.error` rides on a **200** publish
120+
// response, so this is a data-path disclosure no HTTP boundary can
121+
// reach. Measured on `origin/main`: a driver failure under the
122+
// loader's dependency-graph read (`getMetaItem` → `sys_metadata`)
123+
// escaped the loader and arrived here, putting `SQLITE_ERROR: no
124+
// such table: sys_metadata` on the field.
125+
//
126+
// The other population this catch receives — the seed bodies' own
127+
// schema rejection — now DECLARES itself 422 at the parse above, so
128+
// it is quoted in full and the author still learns which seed and
129+
// which key. That declaration is the whole reason this site could
130+
// be converted rather than escalated.
131+
console.warn(`[protocol.applySeedBodies] seed apply failed: ${e?.message ?? e}`);
132+
return {
133+
success: false, inserted: 0, updated: 0,
134+
error: clientFacingFailureText(e, 'seed apply failed'),
135+
// [#10524] A DECLARED refusal's structured findings ride the
136+
// receipt beside the headline `error` — the message is a
137+
// one-sentence headline now, so this is where the per-key
138+
// prose reaches the author. Guarded by the same declaration
139+
// test as the text above: only the declared 422
140+
// (`seedRequestValidationError`) attaches `issues`; no driver
141+
// error carries them (the #8441 measurement), so nothing
142+
// undeclared is routed around the withhold.
143+
...(declaresClientRefusal(e) && Array.isArray(e?.issues)
144+
? { issues: e.issues }
145+
: {}),
146+
};
147+
}
148+
}
149+
150+
/** The body of `ObjectStackProtocolImplementation#collectBatchPendingDeclarations`; the method's docblock there is its contract. */
151+
export async function collectBatchPendingDeclarations(
152+
this: Pick<PublishBatchHost, 'getOverlayRepo' | 'warnClosureReadUnavailable'>,
153+
drafts: ReadonlyArray<{ type: string; name: string; member?: string }>,
154+
): Promise<RuntimePendingDeclarations | undefined> {
155+
// [#13216] Typed as a mapped type over `RuntimePendingDeclarations`
156+
// with `-?`, not as a hand-listed literal. The literal it replaces was
157+
// the third place the collection set is written down, and the only one
158+
// that could fall behind SILENTLY: a key added to `RuntimeStackContext`
159+
// and routed by `CLOSURE_CONTEXT_KEY_BY_TYPE` would simply never be
160+
// accumulated here, so a package publishing a dataset beside the
161+
// dashboard that binds it would keep being refused for the sibling in
162+
// its own batch — the `shyx_customer_ds` shape #10377 was filed for.
163+
// `-?` makes every key REQUIRED, so the next widening is a compile error
164+
// at this line instead — and [#17063]'s NARROWING was one too, which is
165+
// how `pages` left this literal in the same edit it left
166+
// `RuntimeStackContext`.
167+
const pending: { [K in keyof RuntimePendingDeclarations]-?: unknown[] } = {
168+
objects: [], permissions: [], books: [], datasets: [],
169+
};
170+
let any = false;
171+
for (const d of drafts) {
172+
// The canonical fold, same boundary the promote applies: a stored
173+
// manifest-plural spelling must route to the same collection its
174+
// singular does, or one row's shape would decide whether the
175+
// package is judged against itself.
176+
const singular = canonicalMetaType(d.type);
177+
const key = (CLOSURE_CONTEXT_KEY_BY_TYPE as Record<string, keyof RuntimePendingDeclarations>)[singular];
178+
if (!key) continue;
179+
const repo = this.getOverlayRepo();
180+
// ── The repository-shape guard, and why it degrades ALL-OR-NOTHING
181+
//
182+
// This method introduced the batch door's first dependency on
183+
// `repo.get`. `SysMetadataRepository` has always had it, but the
184+
// seam is overridable (`getOverlayRepo` is the injection point every
185+
// publish double replaces), and the door's only previous body read —
186+
// the `seed` capture — fires solely when a seed draft is in the
187+
// batch, so a repository shape without `get` had never been asked
188+
// for one. Measured: nine `publishPackageDrafts` cases in
189+
// `@objectstack/objectql` drive a double declaring `listDrafts`
190+
// alone, and this call turned every one of them into
191+
// `TypeError: repo.get is not a function` — thrown BEFORE any
192+
// promotion, so the batch door died on a shape it used to accept.
193+
//
194+
// The answer is a declared capability check, not a wider `try`: a
195+
// missing member is a fact about the repository, knowable up front,
196+
// and it must not read like a failed read. Degrading returns the
197+
// closure to its pre-#10377 state (the live universe alone), which
198+
// is the safe direction — the gate keeps judging and can only be
199+
// MORE strict, never fail open.
200+
//
201+
// ⛔ All-or-nothing on purpose. Bailing out of the whole collection
202+
// rather than skipping this one draft is what keeps the verdict from
203+
// depending on WHICH org a draft happens to live in: a partial
204+
// closure would resolve some of a package's own names and not
205+
// others, which is a third behaviour nobody declared and nobody
206+
// could reproduce.
207+
if (typeof repo.get !== 'function') {
208+
this.warnClosureReadUnavailable(singular, d.name);
209+
return undefined;
210+
}
211+
const ref = {
212+
type: singular, name: d.name, org: 'env',
213+
} as unknown as Parameters<typeof repo.get>[0];
214+
// [#22877] The draft's own bundle member, as listed.
215+
const draft = await repo.get(ref, { state: 'draft', ...memberOption(d.member) });
216+
if (draft?.body === undefined || draft.body === null) continue;
217+
pending[key].push(draft.body);
218+
any = true;
219+
}
220+
// Absent, not empty: `undefined` is what keeps a batch with no
221+
// closure-relevant drafts byte-identical to the pre-#10377 gate call,
222+
// rather than routing it through a merge that would be a no-op.
223+
return any ? pending : undefined;
224+
}

0 commit comments

Comments
 (0)