Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/quick-melons-swim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@objectstack/metadata-protocol": patch
---

fix(metadata-protocol): 保存成功的回执不再一律自称 "customization overlay"

`saveMetaItem` 的成功 `message` 原本只有两种句式,都写死了 "customization
overlay"。但 `DEFAULT_METADATA_TYPE_REGISTRY` 里有一批类型声明
`supportsOverlay: false` 而按设计可以运行时写入(`object` / `field` / `hook` /
`seed` / `mapping` / `flow` / `action`),对它们的一次全新创建并没有覆盖任何
artifact,却也被回执成 "saved a customization overlay"。

判据不是 `supportsOverlay`,也不是 `allowOrgOverride`(spec 的 TSDoc 把这两件事
分得很清楚:前者是 loader 的合并能力,后者是运行时写入的许可),而是写路径**早已
算出**的 `isArtifactBacked` —— 也就是 `intent: 'override-artifact' |
'runtime-only'` 的来源。回执现在只说这条已知事实,不新增任何读路径查询。

| | FROM | TO |
|:---|:---|:---|
| 覆盖了 code package 的 artifact | `Saved customization overlay (org=…, state=…) — type=…, name=… [seq=N]` | 逐字不变 |
| 无 artifact 的运行时写入 | `Saved customization overlay (env-wide, state=…) — type=…, name=… [seq=N]` | `Saved <type> '<name>' (env-wide, state=…) [seq=N]` |

org 维度照旧在括号里(`org=<id>` / `env-wide`),`state=` 与 `[seq=N]` 两个分支都
保留,所以读取 `seq`(HMR 游标)或 `state` 的消费方不受影响;`message` 本身没有
任何消费方解析,仅作 toast 展示。

