Skip to content

Commit 4f1eb9e

Browse files
committed
test(metadata): pin the ordered-fields hash end to end, and the stamps stored before it
- dogfood: an object-designer reorder saves as a draft, publishes and reads back in the new order through `GET /meta/object/:name`; then, over a row rewound to the order-blind stamp, an identical save records no change and keeps the stamp, its receipt token is honoured by If-Match, and a reorder into sorted order -- whose hash IS the stale stamp -- is published. - SysMetadataRepository: the same two halves at the repository, through `promoteDraft`, on the contract file's engine double. - changesets for the three moved packages. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
1 parent e647cfc commit 4f1eb9e

5 files changed

Lines changed: 345 additions & 0 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/metadata-core': minor
3+
---
4+
5+
The content hash keeps the order of an object's `fields`, so a pure field reorder is a new version instead of "no change" (#21790).
6+
7+
Clause-②: no
8+
9+
- **`canonicalize(value, type?)` and `hashSpec(value, type?)`** take the metadata type. For a type whose body has a map the spec declares ordered, that map keeps its insertion order in the canonical form. Today that is one map: `object.fields`, whose traversal order is the field order the platform presents. Every other map stays key-order independent, including the keys around `fields` and the keys inside each field definition. Called without a type, both functions return exactly what they returned before.
10+
- **`orderedMapKeys(type?)`** is a new export. It returns the top-level keys of a `type` body whose map keeps its order (`['fields']` for `object`, `[]` otherwise).
11+
- `InMemoryRepository` and the repository contract suite hash as `ref.type`. Invariant 4 now reads `item.hash === hashSpec(item.body, item.ref.type)`.
12+
- **Stored hashes.** An object whose `fields` are already in sorted key order hashes exactly as before. Any other object hashes differently from the hash stored before this release. A stored hash is still that row's version token: `@objectstack/metadata-protocol` keeps it as written and compares content to decide whether a save changed anything.
13+
14+
`minor` because two exports widen: a new parameter and a new function. No metadata key, accepted value, wire payload or error code changes.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/metadata-fs': patch
3+
---
4+
5+
`FileSystemRepository` hashes each item as its metadata type, so a reorder of an object's `fields` is a change (#21790). A reordered object is written, and an external edit that only reorders `fields` is reported as an update.
6+
7+
Clause-②: no
8+
9+
Event-log entries written before this release carry the order-blind hash. For an object whose `fields` are not in sorted key order, `get()` and `list()` no longer find that entry until the item next changes. They fall back to the defaults: no parent hash, sequence `0`, the filesystem actor, and the epoch timestamp. The body and the version hash are unaffected.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
Publishing a pure field reorder of an object now saves it. The object designer's drag-to-reorder used to answer success on publish, keep the old order and delete the draft (#21790).
6+
7+
Clause-②: no
8+
9+
- `SysMetadataRepository` hashes each body as its type, so a reorder of an object's `fields` is a content change. It is written, recorded in history and served by `GET /api/v1/meta/object/:name`.
10+
- **Rows stored before this release** keep the `checksum` they were written with. That value is still the version token: reads return it, `If-Match` tokens are derived from it, and the optimistic lock compares against it, so upgrading raises no conflict.
11+
- For an object, "is this save a no-op?" is decided by hashing the stored body under the current rule, not by comparing against the stored checksum. An identical re-save of an old row writes no history row and keeps its checksum. A reorder into sorted key order is written too. Its new hash equals the old order-blind checksum, so a checksum comparison would have dropped it.
12+
- A save that changes nothing returns the stored checksum as its version, so the receipt's token is the one the next `If-Match` save must send.

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

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,3 +580,146 @@ describe('SysMetadataRepository — close() terminates every live watcher (#1102
580580
expect(viaClose).toEqual({ value: undefined, done: true });
581581
});
582582
});
583+
584+
/**
585+
* #21790 — `ObjectSchema.fields` is a map whose KEY ORDER the spec declares to
586+
* be the field order, and the content hash used to sort it away: a pure
587+
* reorder hashed equal, `put`'s no-op short-circuit read the publish as
588+
* "unchanged", and the object designer's reorder was dropped while the publish
589+
* answered success. `hashSpec(body, ref.type)` now keeps that order (see
590+
* `canonicalize` guarantee 8).
591+
*
592+
* The second block is the upgrade half. Every row written before the change
593+
* carries the ORDER-BLIND stamp, and that stamp is the row's version token —
594+
* readers were handed it, and the optimistic lock compares against it. So the
595+
* stamp is accepted as written, and "is this write a no-op?" is answered from
596+
* the stored CONTENT under the current rule. Both directions a stale stamp
597+
* misfires in are pinned: an identical re-save must not record a change, and a
598+
* reorder into sorted order — whose hash IS the stale stamp — must not be
599+
* dropped.
600+
*/
601+
describe('SysMetadataRepository — a declared ordered map: object `fields` (#21790)', () => {
602+
const ref = { org: 'system', type: 'object' as const, name: 'invoice' };
603+
// `object` is `allowRuntimeCreate`, not `allowOrgOverride`: the runtime door.
604+
const intent = 'runtime-only' as const;
605+
const FIELDS: Record<string, { type: string; label: string }> = {
606+
title: { type: 'text', label: 'Title' },
607+
amount: { type: 'currency', label: 'Amount' },
608+
due: { type: 'date', label: 'Due' },
609+
};
610+
const DECLARED = ['title', 'amount', 'due']; // deliberately not sorted
611+
const MOVED = ['due', 'title', 'amount'];
612+
const SORTED = ['amount', 'due', 'title'];
613+
const body = (order: readonly string[]) => ({
614+
name: 'invoice',
615+
label: 'Invoice',
616+
fields: Object.fromEntries(order.map((k) => [k, FIELDS[k]])),
617+
});
618+
619+
type Engine = ReturnType<typeof makeFakeEngine>;
620+
const activeRow = (engine: Engine) =>
621+
engine.rows().find((r) => r.type === 'object' && r.state === 'active')!;
622+
const storedOrder = (engine: Engine) =>
623+
Object.keys((JSON.parse(activeRow(engine).metadata as string) as { fields: object }).fields);
624+
const repoOn = (engine: Engine) => {
625+
const repo = new SysMetadataRepository({ engine, organizationId: null, orgLabel: 'system' });
626+
created.push(repo);
627+
return repo;
628+
};
629+
630+
it('a pure reorder, saved as a draft and published, is written', async () => {
631+
const engine = makeFakeEngine();
632+
const repo = repoOn(engine);
633+
const first = await repo.put(ref, body(DECLARED), { parentVersion: null, actor: 't', intent });
634+
635+
await repo.put(ref, body(MOVED), { parentVersion: null, actor: 't', intent, state: 'draft' });
636+
const published = await repo.promoteDraft(ref, { actor: 't', intent });
637+
638+
expect(published.version).not.toBe(first.version);
639+
expect(published.version).toBe(hashSpec(body(MOVED), 'object'));
640+
expect(storedOrder(engine)).toEqual(MOVED);
641+
expect(Object.keys((await repo.get(ref))!.body.fields as object)).toEqual(MOVED);
642+
expect(engine.historyRows().map((r) => r.operation_type)).toEqual(['create', 'publish']);
643+
});
644+
645+
it('…while an identical re-save is still the no-op it was', async () => {
646+
const engine = makeFakeEngine();
647+
const repo = repoOn(engine);
648+
const first = await repo.put(ref, body(DECLARED), { parentVersion: null, actor: 't', intent });
649+
const again = await repo.put(ref, body(DECLARED), { parentVersion: first.version, actor: 't', intent });
650+
651+
expect(again.version).toBe(first.version);
652+
expect(again.seq).toBe(first.seq);
653+
expect(engine.historyRows()).toHaveLength(1);
654+
});
655+
656+
describe('a row stamped before #21790 — the order-blind stamp, accepted as written', () => {
657+
async function legacyRow(order: readonly string[]) {
658+
const engine = makeFakeEngine();
659+
const stored = body(order);
660+
// The stamp every pre-#21790 write took: `hashSpec` with no type.
661+
const stamp = hashSpec(stored);
662+
const now = new Date().toISOString();
663+
await engine.insert('sys_metadata', {
664+
type: 'object',
665+
name: 'invoice',
666+
organization_id: null,
667+
package_id: null,
668+
state: 'active',
669+
version: 1,
670+
metadata: JSON.stringify(stored),
671+
checksum: stamp,
672+
created_at: now,
673+
updated_at: now,
674+
});
675+
return { engine, repo: repoOn(engine), stamp };
676+
}
677+
678+
it('the fixture is a stale stamp: the current rule hashes the same bytes differently', async () => {
679+
const { stamp } = await legacyRow(DECLARED);
680+
expect(stamp).not.toBe(hashSpec(body(DECLARED), 'object'));
681+
});
682+
683+
it('reads back with the stamp it was written with', async () => {
684+
const { repo, stamp } = await legacyRow(DECLARED);
685+
expect((await repo.get(ref))!.hash).toBe(stamp);
686+
});
687+
688+
it('an identical re-save: no conflict, no history row, the stored stamp handed back', async () => {
689+
const { engine, repo, stamp } = await legacyRow(DECLARED);
690+
const res = await repo.put(ref, body(DECLARED), { parentVersion: stamp, actor: 't', intent });
691+
692+
expect(res.version).toBe(stamp);
693+
expect(engine.historyRows()).toEqual([]);
694+
expect(activeRow(engine).checksum).toBe(stamp);
695+
// The version handed back is the one the next write's lock accepts.
696+
await expect(
697+
repo.put(ref, body(MOVED), { parentVersion: res.version, actor: 't', intent }),
698+
).resolves.toMatchObject({ version: hashSpec(body(MOVED), 'object') });
699+
expect(storedOrder(engine)).toEqual(MOVED);
700+
});
701+
702+
it('a reorder published through a draft is written, chained to the stored stamp', async () => {
703+
const { engine, repo, stamp } = await legacyRow(DECLARED);
704+
await repo.put(ref, body(MOVED), { parentVersion: null, actor: 't', intent, state: 'draft' });
705+
await repo.promoteDraft(ref, { actor: 't', intent });
706+
707+
expect(storedOrder(engine)).toEqual(MOVED);
708+
const [publish] = engine.historyRows();
709+
expect(publish).toMatchObject({ operation_type: 'publish', previous_checksum: stamp });
710+
});
711+
712+
it('a reorder INTO sorted order is written, though its hash IS the stale stamp', async () => {
713+
const { engine, repo, stamp } = await legacyRow(DECLARED);
714+
// The trap: the order-blind stamp is the sorted form's hash, so a
715+
// comparison of hash against stamp reads this reorder as "unchanged".
716+
expect(hashSpec(body(SORTED), 'object')).toBe(stamp);
717+
718+
await repo.put(ref, body(SORTED), { parentVersion: null, actor: 't', intent, state: 'draft' });
719+
await repo.promoteDraft(ref, { actor: 't', intent });
720+
721+
expect(storedOrder(engine)).toEqual(SORTED);
722+
expect(engine.historyRows().map((r) => r.operation_type)).toEqual(['publish']);
723+
});
724+
});
725+
});
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// GOLDEN REGRESSION — #21790: "a field reorder made in the object designer
4+
// survives draft save -> publish -> reload", exercised end-to-end through the
5+
// real HTTP + metadata stack on the showcase.
6+
//
7+
// `ObjectSchema.fields` is a name-keyed map whose traversal order the spec
8+
// declares to BE the field order. The content hash sorted every map, so a pure
9+
// reorder hashed equal to the published row, `SysMetadataRepository.put`'s
10+
// no-op short-circuit read the publish as "unchanged", and the draft was
11+
// drained — the publish answered success, the served object kept the old
12+
// order, and the draft was gone. Reproduced on 17.6.0 and the 17.7
13+
// pre-release.
14+
//
15+
// The second block is the upgrade half. Every object row written before the
16+
// fix carries the ORDER-BLIND stamp, and that stamp is the version token its
17+
// readers hold. It is accepted as written: an identical save must record no
18+
// change, a receipt's token must still be honoured by `If-Match`, and a reorder
19+
// INTO sorted order — whose current hash IS the stale stamp — must not be
20+
// dropped.
21+
22+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
23+
import showcaseStack from '@objectstack/example-showcase';
24+
import { hashSpec } from '@objectstack/metadata-core';
25+
import { bootStack, type VerifyStack } from '@objectstack/verify';
26+
27+
const OBJ = 'dogfood_field_reorder';
28+
const SYSTEM_CTX = { isSystem: true };
29+
30+
const FIELDS: Record<string, { type: string; label: string }> = {
31+
title: { type: 'text', label: 'Title' },
32+
amount: { type: 'number', label: 'Amount' },
33+
due: { type: 'date', label: 'Due' },
34+
};
35+
const DECLARED = ['title', 'amount', 'due']; // deliberately not sorted
36+
const MOVED = ['due', 'title', 'amount']; // the designer drag
37+
const SORTED = ['amount', 'due', 'title']; // the order the old hash sorted to
38+
39+
/** The designer's document, its `fields` in `order`. */
40+
const objectBody = (order: readonly string[]) => ({
41+
name: OBJ,
42+
label: 'Field Reorder',
43+
// The runtime object door refuses an unauthored OWD at publish (#8310).
44+
sharingModel: 'private',
45+
fields: Object.fromEntries(order.map((k) => [k, FIELDS[k]])),
46+
});
47+
48+
interface Row {
49+
id: string;
50+
metadata: string;
51+
checksum: string | null;
52+
}
53+
interface Ql {
54+
find(object: string, query: Record<string, unknown>): Promise<Row[]>;
55+
update(object: string, data: Record<string, unknown>, opts: Record<string, unknown>): Promise<unknown>;
56+
}
57+
58+
describe('dogfood: an object designer field reorder publishes and reads back in the new order (#21790)', () => {
59+
let stack: VerifyStack;
60+
let token: string;
61+
let ql: Ql;
62+
63+
beforeAll(async () => {
64+
stack = await bootStack(showcaseStack);
65+
token = await stack.signIn();
66+
ql = (await stack.kernel.getServiceAsync('objectql')) as unknown as Ql;
67+
}, 90_000);
68+
69+
afterAll(async () => {
70+
await stack?.stop();
71+
});
72+
73+
/** `GET /meta/object/:name` — the authored fields, in the order served. */
74+
const servedOrder = async (): Promise<string[]> => {
75+
const res = await stack.apiAs(token, 'GET', `/meta/object/${OBJ}`);
76+
expect(res.status).toBe(200);
77+
const body = (await res.json()) as { type?: string; name?: string; item?: { fields?: object } };
78+
expect(body).toMatchObject({ type: 'object', name: OBJ });
79+
// The registry may serve injected system columns beside the authored ones;
80+
// the order under test is the authored fields' relative order.
81+
return Object.keys(body.item?.fields ?? {}).filter((k) => k in FIELDS);
82+
};
83+
const saveDraft = (order: readonly string[]) =>
84+
stack.apiAs(token, 'PUT', `/meta/object/${OBJ}?mode=draft`, objectBody(order));
85+
const publish = () => stack.apiAs(token, 'POST', `/meta/object/${OBJ}/publish`, {});
86+
const activeRow = async (): Promise<Row> => {
87+
const rows = await ql.find('sys_metadata', {
88+
where: { type: 'object', name: OBJ, state: 'active' },
89+
context: SYSTEM_CTX,
90+
});
91+
expect(rows).toHaveLength(1);
92+
return rows[0]!;
93+
};
94+
const historyRows = async () =>
95+
(await ql.find('sys_metadata_history', { where: { type: 'object', name: OBJ }, context: SYSTEM_CTX })).length;
96+
97+
describe('the designer round trip', () => {
98+
it('creates and publishes the object in its declared order', async () => {
99+
expect((await saveDraft(DECLARED)).status).toBe(200);
100+
expect((await publish()).status).toBe(200);
101+
expect(await servedOrder()).toEqual(DECLARED);
102+
});
103+
104+
it('a pure reorder, saved as a draft and published, is served — and still served on reload', async () => {
105+
expect((await saveDraft(MOVED)).status).toBe(200);
106+
// The defect: this answered 200 and dropped the reorder.
107+
expect((await publish()).status).toBe(200);
108+
expect(await servedOrder()).toEqual(MOVED);
109+
// The designer's reload is a second read of the same door.
110+
expect(await servedOrder()).toEqual(MOVED);
111+
// …and the stored row holds it, not just a registry entry.
112+
const stored = JSON.parse((await activeRow()).metadata) as { fields: object };
113+
expect(Object.keys(stored.fields)).toEqual(MOVED);
114+
});
115+
});
116+
117+
describe('an object row stamped before the fix (the order-blind stamp)', () => {
118+
let legacyStamp: string;
119+
let receipt: string;
120+
121+
it('rewinds the stored stamp to the one the order-blind rule wrote', async () => {
122+
const row = await activeRow();
123+
const stored = JSON.parse(row.metadata) as Record<string, unknown>;
124+
legacyStamp = hashSpec(stored);
125+
// A real stale stamp: the current rule hashes these bytes differently.
126+
expect(row.checksum).toBe(hashSpec(stored, 'object'));
127+
expect(legacyStamp).not.toBe(row.checksum);
128+
await ql.update('sys_metadata', { id: row.id, checksum: legacyStamp }, { context: SYSTEM_CTX });
129+
expect((await activeRow()).checksum).toBe(legacyStamp);
130+
expect(await servedOrder()).toEqual(MOVED);
131+
});
132+
133+
it('an identical save records no change and leaves the stamp as written', async () => {
134+
const before = await historyRows();
135+
const stored = JSON.parse((await activeRow()).metadata) as Record<string, unknown>;
136+
const res = await stack.apiAs(token, 'PUT', `/meta/object/${OBJ}`, stored);
137+
expect(res.status).toBe(200);
138+
receipt = ((await res.json()) as { version: string }).version;
139+
expect(typeof receipt).toBe('string');
140+
expect(await historyRows()).toBe(before);
141+
expect((await activeRow()).checksum).toBe(legacyStamp);
142+
});
143+
144+
it('the receipt\'s token is honoured by If-Match, and a reorder into sorted order is published', async () => {
145+
// The trap: the stale stamp IS the sorted form's current hash.
146+
const stored = JSON.parse((await activeRow()).metadata) as { fields: Record<string, unknown> };
147+
const sorted = { ...stored, fields: Object.fromEntries(SORTED.map((k) => [k, stored.fields[k]])) };
148+
expect(hashSpec(sorted, 'object')).toBe(legacyStamp);
149+
150+
const before = await historyRows();
151+
const res = await stack.api(`/meta/object/${OBJ}`, {
152+
method: 'PUT',
153+
headers: {
154+
'Content-Type': 'application/json',
155+
Authorization: `Bearer ${token}`,
156+
'If-Match': `"${receipt}"`,
157+
},
158+
body: JSON.stringify(sorted),
159+
});
160+
expect(res.status).toBe(200);
161+
expect(await servedOrder()).toEqual(SORTED);
162+
expect(await historyRows()).toBe(before + 1);
163+
const written = JSON.parse((await activeRow()).metadata) as { fields: object };
164+
expect(Object.keys(written.fields)).toEqual(SORTED);
165+
});
166+
});
167+
});

0 commit comments

Comments
 (0)