Skip to content

Commit efe3598

Browse files
refactor(metadata-protocol): move the publish backfill pair and the draft pair out of protocol.ts into protocol-publish-backfill.ts and protocol-drafts.ts (#25467 cut 12) (#25939)
<!-- 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 12 of the `protocol.ts` split (#24586 ruling A, recorded in `6270097840`). Cuts 1–11 landed as #25494, #25518, #25538, #25589, #25601, #25623, #25642, #25719, #25731, #25740 and #25797. The claim is `6407710285`; the scope is the cut 12 proposal of the cut 11 report (`6397905786`), as the server seat approved it in its dispatch order. Four `ObjectStackProtocolImplementation` members move unedited into two new modules (#23050 shape B, one `engine` getter each), one per family, because the two families share no helper and no member: - `packages/metadata-protocol/src/protocol-publish-backfill.ts`: the publish backfill pair, `planPublishBackfill` and `applyPublishBackfill`. - `packages/metadata-protocol/src/protocol-drafts.ts`: the draft pair, `listDrafts` (public) and `discardDraftInItsScope`. `protocol.ts` loses 146 net lines (29,769 to 29,623). 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. It names no issue, so the claim comment is the identity record. It holds the move commit `31ecbf1a`, the pin commit `6d1256e1`, both on `main` `cb0a2fcf4f` (cut 11's merge `4893a2fb2c` is in its history), and the merge of `main` `f00e8d5c3c` (`d6e35b43`). That merge brought one commit (#25830) that touches no file of `packages/metadata-protocol/` (the package's `src/` tree is `980b4345` at both `cb0a2fcf4f` and `f00e8d5c3c`). **Serial with the file's in-flight PRs.** Measured just before this PR was opened: of the 9 open PRs, two touch `packages/metadata-protocol/src/`. #25813 edits `seed-loader.ts` and its test, disjoint. #25826 has one `protocol.ts` hunk, at lines 9021–9060, textually disjoint from this cut's hunks (the imports near the top, seven module-level declarations above line 4000, and the class between lines 23440 and 24900); whichever lands second merges `main`. ## What moves | Member | Base line | Visibility | Module | AST tokens (params + return + body) | |---|---|---|---|---| | `listDrafts` | `:23440` | public | `protocol-drafts.ts` | 233 | | `planPublishBackfill` | `:23651` | private | `protocol-publish-backfill.ts` | 620 | | `applyPublishBackfill` | `:23743` | private | `protocol-publish-backfill.ts` | 852 | | `discardDraftInItsScope` | `:24875` | private | `protocol-drafts.ts` | 119 | ## The shape (#23050 shape B, as cuts 1–11 apply it) - **The class keeps every member** with its name, docblock, signature and position; `listDrafts` keeps its declared public return type. Each body is now one host literal plus a `.call`, marked `[#25467 cut 12]`. - **Typing.** `PublishBackfillHost` and `DraftsHost` type every member as the class member itself (an indexed access on `ObjectStackProtocolImplementation`). Each moved function types its `this` as the `Pick` of its module's host over exactly the members its body reads. Each stub annotates its literal as `ThisParameterType` of `typeof fn`. No cast, no generic member (so the plain `.call` spelling of cuts 1–10), and no member changes visibility. - **Members read, measured.** `planPublishBackfill` reads `engine` and `getOverlayRepo`; `applyPublishBackfill` reads `engine` and `updateManyData`; `listDrafts` reads `engine`, `ensureOverlayIndex` and `getOverlayRepo`; `discardDraftInItsScope` reads `deleteMetaItem`. `engine` goes as a GETTER, so the read happens when the body makes it (tests replace it on a constructed protocol); each method goes as a closure on the protocol. No private-static member is read, and nothing on the Release's stay list moves: `runPublishSideEffects`, `promoteDraftForPublish`, `publishPackageDrafts` and `discardPackageDrafts` stay in the class. **The order's hypotheses hold.** - **Every other caller still calls the class method**: `publishPackageDrafts` calls the backfill pair; the `_drafts` doors call `listDrafts`; `discardPackageDrafts` and `revertStoredPackage` call `discardDraftInItsScope`. ## Module-level declarations, by reader - **Seven gain `export`, none moves.** The bodies read `canonicalMetaType`, `clientFacingFailureText` and the interface `MetadataIssueEntry` (already exported), plus seven declarations that gain `export` in place, as cuts 8 and 11 did: `isItemOfDisabledPackage`, `bundleMemberDiscriminator`, `clientFacingFailureCode`, `publishBackfillRefusal`, `PUBLISH_BACKFILL_PAGE_SIZE`, and the types `PublishBackfillEntry` and `PublishBackfillReceipt`. Six of the seven keep readers in `protocol.ts` (the two types are named by the stubs' own signatures; `publishBackfillRefusal` is thrown by `readPublishBackfillRequest` too). `PUBLISH_BACKFILL_PAGE_SIZE` has no reader left in `protocol.ts`; it stays with its family's declarations (see Acceptance notes). The new modules import them from `protocol.js`; the cycle is evaluation-safe because each is read only inside a function body. - `index.ts` names what it publishes from `protocol.ts` one by one, so the published surface does not move (measured below). - **Imports in `protocol.ts`.** One import loses its last reader and is dropped: `discriminateDefaultValueShape` from `@objectstack/spec/data` (2 occurrences at base, the import and `planPublishBackfill`'s call). The new modules import it, `DroppedFieldsEvent`, `UpdateManyDataRequest`, `PLURAL_TO_SINGULAR` and `I18nLabel` from the same spec subpaths. The two new statements are the imports of the new modules. ## 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, then the package's `src/` swapped tree-only to base for the base build and restored from `HEAD` under a trap; `git diff HEAD` empty, `git status` clean and all four touched blobs equal to `HEAD`'s after). Measured twice: before the merge (base `cb0a2fcf4f`, head `6d1256e1`) and after it (merged base, head `d6e35b43`, closure rebuilt with a full `pnpm build`, 119/119). - **Raw sha256**: `325970c1661efaa6a3762ce7de9e6bb205ea16099784f2b6c5950d62d175b331` at base and at head, on both `dist/index.d.ts` and `dist/index.d.cts`, 772,838 bytes each, in both measurements (the hash cuts 8–11 reported). **Raw identity holds**, so sorted-union identity holds too. The three private members stay `private NAME;` lines; `listDrafts` prints its declared signature unchanged. - **Normalised sha256**: `aa05995b9d9a32f69b3416eac92ef86b46d79593001a37bf1bb529ed7a9f8952` at both ends (80 string-literal union runs sorted; this run's own normaliser, so the hash is not comparable with cut 11's). - **The two builds differ where they should**: `index.js` hashes `bbe1a227b80604e0…` at base (cut 11's head hash) and `066b577863ed02f0…` at head; each of the four functions' `.call(host` sites 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 `discardDraftInItsScope` and the package rebuilt. Both `.d.ts` files move to `d0a5c1ec3071d8c8…` and print the member's full signature, its parameter docblock included, 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, in both measurements; the sorted list hashes to `9f18a0177ac736ed…` on all of them. None of the moved functions, the two host types or the seven newly exported declarations 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): 1,824 tokens compared, **0 differ**. The parameter, return-type and body text, comments included, is also equal with whitespace collapsed, for all four. Negative control: one changed token in `applyPublishBackfill`'s body (`ids.length === 0` to `ids.length !== 0`, on an in-memory copy) makes the check report `differ=1` on that member and 0 on the other three. - **Class side.** 299 members at base and at head, in the same order. The 295 that did not move are byte-identical to base, leading comments included. Each of the 4 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`.** 231 of the 239 base statements other than the class are byte-identical, leading comments included. Of the other eight, seven gain the `export` keyword and nothing else, and one is the `@objectstack/spec/data` import, which loses the `discriminateDefaultValueShape` line. Head adds two statements, the new modules' imports. - **Size.** +401 / −183 over 4 files, 584 changed lines (the order's estimate was 450–550; the 900-line split bar is not reached). `protocol-publish-backfill.ts` has 239 lines, `protocol-drafts.ts` 122. ## Readers of the moved lines Every test and script that reads `protocol.ts` by path (20 files), and every test file in `packages/` and `scripts/`, was grepped for the four member names and for the seven declarations that gained `export`. - **No by-path reader of the moved bodies.** Outside `protocol.ts` the names `planPublishBackfill`, `applyPublishBackfill`, `discardDraftInItsScope`, `publishBackfillRefusal`, `PUBLISH_BACKFILL_PAGE_SIZE` and `PublishBackfillEntry` occur nowhere but `CHANGELOG.md` entries; `PublishBackfillReceipt` also names an unrelated local type in `ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx`. `listDrafts` is a public verb the tests call through the protocol, not by path. - `protocol.read-verb-canonical-fold.test.ts` scans `protocol.ts` for request-taking verbs whose required `type` is folded; `listDrafts` takes an optional `type?` filter and is out of that population by the pin's own rule, and its signature line stays in place. Green, list unchanged. - **Bracketed-refusal pin** (`PRODUCERS`): `applyPublishBackfill`'s three `new Error(` move with its body, so the new module gets a row, `'protocol-publish-backfill.ts': 3`, the module's own count, beside the rows cuts 1, 3, 5, 6, 7 and 10 added. `protocol.ts` goes from 74 to 71 such sites, still above its floor of 5; `protocol-drafts.ts` has none and gets no row. Control: with the first of the module's throws changed to `new TypeError(` (on disk, restored from `HEAD` under a trap, hash equal to `HEAD`'s after), the new row fails with "protocol-publish-backfill.ts no longer constructs errors here — this pin is scanning the wrong file: expected 2 to be greater than or equal to 3" (1 failed, 13 passed); restored, 14/14 pass. (The control's first attempt matched no anchor and changed nothing, so it is not counted; the run above is the second, with its on-disk counts read before the test: 2 `new Error(`, 1 `new TypeError(`.) - **ADR anchors and decision letters.** `check-adr-anchors` exits 0 at both ends: 85 anchored files, 58,830 citations, **1,847 → 1,846** decision-letter citations, files 8,247 → 8,248. The one letter citation that leaves the anchored surface is `listDrafts`' body comment `[ADR-0131 D6] The environment's pending drafts.`, now in `protocol-drafts.ts` (`protocol.ts` cites ADR-0131 D6 118 times at base, 117 at head). The `protocol.ts` anchor entry does not cover ADR-0131, and no moved body is the mechanism of any entry paragraph, so the new modules get no entry (disclosure only, as cut 8 disclosed its ADR-0090 D11 citation). - **Durability checker**: `check-durability-degradation-log-level` exits 0 at both ends with identical output. In its `--list` (lines stripped of `[@:][0-9]+`), the one difference is the file of the read seam `guards find() in applyPublishBackfill() → no invented answer`, listed under `protocol.ts` at base and under `protocol-publish-backfill.ts` at head; its verdict is unchanged. - **Engine-double contract** (`check-engine-double-contract`): exits 0 at both ends. The `updateData`/`deleteData` seams keep their entries at new line numbers (14022 → 14025, 14047 → 14050). `productionFiles` is 3,701 at base and 3,704 at head: +2 are the new files, +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, +2. The base-end readings were taken in a clean detached worktree at `cb0a2fcf4f`. - **Platform checklist.** `check-platform-checklist --changed-from origin/main`: OK, 1 anchored file in the change set, 29 anchors judged, 0 lost. - **Governed surface.** `check-governed-merges --branch claude/project-thread-ybjm3h`: 0 of 4 paths hit the register, NOT governed; 584 changed lines, under the 3,000-line human-merge bar. ## Verification All at head `d6e35b43` unless stated. - `tsc --noEmit` in `packages/metadata-protocol` exits 0, before and after the merge. `pnpm exec eslint --no-inline-config packages/metadata-protocol` (the whole touched package) 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 merge changed no file of the package): - Base `cb0a2fcf4f`: 21,509 tests (21,490 passed, 0 failed, 19 skipped), 266 files. - Head `6d1256e1`: 21,510 tests (21,491 passed, 0 failed, 19 skipped), 266 files. - None lost, none changed status, one added: the new `PRODUCERS` row's case. The base leg is the package `src/` swapped tree-only from `cb0a2fcf4f` (the new modules removed from the tree) and restored from `HEAD` under a trap. - **Derived gates.** `dispatch-gates --commands --repo objectstack-ai/objectstack` derives 59 gates on 4 changed paths, the same list before and after the merge; all 59 exit 0 on the merged tree, after a full `pnpm build` (119/119, `VERDICT command-exit 0`) and with the plugin-teardown fixture commit fetched first. `dispatch-gates --ran`: 59 derived, 59 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. ## Acceptance notes - `PUBLISH_BACKFILL_PAGE_SIZE` now has its only reader in `protocol-publish-backfill.ts` (it had no other reader at base either). It stays in `protocol.ts` beside the other `[#24253]` backfill declarations, whose remaining readers (`readPublishBackfillRequest`, `publishPackageDrafts`) did not move; a later cut that moves the request judgement and the receipt types could take the family whole. - `listDrafts`' `[ADR-0131 D6]` comment now sits in an unanchored module (see Readers above); the anchor file never named ADR-0131, so nothing that guarded it before stops guarding it. ## Carry-forward Unchanged from cut 11: 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 stay in `protocol.ts`; this cut adds `runPublishSideEffects`, `promoteDraftForPublish`, `publishPackageDrafts` and `discardPackageDrafts` to that list, for the reasons the cut 11 report gave. --- _Generated by [Claude Code](https://claude.ai/code/session_016SP1spj613tyGCgxoD1An6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d65cbb6 commit efe3598

4 files changed

Lines changed: 401 additions & 183 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25467 cut 12] The draft pair of `ObjectStackProtocolImplementation` — the
5+
* environment's pending drafts as the `_drafts` doors list them
6+
* (`listDrafts`) and the per-draft discard step the package batch doors share
7+
* (`discardDraftInItsScope`) — moved out of `protocol.ts` whole.
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: the same reads, the same
12+
* disabled-package filter and member projection, the same delete request.
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 every method — the published `dist/*.d.ts` lists each one's
19+
* name, docblock and declared signature, `listDrafts`' declared return type
20+
* included — and each body is one literal of exactly the {@link DraftsHost}
21+
* members that method reads, plus a `.call`. A method is handed over as a
22+
* closure that calls it on the protocol at call time, so a test double or spy
23+
* set on the instance is still the one the body reaches. Every other reader of
24+
* these two methods keeps calling the class method.
25+
*
26+
* The field the bodies read, `engine`, goes as a GETTER, so each read happens
27+
* when the body makes it: tests replace it on a constructed protocol.
28+
*
29+
* The module-level declarations of `protocol.ts` the bodies read stay there,
30+
* where code that did not move reads them too, and are imported from there:
31+
* `isItemOfDisabledPackage` and `bundleMemberDiscriminator`. The module cycle
32+
* is evaluation-safe because each is read only inside a function body, never
33+
* while the two modules are first evaluated. `index.ts` names what it
34+
* publishes from `protocol.ts` one by one, so the `export` they gained for
35+
* this keeps them off the published surface, and nothing re-exports this
36+
* module, so the published surface does not move.
37+
*/
38+
39+
import type { ObjectStackProtocolImplementation } from './protocol.js';
40+
import { bundleMemberDiscriminator, isItemOfDisabledPackage } from './protocol.js';
41+
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
42+
import type { I18nLabel } from '@objectstack/spec/ui';
43+
44+
/**
45+
* The `ObjectStackProtocolImplementation` members the functions below read —
46+
* every one of them, and no other; each function's `this` is the `Pick` of the
47+
* members IT reads. Each is typed AS the class member (an indexed access on
48+
* the class), so a change to a member's signature on the class is a change
49+
* here too, and naming a member the class does not have is a compile error.
50+
* See the module header for which members are getters.
51+
*/
52+
export interface DraftsHost {
53+
readonly engine: ObjectStackProtocolImplementation['engine'];
54+
ensureOverlayIndex: ObjectStackProtocolImplementation['ensureOverlayIndex'];
55+
getOverlayRepo: ObjectStackProtocolImplementation['getOverlayRepo'];
56+
deleteMetaItem: ObjectStackProtocolImplementation['deleteMetaItem'];
57+
}
58+
59+
/** The body of `ObjectStackProtocolImplementation#listDrafts`; the method's docblock there is its contract. */
60+
export async function listDrafts(
61+
this: Pick<DraftsHost, 'engine' | 'ensureOverlayIndex' | 'getOverlayRepo'>,
62+
request?: {
63+
packageId?: string;
64+
type?: string;
65+
}
66+
): Promise<{
67+
drafts: Array<{
68+
type: string;
69+
name: string;
70+
/** [#23073] The bundle member the draft is (`'zh-CN'`, the canonical `'en-US'`); absent for a type with no members. */
71+
member?: string;
72+
/** The draft body's own top-level `label`, as authored; `null` when it declares none. */
73+
label: I18nLabel | null;
74+
packageId: string | null;
75+
updatedAt: string | null;
76+
updatedBy: string | null;
77+
}>;
78+
}> {
79+
await this.ensureOverlayIndex();
80+
// [ADR-0131 D6] The environment's pending drafts.
81+
const repo = this.getOverlayRepo();
82+
const drafts = await repo.listDrafts({
83+
...(request?.type ? { type: PLURAL_TO_SINGULAR[request.type] ?? request.type } : {}),
84+
...(request?.packageId ? { packageId: request.packageId } : {}),
85+
});
86+
// [#23073] The declared header (`ListDraftsResponseSchema`), with the
87+
// row's stored member on every draft of a bundled type: absent there
88+
// means the canonical member, which is spelled out here.
89+
const served = drafts.filter((draft) => draft.type === 'package'
90+
|| !isItemOfDisabledPackage(this.engine?.registry, { _packageId: draft.packageId }));
91+
return {
92+
drafts: served.map(({ member: stored, ...header }) => {
93+
const member = bundleMemberDiscriminator(header.type, stored);
94+
return member === undefined ? header : { ...header, member };
95+
}),
96+
};
97+
}
98+
99+
/** The body of `ObjectStackProtocolImplementation#discardDraftInItsScope`; the method's docblock there is its contract. */
100+
export async function discardDraftInItsScope(
101+
this: Pick<DraftsHost, 'deleteMetaItem'>,
102+
/**
103+
* [#22877] `member` — the draft's bundle member, as `listDrafts`
104+
* surfaced it: the discard removes THAT member's draft. Without it the
105+
* canonical member's was addressed for every draft of the name, so a
106+
* second locale's draft answered "no pending draft" and stayed.
107+
*/
108+
draft: { type: string; name: string; member?: string; packageId?: string | null },
109+
actor: string | undefined,
110+
): Promise<void> {
111+
await this.deleteMetaItem({
112+
type: draft.type,
113+
name: draft.name,
114+
state: 'draft',
115+
...(actor ? { actor } : {}),
116+
...(draft.member !== undefined ? { member: draft.member } : {}),
117+
// [#23165, ADR-0048] The package the draft was listed under: the
118+
// discard removes THAT package's draft, never another package's
119+
// draft of the same name.
120+
...(draft.packageId !== undefined ? { packageId: draft.packageId } : {}),
121+
});
122+
}
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25467 cut 12] The publish backfill pair of
5+
* `ObjectStackProtocolImplementation` — the judgement half a
6+
* `publishPackageDrafts` backfill runs before anything is promoted
7+
* (`planPublishBackfill`) and the write half it runs after the publish has
8+
* committed (`applyPublishBackfill`) — 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 and writes, in
13+
* the same order, refusing and stopping exactly where they did with the same
14+
* statuses and messages.
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 every method — the published `dist/*.d.ts` lists each one's
21+
* name, docblock and declared signature — and each body is one literal of
22+
* exactly the {@link PublishBackfillHost} members that method reads, plus a
23+
* `.call`. A method is handed over as a closure that calls it on the
24+
* protocol at call time, so a test double or spy set on the instance is still
25+
* the one the body reaches. Every other reader of these two methods keeps
26+
* calling the class method.
27+
*
28+
* The field the bodies read, `engine`, goes as a GETTER, so each read happens
29+
* when the body makes it: tests replace it on a constructed protocol.
30+
*
31+
* The module-level declarations of `protocol.ts` the bodies read stay there
32+
* and are imported from there: `canonicalMetaType`, `clientFacingFailureText`
33+
* and `clientFacingFailureCode`, which code that did not move reads too; the
34+
* types `MetadataIssueEntry`, `PublishBackfillEntry` and
35+
* `PublishBackfillReceipt`, which the class's own signatures name; the refusal
36+
* `publishBackfillRefusal`, which the backfill request's shape judgement
37+
* (`readPublishBackfillRequest`) throws too; and `PUBLISH_BACKFILL_PAGE_SIZE`,
38+
* which sits with that family's declarations. The module cycle is
39+
* evaluation-safe because every runtime import is read only inside a function
40+
* body, never while the two modules are first evaluated. `index.ts` names what
41+
* it publishes from `protocol.ts` one by one, so the `export` some of them
42+
* gained for this keeps them off the published surface, and nothing
43+
* re-exports this module, so the published surface does not move.
44+
*/
45+
46+
import type {
47+
MetadataIssueEntry,
48+
ObjectStackProtocolImplementation,
49+
PublishBackfillEntry,
50+
PublishBackfillReceipt,
51+
} from './protocol.js';
52+
import {
53+
PUBLISH_BACKFILL_PAGE_SIZE,
54+
canonicalMetaType,
55+
clientFacingFailureCode,
56+
clientFacingFailureText,
57+
publishBackfillRefusal,
58+
} from './protocol.js';
59+
import type { UpdateManyDataRequest } from '@objectstack/spec/api';
60+
import { type DroppedFieldsEvent, discriminateDefaultValueShape } from '@objectstack/spec/data';
61+
62+
/**
63+
* The `ObjectStackProtocolImplementation` members the functions below read —
64+
* every one of them, and no other; each function's `this` is the `Pick` of the
65+
* members IT reads. Each is typed AS the class member (an indexed access on
66+
* the class), so a change to a member's signature on the class is a change
67+
* here too, and naming a member the class does not have is a compile error.
68+
* See the module header for which members are getters.
69+
*/
70+
export interface PublishBackfillHost {
71+
readonly engine: ObjectStackProtocolImplementation['engine'];
72+
getOverlayRepo: ObjectStackProtocolImplementation['getOverlayRepo'];
73+
updateManyData: ObjectStackProtocolImplementation['updateManyData'];
74+
}
75+
76+
/** The body of `ObjectStackProtocolImplementation#planPublishBackfill`; the method's docblock there is its contract. */
77+
export async function planPublishBackfill(
78+
this: Pick<PublishBackfillHost, 'engine' | 'getOverlayRepo'>,
79+
packageId: string,
80+
entries: ReadonlyArray<{ object: string; field: string }>,
81+
drafts: ReadonlyArray<{ type: string; name: string; member?: string }>,
82+
): Promise<PublishBackfillEntry[]> {
83+
const repo = this.getOverlayRepo();
84+
const issues: MetadataIssueEntry[] = [];
85+
const plan: PublishBackfillEntry[] = [];
86+
const draftFields = new Map<string, Record<string, any> | undefined>();
87+
for (const [i, e] of entries.entries()) {
88+
const path = `backfill.${i}`;
89+
const draft = drafts.find((d) => canonicalMetaType(d.type) === 'object' && d.name === e.object);
90+
if (!draft) {
91+
issues.push({
92+
path,
93+
message: `Package '${packageId}' has no pending draft of object '${e.object}': a backfill fills `
94+
+ 'fields this publish adds, so it names an object drafted in this package.',
95+
code: 'custom',
96+
});
97+
continue;
98+
}
99+
if (!draftFields.has(e.object)) {
100+
const ref = { type: draft.type, name: draft.name, org: 'env' } as unknown as Parameters<typeof repo.get>[0];
101+
const body = typeof repo.get === 'function'
102+
? (await repo.get(ref, { state: 'draft' }))?.body as { fields?: unknown } | undefined
103+
: undefined;
104+
const fields = body?.fields;
105+
draftFields.set(
106+
e.object,
107+
fields && typeof fields === 'object' && !Array.isArray(fields) ? fields as Record<string, any> : undefined,
108+
);
109+
}
110+
const def = draftFields.get(e.object)?.[e.field];
111+
if (!def || typeof def !== 'object') {
112+
issues.push({
113+
path,
114+
message: `The draft of object '${e.object}' declares no field '${e.field}'.`,
115+
code: 'custom',
116+
});
117+
continue;
118+
}
119+
const live = this.engine?.registry?.getObject?.(e.object) as { fields?: Record<string, unknown> } | undefined;
120+
if (live?.fields && Object.prototype.hasOwnProperty.call(live.fields, e.field)) {
121+
issues.push({
122+
path,
123+
message: `Field '${e.object}.${e.field}' already exists on the published object, so this publish does `
124+
+ 'not add it: a backfill fills only fields a publish adds. Change existing rows through the data API.',
125+
code: 'custom',
126+
});
127+
continue;
128+
}
129+
const dv = (def as { defaultValue?: unknown }).defaultValue;
130+
if (dv === undefined || dv === null) {
131+
issues.push({
132+
path,
133+
message: `Field '${e.object}.${e.field}' declares no defaultValue, so there is no value to write: `
134+
+ 'declare one on the field, or drop it from the backfill.',
135+
code: 'custom',
136+
});
137+
continue;
138+
}
139+
const shape = discriminateDefaultValueShape(dv);
140+
if (shape !== 'literal') {
141+
issues.push({
142+
path,
143+
message: `Field '${e.object}.${e.field}' declares a ${shape === 'token' ? 'runtime token' : 'expression'} `
144+
+ 'defaultValue, resolved for each new record as it is created; a backfill writes a literal value only.',
145+
code: 'custom',
146+
});
147+
continue;
148+
}
149+
plan.push({ object: e.object, field: e.field, value: dv });
150+
}
151+
if (issues.length > 0) throw publishBackfillRefusal(packageId, issues);
152+
return plan;
153+
}
154+
155+
/** The body of `ObjectStackProtocolImplementation#applyPublishBackfill`; the method's docblock there is its contract. */
156+
export async function applyPublishBackfill(
157+
this: Pick<PublishBackfillHost, 'engine' | 'updateManyData'>,
158+
packageId: string,
159+
plan: ReadonlyArray<PublishBackfillEntry>,
160+
context: unknown,
161+
): Promise<PublishBackfillReceipt> {
162+
const fields: PublishBackfillReceipt['fields'] = [];
163+
for (const e of plan) {
164+
let updated = 0;
165+
const written = new Set<string>();
166+
try {
167+
for (;;) {
168+
const rows = (await this.engine.find(e.object, {
169+
where: { [e.field]: { $null: true } },
170+
limit: PUBLISH_BACKFILL_PAGE_SIZE,
171+
context,
172+
} as never)) as Array<{ id?: unknown }> | null | undefined;
173+
const ids = (rows ?? []).map((r) => r?.id).filter((id): id is string | number =>
174+
typeof id === 'string' || typeof id === 'number').map(String);
175+
if (ids.length === 0) break;
176+
const stuck = ids.find((id) => written.has(id));
177+
if (stuck !== undefined) {
178+
throw Object.assign(new Error(
179+
`Record '${stuck}' of '${e.object}' still has no '${e.field}' after it was written, so a hook `
180+
+ 'or rule on the object cleared it; the backfill stopped there.',
181+
), { status: 422 });
182+
}
183+
const res = await this.updateManyData({
184+
object: e.object,
185+
records: ids.map((id) => ({ id, data: { [e.field]: e.value } })),
186+
options: { continueOnError: false } as UpdateManyDataRequest['options'],
187+
context,
188+
});
189+
for (const r of res.results ?? []) {
190+
if (!r.success) continue;
191+
const dropped = (r as { droppedFields?: DroppedFieldsEvent[] }).droppedFields
192+
?.find((ev) => ev.fields.includes(e.field));
193+
if (dropped) {
194+
throw Object.assign(new Error(
195+
`The write of '${e.field}' to record '${String(r.id)}' of '${e.object}' was dropped `
196+
+ `(${dropped.reason}): this caller cannot set the field; the backfill stopped there.`,
197+
), { status: 403 });
198+
}
199+
const kept = (r as { data?: Record<string, unknown> }).data;
200+
if (kept && Object.prototype.hasOwnProperty.call(kept, e.field) && kept[e.field] == null) {
201+
throw Object.assign(new Error(
202+
`Record '${String(r.id)}' of '${e.object}' was written with '${e.field}' left empty, so a `
203+
+ 'hook or rule on the object cleared it; the backfill stopped there.',
204+
), { status: 422 });
205+
}
206+
written.add(String(r.id));
207+
updated++;
208+
}
209+
if (!res.success) {
210+
const failedRow = (res.results ?? []).find((r) => !r.success);
211+
const rowError = (failedRow as { errors?: Array<{ code?: string; message?: string }> } | undefined)?.errors?.[0];
212+
const failure = {
213+
error: `Record '${String(failedRow?.id)}' of '${e.object}' was not written: `
214+
+ `${rowError?.message ?? 'the write failed'}; the backfill stopped there.`,
215+
...(rowError?.code ? { code: rowError.code } : {}),
216+
};
217+
fields.push({ object: e.object, field: e.field, updated, ...failure });
218+
console.warn(`[Protocol] publishPackageDrafts backfill of '${e.object}.${e.field}' (package '${packageId}'): ${failure.error}`);
219+
break;
220+
}
221+
}
222+
if (!fields.some((f) => f.object === e.object && f.field === e.field)) {
223+
fields.push({ object: e.object, field: e.field, updated });
224+
}
225+
} catch (err: unknown) {
226+
const code = clientFacingFailureCode(err);
227+
console.warn(
228+
`[Protocol] publishPackageDrafts backfill of '${e.object}.${e.field}' (package '${packageId}') `
229+
+ `stopped after ${updated} row(s): ${(err as Error)?.message ?? String(err)}`,
230+
);
231+
fields.push({
232+
object: e.object, field: e.field, updated,
233+
error: clientFacingFailureText(err, 'the backfill failed; the reason is in the server log'),
234+
...(code ? { code } : {}),
235+
});
236+
}
237+
}
238+
return { success: fields.every((f) => f.error === undefined), fields };
239+
}

‎packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ const PRODUCERS: Readonly<Record<string, number>> = {
8787
// The served lock state and the stored-row decline, moved out of `protocol.ts`
8888
// whole on the same terms.
8989
'protocol-lock-state.ts': 1,
90+
// The publish backfill pair, moved out of `protocol.ts` whole on the same
91+
// terms.
92+
'protocol-publish-backfill.ts': 3,
9093
'sys-metadata-repository.ts': 5,
9194
'runtime-authoring-gate.ts': 1,
9295
};

0 commit comments

Comments
 (0)