回执不区分「新建」与「更新既有 DB-only 行」:唯一可用的事实 `parentVersion ===
null` 的作用域是 `(state, packageId)`,一个已有 active 行的首个 draft 也会读成
"没有父版本",据此写 `Created …` 只是把一句假话换成另一句假话。中性动词
"Saved" 如实,且不为一句文案发明新的查询。
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,12 @@ describe('code-only metadata types are refused on every kernel (#5086)', () => {
// one answered `200 {"success":true,"message":"Saved customization overlay
// (env-wide) — type=…"}` with no `state=`, no `[seq=…]`, and no history
// row. That is precisely the answer #5086 caught the showcase giving for a
// `job`. They are green before the removal too — a branch nothing reaches
// `job`. (#5265 later split the surviving repository sentence in two: the
// overlay noun is now conditional on `isArtifactBacked`, so a runtime-only
// save reads `Saved <type> '<name>' (env-wide, state=…) [seq=…]`. The
// discriminators these pins assert on — `state=` and `[seq=…]` — are on
// BOTH branches, which is why they are matched here and the noun is not.)
// They are green before the removal too — a branch nothing reaches
// is what "dead" means — so they are not a regression test for the
// deletion; they are the guard that stops a second historyless write path
// from being introduced, and they fail loudly if the #5086 gate is ever
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@
// {"success":true,…,"message":"Saved customization overlay (env-wide, …)"}
// {"success":true,"reset":true,…,"message":"Customization overlay deleted — view/v1 …"}
//
// (#5265 — that first line is the measurement AS TAKEN, kept verbatim rather
// than back-dated. Re-run today it reads `"Saved view 'v1' (env-wide, …)"`:
// this file's registry holds no artifact for `v1`, so the save overlays
// nothing and the receipt no longer claims it does. Neither the direction nor
// the 5/7 split changes — only the noun in the resolved value.)
//
// Predicted 4 (the four in the first describe); the fifth is the last case of
// the artifact describe, which is itself a fail-closed assertion and only lives
// there for narrative reasons. Recorded as measured rather than rounded to the
Expand Down
311 changes: 311 additions & 0 deletions packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #5265 — the save receipt says only what the write path already knows.
*
* `saveMetaItem` had exactly two success sentences and both hardwired the noun
* "customization overlay":
*
* Saved customization overlay (org=…, state=…) — type=…, name=… [seq=N]
* Saved customization overlay (env-wide, state=…) — type=…, name=… [seq=N]
*
* Seven `DEFAULT_METADATA_TYPE_REGISTRY` entries declare `supportsOverlay:
* false` and are still runtime-writable by design (`object`, `field`, `hook`,
* `seed`, `mapping`, `flow`, `action`). A brand-new one of those overlays
* nothing — there is no artifact underneath it — and was told, verbatim, that
* it had "saved a customization overlay". #5086's real showcase boot measured
* the sentence on a `view` (`supportsOverlay: true`, so the receipt was true
* there); the same sentence for `object` / `flow` simply was not.
*
* The discriminator is NOT `supportsOverlay` and NOT `allowOrgOverride` — the
* spec's TSDoc keeps those two apart on purpose (loader merge *capability* vs
* runtime write *permission*), and neither is the fact the sentence claims.
* The fact the sentence claims is "something was overlaid", and the write path
* has already computed it: `isArtifactBacked(type, name)`, the same fact
* `intent: 'override-artifact' | 'runtime-only'` is derived from. So these
* tests drive the split by artifact backing and treat the `supportsOverlay:
* false` population as the motivating case it is, not as the rule.
*
* ---------------------------------------------------------------------------
* Reverse verification, direction predicted BEFORE running
* ---------------------------------------------------------------------------
* Ordinary red, with a deliberately green half. Restoring the unconditional
* template (`message: orgId ? 'Saved customization overlay (org=…' : 'Saved
* customization overlay (env-wide…'`) turns every `runtime-only` case here red
* — predicted 9, measured 9 — and leaves all four `override-artifact` cases
* green, because their sentence is unchanged byte for byte. The green half is
* the point of the split, not slack: a fix that simply stopped saying
* "overlay" everywhere would pass the red half and fail here.
*
* Harness: the real write path over a stub engine, the same shape as
* `protocol.code-only-types.test.ts` — the receipt is built INSIDE
* `saveMetaItem`, so a harness that mocks `saveMetaItem` cannot see it.
*/
import { describe, expect, it } from 'vitest';
// [#5619] The producer's OWN write-verb dispatch decisions (#4550 delete /
// #5480 update). Imported from `@objectstack/metadata-core`, never from
// `@objectstack/objectql`: objectql DEPENDS ON this package, so that import
// would close a dependency cycle turbo rejects outright.
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
import { ObjectStackProtocolImplementation } from './protocol.js';

interface Row {
id: string;
type: string;
name: string;
organization_id: string | null;
state: string;
metadata: string;
}

function makeStubEngine(artifacts: Array<{ type: string; name: string }> = []) {
const rows = new Map<string, Row>();
let nextId = 0;
const artifactKeys = new Set(artifacts.map((a) => `${a.type}|${a.name}`));
const keyOf = (w: Record<string, unknown>) =>
`${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}`;
const engine: any = {
async findOne(_t: string, opts: { where: Record<string, unknown> }) {
for (const row of rows.values()) {
if (opts.where.type !== undefined && row.type !== opts.where.type) continue;
if (opts.where.name !== undefined && row.name !== opts.where.name) continue;
if (opts.where.state !== undefined && row.state !== opts.where.state) continue;
return row;
}
return null;
},
async find() { return []; },
async insert(_t: string, data: Record<string, unknown>) {
if (_t !== 'sys_metadata') return { id: 'side_effect_skip' };
nextId += 1;
const row = { id: `r_${nextId}`, ...(data as any) } as Row;
rows.set(keyOf(data), row);
return { id: row.id };
},
async update(_t: string, data: Record<string, unknown>, opts?: Record<string, unknown>) {
assertEngineUpdateDispatch(data, opts);
return { id: null };
},
async delete(_t: string, opts?: Record<string, unknown>) {
assertEngineDeleteDispatch(opts);
return { deleted: 0 };
},
registry: {
registerItem: () => {},
registerObject: () => {},
listItems: () => [],
getItem: () => undefined,
// `isArtifactBacked` prefers this lookup — a hit here means the
// name is shipped by a code package (`_packageId` provenance).
getArtifactItem: (type: string, name: string) =>
artifactKeys.has(`${type}|${name}`) ? { name, _packageId: 'showcase' } : undefined,
},
};
return { engine, rows };
}

function makeProtocol(artifacts?: Array<{ type: string; name: string }>) {
const { engine, rows } = makeStubEngine(artifacts);
const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any;
return { protocol, rows };
}

/**
* Schema-VALID bodies for the `supportsOverlay: false` types the issue names.
* A minimal payload 422s on spec validation before the receipt is ever built,
* so only a body the schema accepts proves anything about the sentence.
*/
const OVERLAYLESS_PROBES: Record<string, Record<string, unknown>> = {
object: {
name: 'rc5_acct',
label: 'Account',
fields: { name: { type: 'text', label: 'Name' } },
},
hook: { name: 'rc5_acct', object: 'task', events: ['beforeUpdate'] },
seed: { object: 'task', records: [] },
action: { name: 'rc5_acct', label: 'Convert', type: 'script', objectName: 'task', target: 'convertHandler' },
flow: {
name: 'rc5_acct',
label: 'Pause project when hours are logged',
type: 'record_change',
status: 'active',
nodes: [
{ id: 'start', type: 'start', label: 'Start', config: { objectName: 'task', triggerType: 'record-after-update' } },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [{ id: 'e1', source: 'start', target: 'end' }],
},
};

/** A view body the spec accepts — the type #5086's real boot measured. */
const VIEW = {
name: 'rc5_probe_view',
label: 'Probe',
object: 'task',
columns: [{ field: 'name', label: 'Name' }],
};

/**
* The population the issue is about, derived from the registry rather than
* listed here (Prime Directive #7 — no parallel whitelists). A type that flips
* `supportsOverlay` leaves this set and the membership pin below turns red.
*/
const OVERLAYLESS_RUNTIME_WRITABLE = DEFAULT_METADATA_TYPE_REGISTRY
.filter((e) => e.supportsOverlay === false && e.allowRuntimeCreate)
.map((e) => e.type);

describe('#5265 — a save receipt names what was actually written', () => {
it('the registry really does declare overlay-less types that are runtime-writable', () => {
// The premise, pinned. If this ever empties, the whole issue is moot
// and these tests should be read again rather than repaired.
expect(OVERLAYLESS_RUNTIME_WRITABLE.length).toBeGreaterThan(0);
for (const type of Object.keys(OVERLAYLESS_PROBES)) {
expect(OVERLAYLESS_RUNTIME_WRITABLE, `${type} left the overlay-less set`).toContain(type);
}
});

// ── runtime-only: nothing was overlaid, so nothing may claim it was ──

for (const [type, item] of Object.entries(OVERLAYLESS_PROBES)) {
it(`a brand-new ${type} is not reported as a customization overlay`, async () => {
const { protocol } = makeProtocol();

const result = await protocol.saveMetaItem({ type, name: 'rc5_acct', item });

expect(result.success).toBe(true);
expect(result.message).not.toContain('customization overlay');
// Still carries every fact the overlay sentence carried: the type,
// the name, the org dimension, the state and the change-log cursor.
expect(result.message).toBe(
`Saved ${type} 'rc5_acct' (env-wide, state=active) [seq=${result.seq}]`,
);
});
}

it('an org-scoped runtime-only save names the org, not an overlay', async () => {
const { protocol } = makeProtocol();

const result = await protocol.saveMetaItem({
type: 'hook', name: 'rc5_acct', item: OVERLAYLESS_PROBES.hook,
organizationId: 'org_alpha',
});

expect(result.message).not.toContain('customization overlay');
expect(result.message).toBe(
`Saved hook 'rc5_acct' (org=org_alpha, state=active) [seq=${result.seq}]`,
);
});

it('a runtime-only draft still reports its lifecycle state', async () => {
const { protocol } = makeProtocol();

const result = await protocol.saveMetaItem({
type: 'flow', name: 'rc5_acct', item: OVERLAYLESS_PROBES.flow, mode: 'draft',
});

expect(result.state).toBe('draft');
expect(result.message).toBe(
`Saved flow 'rc5_acct' (env-wide, state=draft) [seq=${result.seq}]`,
);
});

it('a type with NO artifact is runtime-only even when it supports overlays', async () => {
// `view` is `supportsOverlay: true`, but a view nobody shipped is
// still a first-ever creation. The receipt follows the fact, not the
// registry flag — this is the case that proves the rule is keyed on
// artifact backing.
const { protocol } = makeProtocol();

const result = await protocol.saveMetaItem({ type: 'view', name: 'rc5_probe_view', item: VIEW });

expect(result.message).toBe(
`Saved view 'rc5_probe_view' (env-wide, state=active) [seq=${result.seq}]`,
);
});

it('the phrase spells the canonical singular type, not the plural the caller sent', async () => {
const { protocol } = makeProtocol();

const result = await protocol.saveMetaItem({ type: 'views', name: 'rc5_probe_view', item: VIEW });

expect(result.message).toContain("Saved view 'rc5_probe_view'");
});

// ── override-artifact: the sentence is TRUE there, and stays verbatim ──

it('an env-wide overlay OF a packaged artifact keeps the original sentence', async () => {
const { protocol } = makeProtocol([{ type: 'view', name: 'rc5_probe_view' }]);

const result = await protocol.saveMetaItem({ type: 'view', name: 'rc5_probe_view', item: VIEW });

expect(result.message).toBe(
`Saved customization overlay (env-wide, state=active) — type=view, name=rc5_probe_view [seq=${result.seq}]`,
);
});

it('an org-scoped overlay OF a packaged artifact keeps the original sentence', async () => {
const { protocol } = makeProtocol([{ type: 'view', name: 'rc5_probe_view' }]);

const result = await protocol.saveMetaItem({
type: 'view', name: 'rc5_probe_view', item: VIEW, organizationId: 'org_alpha',
});

expect(result.message).toBe(
`Saved customization overlay (org=org_alpha, state=active) — type=view, name=rc5_probe_view [seq=${result.seq}]`,
);
});

it('an overlay draft keeps the original sentence too', async () => {
const { protocol } = makeProtocol([{ type: 'view', name: 'rc5_probe_view' }]);

const result = await protocol.saveMetaItem({
type: 'view', name: 'rc5_probe_view', item: VIEW, mode: 'draft',
});

expect(result.message).toBe(
`Saved customization overlay (env-wide, state=draft) — type=view, name=rc5_probe_view [seq=${result.seq}]`,
);
});

it('an overlay of a packaged FLOW — supportsOverlay:false, and still an override', async () => {
// The mirror of the first block, and the sharpest case in this file.
// `flow` sits in the overlay-less population above (`supportsOverlay:
// false`) yet is `allowOrgOverride: true`, so a packaged flow really
// can be overridden at runtime — and then the overlay sentence is the
// true one. A receipt decided by `supportsOverlay` would get this
// exactly backwards; one decided by artifact backing gets it right.
//
// (`object` cannot stand in here: it is `allowOrgOverride: false`, so
// `SysMetadataRepository.assertAllowed` refuses an `override-artifact`
// write with `[NOT_OVERRIDABLE]` before any receipt is built. Measured,
// not assumed — this case was written against `object` first.)
const { protocol } = makeProtocol([{ type: 'flow', name: 'rc5_acct' }]);

const result = await protocol.saveMetaItem({
type: 'flow', name: 'rc5_acct', item: OVERLAYLESS_PROBES.flow,
});

expect(result.message).toBe(
`Saved customization overlay (env-wide, state=active) — type=flow, name=rc5_acct [seq=${result.seq}]`,
);
});

// ── the boundary this message crosses (#5423) ─────────────────────────

it('both sentences stay far below the 500-character response bound', async () => {
// The receipt is forwarded verbatim by `res.json(result)` on
// `PUT /meta/:type/:name`, and #5423's truncation bound is 500. Both
// shapes are an order of magnitude under it, for a realistic name.
const { protocol: plain } = makeProtocol();
const { protocol: overlaid } = makeProtocol([{ type: 'view', name: 'rc5_probe_view' }]);

const runtimeOnly = await plain.saveMetaItem({ type: 'view', name: 'rc5_probe_view', item: VIEW });
const override = await overlaid.saveMetaItem({
type: 'view', name: 'rc5_probe_view', item: VIEW, organizationId: 'org_alpha',
});

expect(runtimeOnly.message.length).toBeLessThan(200);
expect(override.message.length).toBeLessThan(200);
});
});
Loading
Loading