Skip to content

Commit 0a29bec

Browse files
fix(metadata-protocol): a metadata row records who created it and who last changed it (#23067)
Fixes #23033 Clause-②: no `SysMetadataRepository.put` now writes the actor onto the `sys_metadata` row. It stamps `created_by` when the row is first written and `updated_by` on every write, both from `PutOptions.actor`, as the repository's own header mapping (`actor → updated_by`) declares. A write with no actor stores NULL in both columns, never a placeholder user. History's `recorded_by` is unchanged. This follows the triage direction (comment 6124803255). ## Measured first, on `origin/main` `df7a137923` - **Reproduced.** Saving through the metadata door (`saveMetaItem` with `actor: 'usr_alice'`) on a real kernel (`ObjectQLPlugin`: the real engine, the real audit hooks, the real `sys_metadata` declaration) left `created_by` and `updated_by` **absent** on the stored row (`expected undefined to be 'usr_alice'`, 3 of the 4 new real-engine pins red). The history row's `recorded_by` carried the actor (the existing #4556 pins stayed green). - **Why.** `put()` built `parentRowData` with neither column and wrote it with `context: { ...ctx, isSystem: true }`, a context that carries no user. The engine's audit hooks (`sys_stamp_audit_insert` and `sys_stamp_audit_update` in `objectql/src/plugin.ts`) stamp the `*_by` pair only inside `if (session?.userId)`, so they wrote nothing either. - **Does the engine strip or overwrite a client-supplied pair on an `isSystem` write?** No. With no `session.userId` the hooks assign nothing, and `isSystem` exempts the write from the static-readonly strip on insert and on update (`created_by` is `readonly: true` on `sys_metadata`). So the pins read the row the driver stored, not the payload. The real-engine file exists to pin exactly this. - **What callers pass as `actor`.** The REST `/meta` doors pass the authenticated user id or `undefined` (`resolveMetaWriteActor`, #7749 and #7941; `'system'` is only the audit-row default, `recordMetadataAudit`). `saveMetaItem` forwards `request.actor ?? null`, `promoteDraft` and `restoreVersion` forward their own `actor`, and boot or internal writes pass none. The columns are `lookup('sys_user')`. Under `isSystem` the engine runs no reference check, so a non-user string would be stored as given. The repository stamps exactly what `recorded_by` already records: one value, two columns. See the acceptance notes for the one producer that passes a label. ## Other writers of the parent row in this class - `put`: the insert and the update, both stamped (the only two `engine.insert` / `engine.update` calls on `sys_metadata` in the class). - `promoteDraft` and `restoreVersion`: both write the active row through `put(..., { actor: opts.actor })`, so they get the same stamp. A publish or a restore that creates the active row records its own actor as `created_by`; one that updates the row moves `updated_by`. - `delete` and `dropPromotedDraftRow`: both hard-delete the row, so no row is left to stamp. The tombstone's `recorded_by` keeps the actor, as before. - The read side needed no change. `rowToItem`'s `authoredBy` and `listDrafts`' `updatedBy` read `updated_by ?? created_by ?? null`, and with the write side stamping, both name the last writer (pinned below). ## Change - `packages/metadata-protocol/src/sys-metadata-repository.ts`: `updated_by: opts.actor ?? null` in the row `put()` builds, and `created_by = opts.actor ?? null` on the insert branch only, beside the existing `updated_at` / `created_at`. The header's mapping table gains the `created_by` row. - `.changeset/23033-metadata-row-actor.md`: `patch` for `@objectstack/metadata-protocol`. ## Pins - `packages/metadata-protocol/src/sys-metadata-repository.recorded-by.test.ts`: a new `#23033` block beside the #4556 pins, on the file's existing engine double (no new double, no ledger change). It covers first write A, then A/A; second write B, then A/B with history `[A, B]`; no actor, then NULL/NULL; an authored write followed by an actor-less one, then A/NULL; and the read side (`get().authoredBy` and `listDrafts().updatedBy` name the last writer). - `packages/objectql/src/protocol-metadata-row-actor.test.ts` (new): the same rule through the door, read off the row the real engine stored. It covers first write A, then A/A with history `[A]`; second write B, then A/B with history `[A, B]`; the CONTROL (no actor, then both null, history `[null]`); and `listDrafts` answering `updatedBy` from the draft row. It sits in `objectql` because `metadata-protocol` must not depend on `objectql`, the same reason `plugin.authoring-channel.test.ts` sits there. - **Ablation** (fix committed first; script `trap`-guarded; restore by `git checkout HEAD --`, proved by blob == HEAD and an empty `git diff HEAD`). Both stamp lines were deleted through `scripts/ablation-replace.mjs` (anchor 1 to 0 each), `metadata-protocol` was rebuilt, and `ablation-dist-preflight --absent` confirmed both markers were gone from `dist/`. - Repository pins: 5 red, and the 8 #4556 pins stayed green. - Real-engine pins: 3 red, and the no-actor CONTROL stayed green. - Restore: rebuilt, both markers present in `dist/index.js`, whole tree clean, 13/13 and 4/4 green. ## Verification (head `d4ac870f46`, after merging `origin/main` `0cbfb7c32c`) - `@objectstack/metadata-protocol` `vitest run`: 227 files passed, 3 skipped; 28197 tests passed. `typecheck` (`tsc --noEmit`, `include: src/**/*`, tests included): exit 0. - `@objectstack/objectql` `vitest run --project local`: 407 files passed, 7940 tests passed. `typecheck` (`tsc --noEmit && check:test-typecheck`): exit 0, and the new test file compiles under `tsconfig.test.json` with no debt entry. - Gates: the dispatch list (53 commands) plus the 13 added by re-derivation on the real diff (changeset families), 66 in all, each exit captured before any pipe. All exit 0 except `pnpm check:dual-build-cjs-loads`, which is **NOT MEASURED**: it printed `PREREQUISITE NOT MET` because the whole workspace's `dist/` is not built in this worktree, and CI runs it. - `integration`-tier and other whole-tree runs are left to CI. ## Acceptance notes - **A producer passes a label as `actor` (out of scope here; reported for the seat to file).** `migrateStoredMetadata` defaults `actor` to `'migrate-stored'`, and its three callers pass labels, not user ids: `POST /meta/_migrate-stored` passes `'USER (POST PREFIX/_migrate-stored)'` (`rest-server.ts`), the runtime door passes the same shape (`runtime/src/domains/meta.ts`), and the CLI passes `'os migrate meta --stored'`. `PutOptions.actor` declares the value lands in a `lookup('sys_user')` and must be `null`, never a label, when there is no user id. Those labels already reach `recorded_by`. With this change they also reach `updated_by` on the rows the pass rewrites. The repository cannot tell a label from a user id, and contract-first puts the fix at the producer, which also lies outside this card's file surface. The fix is to pass the user id or `null` and carry the door's name in `source`. - **A second writer of `sys_metadata` lives outside this class.** `persistDatasourceRow` (`service-datasource/src/datasource-admin-plugin.ts`) inserts and updates the row directly, with no actor in hand and no history row. A `/meta`-written datasource row that is later edited through the datasource admin door keeps the `/meta` writer's `updated_by` (read from the code, not run). Not touched; there is no carrier. - The install request's actor field (`InstallPackageRequestSchema`) is a new contract key and is deliberately not here, per triage. - Rows written before this release keep null columns until their next write. Nothing backfills them, since history alone knows the writer. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c5b43b6 commit 0a29bec

4 files changed

Lines changed: 302 additions & 1 deletion

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): a metadata row records who created it and who last changed it
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** A metadata write that named its author stored no author on the `sys_metadata` row. `created_by` and `updated_by` read null on every row, though the repository's own schema mapping declared `actor → updated_by` and its read side reads that column. Only the history row's `recorded_by` named the writer, so "who changed this object?" could be answered from history alone.
10+
- **What it does now.** Every metadata write stamps the row from the write's actor: `created_by` when the row is first written, and `updated_by` on every write. Studio saves, `PUT /api/v1/meta/:type/:name`, publishing a draft, restoring a version and an in-process `saveMetaItem({ actor })` all write through this one path. A write with no actor (a boot sync, a migration with no user, an unauthenticated internal call) stores null in both columns, never a placeholder user. A save that changes nothing writes nothing, so it leaves both columns as they were.
11+
- **What an operator or a consumer sees.** A row written from now on names its creator and its last writer, so a consumer (such as cloud's AI build agent, whose staged drafts name their author) can read the author from the row itself. The pending-changes list (`listDrafts`' `updatedBy`) names the draft's last writer. Rows written before this release keep their null columns until their next write. History's `recorded_by` is unchanged.

