Skip to content

Commit 98f640d

Browse files
fix(plugin-designer): the fields and objects pages save an object as authored, not as served (#30877)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao5ZvZUE3RgkiyeEu1r7wiff)_ Fixes #30786: Wanted 3, the two plugin-designer pages. Wanted 1, 2 and 4 for Setup's editor landed in #30858. Clause-②: yes Metadata-protocol: none ## 维护者速读 - 改了什么:plugin-designer 的字段页和对象页保存对象时,改以分层读取里作者原样存的 `overlay ?? code` 为底,不再以按控制台语言翻译、且带平台注入列的列表或单项读取为底;对象页只写回本次编辑改动的键。这条读法移到 `@object-ui/data-objectstack` 导出,app-shell 原样转出。 - 为什么改:中文控制台里,在字段页改一个字段标签,或在对象页改一次描述,整个对象其余的中文标签和 `owner_id` 等注入列都会被当作作者原文存回去。 - 风险与回滚:只改客户端读哪一层,不改服务端和协议。data-objectstack 多了导出(Clause-② yes,仅加宽)。字段页现在显示作者原文的字段标签,而不是翻译后的。回滚即 revert 本 PR。 - 先看:`authored-layers.ts`(从 app-shell 原样搬来),`MetadataFieldsPage.tsx` 的 `reload`,`MetadataObjectsPage.tsx` 的改动键合并,以及新测试 `MetadataPages.authoredBase-30786.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-designer/src/MetadataPages.authoredBase-30786.test.tsx packages/data-objectstack/src/authored-layers.test.ts`,应见 17 个测试全绿。 - 需要你做的:无,席位落地。 - **席位意见**:ui 席 2 对照 head `fc2fd5ab` 的 diff 复核:23 个文件、+782 / −168,无受治理路径。#30858 落地的读法原样搬进 `@object-ui/data-objectstack`(只加文档段),从入口新增导出,属仅加宽,`Metadata-protocol: none` 故三包 patch 合规;app-shell 的 `authoredObjectBuffer.ts` 只剩转出口且不在 app-shell 入口。字段页改从分层读取的 `overlay ?? code` 编辑,对象页只把这次改动的键盖在 `overlay ?? code` 上,两层都读不到就不保存并说明。席位 `TIER_DEFAULT` 契约复审 PASS(6681037914);CI 全绿。同意落地。 ## What changes - `@object-ui/data-objectstack` gains `authored-layers.ts`, the reading #30858 put in app-shell's `authoredObjectBuffer.ts`, moved as it was: `editsAuthoredLayers`, `authoredBuffer`, `authoredPublished`, and the `AuthoredBuffer` and `AuthoredLayers` types, exported from the package index beside `MetadataClient.layered`. It moves because plugin-designer cannot import app-shell (app-shell depends on plugin-designer). App-shell's `authoredObjectBuffer.ts` now re-exports them and keeps its object spellings and `servedOnlyFields`, so `ResourceEditPage.tsx` and `StudioDesignSurface.tsx` are not touched and behave the same. - `MetadataFieldsPage`: `reload()` reads `client.layered('object', name)` and edits `authoredPublished('object', layers)`, which is `overlay ?? code`, in place of `client.get`. The designer is handed the authored fields, and the save spreads that document. When the layered read holds neither layer (it answers a 404 as all-null layers), the page shows the same "not found" it showed for the item read's `null`. - `MetadataObjectsPage`: the manager still shows the listed object as served (display stays translated). On a save, the page reads the layered read for that object, and lays only the keys the edit changed (`label`, `pluralLabel`, `description`, `icon`, `isSystem`, each compared with what the manager was shown) over `overlay ?? code`. A key the manager hands back unchanged is the served translation, so it is never written. When the layered read holds neither layer, nothing is sent, and the row's error says so instead of saving a body with no fields. A new object (no listed row) is saved as before. - The `group` strip (objectui#6223) and `dropServedPicklistOptions` (objectui#11692) stay on the write side of both pages. ## Tests - `MetadataPages.authoredBase-30786.test.tsx` (new, 10 tests). It uses a real `MetadataClient` with a zh-CN fetch double: the list and item reads serve Chinese labels plus an injected `owner_id`, and `/layers` serves `code` in English, an optional `overlay`, and a governed `effective`. Fields page: the designer gets the authored fields with no injected column and no item read. A relabel PUTs source text and no `owner_id`, and so does the second save after the reload. An overlay is the base when there is one, and a 404 shows "not found" and sends nothing. Objects page: the manager still shows `任务`. A description edit, and a label edit, PUT the authored labels and fields. An overlay is the base. A layered read with no layer sends nothing and names the row. - `authored-layers.test.ts` (new, 7 tests) pins the reading itself, case by case. - Twelve `MetadataFieldsPage.*` suites and three `MetadataObjectsPage.*` suites used fetch doubles that served only the item or list read. Each now also answers `/layers` with the stored document it already served as the `code` layer (`servedAsLayers` / `layersAnswer`, one small helper per file). No assertion changed. <details><summary>Ablations (head fc2fd5a)</summary> `node scripts/ablation-replace.mjs`, each against `MetadataPages.authoredBase-30786.test.tsx`; each reported anchor 1 to 0 and then `ok restored: blob == HEAD`. | file | anchor | replacement | result | |:---|:---|:---|:---| | `MetadataFieldsPage.tsx` | `const layers = await client.layered('object', objectName);` | `const layers = { overlay: null, code: await client.get('object', objectName) };` (the served read) | 5 failed, 5 passed: all five fields-page cases red | | `MetadataObjectsPage.tsx` | `if (!shown \|\| updated[key] !== shown[key]) {` | `if (shown \|\| !shown) {` (every listed key written) | 3 failed: description edit, label edit, overlay base | | `MetadataObjectsPage.tsx` | `base = authoredPublished('object', layers);` | `base = { ...previous };` (the served base) | 3 failed: description edit, label edit, overlay base | </details> ## Acceptance notes - The fields page now shows the authored field labels (English in a zh-CN console), because what it shows is what it saves. The card allowed display to stay on the served read; the designer is the editor here, so it shows the buffer. The objects page's list stays as served. - Neither page edits a pending draft: both saved over the published item before and still do, now as authored. The draft half of the reading (`authoredBuffer`) is used by Setup's editor only. - Neither page passes `?package=` on the layered read, matching their save, which names no package either. That is unchanged. - Out of scope: the same class in Studio's editors is #30859, filed by the seat; `EmbeddedItemEditor.tsx`'s object parent was reported with it in #30858. - These pages have no mount in this repo; there is no live measurement. The served shape the doubles use is the one measured for #30858 (objects: the item and list reads are translated and governed, and `/layers` `code` and `overlay` are untranslated). ## Verification <details><summary>Local gates and tests (head fc2fd5a)</summary> Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` against this diff (37 families, plus `pnpm check:changeset-fixed`, whose roster sits under `.changeset/`), each run with stdin closed; reconciled with `--ran`: 37 derived, 37 run, 0 NOT-MEASURED, 0 UNRUN. The `vitest run scripts/` gate first timed out in the verify-lock queue (exit 99, never ran) behind the ui suite below, and was then run on its own: 170 files passed, 2 skipped. ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/ci/snapshot-next.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm check:changeset-fixed :: exit 0 pnpm --filter @object-ui/data-objectstack run type-check :: exit 0 pnpm --filter @object-ui/plugin-designer run type-check :: exit 0 pnpm --filter @object-ui/app-shell run type-check :: exit 0 ``` UI tests (vitest root `ui/`, under the verify lock): `pnpm --dir ui exec vitest run packages/data-objectstack/ packages/plugin-designer/ packages/app-shell/src/views/metadata-admin/ packages/app-shell/src/views/studio-design/`: `Test Files 1229 passed (1229)`, `Tests 12009 passed | 1 skipped (12010)`, on head fc2fd5a. </details> <!-- signed: domain:ui#2 · os-tesla · session_01L9qibw7DekEe3wx5f3zphf --> Co-authored-by: Claude <noreply@anthropic.com>
1 parent d55acb7 commit 98f640d

23 files changed

Lines changed: 782 additions & 168 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/data-objectstack': patch
3+
'@object-ui/plugin-designer': patch
4+
'@object-ui/app-shell': patch
5+
---
6+
7+
fix(plugin-designer): the fields page and the objects page save an object as its author wrote it, not as the console is served it
8+
9+
Clause-②: yes
10+
Metadata-protocol: none
11+
12+
`MetadataFieldsPage` took its edit base from the item read and `MetadataObjectsPage` from the list read. The server translates both for the console's `Accept-Language` and fills both with the platform's injected columns (`owner_id` and the rest), and both pages spread that served object back as the save body. In a Chinese console one field relabel or one description edit therefore stored every other label of the object in Chinese, and the injected columns as authored fields. Both pages now build the save body on the object as authored, `overlay ?? code` from the layered read (`GET /meta/object/:name/layers`, served untranslated). The fields page shows and edits the authored fields. The objects page keeps showing the listed object as served and writes back only the keys the edit changed. When the layered read holds neither layer, the fields page says the object was not found and the objects page saves nothing and says why.
13+
14+
`@object-ui/data-objectstack` now exports the reading itself, `authoredBuffer`, `authoredPublished` and `editsAuthoredLayers` with the `AuthoredBuffer` and `AuthoredLayers` types, beside `MetadataClient.layered`. `@object-ui/app-shell` re-exports them from its `authoredObjectBuffer.ts` with no change in behaviour, so its editors and plugin-designer's pages share one copy.

‎ui/packages/app-shell/src/views/metadata-admin/authoredObjectBuffer.ts‎

Lines changed: 7 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,112 +1,21 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* objectstack#29793, objectstack#30786 — the ONE reading of which document a
5-
* metadata editor edits: the item as its author wrote it, never the item as it
6-
* is served.
7-
*
8-
* The served reads are for display. For every type whose served reads the
9-
* server translates (`TRANSLATABLE_METADATA_TYPES`, the predicate the REST
10-
* read itself asks), `GET /meta/:type/:name?state=draft` answers the pending
11-
* draft translated for the console's `Accept-Language`: a view's or a page's
12-
* `label`, an app's navigation labels, a dashboard's widget titles. An object's
13-
* draft read is governed besides, with the platform's injected columns (`owner_id`,
14-
* `queue`, the audit and scope columns) in `fields`, and so is the layered read's
15-
* `effective` for an object. An editor that seeded its buffer from the served
16-
* draft and saved it back stored the translations as the author's text, and an
17-
* object's injected columns as author fields.
18-
*
19-
* The author-owned document is the layered read's, spelled by the contract
20-
* (`GetMetaItemLayeredResponseSchema.draft`): the `draft` layer, the pending
21-
* draft row as stored, `null` when none is pending. A server serves it only to a
22-
* caller its draft-read admission admits, and that same admission gates the
23-
* `?state=draft` read, so a caller that was served a draft body was served the
24-
* layer too. When the two disagree (a draft body served, no `draft` key on the
25-
* layers) there is no author-owned copy of that draft to edit: the answer is
26-
* `unserved`, the editor installs no buffer, and nothing is sent over the draft.
27-
* ⛔ Never fall back to the served draft body there: that is the translated
28-
* document this module exists to keep out of the buffer.
29-
*
30-
* With no draft pending, the buffer is the published item as authored
31-
* ({@link authoredPublished}): an object's `overlay ?? code`, because its
32-
* `effective` is governed; every other type's `effective ?? code`, which the
33-
* layered read serves untranslated and which stays the server's own reading of
34-
* the published item (the layered read decides it, not this module).
35-
*
36-
* Display may stay translated: the projection reads (published field names,
37-
* diagnostics, the header) keep reading what is served. Only the buffer moves.
4+
* objectstack#29793, objectstack#30786 — the editors' reading of which document
5+
* they edit is `@object-ui/data-objectstack`'s `authoredBuffer` (its docblock is
6+
* the authority), re-exported here with the object editor's spellings and the
7+
* display-only {@link servedOnlyFields}.
388
*/
399

