Skip to content

Commit 2081a5e

Browse files
fix(runtime): the customizations read attributes a contributed field and flow by one stamp rule (#28807)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XQB8PvuLXjvzVKRUBkB8969)_ Fixes #28796 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:上一版「定制读取」里的 `contributions`(别的包贡献的字段和流程)对字段和流程用了两套判断来认定「这是哪个包的」。现在两者共用一个判断:必须是代码包的标记,不是 `sys_metadata` 占位,也不是本环境自己写的内容,并且不是基础包或它的 `.custom` 配对包。spec 里这段说明也改准了:配对包的东西分别在 `overrides` 和 `extensions` 里,不在 `contributions` 里。 - **为什么改**:这是上一版 PR #28749 评审提出的两条非阻塞修正。两套判断今天对所有真实输入给出的结果一样,但以后容易走偏。 - **风险与回滚**:对现有安装的应答没有任何变化:列出谁不变,应答形状也不变。回滚 = revert 本 PR。 - **先看**:① `packages/runtime/src/domains/packages.ts` 的 `codePackageOf`(两种类型共用的判断);② `packages/runtime/src/domains/packages-customizations.test.ts` 新增的「两种类型同一读法」测试;③ `packages/spec/src/api/package-lifecycle.zod.ts` 的说明文字。 - **两分钟验证**:`pnpm --filter @objectstack/runtime exec vitest run src/domains/packages-customizations.test.ts`,预期 18 个测试全绿。 - **需要你做的**:无,席位落地。 ## What changes - **One stamp predicate** — `packages/runtime/src/domains/packages.ts#readBaseContributions`: both branches now attribute through `codePackageOf(packageId, body)`, which returns the package only when it is neither the base nor its pair and `isCodeArtifactBody` accepts the stamp (not the `sys_metadata` sentinel, not a tenant-authored `_provenance: 'org'` body). Only where the stamp is read differs: the field branch passes the `extend` contributor's `packageId` and its `definition`; the flow branch passes the flow's own `_packageId` and the flow. - **Which reading, and why.** The flow branch's `isCodeArtifactBody` reading is taken for both, as the order expects: - Measured: the only `'extend'` registration is `ObjectQL.registerApp` (`packages/objectql/src/engine.ts`, objectExtensions loop), which registers under the manifest's package id. So every field contributor in the tree is a code package's, and the shared predicate lists exactly what the field branch listed before. - The field branch's bare id check cannot be used for flows: a flow carries its package only as a body stamp, which a tenant-authored overlay or the `sys_metadata` sentinel can also wear. - **Docblock** — `packages/spec/src/api/package-lifecycle.zod.ts`, the TSDoc above `GetPackageCustomizationsResponseSchema`: it names the two kinds that are listed. It drops the "(listed above)" claim and says where the pair's items are instead: an overriding pair item, a flow included, is in `overrides`; its extension members are in `extensions`; and any other pair flow is in no list of the answer. No `.describe()` text changed, and `check:generated` reports all 14 artifacts up to date. - `.changeset/28796-contributions-one-stamp.md`: `patch` on `@objectstack/runtime` and `@objectstack/spec` (the TSDoc ships in the spec's `.d.ts`). ## Tests | Test | Head | Base / ablation | |---|---|---| | `packages/runtime/src/domains/packages-customizations.test.ts` (18) | 18/18 | `packages.ts` at base `8196881e`: 1 red, the new divergence pin | | ablation: field branch back to its own id-only reading | | 1 red (the divergence pin) | | ablation: flow branch to an id-only reading | | 2 red (the divergence pin and the environment-flow test) | The new test registers, on the base's object, one field extension and one flow per stamp: a code package's (listed), the `sys_metadata` sentinel (not listed), and a tenant-authored body wearing a real package id (not listed). It asserts that the stamps whose field is listed are exactly the stamps whose flow is listed, and then the exact answer. It reds whenever the two branches read a stamp differently. Each ablation ran through `scripts/ablation-replace.mjs`, and each restore was proven: blob equal to HEAD and `git diff HEAD` empty. | Suite | Result | |---|---| | `pnpm --filter @objectstack/runtime test` | 508 files, 8229 passed, 19 skipped, exit 0 | | `pnpm --filter @objectstack/runtime typecheck`, `pnpm --filter @objectstack/spec typecheck` | exit 0 | | `pnpm build` (whole repo) | exit 0 | ## Acceptance notes - Nothing in an installed composition is attributed differently: the base leg reds only on the new test's hand-built sentinel and tenant stamps. - No `ui/**` change, and no change to who is listed or to the answer's shape. ## Verification All readings at head `6eeaf6a5`. <details><summary>Derived gates: 81 derived, 81 run, 0 NOT-MEASURED, 0 UNRUN</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on the real diff gave 81 commands; re-derived after the loop, the list is identical. - A whole-repo `pnpm build` (exit 0) ran first, so the dist-reading families measured. - Every one of the 81 ran with exit 0 on the first pass. - `--ran` verdict: "81 derived famil(ies) accounted for — 81 run, 0 NOT-MEASURED", exit 0. - `pnpm --filter @objectstack/spec check:generated --fix`: all 14 artifacts up to date, nothing regenerated. - eslint `--no-inline-config` over the changed `packages/` TS files: exit 0. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5ogKLgaK3fAZF4kmeF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 663b7c0 commit 2081a5e

4 files changed

Lines changed: 73 additions & 14 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
fix(runtime): the customizations read's `contributions` attributes a field and a flow by the same stamp rule
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
`GET /packages/:id/customizations` now applies one rule to both kinds of contribution. A field from another package's object extension and a flow that package ships are listed only when their package stamp is a code package's: not the package-less `sys_metadata` sentinel, not a tenant-authored body, and not the base or its `.custom` pair. Before this, flows were judged that way and fields only by the package id. Nothing in an installed composition reads differently, so who is listed and the shape of the answer do not change.
12+
13+
The `GetPackageCustomizationsResponseSchema` docblock now names exactly what `contributions` lists, and where the pair's items are instead: its overrides in `overrides`, its extension members in `extensions`, and no other pair flow in any list.

‎packages/runtime/src/domains/packages-customizations.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,33 @@ describe('[#28722] GET /packages/:id/customizations — the fields and flows oth
574574
expect(contributions.map((c: any) => `${c.kind}:${c.name}`)).toEqual(['field:acme_sync_id', 'flow:acme_task_sync']);
575575
});
576576

