diff --git a/.changeset/quick-melons-swim.md b/.changeset/quick-melons-swim.md new file mode 100644 index 0000000000..ebec014e19 --- /dev/null +++ b/.changeset/quick-melons-swim.md @@ -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 '' (env-wide, state=…) [seq=N]` | + +org 维度照旧在括号里(`org=` / `env-wide`),`state=` 与 `[seq=N]` 两个分支都 +保留,所以读取 `seq`(HMR 游标)或 `state` 的消费方不受影响;`message` 本身没有 +任何消费方解析,仅作 toast 展示。 + +回执不区分「新建」与「更新既有 DB-only 行」:唯一可用的事实 `parentVersion === +null` 的作用域是 `(state, packageId)`,一个已有 active 行的首个 draft 也会读成 +"没有父版本",据此写 `Created …` 只是把一句假话换成另一句假话。中性动词 +"Saved" 如实,且不为一句文案发明新的查询。 diff --git a/packages/metadata-protocol/src/protocol.code-only-types.test.ts b/packages/metadata-protocol/src/protocol.code-only-types.test.ts index 7412be76b5..c123ed6e58 100644 --- a/packages/metadata-protocol/src/protocol.code-only-types.test.ts +++ b/packages/metadata-protocol/src/protocol.code-only-types.test.ts @@ -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 '' (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 diff --git a/packages/metadata-protocol/src/protocol.lock-gate-fail-closed.test.ts b/packages/metadata-protocol/src/protocol.lock-gate-fail-closed.test.ts index 9c874ab40a..40c1b18dd2 100644 --- a/packages/metadata-protocol/src/protocol.lock-gate-fail-closed.test.ts +++ b/packages/metadata-protocol/src/protocol.lock-gate-fail-closed.test.ts @@ -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 diff --git a/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts b/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts new file mode 100644 index 0000000000..1ed1ace511 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts @@ -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(); + let nextId = 0; + const artifactKeys = new Set(artifacts.map((a) => `${a.type}|${a.name}`)); + const keyOf = (w: Record) => + `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}`; + const engine: any = { + async findOne(_t: string, opts: { where: Record }) { + 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) { + 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, opts?: Record) { + assertEngineUpdateDispatch(data, opts); + return { id: null }; + }, + async delete(_t: string, opts?: Record) { + 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> = { + 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); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 66efdd7c4c..1b46f064d2 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -7944,9 +7944,37 @@ export class ObjectStackProtocolImplementation implements // once the spec declared `state` as the closed set it has always // emitted. Type-only: the value is unchanged. state: (mode === 'draft' ? 'draft' : 'active') as 'draft' | 'active', - message: orgId - ? `Saved customization overlay (org=${orgId}, state=${mode === 'draft' ? 'draft' : 'active'}) — type=${request.type}, name=${request.name} [seq=${result.seq}]` - : `Saved customization overlay (env-wide, state=${mode === 'draft' ? 'draft' : 'active'}) — type=${request.type}, name=${request.name} [seq=${result.seq}]`, + // #5265 — the receipt says only what this write path already + // KNOWS. `artifactBacked` (computed above, and the same fact + // `intent` is derived from) is exactly the difference between + // the two things a save can be: + // + // • override-artifact — a code-shipped artifact exists under + // this (type, name), so the row we just wrote customizes + // it. "customization overlay" is literally true; the + // sentence is unchanged, verbatim, on purpose. + // • runtime-only — nothing is being overlaid. The row IS the + // item. Seven registry types declare `supportsOverlay: + // false` yet are writable at runtime by design (`object`, + // `field`, `hook`, `seed`, `mapping`, `flow`, `action`), + // and every one of them used to be told it had "saved a + // customization overlay" of nothing. + // + // Deliberately NOT split further into created-vs-updated. The + // available fact is `parentVersion === null`, and that is + // scoped to (state, packageId): the first DRAFT of an item + // that already has a live active row reads as "no parent", so + // a `Created …` receipt derived from it would swap one false + // claim for another. Distinguishing it honestly needs a read + // this path does not already make, and a receipt is not worth + // a query — so the verb stays the neutral, true "Saved". + message: artifactBacked + ? (orgId + ? `Saved customization overlay (org=${orgId}, state=${mode === 'draft' ? 'draft' : 'active'}) — type=${request.type}, name=${request.name} [seq=${result.seq}]` + : `Saved customization overlay (env-wide, state=${mode === 'draft' ? 'draft' : 'active'}) — type=${request.type}, name=${request.name} [seq=${result.seq}]`) + : (orgId + ? `Saved ${singularTypeForRepo} '${request.name}' (org=${orgId}, state=${mode === 'draft' ? 'draft' : 'active'}) [seq=${result.seq}]` + : `Saved ${singularTypeForRepo} '${request.name}' (env-wide, state=${mode === 'draft' ? 'draft' : 'active'}) [seq=${result.seq}]`), }; } catch (err: any) { if (err instanceof ConflictError) { diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index db23269a72..677064f29e 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -312,8 +312,32 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { const result = await protocol.saveMetaItem({ type: 'app', name: 'test_app', item: sampleApp }); expect(result.success).toBe(true); - // env-wide (no organizationId) overlay save - expect(result.message).toMatch(/Saved customization overlay/); + // #5265 — the registry here is empty, so `test_app` is backed by no + // code artifact: this write overlays nothing, and the receipt must + // not claim it did. It still has to carry the same substance the + // overlay sentence carries — the type, the name, the org dimension + // and the change-log cursor. + expect(result.message).not.toMatch(/customization overlay/); + expect(result.message).toMatch(/^Saved app 'test_app' \(env-wide, state=active\) \[seq=\d+\]$/); + }); + + it('[#5265] an artifact-backed save still reports a customization overlay', async () => { + // The other half of the same fact: when a code package DOES ship + // this name, the row genuinely customizes it and the historical + // sentence is the true one. Pinned so the split cannot collapse + // into "never say overlay". + // A clone: `registerItem` stamps `_packageId` onto the item it is + // given, and `sampleApp` is shared by every case in this file. + registry.registerItem('app', { ...sampleApp }, 'name', 'com.acme.showcase'); + + const result = await protocol.saveMetaItem({ + type: 'app', name: 'test_app', item: sampleApp, organizationId: 'org_alpha', + }); + + expect(result.success).toBe(true); + expect(result.message).toMatch( + /^Saved customization overlay \(org=org_alpha, state=active\) — type=app, name=test_app \[seq=\d+\]$/, + ); }); it('should fail-fast with 500 when DB findOne is unavailable (ADR-0005)', async () => { diff --git a/packages/rest/src/rest-meta-save-receipt-envelope.test.ts b/packages/rest/src/rest-meta-save-receipt-envelope.test.ts new file mode 100644 index 0000000000..a7cff2eae6 --- /dev/null +++ b/packages/rest/src/rest-meta-save-receipt-envelope.test.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #5265 — the corrected save receipt crosses the REST boundary intact. + * + * The protocol-side pins live in + * `packages/metadata-protocol/src/protocol.save-receipt-wording.test.ts`. This + * file answers the other half: the sentence a client actually reads. Nothing + * here is hand-built — a REAL better-sqlite3 `:memory:` engine, a REAL + * `ObjectStackProtocolImplementation`, and the REAL `PUT /meta/:type/:name` + * route, whose handler ends in `res.json(result)`. + * + * Two things it proves that a protocol-level test cannot: + * + * 1. `message` is forwarded verbatim — the route does not reshape, rename or + * re-derive it, so the truthful sentence is the one that reaches Studio's + * toast rather than a REST-layer paraphrase. + * 2. It is nowhere near #5423's 500-character bound, so no truncation path + * can silently eat the org dimension or the `[seq=…]` cursor off the end. + * + * The showcase repro from the issue is replayed literally: `PUT + * /api/v1/meta/view/rc5_probe_view` on a fresh environment, which is a + * first-ever creation and therefore an overlay of nothing. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +// The REAL overlay store definitions the protocol writes to — not a mirror. +// `@objectstack/platform-objects` is already a production dependency of this +// package, and a hand-declared `sys_metadata` would only reset the drift clock +// #5785 stopped (an import-job mirror that had silently fallen three columns +// behind the object it mirrored). +import { SysMetadata, SysMetadataHistoryObject } from '@objectstack/platform-objects/metadata'; +import { RestServer } from './rest-server'; + +/** The real backend, constructed the canonical way (`examples/app-crm`). */ +function makeSqliteDriver() { + return new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); +} + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +const TASK = { + name: 'task', label: 'Task', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + name: { name: 'name', type: 'text' as const, label: 'Name' }, + }, +}; + +async function boot() { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(makeSqliteDriver(), true); + await engine.init(); + engine.registry.registerObject(TASK as any); + engine.registry.registerObject(SysMetadata as any); + engine.registry.registerObject(SysMetadataHistoryObject as any); + // Real DDL, `sys_metadata` included — the overlay store the PUT writes to. + await engine.syncSchemas(); + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const route = rest.getRoutes() + .find((r: any) => r.method === 'PUT' && r.path === '/api/v1/meta/:type/:name'); + if (!route) throw new Error('PUT /api/v1/meta/:type/:name is not registered'); + return { engine, protocol, route }; +} + +const putMeta = async (route: any, type: string, name: string, body: unknown) => { + const res = makeRes(); + await route.handler({ params: { type, name }, query: {}, headers: {}, body } as any, res); + return res; +}; + +const VIEW = (name: string) => ({ + name, + label: 'Probe', + object: 'task', + columns: [{ field: 'name', label: 'Name' }], +}); + +describe('[#5265] PUT /meta receipt reaches the envelope truthfully', () => { + it('a first-ever create is not announced as a customization overlay', async () => { + const { route } = await boot(); + + const res = await putMeta(route, 'view', 'rc5_probe_view', VIEW('rc5_probe_view')); + + expect(res._json?.success).toBe(true); + // The exact sentence the issue measured, corrected. Asserted whole, so + // a REST-layer paraphrase or a dropped suffix is a failure here. + expect(res._json.message).toBe( + `Saved view 'rc5_probe_view' (env-wide, state=active) [seq=${res._json.seq}]`, + ); + expect(res._json.message).not.toContain('customization overlay'); + }, 60_000); + + it('the receipt survives the envelope whole — well under the #5423 bound', async () => { + const { route } = await boot(); + + const res = await putMeta(route, 'view', 'rc5_probe_view', VIEW('rc5_probe_view')); + + // #5423 replaces a client-facing message above 500 characters. Both + // receipt shapes are an order of magnitude below that, so the org + // dimension and the `[seq=…]` cursor can never be cut off the end. + expect(res._json.message.length).toBeLessThan(200); + expect(res._json.message.endsWith(`[seq=${res._json.seq}]`)).toBe(true); + // And it really is the envelope's own field, not a nested echo. + expect(JSON.parse(JSON.stringify(res._json)).message).toBe(res._json.message); + }, 60_000); + + it('a second save of the same item keeps the same truthful shape', async () => { + // Deliberate: the receipt does NOT split created-vs-updated (the only + // available fact, `parentVersion === null`, is scoped to (state, + // packageId) and would mislabel a first draft of a live item). The + // neutral verb is therefore expected to repeat, and that is pinned so + // nobody "improves" it into a claim the write path cannot back. + const { route } = await boot(); + + const first = await putMeta(route, 'view', 'rc5_probe_view', VIEW('rc5_probe_view')); + const second = await putMeta(route, 'view', 'rc5_probe_view', { + ...VIEW('rc5_probe_view'), label: 'Probe (edited)', + }); + + expect(first._json.message).toBe( + `Saved view 'rc5_probe_view' (env-wide, state=active) [seq=${first._json.seq}]`, + ); + expect(second._json.message).toBe( + `Saved view 'rc5_probe_view' (env-wide, state=active) [seq=${second._json.seq}]`, + ); + expect(second._json.seq).toBeGreaterThan(first._json.seq); + }, 60_000); +});