40-
import { TRANSLATABLE_METADATA_TYPES } from '@objectstack/spec/system';
10+
import { authoredBuffer, authoredPublished, type AuthoredBuffer, type AuthoredLayers } from '@object-ui/data-objectstack';
4111
import { readFields, type FieldEntry } from './previews/object-fields-io.js';
4212

43-
/** The parts of the layered read this reading takes. */
44-
export interface AuthoredLayers {
45-
code?: unknown;
46-
overlay?: unknown;
47-
effective?: unknown;
48-
draft?: unknown;
49-
}
13+
export { authoredBuffer, authoredPublished, editsAuthoredLayers } from '@object-ui/data-objectstack';
14+
export type { AuthoredBuffer, AuthoredLayers } from '@object-ui/data-objectstack';
5015

5116
/** The object editor's spelling of {@link AuthoredLayers}. */
5217
export type ObjectLayers = AuthoredLayers;
5318

54-
export type AuthoredBuffer =
55-
| {
56-
kind: 'buffer';
57-
/** The document the editor edits and saves. */
58-
body: Record<string, unknown>;
59-
/** True when `body` is the pending draft row. */
60-
fromDraft: boolean;
61-
}
62-
| {
63-
/** A draft is pending, and the layered read served no stored copy of it. */
64-
kind: 'unserved';
65-
};
66-
67-
function asDocument(value: unknown): Record<string, unknown> | null {
68-
return value !== null && typeof value === 'object' && !Array.isArray(value)
69-
? (value as Record<string, unknown>)
70-
: null;
71-
}
72-
73-
/**
74-
* Whether an editor of `type` takes its buffer from the layers: the served reads
75-
* of the type are translated, so its served draft is not the author's document.
76-
* Any other type's served draft is the stored row (`objectui#10765`), and its
77-
* editors keep reading it.
78-
*/
79-
export function editsAuthoredLayers(type: string): boolean {
80-
return TRANSLATABLE_METADATA_TYPES.has(type);
81-
}
82-
83-
/**
84-
* The editor's buffer for `type` from the layered read, and the draft body the
85-
* `?state=draft` read served (`extractDraftBody`, `null` when none is pending).
86-
* The served body only says whether a draft is pending; its content is never
87-
* the buffer.
88-
*/
89-
export function authoredBuffer(
90-
type: string,
91-
layers: AuthoredLayers | null | undefined,
92-
servedDraft: Record<string, unknown> | null,
93-
): AuthoredBuffer {
94-
const stored = layers && 'draft' in layers ? asDocument(layers.draft) : null;
95-
if (stored) return { kind: 'buffer', body: stored, fromDraft: true };
96-
if (servedDraft) return { kind: 'unserved' };
97-
return { kind: 'buffer', body: authoredPublished(type, layers), fromDraft: false };
98-
}
99-
100-
/**
101-
* The published item as its author wrote it: the buffer after a step that
102-
* leaves no draft to edit (a discard, a reset, a publish that promoted it).
103-
* An object's `overlay ?? code`; every other type's `effective ?? code`.
104-
*/
105-
export function authoredPublished(type: string, layers: AuthoredLayers | null | undefined): Record<string, unknown> {
106-
if (type === 'object') return asDocument(layers?.overlay) ?? asDocument(layers?.code) ?? {};
107-
return asDocument(layers?.effective) ?? asDocument(layers?.code) ?? {};
108-
}
109-
11019
/** {@link authoredBuffer} for an object. */
11120
export function authoredObjectBuffer(
11221
layers: ObjectLayers | null | undefined,
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#30786 — pins the one reading of which document an editor edits
5+
* (`authored-layers.ts`), now that it lives here for every editor that saves a
6+
* metadata item. The editors' own suites pin what reaches the wire; this file
7+
* pins the reading itself, case by case.
8+
*/
9+
10+
import { describe, expect, it } from 'vitest';
11+
import { TRANSLATABLE_METADATA_TYPES } from '@objectstack/spec/system';
12+
import { authoredBuffer, authoredPublished, editsAuthoredLayers } from './index';
13+
14+
const CODE = { name: 'n', label: 'Code' };
15+
const OVERLAY = { name: 'n', label: 'Overlay' };
16+
const EFFECTIVE = { name: 'n', label: 'Effective' };
17+
const DRAFT = { name: 'n', label: 'Draft' };
18+
const SERVED_DRAFT = { name: 'n', label: '草稿' };
19+
20+
describe('editsAuthoredLayers', () => {
21+
it('answers by the spec set the server translates by, and by nothing else', () => {
22+
for (const type of TRANSLATABLE_METADATA_TYPES) expect(editsAuthoredLayers(type)).toBe(true);
23+
expect(editsAuthoredLayers('flow')).toBe(false);
24+
expect(editsAuthoredLayers('datasource')).toBe(false);
25+
});
26+
});
27+
28+
describe('authoredPublished', () => {
29+
it('an object is `overlay ?? code`, never its governed `effective`', () => {
30+
expect(authoredPublished('object', { code: CODE, overlay: OVERLAY, effective: EFFECTIVE })).toBe(OVERLAY);
31+
expect(authoredPublished('object', { code: CODE, overlay: null, effective: EFFECTIVE })).toBe(CODE);
32+
expect(authoredPublished('object', { code: null, overlay: OVERLAY, effective: EFFECTIVE })).toBe(OVERLAY);
33+
});
34+
35+
it('any other type is the layered read\'s own `effective ?? code`', () => {
36+
expect(authoredPublished('view', { code: CODE, overlay: OVERLAY, effective: EFFECTIVE })).toBe(EFFECTIVE);
37+
expect(authoredPublished('view', { code: CODE, overlay: null, effective: null })).toBe(CODE);
38+
});
39+
40+
it('no layer at all, or a layer that is not a document, is `{}`', () => {
41+
expect(authoredPublished('object', null)).toEqual({});
42+
expect(authoredPublished('object', { code: 'text', overlay: ['x'] })).toEqual({});
43+
});
44+
});
45+
46+
describe('authoredBuffer', () => {
47+
it('a pending draft is the stored `draft` layer, as stored', () => {
48+
expect(authoredBuffer('view', { code: CODE, draft: DRAFT }, SERVED_DRAFT)).toEqual({
49+
kind: 'buffer',
50+
body: DRAFT,
51+
fromDraft: true,
52+
});
53+
});
54+
55+
it('a served draft with no `draft` layer is `unserved`, never the served body', () => {
56+
expect(authoredBuffer('view', { code: CODE }, SERVED_DRAFT)).toEqual({ kind: 'unserved' });
57+
expect(authoredBuffer('object', { code: CODE, draft: null }, SERVED_DRAFT)).toEqual({ kind: 'unserved' });
58+
});
59+
60+
it('no draft pending is the published item as authored', () => {
61+
expect(authoredBuffer('object', { code: CODE, overlay: OVERLAY, effective: EFFECTIVE, draft: null }, null)).toEqual({
62+
kind: 'buffer',
63+
body: OVERLAY,
64+
fromDraft: false,
65+
});
66+
expect(authoredBuffer('dashboard', { code: CODE, effective: EFFECTIVE }, null)).toEqual({
67+
kind: 'buffer',
68+
body: EFFECTIVE,
69+
fromDraft: false,
70+
});
71+
});
72+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#29793, objectstack#30786 — the ONE reading of which document a
5+
* metadata editor edits: the item as its author wrote it, never the item as it
6+
* is served.
7+
*
8+
* The served reads are for display. For every type whose served reads the
9+
* server translates (`TRANSLATABLE_METADATA_TYPES`, the predicate the REST
10+
* read itself asks), `GET /meta/:type/:name?state=draft` answers the pending
11+
* draft translated for the console's `Accept-Language`: a view's or a page's
12+
* `label`, an app's navigation labels, a dashboard's widget titles. An object's
13+
* draft read is governed besides, with the platform's injected columns (`owner_id`,
14+
* `queue`, the audit and scope columns) in `fields`, and so is the layered read's
15+
* `effective` for an object. An editor that seeded its buffer from the served
16+
* draft and saved it back stored the translations as the author's text, and an
17+
* object's injected columns as author fields.
18+
*
19+
* The author-owned document is the layered read's, spelled by the contract
20+
* (`GetMetaItemLayeredResponseSchema.draft`): the `draft` layer, the pending
21+
* draft row as stored, `null` when none is pending. A server serves it only to a
22+
* caller its draft-read admission admits, and that same admission gates the
23+
* `?state=draft` read, so a caller that was served a draft body was served the
24+
* layer too. When the two disagree (a draft body served, no `draft` key on the
25+
* layers) there is no author-owned copy of that draft to edit: the answer is
26+
* `unserved`, the editor installs no buffer, and nothing is sent over the draft.
27+
* ⛔ Never fall back to the served draft body there: that is the translated
28+
* document this module exists to keep out of the buffer.
29+
*
30+
* With no draft pending, the buffer is the published item as authored
31+
* ({@link authoredPublished}): an object's `overlay ?? code`, because its
32+
* `effective` is governed; every other type's `effective ?? code`, which the
33+
* layered read serves untranslated and which stays the server's own reading of
34+
* the published item (the layered read decides it, not this module).
35+
*
36+
* It lives beside `MetadataClient.layered`, whose answer it reads, so every
37+
* editor that saves a metadata item (Setup's and Studio's editors in
38+
* `@object-ui/app-shell`, `@object-ui/plugin-designer`'s pages) reads it once.
39+
*
40+
* Display may stay translated: the projection reads (published field names,
41+
* diagnostics, the header) keep reading what is served. Only the buffer moves.
42+
*/
43+
44+
import { TRANSLATABLE_METADATA_TYPES } from '@objectstack/spec/system';
45+
46+
/** The parts of the layered read this reading takes. */
47+
export interface AuthoredLayers {
48+
code?: unknown;
49+
overlay?: unknown;
50+
effective?: unknown;
51+
draft?: unknown;
52+
}
53+
54+
export type AuthoredBuffer =
55+
| {
56+
kind: 'buffer';
57+
/** The document the editor edits and saves. */
58+
body: Record<string, unknown>;
59+
/** True when `body` is the pending draft row. */
60+
fromDraft: boolean;
61+
}
62+
| {
63+
/** A draft is pending, and the layered read served no stored copy of it. */
64+
kind: 'unserved';
65+
};
66+
67+
function asDocument(value: unknown): Record<string, unknown> | null {
68+
return value !== null && typeof value === 'object' && !Array.isArray(value)
69+
? (value as Record<string, unknown>)
70+
: null;
71+
}
72+
73+
/**
74+
* Whether an editor of `type` takes its buffer from the layers: the served reads
75+
* of the type are translated, so its served draft is not the author's document.
76+
* Any other type's served draft is the stored row (`objectui#10765`), and its
77+
* editors keep reading it.
78+
*/
79+
export function editsAuthoredLayers(type: string): boolean {
80+
return TRANSLATABLE_METADATA_TYPES.has(type);
81+
}
82+
83+
/**
84+
* The editor's buffer for `type` from the layered read, and the draft body the
85+
* `?state=draft` read served (`extractDraftBody`, `null` when none is pending).
86+
* The served body only says whether a draft is pending; its content is never
87+
* the buffer.
88+
*/
89+
export function authoredBuffer(
90+
type: string,
91+
layers: AuthoredLayers | null | undefined,
92+
servedDraft: Record<string, unknown> | null,
93+
): AuthoredBuffer {
94+
const stored = layers && 'draft' in layers ? asDocument(layers.draft) : null;
95+
if (stored) return { kind: 'buffer', body: stored, fromDraft: true };
96+
if (servedDraft) return { kind: 'unserved' };
97+
return { kind: 'buffer', body: authoredPublished(type, layers), fromDraft: false };
98+
}
99+
100+
/**
101+
* The published item as its author wrote it: the buffer after a step that
102+
* leaves no draft to edit (a discard, a reset, a publish that promoted it).
103+
* An object's `overlay ?? code`; every other type's `effective ?? code`.
104+
*/
105+
export function authoredPublished(type: string, layers: AuthoredLayers | null | undefined): Record<string, unknown> {
106+
if (type === 'object') return asDocument(layers?.overlay) ?? asDocument(layers?.code) ?? {};
107+
return asDocument(layers?.effective) ?? asDocument(layers?.code) ?? {};
108+
}

‎ui/packages/data-objectstack/src/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7964,6 +7964,11 @@ export { MetadataClient, readSaveAdvisories } from './metadata-client';
79647964
// `getDraft` that produces the envelope, because the unwrap-and-strip is part
79657965
// of that method's contract rather than a detail of any one view.
79667966
export { extractDraftBody } from './draft-envelope';
7967+
// objectstack#29793, objectstack#30786 - the one reading of which document an
7968+
// editor edits (the item as authored, from `layered()`), exported beside the
7969+
// client whose answer it reads so every editor that saves an item asks it.
7970+
export { authoredBuffer, authoredPublished, editsAuthoredLayers } from './authored-layers';
7971+
export type { AuthoredBuffer, AuthoredLayers } from './authored-layers';
79677972
// objectui#12725 - the reader of the same envelope's per-key provenance
79687973
// (`draftChanges`, objectstack#24298): which surface last changed a key of the
79697974
// pending draft. One reader, so the longest-prefix rule is spelled once.

‎ui/packages/plugin-designer/src/MetadataFieldsPage.carriedThroughReference-8896.test.tsx‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,16 @@ vi.mock('./FieldDesigner', () => ({
9191

9292
import { MetadataFieldsPage } from './MetadataFieldsPage';
9393

94+
/**
95+
* objectstack#30786 — the page takes its edit base off the layered read
96+
* (`GET …/layers`, `overlay ?? code`), not the served item read, so this double
97+
* answers that read: the item it used to serve becomes the `code` layer (and
98+
* `effective`), beside the same envelope carriers.
99+
*/
100+
function servedAsLayers({ item, ...carriers }: { item: unknown } & Record<string, unknown>) {
101+
return { ...carriers, code: item, overlay: null, effective: item };
102+
}
103+
94104
let puts: Array<Record<string, unknown>> = [];
95105

96106
function json(body: unknown, status = 200): Response {
@@ -110,15 +120,15 @@ function clientServing(fields: Record<string, Record<string, unknown>>): Metadat
110120
puts.push(JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>);
111121
return json({ success: true, name: 'probe_widget' });
112122
}
113-
if (/\/meta\/object\/probe_widget(\?|$)/.test(url)) {
114-
return json({
123+
if (/\/meta\/object\/probe_widget\/layers(\?|$)/.test(url)) {
124+
return json(servedAsLayers({
115125
type: 'object',
116126
name: 'probe_widget',
117127
item: { name: 'probe_widget', label: 'Widget', fields },
118128
lock: 'none',
119129
provenance: 'org',
120130
editable: true,
121-
});
131+
}));
122132
}
123133
return json({ items: [] });
124134
}) as unknown as typeof fetch,

‎ui/packages/plugin-designer/src/MetadataFieldsPage.externalIdRoundTrip-26674.test.tsx‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ vi.mock('@object-ui/plugin-form', async (importOriginal) => ({
3535

3636
import { MetadataFieldsPage } from './MetadataFieldsPage';
3737

38+
/**
39+
* objectstack#30786 — the page takes its edit base off the layered read
40+
* (`GET …/layers`, `overlay ?? code`), not the served item read, so this double
41+
* answers that read: the item it used to serve becomes the `code` layer (and
42+
* `effective`), beside the same envelope carriers.
43+
*/
44+
function servedAsLayers({ item, ...carriers }: { item: unknown } & Record<string, unknown>) {
45+
return { ...carriers, code: item, overlay: null, effective: item };
46+
}
47+
3848
/**
3949
* `code` is the field the author edits; `sku` carries the flag on a field the
4050
* author never opens; `title` is the control with no flag at all.
@@ -77,7 +87,7 @@ function realClient(): MetadataClient {
7787
puts.push(JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>);
7888
return json({ success: true, name: 'probe_widget' });
7989
}
80-
if (/\/meta\/object\/probe_widget(\?|$)/.test(url)) return json(OBJECT_ENVELOPE);
90+
if (/\/meta\/object\/probe_widget\/layers(\?|$)/.test(url)) return json(servedAsLayers(OBJECT_ENVELOPE));
8191
return json({ items: [] });
8292
}) as unknown as typeof fetch,
8393
});

0 commit comments

Comments
 (0)