577+
it('reads a field\'s stamp and a flow\'s stamp the same way: each stamp lists both kinds or neither', async () => {
578+
const { dispatcher, engine } = await boot();
579+
// One field extension and one flow per stamp, on the base's object.
580+
// A code package's stamp is a contribution; the package-less
581+
// `sys_metadata` sentinel and a tenant-authored body wearing a real
582+
// package's id are the environment's, whichever kind carries them.
583+
const stamps = [
584+
{ tag: 'code', packageId: THIRD, body: {} },
585+
{ tag: 'sentinel', packageId: 'sys_metadata', body: {} },
586+
{ tag: 'tenant', packageId: FOURTH, body: { _provenance: 'org' } },
587+
] as const;
588+
for (const { tag, packageId, body } of stamps) {
589+
extend(engine, packageId, 'showcase_task', { ...body, fields: { [`${tag}_field`]: { name: `${tag}_field`, label: tag, type: 'text' } } });
590+
engine.registry.registerItem('flow', { ...flowOn(`${tag}_flow`, 'showcase_task'), _packageId: packageId, ...body } as any);
591+
}
592+
const { contributions } = (await customizations(dispatcher, BASE)).response?.body?.data;
593+
const listedTags = (kind: string) => contributions
594+
.filter((c: any) => c.kind === kind)
595+
.map((c: any) => c.name.replace(/_(field|flow)$/, ''))
596+
.sort();
597+
expect(listedTags('field')).toEqual(listedTags('flow'));
598+
expect(contributions).toEqual([
599+
{ kind: 'field', object: 'showcase_task', name: 'code_field', packageId: THIRD },
600+
{ kind: 'flow', object: 'showcase_task', name: 'code_flow', packageId: THIRD },
601+
]);
602+
});
603+
577604
it('another base\'s read lists what is contributed to its own objects only', async () => {
578605
const { dispatcher } = await contributedFixture();
579606
const data = (await customizations(dispatcher, OTHER)).response?.body?.data;

‎packages/runtime/src/domains/packages.ts‎

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3205,10 +3205,15 @@ async function readPackageCustomizations(
32053205
* - **flow** — the metadata registry's flows (`listItems('flow')`, which
32063206
* already leaves a disabled package's out), whose `start` node's
32073207
* `config.objectName` is a base-owned object, attributed to the package
3208-
* stamp the registration put on it when that stamp is a code package's
3209-
* ({@link isCodeArtifactBody}: not the package-less `sys_metadata`
3210-
* sentinel, not a tenant-authored overlay). A flow authored in this
3211-
* environment is the environment's, not a package's, and is not listed.
3208+
* stamp the registration put on it (its `_packageId`).
3209+
*
3210+
* Both kinds ask ONE question of their stamp, {@link codePackageOf}: is it a
3211+
* code package's ({@link isCodeArtifactBody}: not the package-less
3212+
* `sys_metadata` sentinel, not a tenant-authored body), and neither the base
3213+
* nor its pair? Only where the stamp is read differs: the contributor's
3214+
* `packageId` beside its `definition`, or the flow's own `_packageId`. An item
3215+
* authored in this environment is the environment's, not a package's, and is
3216+
* not listed, whichever kind it is.
32123217
*
32133218
* Ordered by object, kind, name and package.
32143219
*/
@@ -3220,8 +3225,14 @@ function readBaseContributions(
32203225
type Contribution = GetPackageCustomizationsResponse['contributions'][number];
32213226
const contributions: Contribution[] = [];
32223227
if (typeof registry?.getAllObjects !== 'function') return contributions;
3223-
const isContributor = (packageId: unknown): packageId is string =>
3224-
typeof packageId === 'string' && packageId !== '' && packageId !== baseId && packageId !== pairId;
3228+
// The one stamp reading both kinds share: the package `packageId` names
3229+
// when `body` is a code package's item and the package is neither the base
3230+
// nor its pair; `undefined` otherwise.
3231+
const codePackageOf = (packageId: unknown, body: unknown): string | undefined => {
3232+
if (typeof packageId !== 'string' || packageId === baseId || packageId === pairId) return undefined;
3233+
const stamped = { _packageId: packageId, _provenance: (body as { _provenance?: unknown } | null | undefined)?._provenance };
3234+
return isCodeArtifactBody(stamped) ? packageId : undefined;
3235+
};
32253236

32263237
const owned = new Set<string>();
32273238
for (const object of registry.getAllObjects(baseId) as Array<{ name?: unknown }>) {
@@ -3231,22 +3242,25 @@ function readBaseContributions(
32313242

32323243
for (const name of owned) {
32333244
for (const contributor of registry.getObjectContributors(name) as Array<{ packageId?: unknown; ownership?: unknown; definition?: any }>) {
3234-
if (contributor.ownership !== 'extend' || !isContributor(contributor.packageId)) continue;
3245+
if (contributor.ownership !== 'extend') continue;
3246+
const packageId = codePackageOf(contributor.packageId, contributor.definition);
3247+
if (packageId === undefined) continue;
32353248
for (const field of Object.keys(contributor.definition?.fields ?? {})) {
3236-
contributions.push({ kind: 'field', object: name, name: field, packageId: contributor.packageId });
3249+
contributions.push({ kind: 'field', object: name, name: field, packageId });
32373250
}
32383251
}
32393252
}
32403253

32413254
if (owned.size > 0 && typeof registry.listItems === 'function') {
32423255
for (const flow of registry.listItems('flow') as Array<{ name?: unknown; nodes?: unknown; _packageId?: unknown }>) {
3243-
if (!isCodeArtifactBody(flow) || !isContributor(flow._packageId) || typeof flow.name !== 'string') continue;
3256+
const packageId = codePackageOf(flow._packageId, flow);
3257+
if (packageId === undefined || typeof flow.name !== 'string') continue;
32443258
const start = Array.isArray(flow.nodes)
32453259
? flow.nodes.find((node: any) => node?.type === 'start')
32463260
: undefined;
32473261
const object = start?.config?.objectName;
32483262
if (typeof object !== 'string' || !owned.has(object)) continue;
3249-
contributions.push({ kind: 'flow', object, name: flow.name, packageId: flow._packageId });
3263+
contributions.push({ kind: 'flow', object, name: flow.name, packageId });
32503264
}
32513265
}
32523266

‎packages/spec/src/api/package-lifecycle.zod.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -350,10 +350,15 @@ export const GetPackageCustomizationsRequestSchema = lazySchema(() => z.object({
350350
* [#28722, ADR-0029 D7 as amended by ADR-0146 D3] Beside the pair's changes,
351351
* the same read names what OTHER installed packages bring to X's surface, so
352352
* Studio can draw those items greyed with their source: `contributions`, one
353-
* entry per item, each stamped with the package that contributes it. The
354-
* stamp is on the item, so two packages adding a field of the same name are
355-
* two entries and nothing is joined by name on the client. X's own items and
356-
* the pair's (listed above) are never contributions.
353+
* entry per item, each stamped with the package that contributes it. What is
354+
* listed is exactly two kinds: the fields another package's object extension
355+
* adds to an object X owns, and the flows another package ships whose trigger
356+
* object X owns. The stamp is on the item, so two packages adding a field of
357+
* the same name are two entries and nothing is joined by name on the client.
358+
* Neither X's own items nor its pair's are contributions: a pair item that
359+
* overrides one of X's (a flow included) is in `overrides`, the pair's
360+
* extension members are in `extensions`, and any other pair flow is in no
361+
* list of this answer.
357362
*/
358363
export const GetPackageCustomizationsResponseSchema = lazySchema(() => z.object({
359364
packageId: z.string().describe('The base package the customizations are of (the request\'s id).'),

0 commit comments

Comments
 (0)