‎packages/metadata-protocol/src/sys-metadata-repository.recorded-by.test.ts‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,3 +188,75 @@ describe('#4556 — recorded_by holds a user id or NULL, never a sentinel', () =
188188
expect(headers[0]!.authoredBy).not.toBe('unknown');
189189
});
190190
});
191+
192+
/**
193+
* #23033 — the PARENT row records the actor too: `created_by` on the row's
194+
* first write, `updated_by` on every write, from `PutOptions.actor` — the
195+
* header's `actor → updated_by` mapping, which `put()` used to declare and not
196+
* write. A missing actor stores NULL in both, never a placeholder user, for the
197+
* same reason `recorded_by` does above.
198+
*
199+
* This fake stores exactly what it is handed, so these pins prove the
200+
* repository HANDS the engine the pair. That the real engine KEEPS it — the
201+
* `isSystem` write path, the readonly strip and the audit hooks — is pinned on
202+
* the stored row in `packages/objectql/src/protocol-metadata-row-actor.test.ts`.
203+
*/
204+
describe('#23033 — the sys_metadata row records who created it and who last changed it', () => {
205+
let engine: ReturnType<typeof makeFakeEngine>;
206+
let repo: SysMetadataRepository;
207+
// Environment-wide, the topology the protocol's own overlay repository has
208+
// (ADR-0131 D6) and the scope `listDrafts` reads.
209+
const ref = { org: 'system', type: 'view' as const, name: 'case_grid' };
210+
const onlyRow = () => {
211+
expect(engine.rows.size).toBe(1);
212+
return Array.from(engine.rows.values())[0]!;
213+
};
214+
215+
beforeEach(() => {
216+
engine = makeFakeEngine();
217+
repo = new SysMetadataRepository({ engine });
218+
});
219+
220+
it('a first write with actor A stamps created_by A and updated_by A', async () => {
221+
await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_alice' });
222+
const row = onlyRow();
223+
expect(row.created_by).toBe('usr_alice');
224+
expect(row.updated_by).toBe('usr_alice');
225+
});
226+
227+
it('a second write with actor B keeps created_by A and moves updated_by to B', async () => {
228+
const first = await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_alice' });
229+
await repo.put(ref, view('B'), { parentVersion: first.version, actor: 'usr_bob' });
230+
const row = onlyRow();
231+
expect(row.created_by).toBe('usr_alice');
232+
expect(row.updated_by).toBe('usr_bob');
233+
// History is unchanged: each event names its own write's actor.
234+
expect(engine.historyRows.map((h) => h.recorded_by)).toEqual(['usr_alice', 'usr_bob']);
235+
});
236+
237+
it('a write with no actor stamps NULL in both — never a placeholder user', async () => {
238+
await repo.put(ref, view('A'), { parentVersion: null, actor: null });
239+
const row = onlyRow();
240+
expect(row.created_by).toBeNull();
241+
expect(row.updated_by).toBeNull();
242+
});
243+
244+
it('an actor-less write after an authored one clears updated_by and keeps created_by', async () => {
245+
// "Who last changed this?" — nobody named; the previous writer did not make this change.
246+
const first = await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_alice' });
247+
await repo.put(ref, view('B'), { parentVersion: first.version, actor: null });
248+
const row = onlyRow();
249+
expect(row.created_by).toBe('usr_alice');
250+
expect(row.updated_by).toBeNull();
251+
});
252+
253+
it('the read side follows the row: get().authoredBy and listDrafts().updatedBy name the last writer', async () => {
254+
const first = await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_alice' });
255+
await repo.put(ref, view('B'), { parentVersion: first.version, actor: 'usr_bob' });
256+
expect((await repo.get(ref))!.authoredBy).toBe('usr_bob');
257+
258+
await repo.put(ref, view('draft'), { parentVersion: null, actor: 'usr_carol', state: 'draft' });
259+
const drafts = await repo.listDrafts({ type: 'view' });
260+
expect(drafts.map((d) => [d.name, d.updatedBy])).toEqual([['case_grid', 'usr_carol']]);
261+
});
262+
});

