Skip to content

Commit 888a5c1

Browse files
os-zhuangCopilot
andcommitted
feat(objectql): PR-10d.3 — feature flag for repository write path
Introduces an opt-in path in ObjectStackProtocolImplementation.saveMetaItem that writes overlay metadata through SysMetadataRepository.put instead of the raw engine, so writes append to the change-log and emit HMR seq events. Behavioural changes (all behind options.useRepositoryWritePath / OBJECTSTACK_USE_REPOSITORY_WRITE_PATH=1): - saveMetaItem request gained optional parentVersion (If-Match) and actor fields. ConflictError -> 409 metadata_conflict. - Plural type aliases (views, dashboards, ...) normalized to singular before the repo's overlay-allowlist gate (rubber-duck #5). - Object-registry mutation moved AFTER successful put() so a conflict does not leave the in-memory registry stale (rubber-duck #3 invariant test added). Repo/test-fake fixes uncovered by rubber-duck review: - SysMetadataRepository.put/delete now update/delete by row id because the engine's strict .update requires id or multi:true (rubber-duck #1). - sys_metadata.checksum column widened from 64 -> 71 chars to hold the sha256: prefix produced by hashSpec() (rubber-duck #2). - Three test fake engines extended to support both overlay-tuple and id-based where lookups. 333/333 objectql tests pass. Deferred to PR-10d.4: REST plumbing for parentVersion/actor (rubber-duck #6), race-window retry for omitted parentVersion (rubber-duck #4), default flag flip + legacy path removal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6ee42b8 commit 888a5c1

7 files changed

Lines changed: 483 additions & 30 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/platform-objects": patch
4+
---
5+
6+
PR-10d.3 — feature flag for `SysMetadataRepository.put` write path in `saveMetaItem`.
7+
8+
- `ObjectStackProtocolImplementation` now accepts an `options.useRepositoryWritePath` flag
9+
(also honored via `OBJECTSTACK_USE_REPOSITORY_WRITE_PATH=1`) that routes overlay writes
10+
through `SysMetadataRepository.put`, appending to the change-log and emitting HMR `seq`.
11+
- `saveMetaItem` request grew optional `parentVersion` (If-Match) and `actor` fields.
12+
`ConflictError` is mapped to a 409 `metadata_conflict` API error.
13+
- Plural metadata type aliases (`views`, `dashboards`, ...) are normalized to singular
14+
before the repo's overlay-allowlist gate.
15+
- `SysMetadataRepository.put`/`delete` now update/delete by row `id` (the engine's
16+
strict `.update` semantics require an id or `multi:true`).
17+
- `sys_metadata.checksum` column widened from 64 → 71 chars to hold the `"sha256:"`
18+
prefix produced by `hashSpec()`.
19+
- Default behaviour unchanged: legacy raw-engine path remains until PR-10d.4 flips the
20+
flag and removes it.

packages/objectql/src/layered-overlay-integration.test.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ function makeFakeEngine() {
3535
const rows = new Map<string, Row>();
3636
const keyOf = (w: Record<string, unknown>) =>
3737
`${w.type}|${w.name}|${String(w.organization_id ?? 'null')}`;
38+
const findRow = (w: Record<string, unknown>): { key: string; row: Row } | null => {
39+
if (w.id !== undefined) {
40+
for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r };
41+
return null;
42+
}
43+
const k = keyOf(w);
44+
const r = rows.get(k);
45+
return r ? { key: k, row: r } : null;
46+
};
3847
return {
3948
rows,
4049
async find(_t: string, opts: { where: Record<string, unknown> }) {
@@ -46,21 +55,24 @@ function makeFakeEngine() {
4655
);
4756
},
4857
async findOne(_t: string, opts: { where: Record<string, unknown> }) {
49-
return rows.get(keyOf(opts.where)) ?? null;
58+
return findRow(opts.where)?.row ?? null;
5059
},
5160
async insert(_t: string, data: Record<string, unknown>) {
5261
const row: Row = { id: `r_${rows.size + 1}`, ...(data as any) };
5362
rows.set(keyOf(data), row);
5463
return { id: row.id };
5564
},
5665
async update(_t: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
57-
const k = keyOf(opts.where);
58-
const cur = rows.get(k)!;
59-
rows.set(k, { ...cur, ...(data as any) });
60-
return { id: cur.id };
66+
const found = findRow(opts.where);
67+
if (!found) return { id: null };
68+
rows.set(found.key, { ...found.row, ...(data as any) });
69+
return { id: found.row.id };
6170
},
6271
async delete(_t: string, opts: { where: Record<string, unknown> }) {
63-
return { deleted: rows.delete(keyOf(opts.where)) ? 1 : 0 };
72+
const found = findRow(opts.where);
73+
if (!found) return { deleted: 0 };
74+
rows.delete(found.key);
75+
return { deleted: 1 };
6476
},
6577
};
6678
}
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, expect, it } from 'vitest';
4+
import { hashSpec } from '@objectstack/metadata-core';
5+
import { ObjectStackProtocolImplementation } from './protocol';
6+
7+
/**
8+
* PR-10d.3 — repository write path behind a feature flag.
9+
*
10+
* These tests verify the new branch in `saveMetaItem` that routes through
11+
* `SysMetadataRepository.put` when `useRepositoryWritePath` is on. We
12+
* stub the engine surface that both the protocol and the repository touch:
13+
* findOne / find / insert / update / delete on `sys_metadata`, plus a
14+
* minimal registry (only consulted for `type === 'object'`).
15+
*/
16+
17+
interface Row {
18+
id: string;
19+
type: string;
20+
name: string;
21+
organization_id: string | null;
22+
state: string;
23+
metadata: string;
24+
checksum?: string;
25+
version?: number;
26+
updated_at?: string;
27+
created_at?: string;
28+
}
29+
30+
function keyOf(w: Record<string, unknown>) {
31+
return `${w.type}|${w.name}|${w.organization_id ?? '__env__'}`;
32+
}
33+
34+
function makeStubEngine() {
35+
const rows = new Map<string, Row>();
36+
let nextId = 0;
37+
const findRow = (w: Record<string, unknown>): { key: string; row: Row } | null => {
38+
if (w.id !== undefined) {
39+
for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r };
40+
return null;
41+
}
42+
const k = keyOf(w);
43+
const r = rows.get(k);
44+
return r ? { key: k, row: r } : null;
45+
};
46+
const engine: any = {
47+
async findOne(_t: string, opts: { where: Record<string, unknown> }) {
48+
return findRow(opts.where)?.row ?? null;
49+
},
50+
async find(_t: string, opts: { where: Record<string, unknown> }) {
51+
return Array.from(rows.values()).filter((r) => {
52+
if (opts.where.type && r.type !== opts.where.type) return false;
53+
if (opts.where.organization_id !== undefined
54+
&& r.organization_id !== opts.where.organization_id) return false;
55+
if (opts.where.state && r.state !== opts.where.state) return false;
56+
return true;
57+
});
58+
},
59+
async insert(_t: string, data: Record<string, unknown>) {
60+
nextId += 1;
61+
const row = { id: `r_${nextId}`, ...(data as any) } as Row;
62+
rows.set(keyOf(data), row);
63+
return { id: row.id };
64+
},
65+
async update(_t: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
66+
const found = findRow(opts.where);
67+
if (!found) return { id: null };
68+
rows.set(found.key, { ...found.row, ...(data as any) });
69+
return { id: found.row.id };
70+
},
71+
async delete(_t: string, opts: { where: Record<string, unknown> }) {
72+
const found = findRow(opts.where);
73+
if (!found) return { deleted: 0 };
74+
rows.delete(found.key);
75+
return { deleted: 1 };
76+
},
77+
registry: {
78+
registerItem: () => {},
79+
registerObject: () => {},
80+
},
81+
};
82+
return { engine, rows };
83+
}
84+
85+
describe('saveMetaItem — repository write path (PR-10d.3)', () => {
86+
it('legacy path remains the default when no flag is passed', async () => {
87+
const { engine, rows } = makeStubEngine();
88+
const protocol = new ObjectStackProtocolImplementation(engine);
89+
const result = await protocol.saveMetaItem({
90+
type: 'view',
91+
name: 'case_grid',
92+
organizationId: 'org_alpha',
93+
item: { name: 'case_grid', type: 'grid', label: 'Cases', columns: ['id', 'title'] },
94+
});
95+
expect(result.success).toBe(true);
96+
// Legacy path does NOT set checksum / does NOT emit a seq.
97+
expect((result as any).seq).toBeUndefined();
98+
const row = Array.from(rows.values())[0];
99+
expect(row.checksum).toBeUndefined();
100+
});
101+
102+
it('repository path writes the checksum and surfaces seq', async () => {
103+
const { engine, rows } = makeStubEngine();
104+
const protocol = new ObjectStackProtocolImplementation(
105+
engine, undefined, undefined, undefined,
106+
{ useRepositoryWritePath: true },
107+
);
108+
const body = { name: 'case_grid', type: 'grid', label: 'Cases', columns: ['id', 'title'] };
109+
const result = await protocol.saveMetaItem({
110+
type: 'view',
111+
name: 'case_grid',
112+
organizationId: 'org_alpha',
113+
item: body,
114+
});
115+
expect(result.success).toBe(true);
116+
expect((result as any).seq).toBe(1);
117+
const row = Array.from(rows.values())[0];
118+
expect(row.checksum).toBe(hashSpec(body));
119+
});
120+
121+
it('repository path increments seq across writes and updates the body', async () => {
122+
const { engine, rows } = makeStubEngine();
123+
const protocol = new ObjectStackProtocolImplementation(
124+
engine, undefined, undefined, undefined,
125+
{ useRepositoryWritePath: true },
126+
);
127+
const r1 = await protocol.saveMetaItem({
128+
type: 'view', name: 'v', organizationId: 'org',
129+
item: { name: 'view_one', type: 'grid', label: 'A', columns: ['id'] },
130+
});
131+
const r2 = await protocol.saveMetaItem({
132+
type: 'view', name: 'v', organizationId: 'org',
133+
item: { name: 'view_one', type: 'grid', label: 'B', columns: ['id'] },
134+
});
135+
expect((r1 as any).seq).toBe(1);
136+
expect((r2 as any).seq).toBe(2);
137+
const row = Array.from(rows.values())[0];
138+
expect(JSON.parse(row.metadata).label).toBe('B');
139+
});
140+
141+
it('repository path returns 409 on parentVersion mismatch', async () => {
142+
const { engine } = makeStubEngine();
143+
const protocol = new ObjectStackProtocolImplementation(
144+
engine, undefined, undefined, undefined,
145+
{ useRepositoryWritePath: true },
146+
);
147+
// First write establishes a HEAD.
148+
await protocol.saveMetaItem({
149+
type: 'view', name: 'v', organizationId: 'org',
150+
item: { name: 'view_one', type: 'grid', label: 'A', columns: ['id'] },
151+
});
152+
// Second write with an explicit stale parentVersion → conflict.
153+
await expect(
154+
protocol.saveMetaItem({
155+
type: 'view', name: 'v', organizationId: 'org',
156+
item: { name: 'view_one', type: 'grid', label: 'B', columns: ['id'] },
157+
parentVersion: 'sha256:notTheCurrentHead',
158+
}),
159+
).rejects.toMatchObject({
160+
code: 'metadata_conflict',
161+
status: 409,
162+
});
163+
});
164+
165+
it('repository path no-ops when body is identical (idempotent put)', async () => {
166+
const { engine, rows } = makeStubEngine();
167+
const protocol = new ObjectStackProtocolImplementation(
168+
engine, undefined, undefined, undefined,
169+
{ useRepositoryWritePath: true },
170+
);
171+
const body = { name: 'view_one', type: 'grid', label: 'A', columns: ['id'] };
172+
const r1 = await protocol.saveMetaItem({
173+
type: 'view', name: 'v', organizationId: 'org', item: body,
174+
});
175+
const r2 = await protocol.saveMetaItem({
176+
type: 'view', name: 'v', organizationId: 'org', item: body,
177+
});
178+
// No new seq allocated for an identical body.
179+
expect((r1 as any).seq).toBe(1);
180+
expect((r2 as any).seq).toBe(1);
181+
// Still only one row in the store.
182+
expect(rows.size).toBe(1);
183+
});
184+
185+
it('env-wide overlays (organizationId omitted) use a separate repo bucket', async () => {
186+
const { engine, rows } = makeStubEngine();
187+
const protocol = new ObjectStackProtocolImplementation(
188+
engine, undefined, undefined, undefined,
189+
{ useRepositoryWritePath: true },
190+
);
191+
await protocol.saveMetaItem({
192+
type: 'view', name: 'v',
193+
item: { name: 'view_one', type: 'grid', label: 'env-wide', columns: ['id'] },
194+
});
195+
await protocol.saveMetaItem({
196+
type: 'view', name: 'v', organizationId: 'org_alpha',
197+
item: { name: 'view_one', type: 'grid', label: 'org_alpha', columns: ['id'] },
198+
});
199+
// Two rows: one with organization_id=null, one with org_alpha.
200+
expect(rows.size).toBe(2);
201+
const orgs = Array.from(rows.values()).map((r) => r.organization_id).sort();
202+
expect(orgs).toEqual([null, 'org_alpha']);
203+
});
204+
205+
it('plural type (e.g. "views") is normalized to singular before the repo gate (rubber-duck #5)', async () => {
206+
const { engine, rows } = makeStubEngine();
207+
const protocol = new ObjectStackProtocolImplementation(
208+
engine, undefined, undefined, undefined,
209+
{ useRepositoryWritePath: true },
210+
);
211+
// 'views' must succeed — the repo only knows the singular form, so
212+
// without normalization this would throw 403 not_overridable.
213+
const result = await protocol.saveMetaItem({
214+
type: 'views',
215+
name: 'case_grid',
216+
organizationId: 'org',
217+
item: { name: 'case_grid', type: 'grid', label: 'OK', columns: ['id'] },
218+
});
219+
expect(result.success).toBe(true);
220+
const row = Array.from(rows.values())[0];
221+
// The stored row keeps the SINGULAR type since the repo writes it.
222+
expect(row.type).toBe('view');
223+
});
224+
225+
it('on ConflictError the overlay row body is unchanged (rubber-duck #3 invariant)', async () => {
226+
const { engine, rows } = makeStubEngine();
227+
const protocol = new ObjectStackProtocolImplementation(
228+
engine, undefined, undefined, undefined,
229+
{ useRepositoryWritePath: true },
230+
);
231+
232+
// Establish a HEAD overlay.
233+
await protocol.saveMetaItem({
234+
type: 'view',
235+
name: 'cases',
236+
organizationId: 'org_x',
237+
item: { name: 'cases', type: 'grid', label: 'Original', columns: ['id'] },
238+
});
239+
const beforeBody = (Array.from(rows.values())[0] as any).metadata;
240+
241+
// Stale parentVersion → 409. The stored body must not change.
242+
await expect(
243+
protocol.saveMetaItem({
244+
type: 'view',
245+
name: 'cases',
246+
organizationId: 'org_x',
247+
item: { name: 'cases', type: 'grid', label: 'Mutated (should not land)', columns: ['id'] },
248+
parentVersion: 'sha256:stale',
249+
}),
250+
).rejects.toMatchObject({ code: 'metadata_conflict', status: 409 });
251+
252+
const afterBody = (Array.from(rows.values())[0] as any).metadata;
253+
expect(afterBody).toBe(beforeBody);
254+
});
255+
});

0 commit comments

Comments
 (0)