Skip to content

Commit 4443642

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21776-reseed-intact-baseline
2 parents 278fd33 + 08adfea commit 4443642

26 files changed

Lines changed: 1445 additions & 57 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/plugin-email": patch
3+
---
4+
5+
An email template edited through `PUT /api/v1/meta/email_template/:name` (the Studio editor's door) now keeps the admin's wording in `sys_email_template` across a restart. Before, the boot sweep wrote the package wording back over the sending row while `GET /meta` kept serving the admin's, so mail went out with the package wording after every boot.
6+
7+
Clause-②: no
8+
9+
- The cause: on a deployment with a Default Organization the admin's save is an org-scoped overlay, and boot hydration keeps org-scoped overlays out of the registry the sweep read. An env-wide overlay was already kept.
10+
- `EmailServicePlugin`'s boot sweep now projects the effective template: the layered list `protocol.getMetaItems` serves, read in the organization `tenancy.defaultOrgId()` names. That is the Default Organization under the `single` posture. A host without a `protocol` service reads the registry as before.
11+
- A failed effective read projects nothing for that boot, so the rows keep their last projection. It does not fall back to the package wording.
12+
- Seed-not-clobber is unchanged. A row an admin created (`managed_by: 'admin'`) or edited through the data API (`customized: true`) is still never overwritten.
13+
- The published API is unchanged. The exported `bootstrapDeclaredEmailTemplates` keeps its signature and still reads the registry, so a caller outside the plugin sees the same behaviour as before.
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-②: yes
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-②: yes
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-②: yes
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.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
A declared `PATCH` AI route is reachable over HTTP: the dispatcher's `/ai/*` method wildcards mount `patch` beside `get`, `post`, `put` and `delete`, so `PATCH /api/v1/ai/conversations/:id` (the SDK's `ai.conversations.update`, the console's conversation rename) reaches its handler instead of answering `405` (#21806).
6+
7+
Clause-②: no
8+
9+
- **Where it failed.** On a host where the wildcards are the only door into `/ai/**`, a `PATCH` never reached the dispatcher. The server adapter answered `405 METHOD_NOT_ALLOWED` with `Allow: DELETE, GET, HEAD, POST, PUT`, because the path matched the wildcards under the four other verbs. Both bases the wildcards serve are fixed: `/api/v1` and `/api/v1/environments/:environmentId`.
10+
- **An undeclared method still answers `405`.** The AI route table now tells its two misses apart. A method the table does not declare on a path it does declare answers `405 METHOD_NOT_ALLOWED`, with an `Allow` header that names exactly the declared methods, and no handler runs. A path the table declares under no method still answers `404 ROUTE_NOT_FOUND`. The rule is the same for every verb.
11+
- **What a caller sees change.** A `GET`, `POST`, `PUT` or `DELETE` that names a declared AI path under the wrong method used to answer `404 ROUTE_NOT_FOUND`. It now answers `405` with `Allow`. A `PATCH` to an AI path the table does not declare at all used to answer the adapter's `405`. It now answers `404 ROUTE_NOT_FOUND`. No request that was refused before is served now, except a `PATCH` to a route the table declares.

‎content/docs/concepts/metadata-lifecycle.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ The switches are operator-only where one install serves several organizations. T
146146

147147
Every `put()` requires a `parentVersion` (or `null` for fresh creates). If the head has moved since the caller read it, the repository throws `ConflictError(ref, expectedParent, actualHead)` with code `METADATA_CONFLICT`. `protocol.ts` catches it at every write entry point (save, publish, rollback, overlay-delete) and re-throws an API error with code `metadata_conflict`, **HTTP 409 Conflict**, and the `expectedParent` / `actualHead` pair attached. Clients are expected to re-read the item and retry; the console does not yet surface a dedicated conflict-resolution UI.
148148

149-
The hash is `sha256:` + 64-hex of a canonical (sorted-keys, no-undefined) JSON serialization of the body. Identical bodies produce identical hashes — `put()` short-circuits on no-op writes.
149+
The hash is `sha256:` + 64-hex of a canonical JSON serialization of the body, with `undefined` values dropped. The canonical form sorts object keys, except in the maps the spec declares ordered. Today that is one map: an `object`'s `fields`, which keeps its declared order, so a pure reorder of fields is a new version. `put()` still short-circuits on a write whose body is unchanged. A row written before this release keeps its stored hash as its version token until its content next changes.
150150

151151
---
152152

‎packages/metadata-core/src/canonicalize.ts‎

Lines changed: 104 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
* Guarantees:
88
*
99
* 1. **Key order independence.** Object keys are sorted lexicographically.
10-
* `{a:1, b:2}` and `{b:2, a:1}` produce the same canonical form.
10+
* `{a:1, b:2}` and `{b:2, a:1}` produce the same canonical form — for
11+
* every map EXCEPT the ones guarantee 8 names, whose order is content.
1112
* 2. **Whitespace independence.** No incidental whitespace.
1213
* 3. **Type preservation.** `undefined` properties are dropped (matching
1314
* JSON.stringify), `null` is preserved, arrays preserve order.
@@ -40,6 +41,31 @@
4041
* are untouched by this: their canonical form is byte-identical to what
4142
* it was before, so no already-stored version changes meaning.
4243
*
44+
* 8. **Declared map order (#21790).** A map whose key order the spec
45+
* declares meaningful keeps its insertion order instead of being sorted,
46+
* so a pure reorder of it is a change. Which maps those are is a fact
47+
* about the metadata TYPE, so it applies only when the caller names the
48+
* type (`canonicalize(body, type)`); see {@link orderedMapKeys} for the
49+
* table and the spec text behind each row. Everything else in the body —
50+
* the keys AROUND an ordered map and the keys INSIDE each of its values —
51+
* stays key-order independent (guarantee 1). With no type, or a type with
52+
* no row, the canonical form is byte-identical to what it was before.
53+
*
54+
* Guarantees 5 and 7 hold unchanged: `JSON.stringify` emits own keys in
55+
* the order `Object.keys` reports them and `JSON.parse` recreates them in
56+
* that order, so the order kept here survives every serialisation round
57+
* trip, and the canonical output of an ordered map re-canonicalises to
58+
* itself.
59+
*
60+
* The cost is the hashes already stored: a body whose ordered map is not
61+
* already in sorted key order hashes differently under this rule than
62+
* under the old one. A stamp written before #21790 still names its row —
63+
* it is the row's version token, and the optimistic lock compares a
64+
* caller's token with that stored stamp, never with a re-derivation — so
65+
* the comparison that needed teaching is "is this write a no-op?", which
66+
* must compare CONTENT under the current rule rather than a stored stamp
67+
* taken under an older one (`SysMetadataRepository.put`).
68+
*
4369
* Non-goals (deliberately not supported):
4470
*
4571
* - Functions and symbols. These have no canonical JSON form. Callers must
@@ -54,10 +80,57 @@
5480

5581
import { createHash } from 'node:crypto';
5682

57-
/** Stable JSON serialisation. See module-level doc for guarantees. */
58-
export function canonicalize(value: unknown): string {
83+
const NO_ORDERED_MAPS: readonly string[] = Object.freeze([]);
84+
85+
/**
86+
* Guarantee 8's table — per metadata type, the top-level keys of its body
87+
* whose value is a map with MEANINGFUL KEY ORDER.
88+
*
89+
* A row is owed only where `packages/spec` declares the order; ⛔ never an
90+
* order inferred from what a renderer happens to do with a map, because a
91+
* row here turns every reorder of that map into a new version.
92+
*
93+
* - `object` → `fields`. `ObjectSchema.fields` is a name-keyed record whose
94+
* traversal order IS the field order the platform presents: the
95+
* `ObjectFieldGroupSchema` contract (`data/object.zod.ts`) sets "the
96+
* in-group display order equals the traversal order of
97+
* `ObjectSchema.fields`" and lays ungrouped fields out "preserving their
98+
* field declaration order"; `fieldGroups` repeats it ("fields are
99+
* displayed in their `ObjectSchema.fields` declaration order"); the
100+
* `record` field type it is authored through declares "Insertion order =
101+
* display order" (`data/field.zod.ts`); and the display-name derivation
102+
* picks the first title-eligible field "by declaration order"
103+
* (`data/display-name.ts`). A hash blind to that order called a pure
104+
* reorder "no change", and the object designer's publish was dropped.
105+
*
106+
* Keyed by the canonical (singular) type name the repositories write under.
107+
* A `Map`, so a type name that happens to be an `Object.prototype` member
108+
* reads as "no row" rather than as an inherited function.
109+
*/
110+
const ORDERED_MAPS: ReadonlyMap<string, readonly string[]> = new Map([
111+
['object', Object.freeze(['fields'])],
112+
]);
113+
114+
/**
115+
* The top-level keys of a `type` body whose map value keeps its key order in
116+
* the canonical form (guarantee 8). Empty when `type` is omitted or has no
117+
* declared ordered map — the case in which canonicalization is exactly the
118+
* type-blind form it has always been.
119+
*/
120+
export function orderedMapKeys(type?: string): readonly string[] {
121+
return (type === undefined ? undefined : ORDERED_MAPS.get(type)) ?? NO_ORDERED_MAPS;
122+
}
123+
124+
/**
125+
* Stable JSON serialisation. See module-level doc for guarantees.
126+
*
127+
* @param type The metadata type of `value`, when it is a metadata body.
128+
* Decides which maps keep their order (guarantee 8); omit it for
129+
* a value that is not one, which sorts every map.
130+
*/
131+
export function canonicalize(value: unknown, type?: string): string {
59132
// `''` is the key `JSON.stringify` hands a root-position `toJSON`.
60-
return JSON.stringify(normalise(value, ''));
133+
return JSON.stringify(normalise(value, '', orderedMapKeys(type), false));
61134
}
62135

63136
/**
@@ -83,8 +156,19 @@ function resolveToJson(value: unknown, key: string): unknown {
83156
* @param key The position's key — `''` at the root, the property name
84157
* inside an object, the stringified index inside an array.
85158
* Passed to `toJSON` because `JSON.stringify` passes it.
159+
* @param orderedChildren Keys of THIS position's object whose values keep
160+
* their own key order (guarantee 8). Non-empty only at the root;
161+
* every deeper position receives none.
162+
* @param keepOwnOrder Whether THIS position, when it is an object, keeps its
163+
* keys in insertion order instead of sorting them. Applies to
164+
* this level only — its values are normalised as usual.
86165
*/
87-
function normalise(rawValue: unknown, key: string): unknown {
166+
function normalise(
167+
rawValue: unknown,
168+
key: string,
169+
orderedChildren: readonly string[],
170+
keepOwnOrder: boolean,
171+
): unknown {
88172
const value = resolveToJson(rawValue, key);
89173
if (value === null) return null;
90174
if (typeof value === 'undefined') return undefined; // caller-dropped
@@ -107,16 +191,21 @@ function normalise(rawValue: unknown, key: string): unknown {
107191
// the second argument, which is this function's `key` parameter. The
108192
// index is the right key, but it has to arrive as the string
109193
// `JSON.stringify` would hand a `toJSON`, not as a number.
110-
return value.map((element, index) => normalise(element, String(index)));
194+
return value.map((element, index) =>
195+
normalise(element, String(index), NO_ORDERED_MAPS, false),
196+
);
111197
}
112198

113-
// Plain object: sort keys, drop undefineds.
199+
// Plain object: sort keys (unless this is a declared ordered map), drop
200+
// undefineds.
114201
if (typeof value === 'object') {
115202
const obj = value as Record<string, unknown>;
116203
const out: Record<string, unknown> = {};
117-
const keys = Object.keys(obj).sort();
204+
// `Object.keys` order is the order `JSON.stringify` writes, so keeping it
205+
// is what keeps guarantee 7 for an ordered map.
206+
const keys = keepOwnOrder ? Object.keys(obj) : Object.keys(obj).sort();
118207
for (const k of keys) {
119-
const v = normalise(obj[k], k);
208+
const v = normalise(obj[k], k, NO_ORDERED_MAPS, orderedChildren.includes(k));
120209
if (typeof v === 'undefined') continue;
121210
out[k] = v;
122211
}
@@ -129,9 +218,13 @@ function normalise(rawValue: unknown, key: string): unknown {
129218
/**
130219
* Compute the canonical sha256 hash of a spec, returned as
131220
* `"sha256:<64-hex>"`. Equal hashes imply equal canonical forms.
221+
*
222+
* @param type The metadata type of `value` — a repository passes its
223+
* `ref.type`, so a reorder of a declared ordered map is a new
224+
* version (guarantee 8). Omitted, every map is order-blind.
132225
*/
133-
export function hashSpec(value: unknown): string {
134-
const json = canonicalize(value);
226+
export function hashSpec(value: unknown, type?: string): string {
227+
const json = canonicalize(value, type);
135228
const digest = createHash('sha256').update(json, 'utf8').digest('hex');
136229
return `sha256:${digest}`;
137230
}

‎packages/metadata-core/src/contract-suite.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
* 1. Atomic put
1313
* 2. Monotonic seq per branch
1414
* 3. Optimistic locking (ConflictError)
15-
* 4. Canonical hashing (hash === hashSpec(body))
15+
* 4. Canonical hashing (hash === hashSpec(body, ref.type))
1616
* 5. Event ordering (monotonic seq, no gaps)
1717
* 6. Resumability (a numeric `since` replays; a bare `watch(filter)` is
1818
* owed live events only)
@@ -204,7 +204,7 @@ export function runRepositoryContractTests(
204204
const ref = refOf();
205205
const res = await repo.put(ref, spec('hello'), { parentVersion: null, actor: 'tester' });
206206
expect(res.version).toMatch(/^sha256:[0-9a-f]{64}$/);
207-
expect(res.version).toBe(hashSpec(spec('hello')));
207+
expect(res.version).toBe(hashSpec(spec('hello'), ref.type));
208208
expect(res.seq).toBeGreaterThan(0);
209209
expect(res.item.parentHash).toBeNull();
210210
expect(res.item.authoredBy).toBe('tester');
@@ -227,12 +227,12 @@ export function runRepositoryContractTests(
227227
expect(b.seq).toBeGreaterThan(a.seq);
228228
});
229229

230-
it('canonical hash invariant: item.hash === hashSpec(item.body)', async () => {
230+
it('canonical hash invariant: item.hash === hashSpec(item.body, item.ref.type)', async () => {
231231
const repo = await factory();
232232
const ref = refOf();
233233
await repo.put(ref, { z: 1, a: 2, m: [3, 1, 2] }, { parentVersion: null, actor: 't' });
234234
const got = await repo.get(ref);
235-
expect(got!.hash).toBe(hashSpec(got!.body));
235+
expect(got!.hash).toBe(hashSpec(got!.body, got!.ref.type));
236236
});
237237

238238
// ── #7856 — the version identifies the STORED BYTES ───────────
@@ -267,7 +267,7 @@ export function runRepositoryContractTests(
267267
const got = await repo.get(ref);
268268
expect(got).not.toBeNull();
269269
expect(got!.hash).toBe(put.version);
270-
expect(got!.hash).toBe(hashSpec(got!.body));
270+
expect(got!.hash).toBe(hashSpec(got!.body, got!.ref.type));
271271
});
272272

273273
it(`re-putting the same spec is a no-op — ${shape.label}`, async () => {

‎packages/metadata-core/src/in-memory-repository.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,8 @@ export class InMemoryRepository implements MetadataRepository {
101101
throw new ConflictError(ref, opts.parentVersion ?? null, currentHead);
102102
}
103103

104-
const hash = hashSpec(spec);
104+
// [#21790] Hashed as its type — a declared ordered map keeps its order.
105+
const hash = hashSpec(spec, ref.type);
105106

106107
// No-op write — same content. Still consumes nothing; no event emitted.
107108
if (current && current.hash === hash) {

‎packages/metadata-core/src/repository.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@
2121
* scoped to a single org may treat the entire repo as one log.)
2222
* 3. **Optimistic locking.** `put` and `delete` throw `ConflictError`
2323
* when `parentVersion` does not match the current HEAD.
24-
* 4. **Canonical hashing.** `item.hash === hashSpec(item.body)` — always.
24+
* 4. **Canonical hashing.** `item.hash === hashSpec(item.body, item.ref.type)`
25+
* — always. The type decides which maps keep their key order
26+
* (`canonicalize` guarantee 8). One stated exception, owned by
27+
* `SysMetadataRepository`: a row stamped before its type's canonical form
28+
* changed keeps the stamp it was written with until its content next
29+
* changes — that stamp is its version token.
2530
* 5. **Event ordering.** Subscribers to `watch()` receive events in
2631
* monotonically-increasing `seq` order with no gaps.
2732
* 6. **Resumability, and where it stops.** `watch(_, since)` called with a

0 commit comments

Comments
 (0)