‎packages/metadata-protocol/src/sys-metadata-repository.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@
4646
* hash (sha256) → checksum (text(64))
4747
* monotonic version int → version (number)
4848
* org isolation → organization_id (lookup)
49-
* actor → updated_by (lookup, optional)
49+
* actor → updated_by (lookup, optional; every write)
50+
* first write's actor → created_by (lookup, optional; insert only)
5051
*
5152
* Composition: PR-10c will compose
5253
* `LayeredRepository([FileSystemRepository, SysMetadataRepository])`
@@ -908,6 +909,14 @@ export class SysMetadataRepository implements MetadataRepository {
908909
state,
909910
version,
910911
updated_at: now,
912+
// [#23033] The header's `actor → updated_by`, on every write. Written
913+
// HERE because nothing below stamps it: the row goes in under
914+
// `isSystem` with no user on the context, and the engine's audit hooks
915+
// stamp the `*_by` pair only from `session.userId`. No actor → NULL,
916+
// never a placeholder user — the `recorded_by` rule (#4556), since
917+
// both are `lookup('sys_user')`. History's `recorded_by` below stays
918+
// as it was: the same actor, on the event row.
919+
updated_by: opts.actor ?? null,
911920
};
912921
// [#22877] Only for a type whose identity is a pair: every other type's
913922
// row payload stays byte-identical to what it was before the column.
@@ -935,6 +944,9 @@ export class SysMetadataRepository implements MetadataRepository {
935944
});
936945
} else {
937946
parentRowData.created_at = now;
947+
// [#23033] Who created the row: the first write's actor, set on the
948+
// insert only — an update never names `created_by`, so it keeps it.
949+
parentRowData.created_by = opts.actor ?? null;
938950
await this.engine.insert('sys_metadata', parentRowData, { context: { ...ctx, isSystem: true } });
939951
}
940952

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #23033 — a metadata write that names its author stores that author on the
5+
* `sys_metadata` row, read back from the row the REAL engine stored.
6+
*
7+
* ## The defect
8+
*
9+
* `SysMetadataRepository`'s header maps `actor → updated_by`, and its read
10+
* side (`rowToItem`'s `authoredBy`, `listDrafts`' `updatedBy`) reads
11+
* `updated_by ?? created_by`. But `put()` built the parent row with neither
12+
* column and wrote it under `isSystem`, whose context carries no user, so the
13+
* engine's own audit stamp (`sys_stamp_audit_insert` / `_update`, which stamp
14+
* the `*_by` pair only from `session.userId`) had nobody to write either.
15+
* Every metadata row read `created_by` / `updated_by` null; the actor reached
16+
* only the history row's `recorded_by`.
17+
*
18+
* ## Why this file is here and not beside the repository
19+
*
20+
* The repository suite (`sys-metadata-repository.recorded-by.test.ts`) can only
21+
* prove the repository HANDS the engine the pair — its engine is a fake that
22+
* stores what it is given. What this card owes on top is that the pair
23+
* SURVIVES the engine: `created_by` is `readonly: true` on `sys_metadata`, the
24+
* static-readonly strip runs inside `engine.insert` / `engine.update` after the
25+
* `before*` hooks, and the audit hooks rewrite the `*_by` pair whenever a
26+
* session user is present. So the write goes through a real kernel —
27+
* `ObjectQLPlugin` binds the real audit hooks and assembles the real protocol
28+
* and `sys_metadata` declaration — and every pin reads the stored row out of
29+
* the driver, never the payload. `metadata-protocol` cannot host this: it must
30+
* not depend on `objectql`, even as a devDependency (the same reason
31+
* `plugin.authoring-channel.test.ts` lives here).
32+
*
33+
* ## The pins (the triage direction's)
34+
*
35+
* - a first write with actor A stores `created_by` A and `updated_by` A;
36+
* - a second write with actor B stores `created_by` A and `updated_by` B;
37+
* - CONTROL: a write with no actor stores both null — no placeholder user;
38+
* - history's `recorded_by` is unchanged: each history row names its own
39+
* write's actor, exactly as before;
40+
* - the door's read-back: `listDrafts` answers `updatedBy` from the row.
41+
*/
42+
43+
import { randomUUID } from 'node:crypto';
44+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
45+
import { ObjectKernel } from '@objectstack/core';
46+
import { ObjectQL } from './engine.js';
47+
import { ObjectQLPlugin } from './plugin.js';
48+
49+
type Stores = Map<string, Map<string, Record<string, unknown>>>;
50+
51+
/** In-memory driver, copied from `environment-overlay-email-locale-restart.test.ts`; equality-only WHERE. */
52+
function makeMemoryDriver(stores: Stores) {
53+
const storeFor = (obj: string) => {
54+
let s = stores.get(obj);
55+
if (!s) { s = new Map(); stores.set(obj, s); }
56+
return s;
57+
};
58+
// `$and` / `$or` are conjoined WITH their sibling keys, the way a real
59+
// driver ANDs them.
60+
const matchesWhere = (row: Record<string, unknown>, where: any): boolean => {
61+
if (!where || typeof where !== 'object') return true;
62+
for (const [k, v] of Object.entries(where)) {
63+
if (k === '$and' && Array.isArray(v)) {
64+
if (!v.every((w: any) => matchesWhere(row, w))) return false;
65+
continue;
66+
}
67+
if (k === '$or' && Array.isArray(v)) {
68+
if (!v.some((w: any) => matchesWhere(row, w))) return false;
69+
continue;
70+
}
71+
if (k.startsWith('$')) continue;
72+
const rowVal = row[k];
73+
const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;
74+
const a = rowVal === undefined ? null : rowVal;
75+
const b = expected === undefined ? null : expected;
76+
if (a !== b) return false;
77+
}
78+
return true;
79+
};
80+
const driver: any = {
81+
name: 'memory', version: '0.0.0', supports: {} as any,
82+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
83+
async execute() { return null; },
84+
async find(object: string, ast: any) {
85+
const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where));
86+
// The caller's bound, after the filter, by presence (`check:objectql-double-limit`).
87+
return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows;
88+
},
89+
async findOne(object: string, ast: any) {
90+
for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r;
91+
return null;
92+
},
93+
async create(object: string, data: Record<string, unknown>) {
94+
const id = (data.id as string) ?? `r_${randomUUID()}`;
95+
const row = { ...data, id };
96+
storeFor(object).set(id, row);
97+
return row;
98+
},
99+
async update(object: string, id: string, data: Record<string, unknown>) {
100+
const s = storeFor(object);
101+
const cur = s.get(id);
102+
if (!cur) throw new Error(`not found: ${object}/${id}`);
103+
const updated = { ...cur, ...data, id };
104+
s.set(id, updated);
105+
return updated;
106+
},
107+
async upsert(object: string, data: Record<string, unknown>) {
108+
const id = data.id as string | undefined;
109+
if (id && storeFor(object).has(id)) return this.update(object, id, data);
110+
return this.create(object, data);
111+
},
112+
async delete(object: string, id: string) { return storeFor(object).delete(id); },
113+
async count(object: string, ast: any) { return (await this.find(object, ast)).length; },
114+
async bulkCreate(object: string, rows: Record<string, unknown>[]) {
115+
return Promise.all(rows.map((r) => this.create(object, r)));
116+
},
117+
async bulkUpdate() { return []; }, async bulkDelete() {},
118+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
119+
async commit() {}, async rollback() {},
120+
};
121+
return driver;
122+
}
123+
124+
const ALICE = 'usr_alice';
125+
const BOB = 'usr_bob';
126+
const NAME = 'case_grid';
127+
128+
// [#21470] `name` is the row's: a body `name` that is not its row's is refused by the write doors.
129+
const viewBody = (label: string) => ({ name: NAME, type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list' });
130+
131+
describe('#23033 — the sys_metadata row records who created it and who last changed it', () => {
132+
let kernel: ObjectKernel;
133+
let stores: Stores;
134+
let protocol: any;
135+
136+
beforeEach(async () => {
137+
// `logger.level` is the real config key, and gracefulShutdown:false keeps
138+
// process signal handlers out of vitest's fork workers (#4250).
139+
kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
140+
stores = new Map();
141+
const engine = new ObjectQL();
142+
engine.registerDriver(makeMemoryDriver(stores), true);
143+
await kernel.use(new ObjectQLPlugin({ ql: engine }));
144+
await kernel.bootstrap();
145+
protocol = kernel.getService('protocol');
146+
});
147+
afterEach(async () => {
148+
if (kernel.getState() === 'running') await kernel.shutdown();
149+
});
150+
151+
/** The stored `sys_metadata` rows for the view, as the driver holds them. */
152+
const parentRows = (state: 'active' | 'draft' = 'active') =>
153+
Array.from(stores.get('sys_metadata')?.values() ?? [])
154+
.filter((r) => r.type === 'view' && r.name === NAME && r.state === state);
155+
const parentRow = (state: 'active' | 'draft' = 'active') => {
156+
const rows = parentRows(state);
157+
expect(rows, `exactly one ${state} row for view/${NAME}`).toHaveLength(1);
158+
return rows[0]!;
159+
};
160+
/** `recorded_by` per history row, in write order. */
161+
const recordedBy = () =>
162+
Array.from(stores.get('sys_metadata_history')?.values() ?? [])
163+
.filter((h) => h.type === 'view' && h.name === NAME)
164+
.sort((a, b) => Number(a.version) - Number(b.version))
165+
.map((h) => h.recorded_by ?? null);
166+
167+
it('a first write with actor A stores created_by A and updated_by A', async () => {
168+
await protocol.saveMetaItem({ type: 'view', name: NAME, item: viewBody('A'), actor: ALICE });
169+
170+
const row = parentRow();
171+
expect(row.created_by).toBe(ALICE);
172+
expect(row.updated_by).toBe(ALICE);
173+
expect(recordedBy()).toEqual([ALICE]);
174+
});
175+
176+
it('a second write with actor B keeps created_by A and moves updated_by to B', async () => {
177+
await protocol.saveMetaItem({ type: 'view', name: NAME, item: viewBody('A'), actor: ALICE });
178+
await protocol.saveMetaItem({ type: 'view', name: NAME, item: viewBody('B'), actor: BOB });
179+
180+
const row = parentRow();
181+
expect(row.created_by, 'created_by never changes after the insert').toBe(ALICE);
182+
expect(row.updated_by, 'updated_by names the latest writer').toBe(BOB);
183+
// History is unchanged: each row still names its own write's actor.
184+
expect(recordedBy()).toEqual([ALICE, BOB]);
185+
});
186+
187+
it('CONTROL: a write with no actor stores both columns null — no placeholder user', async () => {
188+
await protocol.saveMetaItem({ type: 'view', name: NAME, item: viewBody('A') });
189+
190+
const row = parentRow();
191+
expect(row.created_by ?? null).toBeNull();
192+
expect(row.updated_by ?? null).toBeNull();
193+
expect(recordedBy()).toEqual([null]);
194+
});
195+
196+
it('the door reads the row back: listDrafts answers updatedBy from the draft row', async () => {
197+
await protocol.saveMetaItem({ type: 'view', name: NAME, item: viewBody('A'), actor: ALICE, mode: 'draft' });
198+
await protocol.saveMetaItem({ type: 'view', name: NAME, item: viewBody('B'), actor: BOB, mode: 'draft' });
199+
200+
const row = parentRow('draft');
201+
expect(row.created_by).toBe(ALICE);
202+
expect(row.updated_by).toBe(BOB);
203+
const { drafts } = await protocol.listDrafts({ type: 'view' });
204+
expect(drafts.map((d: any) => [d.name, d.updatedBy])).toEqual([[NAME, BOB]]);
205+
});
206+
});

0 commit comments

Comments
 (0)