Skip to content

Commit 48914fd

Browse files
refactor(metadata-protocol): generate the protocol.ts section split with a deterministic codemod (#29998)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB7J3TmFTYMYQrfpye6GQNg9)_ Part of #29985 ## 维护者速读 - 改了什么:codemod `scripts/codemod-split-protocol.mjs` 按裁决 6624345766(门面形态,方案 A)生成 `protocol.ts` 的拆分。`ObjectStackProtocolImplementation` 成为门面:85 个成员各留一行委派,方法体移入 10 个无状态 facet 类(`<Module>Facet`,静态方法,首参是 host);私有成员的文档块随方法体移走(54 个),公开成员的文档块留在门面;只读参数的私有静态方法也移出(7 个)。状态全部留在门面。生成文件未手改。 - 为什么改:#29465 大文件拆分;裁决 6624258595 把目标改为门面形态,6624345766 把 `.d.ts` 门槛改为「公开面相等」。 - 风险与回滚:纯搬移。85/85 方法体逐字节还原;`--verify` 先把门面形态逆提回 (a) 的模块形态,再逆推出 base 的 `protocol.ts` 和 `protocol-host.ts` 逐字节相同。公开面 `.d.ts` 相等(对照抓到)。测试 base 22,346 → head 22,347(+1 条非空检查)。回滚即 revert。 - 先看:① 「门面读数」一节的例外清单(#30019 门禁的初始白名单);② codemod 的 `lift` / `unlift`;③ `check-durability-degradation-log-level.mjs` 对 `<X>Facet.fn(...)` 存根的跟随和 3 条自测。 - 两分钟验证:`pnpm install --frozen-lockfile` 后运行 `node scripts/codemod-split-protocol.mjs --verify --base eee4149`,预期 `85/85 moved bodies restore to the base body exactly`、`23135 -> 13647 lines`,退出码 0;`--self-test` 55 例通过。 - 需要你做的:超过 3,000 行,需您批准,并在您定的窗口冻结 `protocol.ts` 后落地;本 PR 保持草稿。 Clause-②: no Metadata-protocol: none ## What changes - **`scripts/codemod-split-protocol.mjs`**, three additions over the module form PR #29998 had before: - **The facade layer** (`lift`, ruling 6624345766). Each section module's functions become the static methods of one stateless facet class, `<Module>Facet` (`protocol-save.ts` → `ProtocolSaveFacet`). A method still takes the host as its first parameter, so its body is the E1 body re-indented back to the class's depth. A facet holds no state: every field stays on the class and is read through the host. Each stub on the class calls `<Facet>.<member>(…)`. - **Docblock ownership.** A private member's docblock moves verbatim onto its facet method, and the class keeps one line: `/** Docblock and body: {@link <Facet>.<member>} in \`<file>\` [#29985 codemod]. */`. A public member's docblock stays on the facade, as the ruling requires, so the published `.d.ts` and consumers' hover text are unchanged. - **Private-static-through-host.** A private static whose body reads nothing of the class (no `this`, `super`, `arguments` or class name) moves to its section's facet with no host parameter. The class keeps the delegation, so moved instance bodies still reach it as `host.<static>`. - The pilot's `no-yield` rule (a body no longer than its stub stays) is dropped: under the facade rule the class keeps delegations only. - **The proof of the layer is its inverse** (`unlift`). `--verify` reads the head alone, turns it back into the module form, and refuses anything the lift would not have written: a public docblock moved, a stub calling another facet, a facet member that is not a static method, a body line the lift did not indent. It then runs every earlier proof on that module form, `host-split.mjs`'s `checkMove` included. - `--self-test`: 55 cases in 7 pinned batteries (a new `facade layer` battery of 10). - **The generated split** is `--write` on `main` `eee41490`, unedited. Its modules are `protocol-class-head.ts`, `protocol-discovery-and-bases.ts`, `protocol-data-crud.ts` (new), `protocol-search-and-cache.ts`, `protocol-write-gates.ts`, `protocol-registry-hydration.ts`, `protocol-save.ts`, `protocol-history-and-publish.ts`, `protocol-package-commits.ts` and `protocol-diff-delete-load.ts`. Nothing re-exports them. - **Readers widened** (ruling 6597555122 point 3) to the facet form: - `register-item`, `lookup-artifact-item` and `view-expansion-reconcile` census headers accept a facet's `static` member. - `read-verb-canonical-fold` follows `<Facet>.<fn>` stubs. - The receive-path test reads `applyRemoteMetadataMutation` as a static method. - Two newly moved members are re-keyed: `isShippedByAnotherPackage` gets a `file` row. `anotherPackageShips` is read as its delegation plus its facet body. - **`scripts/check-durability-degradation-log-level.mjs`**: a `<X>Facet.fn(protocolHost(this), …)` stub, or a private static's `<X>Facet.fn(…)`, is followed into the facet's static method. Without that, 3 read seams left the census (102 → 99). There are three new self-test rows, and the read-seam floor goes 70 → 73. ## Verification | Reading | Base `eee41490` | Head | |:--|:--|:--| | `protocol.ts` lines | 23,135 | **13,647** | | Byte-identity | | 85/85 bodies; `checkMove` 78/78 instance (statics compared directly); the unlift, then the undo, rebuilds both base files | | Determinism | | output tree sha256 `85bbdf726e8fbb750229627f744bb152aa79fcfa02d95b939885bbdf3430fe92` on two runs; regeneration reproduces the committed tree | | `metadata-protocol` `typecheck` | exit 0 | exit 0 | | `metadata-protocol` `test` | 312 files, 22,346 passed, 26 skipped | 312 files, 22,347 passed, 26 skipped | | `check:durability-log-level` | 51 seams (9 propagating), 102 read seams (13 discriminated) | the same, and the output is byte-identical | | core / objectql / spec `test:repo`, `pnpm lint`, `pnpm build` | | core 94, objectql 5, spec 1,539 passed; lint exit 0; build exit 0 | | `dispatch-gates --commands` (73) | | 73 derived, 73 run, every exit 0; `--ran`: 0 NOT-MEASURED, 0 UNRUN | ### `.d.ts`: ruling 6624345766 item 1, "public surface EQUAL" The reading is `dts-public.mjs`, in the project files at `issue-29985/dts-public.mjs`, sha256 `211b32062250606e658acd7fb8880e0079b4043121fb7b970c658b4b57bf5020`. It is dtsnorm (6441032442 B) with one step added first: every private class member, together with its docblock, is cut from both texts. Everything else must then be equal statement for statement, which is stricter than item 1. | Reading | `index.d.ts` | `index.d.cts` | |:--|:--|:--| | raw | differs (private docblocks, and one union order) | differs (the same) | | private members cut, union order, second normalisation | **equal** | **equal** | | declared surface | equal | equal | - The control that edits one public docblock is caught. - The control that drops one private member passes. - The facet classes do not appear in the `.d.ts`. - 6441032442 B as written reads DIFFER, from the private docblocks only. That is what the ruling allows. ### Facade reading (the #30019 gate's starting allow-list) - **85 members** delegate to 10 facets: | Facet | Members | Docblocks moved | |:--|--:|--:| | ClassHead | 17 | 7 | | DiscoveryAndBases | 12 | 6 | | DataCrud | 1 | 1 | | SearchAndCache | 1 | 0 | | WriteGates | 18 | 17 | | RegistryHydration | 14 | 14 | | Save | 4 | 3 | | HistoryAndPublish | 5 | 3 | | PackageCommits | 9 | 2 | | DiffDeleteLoad | 4 | 1 | - **190 hand-series delegations** in their own forms: `.call(host, …)` 162, plain 12, `protocolHost(this)` 16. Their docblocks stay on the class, because their modules are the hand series' and are not facets. - **26 methods with a body stay** as named exceptions, plus the constructor (15 lines) and the static block (the host factory, 203 lines): - **`inferred-return-type`** (a public type the `.d.ts` would re-infer): `getDiscovery` 460 lines, `findData` 751, `getData` 75, `cloneData` 105, and `saveMetaItem`, `publishMetaItem`, `rollbackMetaItem`, `deleteMetaItem` at 7 lines each. - **`closed-set`** (outside E1's rewrite set): `publishPackageDrafts` 993 lines (a bare `this`), `reconcileViewExpansions` 53, `packagelessOverlayWriteRefusal` 14, `namedBaseOverlayWriteRefusal` 12. - **`static-reads-class`**: `mergesOverlayAtRead`, `registryAllowsOverlay`, `isOverlayAllowed`, `isRuntimeCreateAllowed`, `codeOnlySourceHint`, `codeOnlyCreateError`, `codeOnlyOverrideError`, `refuseOrganizationScopedWrite`, `overlayPackageMismatchError`. - **`no-host-read`**: `rethrowUnlessMetadataStoreUnprovisioned`, `triggerAutomation`. - **`name-taken`**: `objectExtensionItemRefusal`. - **`host-entry-predicate`**: `packagedBaseRefusal`. - **`static-shape`** (public static): `resetEnvWritableCache`. ### Ablations - The facade battery's public-docblock case goes red when `unlift`'s public-docblock check is removed. - The durability gate's two facet rows go red when the facet branch is removed, and the census drops to 99. - The facade battery also checks that a one-character edit in a moved docblock, a stub calling another facet, an extra facet member and an unindented body line each fail. ### Findings outside this PR `protocol-packageless-overlay-migration.ts` is #30000; it is unchanged here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzgddMBbTQd9yVo4Yuz <!-- signed: domain:server#6 · os-support-ai · session_01WGufzgddMBbTQd9yVo4Yuz --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9ebca94 commit 48914fd

25 files changed

Lines changed: 14256 additions & 10166 deletions

‎packages/metadata-protocol/src/protocol-class-head.ts‎

Lines changed: 469 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29985 codemod] The "Data CRUD" facet of
5+
* `ObjectStackProtocolImplementation`: the bodies of `probeRecord` moved out of
6+
* `protocol.ts` whole by `scripts/codemod-split-protocol.mjs`.
7+
*
8+
* A MOVE, not a redesign. `ProtocolDataCrudFacet` is a stateless facet over the host
9+
* (ruling 6624345766): each static method below is the body of the class
10+
* member of the same name as `packages/metadata-protocol/scripts/host-split.mjs`
11+
* moves one (ruling E, item E1), with `this.<member>` read as `host.<member>`
12+
* and nothing else changed. The facet holds no state: the fields stay on the
13+
* class, and a body reaches them through the host (`protocol-host.ts`).
14+
*
15+
* The class keeps every member as a one-line delegation. A public member
16+
* keeps its docblock there, which the published `.d.ts` prints; a private
17+
* member's docblock moved here with its body (docblock ownership).
18+
*
19+
* What a body reads from elsewhere, it imports from where `protocol.ts`
20+
* imports it. Nothing re-exports this module, so the published surface does
21+
* not move.
22+
*/
23+
24+
import type { ProtocolHost } from './protocol-host.js';
25+
26+
export class ProtocolDataCrudFacet {
27+
/**
28+
* [#4435] Does this row EXIST? A fact about the database — deliberately
29+
* NOT "may this caller see it".
30+
*
31+
* Read with a system context so no row-level policy narrows it. That is
32+
* load-bearing, and the first version of this fix got it wrong: probing
33+
* with the CALLER's context turns the existence gate into an authorization
34+
* gate, because a row the caller cannot read comes back `null` and the
35+
* PATCH answers 404. Two things break when it does.
36+
*
37+
* 1. It silently changes RLS semantics. Whether an unreadable row may be
38+
* written by id is the #1994 pre-image check's decision, made inside
39+
* `engine.update` where the write policy lives. A probe in front of it
40+
* quietly adds a second, different rule — scope creep into the security
41+
* model, from a bug fix about missing records.
42+
*
43+
* 2. It disarms a revert-provable security proof. `@proof: rls-by-id-write`
44+
* (`packages/qa/dogfood/test/rls-fixture.dogfood.test.ts`, referenced by
45+
* the `permission.rowLevelSecurity.using` liveness ledger entry) boots a
46+
* fixture whose member can read nothing but has no write policy, and
47+
* asserts the runner reports `rls-hole`. A caller-scoped probe 404s that
48+
* PATCH, so the RED half goes green — and the gate can no longer prove
49+
* it is able to go red. If the #1994 fix were ever reverted, this probe
50+
* would MASK it. Accidentally hardening one path is not worth
51+
* permanently blinding the gate that watches the whole class.
52+
*
53+
* So the probe answers existence only, and authorization stays exactly
54+
* where it was. The one behaviour this adds is the 404 the issue asked for:
55+
* an id that names no row at all.
56+
*
57+
* ⛔ And what it finds never answers IN FRONT of the write's
58+
* authorization. Its readers are OCC's `updated_at` comparison, asked only
59+
* when the caller sent a token ({@link assertVersionMatch}) and answering
60+
* only for a row the caller's own read returns ({@link assertVersionOf}),
61+
* and the by-id update's existence check, asked only after `engine.update`
62+
* has judged and resolved the write (`updateData`, and the bulk faces
63+
* through {@link assertRecordExists}) — so an update by id answers the same
64+
* refusal whether or not the id exists, as delete does. With a mismatched
65+
* token, a caller whose read of the row is refused gets that read refusal.
66+
*/
67+
static async probeRecord(host: Pick<ProtocolHost, 'engine'>, object: string, id: string): Promise<any> {
68+
return host.engine.findOne(object, { where: { id }, context: { isSystem: true } });
69+
}
70+
}

‎packages/metadata-protocol/src/protocol-diff-delete-load.ts‎

Lines changed: 1658 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/metadata-protocol/src/protocol-discovery-and-bases.ts‎

Lines changed: 416 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/metadata-protocol/src/protocol-history-and-publish.ts‎

Lines changed: 1211 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/metadata-protocol/src/protocol-host.ts‎

Lines changed: 139 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/metadata-protocol/src/protocol-package-commits.ts‎

Lines changed: 2181 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/metadata-protocol/src/protocol-registry-hydration.ts‎

Lines changed: 1665 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/metadata-protocol/src/protocol-save.ts‎

Lines changed: 2606 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29985 codemod] The "Global search and the metadata cache" facet of
5+
* `ObjectStackProtocolImplementation`: the bodies of `getMetaItemCached` moved
6+
* out of `protocol.ts` whole by `scripts/codemod-split-protocol.mjs`.
7+
*
8+
* A MOVE, not a redesign. `ProtocolSearchAndCacheFacet` is a stateless facet over the host
9+
* (ruling 6624345766): each static method below is the body of the class
10+
* member of the same name as `packages/metadata-protocol/scripts/host-split.mjs`
11+
* moves one (ruling E, item E1), with `this.<member>` read as `host.<member>`
12+
* and nothing else changed. The facet holds no state: the fields stay on the
13+
* class, and a body reaches them through the host (`protocol-host.ts`).
14+
*
15+
* The class keeps every member as a one-line delegation. A public member
16+
* keeps its docblock there, which the published `.d.ts` prints; a private
17+
* member's docblock moved here with its body (docblock ownership).
18+
*
19+
* What a body reads from elsewhere, it imports from where `protocol.ts`
20+
* imports it. Nothing re-exports this module, so the published surface does
21+
* not move.
22+
*/
23+
24+
import type { FlowRegistration, MetadataCacheRequest, MetadataCacheResponse } from '@objectstack/spec/api';
25+
import { canonicalizeMetaRequestType } from './protocol-meta-type-schema.js';
26+
import { simpleHash } from './protocol-version-tokens.js';
27+
import { metadataItemNotFoundError } from './protocol-client-failures.js';
28+
import type { ProtocolHost } from './protocol-host.js';
29+
30+
export class ProtocolSearchAndCacheFacet {
31+
/** The body of `ObjectStackProtocolImplementation#getMetaItemCached`; the method's docblock there is its contract. */
32+
static async getMetaItemCached(host: Pick<ProtocolHost, 'getMetaItem'>, request: { type: string, name: string, cacheRequest?: MetadataCacheRequest, locale?: string }): Promise<MetadataCacheResponse & { registration?: FlowRegistration; disabledActions?: string[]; cascadeDeleteObjects?: string[] }> {
33+
// #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. The ETag
34+
// and the cache entry are keyed by type, so two spellings would cache
35+
// the same item twice and invalidate only one of them.
36+
request = canonicalizeMetaRequestType(request);
37+
try {
38+
// Delegate to getMetaItem so the customization-overlay read order
39+
// (sys_metadata → registry → MetadataService) is honoured here too
40+
// (ADR-0005). Without this, cached reads silently bypass overlays.
41+
const result = await host.getMetaItem({
42+
type: request.type,
43+
name: request.name,
44+
});
45+
const item = (result as any)?.item;
46+
// [#23316] The automation engine's verdict on a flow, as the
47+
// delegate read above just resolved it — live, on every call. It
48+
// rides beside `data` for the REST layer's default door, and is
49+
// never hashed into the ETag below (see the 304 note there).
50+
const registration = (result as { registration?: FlowRegistration } | undefined)?.registration;
51+
// [#27842] …and, for an object, the packaged actions the activation
52+
// ledger switches off, on exactly those terms: live, beside `data`,
53+
// never hashed (see the 304 note below).
54+
const disabledActions = (result as { disabledActions?: string[] } | undefined)?.disabledActions;
55+
// [#28001] …and the objects a delete of one of its records can
56+
// cascade into, on the same terms.
57+
const cascadeDeleteObjects = (result as { cascadeDeleteObjects?: string[] } | undefined)?.cascadeDeleteObjects;
58+
59+
if (!item) {
60+
// [#5532] Structured: 404 + the catalog's `RESOURCE_NOT_FOUND`.
61+
// Reaching here now means a real miss — `getMetaItem` throws
62+
// 503 rather than answering `undefined` when the store could
63+
// not be read (see
64+
// {@link rethrowUnlessMetadataStoreUnprovisioned}) — so the
65+
// 404 is a claim this layer is finally entitled to make.
66+
//
67+
// [#5840] That entitlement was only three-quarters earned when
68+
// it was written: it held for the `sys_metadata` overlay read,
69+
// whose failure arrives as a throw, and NOT for the
70+
// MetadataService read, whose failure `MetadataManager`
71+
// warn-logs and skips — so a loader outage still reached this
72+
// line as a plain missing `item` and was answered 404. Both
73+
// halves now refuse to guess (see
74+
// {@link readItemFromMetadataService}).
75+
throw metadataItemNotFoundError(request.type, request.name);
76+
}
77+
78+
// Calculate ETag (simple hash of the stringified metadata).
79+
//
80+
// The ETag MUST vary by locale. The REST layer translates the
81+
// response body *after* this validator check, so an ETag computed
82+
// only from the (untranslated) content would let a language switch
83+
// match the prior `If-None-Match` and return `304 Not Modified`
84+
// carrying a stale-locale body — labels/headers stuck in the old
85+
// language until a hard refresh (issue #1319). Folding the resolved
86+
// locale into the hash gives each locale a distinct validator.
87+
//
88+
// [ADR-0131 D6] No organization is part of the validator: the
89+
// read is the environment's for every caller, so the document
90+
// hashed below is the whole scope. An org-less caller's validator
91+
// is byte-for-byte the one it was issued before.
92+
//
93+
// ⛔ The ONE invariant this block depends on: `content` — the bytes
94+
// actually being sent — is inside the hash below. A 304 is answered
95+
// only on an exact match over those bytes, so a caller is only ever
96+
// pinned to the representation IT received. Hashing anything
97+
// cheaper than the document — a version marker — destroys that
98+
// silently.
99+
const content = JSON.stringify(item);
100+
const scope = [
101+
request.locale || undefined,
102+
].filter((part): part is string => part !== undefined);
103+
const hash = simpleHash(
104+
scope.length > 0
105+
? `${scope.join('\u0000')}\u0000${content}`
106+
: content,
107+
);
108+
const etag = { value: hash, weak: false };
109+
110+
// Check If-None-Match header
111+
//
112+
// [#23316] …except for a read that carries `registration`: the
113+
// served body holds the engine's verdict and this validator does
114+
// not (it hashes the document alone, and the verdict stays out of
115+
// it), so a 304 here would pin the caller to the verdict it was
116+
// served before — after the engine re-registered or refused the
117+
// flow under an unchanged body. Such a read is always answered in
118+
// full; the work is the same either way, since this method always
119+
// performs the delegate read.
120+
//
121+
// [#27842] The same holds for `disabledActions`: an administrator's
122+
// switch moves no byte of the object document, so a 304 would keep
123+
// the caller on the switched-off list it was served before.
124+
// [#28001] So does `cascadeDeleteObjects`: an object registered
125+
// later that cascades into this one moves no byte of it either.
126+
if (
127+
request.cacheRequest?.ifNoneMatch
128+
&& registration === undefined && disabledActions === undefined && cascadeDeleteObjects === undefined
129+
) {
130+
const clientEtag = request.cacheRequest.ifNoneMatch.replace(/^"(.*)"$/, '$1').replace(/^W\/"(.*)"$/, '$1');
131+
if (clientEtag === hash) {
132+
// Return 304 Not Modified
133+
return {
134+
notModified: true,
135+
etag,
136+
};
137+
}
138+
}
139+
140+
// Return full metadata with cache headers
141+
return {
142+
data: item,
143+
...(registration !== undefined ? { registration } : {}),
144+
...(disabledActions !== undefined ? { disabledActions } : {}),
145+
...(cascadeDeleteObjects !== undefined ? { cascadeDeleteObjects } : {}),
146+
etag,
147+
lastModified: new Date().toISOString(),
148+
cacheControl: {
149+
// Metadata is invalidated by publish, so freshness must be
150+
// gated by the ETag validator — not a TTL. `no-cache` lets
151+
// clients store the body but forces an `If-None-Match`
152+
// revalidation on every use: a cheap 304 when unchanged,
153+
// fresh fields the instant a publish bumps the ETag. The old
154+
// `max-age=3600` pinned the schema for up to an hour, so the
155+
// AI-build "New" form kept rendering pre-publish fields until
156+
// the TTL lapsed (no revalidation in between). `private` also
157+
// keeps per-tenant metadata out of shared CDN/proxy caches.
158+
directives: ['private', 'no-cache'],
159+
},
160+
notModified: false,
161+
};
162+
} catch (error: any) {
163+
throw error;
164+
}
165+
}
166+
}

0 commit comments

Comments
 (0)