Skip to content

Commit fc3a819

Browse files
os-zhuangclaude
andauthored
fix(objectql): converge the multi-tenant tenant-scope index at every /meta read exit (#8375) (#8458)
`GET /meta/object/:name` served a multi-tenant object with no `indexes` key at all when the answer came from the `metadata` service or a `sys_metadata` overlay row, while the registry's own resolved schema — and the list read — carried `indexes: [{ fields: ['organization_id'] }]`. The cause was a second implementation, not a missing line. `applyInjectedSystemColumns` (`@objectstack/metadata-core`) converges the #6562 system-column stamp at the read exits and cannot import its producer (`applySystemFields`, `@objectstack/objectql`) without running UP the dependency graph, so it re-implemented the half it could reach — the fields map — and omitted the index. So the decision moves into ONE function, `provisionTenantScopeIndex`, called by the producer and by `materializeBaseLayer` — the #8268 seam every read exit already replays. Adding a stamp there IS the convergence, which is the property that seam exists to provide. The write path owes the counterpart, and `indexes` concatenates under `mergeObjectDefinitions`: `stripProvisionedTenantIndexFrom` removes the LAST entry identical to the platform's own and keeps the removal only when re-stamping the remainder reproduces the arriving list byte-for-byte. A named entry, an author's tenant index ordered before their others, and the same entry on a single-tenant deployment are all kept. Two deliberate expected-divergence pins flip to assert convergence, as each was written to: the `indexes` case in the rest materialization-agreement suite, and the #6562/#6810 residual in `protocol-meta-effective-schema`. Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH Co-authored-by: Claude <noreply@anthropic.com>
1 parent d71ff32 commit fc3a819

5 files changed

Lines changed: 635 additions & 81 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): `GET /meta/object/:name` serves the multi-tenant tenant-scope index (#8375)
6+
7+
On a multi-tenant deployment the registry stamps `indexes: [{ fields:
8+
['organization_id'] }]` onto every object it materializes, but the by-name
9+
`/meta` read served the same object with **no `indexes` key at all** whenever the
10+
answer came from the `metadata` service or a `sys_metadata` overlay row. Same
11+
object, same moment, same host — the list read reported the index and the by-name
12+
read denied it.
13+
14+
`indexes` is not decoration: a consumer reading that answer concludes the object
15+
has no tenant index, which is the input to migration planning, to index-advice
16+
tooling and to any consumer reasoning about query cost. The platform does create
17+
the index; only this read denied it.
18+
19+
The cause was a second implementation rather than a missing line. The read exits
20+
converge the injected system columns with `applyInjectedSystemColumns`
21+
(`@objectstack/metadata-core`), which cannot import the producer
22+
(`applySystemFields`, `@objectstack/objectql`) without running up the dependency
23+
graph — so it re-implemented the half it could reach, the fields map, and
24+
silently omitted the index. The fix deletes that split: the decision is now one
25+
function called by the producer and by the registry's object-materialization
26+
seam, which every read exit already replays, so the two answers are one answer.
27+
28+
The write path takes it back off again, exactness-bounded, so the standard Studio
29+
GET → edit → PUT still stores a byte-identical body: an author's own tenant index
30+
— named, ordered before their others, or declared on a single-tenant deployment
31+
where the platform would add none — survives the round trip untouched.
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#8375] The write path takes back exactly what the converged read added
5+
* (#4326) — the multi-tenant `indexes` half.
6+
*
7+
* `packages/rest/src/meta-object-materialization-agreement.test.ts` pins the
8+
* READ: `GET /meta/object/:name` now serves the tenant-scope index the registry
9+
* stamps, so an object answered from the `metadata` service or a `sys_metadata`
10+
* overlay row no longer reports that a walled deployment has no index on its
11+
* hottest predicate. That convergence writes a real, authorable key onto bodies
12+
* that did not have one, and the write path persists a request body VERBATIM by
13+
* design (ADR-0005 §Validation) — so without a strip counterpart the ordinary
14+
* Studio GET → edit → PUT bakes a platform-computed index into
15+
* `sys_metadata.metadata`, into its checksum, and into every history diff.
16+
*
17+
* ## Why this file exists rather than one more case in the `nameField` pin
18+
*
19+
* `indexes` is the first CONCATENATING key to cross this seam, and that changes
20+
* the shape of the risk rather than repeating it. `nameField` is a scalar and
21+
* `fields` is keyed by name, so a re-added stamp overwrites its predecessor and
22+
* the worst case is a wrong value. A list under `mergeObjectDefinitions`
23+
* accumulates: a strip that is not exactness-bounded leaves the entry in the
24+
* stored row, and every actor that concatenates over that row — the extender
25+
* fold, an overlay merge — is then working from a base that already contains
26+
* what it is about to contribute.
27+
*
28+
* So the measurement that matters here is not "does one round trip come back
29+
* clean" but "does the list stay the same length across TWO of them, with the
30+
* stored row unchanged". A strip that never fires and a strip that is bounded
31+
* are indistinguishable on a single cycle read only at the served document —
32+
* both serve one entry. They differ in the ROW, immediately, and in the list
33+
* length as soon as anything concatenates.
34+
*
35+
* ## The boundary
36+
*
37+
* The strip removes the LAST entry identical to the platform's own and keeps the
38+
* removal only when re-stamping the remainder reproduces the arriving list
39+
* byte-for-byte (see `stripProvisionedTenantIndexFrom`). The cases below are the
40+
* four that boundary has to separate, and each is a real authoring shape: a
41+
* named entry, an author's own tenant index sitting before their others, the
42+
* same entry on a SINGLE-TENANT deployment where the seam would add nothing at
43+
* all, and an object that opts out of tenancy entirely.
44+
*
45+
* Lives in this package for the reason its two siblings do: the claim is about
46+
* the REAL `SchemaRegistry` and the REAL protocol write agreeing, and only this
47+
* package has both — `@objectstack/objectql` depends on
48+
* `@objectstack/metadata-protocol`, never the reverse.
49+
*/
50+
51+
import { describe, it, expect } from 'vitest';
52+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
53+
// [#5619] The producer's OWN write-verb dispatch decisions, so the fake engine
54+
// below cannot accept a call ObjectQL refuses.
55+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core';
56+
import { SchemaRegistry } from './registry.js';
57+
58+
interface Row {
59+
id: string;
60+
type: string;
61+
name: string;
62+
organization_id: string | null;
63+
package_id: string | null;
64+
state: string;
65+
metadata: string;
66+
checksum?: string;
67+
version?: number;
68+
}
69+
70+
/** The platform's own entry — the exact value the seam appends. */
71+
const PLATFORM_TENANT_INDEX = { fields: ['organization_id'] };
72+
73+
/** A plain business object: one authored field, no indexes of its own. */
74+
const AUTHORED = {
75+
name: 'crm_lead',
76+
label: 'Lead',
77+
fields: {
78+
name: { name: 'name', label: 'Name', type: 'text' },
79+
code_label: { name: 'code_label', label: 'Code label', type: 'text' },
80+
},
81+
};
82+
83+
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
84+
85+
function matches(r: Row, where: Record<string, unknown>): boolean {
86+
for (const [k, v] of Object.entries(where)) {
87+
if (v === undefined) continue;
88+
if ((r as any)[k] !== v) return false;
89+
}
90+
return true;
91+
}
92+
93+
function keyOf(w: Record<string, unknown>) {
94+
return `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`;
95+
}
96+
97+
/** A `sys_metadata` store over a REAL {@link SchemaRegistry}. */
98+
function makeHost(multiTenant: boolean) {
99+
// Companions OFF: this file is about the tenant index, and leaving the other
100+
// stamps' deployment gate out keeps a failure here unambiguous about which
101+
// half moved. The title designation still travels — it is not gated.
102+
const registry = new SchemaRegistry({ multiTenant, searchCompanion: false } as never);
103+
const rows = new Map<string, Row>();
104+
let nextId = 0;
105+
const findRow = (w: Record<string, unknown>) => {
106+
for (const [k, r] of rows) if (matches(r, w)) return { key: k, row: r };
107+
return null;
108+
};
109+
const engine: any = {
110+
registry,
111+
async findOne(_t: string, o: { where: Record<string, unknown> }) {
112+
return findRow(o.where)?.row ?? null;
113+
},
114+
async find(_t: string, o: { where: Record<string, unknown> }) {
115+
return Array.from(rows.values()).filter((r) => matches(r, o.where));
116+
},
117+
async insert(table: string, data: Record<string, unknown>) {
118+
if (table !== 'sys_metadata') return { id: 'side_table' };
119+
const row = { id: `r_${++nextId}`, ...(data as any) } as Row;
120+
rows.set(keyOf(data), row);
121+
return { id: row.id };
122+
},
123+
async update(table: string, data: Record<string, unknown>, o: { where: Record<string, unknown> }) {
124+
assertEngineUpdateDispatch(data, o);
125+
if (table !== 'sys_metadata') return { id: null };
126+
const found = findRow(o.where);
127+
if (!found) return { id: null };
128+
const merged = { ...found.row, ...(data as any) };
129+
rows.delete(found.key);
130+
rows.set(keyOf(merged), merged);
131+
return { id: found.row.id };
132+
},
133+
async delete(_t: string, o?: Record<string, unknown>) {
134+
assertEngineDeleteDispatch(o);
135+
return { deleted: 0 };
136+
},
137+
async transaction<T>(cb: (c: any, i: { owned: boolean }) => Promise<T>): Promise<T> {
138+
return cb(undefined, { owned: true });
139+
},
140+
async syncObjectSchema() { /* no DDL in this stub */ },
141+
async count() { return 0; },
142+
async aggregate() { return []; },
143+
};
144+
const protocol = new ObjectStackProtocolImplementation(engine as never, () => new Map() as never);
145+
const row = () => Array.from(rows.values()).find((r) => r.name === AUTHORED.name);
146+
const storedBody = () => {
147+
const r = row();
148+
return r ? (JSON.parse(r.metadata) as Record<string, any>) : undefined;
149+
};
150+
return { protocol, rows, registry, storedBody, row };
151+
}
152+
153+
async function seed(multiTenant: boolean, item: Record<string, unknown> = clone(AUTHORED)) {
154+
const host = makeHost(multiTenant);
155+
await host.protocol.saveMetaItem({ type: 'object', name: AUTHORED.name, item } as never);
156+
return host;
157+
}
158+
159+
/** The served document, as a client actually holds it. */
160+
async function served(host: { protocol: ObjectStackProtocolImplementation }) {
161+
return (await host.protocol.getMetaItem({
162+
type: 'object', name: AUTHORED.name,
163+
} as never)).item as any;
164+
}
165+
166+
describe('[#8375] the write path takes back the tenant index the read added (#4326)', () => {
167+
it('GET → PUT → GET → PUT: the list does not grow, and the row never carries the stamp', async () => {
168+
// The anti-vacuity arm of this file. One cycle cannot separate a strip
169+
// that is exactness-bounded from one that never fires — both SERVE a
170+
// single entry. Two cycles plus the stored row can.
171+
const host = await seed(true);
172+
const firstStored = host.storedBody()!;
173+
// Precondition: the author's own row never carried an index at all.
174+
expect(firstStored.indexes).toBeUndefined();
175+
176+
for (const cycle of [1, 2]) {
177+
const item = await served(host);
178+
// The read really does add it — non-vacuous on every cycle, not
179+
// only the first.
180+
expect(item.indexes, `cycle ${cycle} served`).toEqual([PLATFORM_TENANT_INDEX]);
181+
// …and adds exactly ONE, however many times we have been round.
182+
expect(item.indexes.length, `cycle ${cycle} length`).toBe(1);
183+
184+
await host.protocol.saveMetaItem({
185+
type: 'object', name: AUTHORED.name, item,
186+
} as never);
187+
188+
// The row is where duplication would accumulate, and it is the
189+
// assertion a served-document check cannot make for you.
190+
expect(host.storedBody()!.indexes, `cycle ${cycle} stored`).toBeUndefined();
191+
expect(host.storedBody(), `cycle ${cycle} body`).toEqual(firstStored);
192+
}
193+
});
194+
195+
it('a round-trip with NO edit leaves the stored body and its checksum identical', async () => {
196+
const host = await seed(true);
197+
const firstStored = host.storedBody()!;
198+
const firstChecksum = host.row()!.checksum;
199+
200+
await host.protocol.saveMetaItem({
201+
type: 'object', name: AUTHORED.name, item: await served(host),
202+
} as never);
203+
204+
expect(host.storedBody()).toEqual(firstStored);
205+
// The checksum is the half a byte-identity assertion can still miss —
206+
// it is what history diffs and change detection read.
207+
expect(host.row()!.checksum).toBe(firstChecksum);
208+
});
209+
210+
it('an EDIT round-trip stores the edit and nothing else', async () => {
211+
const host = await seed(true);
212+
const firstStored = host.storedBody()!;
213+
214+
const item = await served(host);
215+
await host.protocol.saveMetaItem({
216+
type: 'object', name: AUTHORED.name, item: { ...item, label: 'Lead (edited)' },
217+
} as never);
218+
219+
const stored = host.storedBody()!;
220+
expect(stored.label).toBe('Lead (edited)');
221+
expect(stored.indexes).toBeUndefined();
222+
// Everything except the edited key is byte-identical to the first save.
223+
expect({ ...stored, label: firstStored.label }).toEqual(firstStored);
224+
});
225+
226+
// ── The boundary that makes the strip safe ──────────────────────────────
227+
228+
it('KEEPS an author’s NAMED tenant index — never a candidate for the strip', async () => {
229+
// A named entry is not the value the seam appends, so the seam leaves it
230+
// alone on the way out (`declaresTenantIndex` already covers the single
231+
// organization_id column, named or not) and the strip never considers
232+
// it on the way in.
233+
const authored = { ...clone(AUTHORED), indexes: [{ name: 'my_tenant_idx', fields: ['organization_id'] }] };
234+
const host = await seed(true, authored);
235+
expect(host.storedBody()!.indexes).toEqual(authored.indexes);
236+
237+
const item = await served(host);
238+
// The read adds nothing: the object already declares a tenant index.
239+
expect(item.indexes).toEqual(authored.indexes);
240+
241+
await host.protocol.saveMetaItem({ type: 'object', name: AUTHORED.name, item } as never);
242+
expect(host.storedBody()!.indexes).toEqual(authored.indexes);
243+
});
244+
245+
it('KEEPS an author’s own tenant index declared BEFORE their other indexes', async () => {
246+
// The ORDER case, and the reason the strip compares whole lists rather
247+
// than asking "is there a matching entry". The author's tenant index is
248+
// byte-identical to the platform's, but it is not where the seam APPENDS
249+
// — so re-stamping the remainder produces a different list and the
250+
// removal is refused.
251+
const authored = {
252+
...clone(AUTHORED),
253+
indexes: [{ fields: ['organization_id'] }, { fields: ['code_label'] }],
254+
};
255+
const host = await seed(true, authored);
256+
expect(host.storedBody()!.indexes).toEqual(authored.indexes);
257+
258+
const item = await served(host);
259+
expect(item.indexes).toEqual(authored.indexes);
260+
261+
await host.protocol.saveMetaItem({ type: 'object', name: AUTHORED.name, item } as never);
262+
expect(host.storedBody()!.indexes).toEqual(authored.indexes);
263+
});
264+
265+
it('KEEPS the identical entry on a SINGLE-TENANT deployment — the seam adds nothing there', async () => {
266+
// The control that separates "bounded" from "removes anything that
267+
// looks like the platform's entry". These are the same BYTES as the
268+
// stamp; what differs is that on this deployment the seam would never
269+
// have produced them, so re-stamping cannot reproduce the list.
270+
const authored = { ...clone(AUTHORED), indexes: [{ fields: ['organization_id'] }] };
271+
const host = await seed(false, authored);
272+
expect(host.storedBody()!.indexes).toEqual(authored.indexes);
273+
274+
const item = await served(host);
275+
// …and the read adds no second copy either.
276+
expect(item.indexes).toEqual(authored.indexes);
277+
278+
await host.protocol.saveMetaItem({ type: 'object', name: AUTHORED.name, item } as never);
279+
expect(host.storedBody()!.indexes).toEqual(authored.indexes);
280+
});
281+
282+
it('adds and strips NOTHING on an object that opts out of the tenant column', async () => {
283+
// The stamp is gated on the spec's own derivation, not on the
284+
// deployment flag alone: `systemFields.tenant: false` withholds the
285+
// index exactly as it withholds the column, on a multi-tenant host.
286+
const opted = { ...clone(AUTHORED), systemFields: { tenant: false } };
287+
const host = await seed(true, opted);
288+
const firstStored = host.storedBody()!;
289+
expect(firstStored.indexes).toBeUndefined();
290+
291+
const item = await served(host);
292+
expect(item.indexes).toBeUndefined();
293+
expect(Object.keys(item.fields)).not.toContain('organization_id');
294+
295+
await host.protocol.saveMetaItem({ type: 'object', name: AUTHORED.name, item } as never);
296+
expect(host.storedBody()).toEqual(firstStored);
297+
});
298+
});

packages/objectql/src/protocol-meta-effective-schema.test.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -286,16 +286,26 @@ describe.each([true, false])('[#6562] /meta object read — effective schema (mu
286286
);
287287
expect(tenantIndexes(registryBacked)).toEqual(multiTenant ? [{ fields: ['organization_id'] }] : []);
288288

289-
// The one residual this fix leaves, recorded rather than left to be
290-
// rediscovered: the DECLARATION does not converge the way the field set
291-
// does. `divergences()` above compares fields, and the overlay-backed
292-
// answer is rebuilt from the stored body, which declares no indexes. It
293-
// is inert on this surface — a driver materializes from the REGISTERED
294-
// schema, never from a served document (the same reasoning #6562 used to
295-
// leave the flag at the injection site), and both answers parse green
296-
// either way. If a served-document consumer of `indexes[]` ever appears,
297-
// this is the line that says so.
298-
expect(tenantIndexes(overlayBacked)).toEqual([]);
289+
// [#8375 — FLIPPED, deliberately] This read `.toEqual([])`, under a note
290+
// recording the residual #6562/#6810 left: "the DECLARATION does not
291+
// converge the way the field set does… If a served-document consumer of
292+
// `indexes[]` ever appears, this is the line that says so."
293+
//
294+
// It appeared, and the line said so. `GET /meta/object/:name` IS a
295+
// served-document consumer of `indexes[]`: a caller reading the
296+
// overlay-backed answer concluded the object has no tenant index, which
297+
// is the input to migration planning, to index-advice tooling and to any
298+
// consumer reasoning about query cost — while the platform does create
299+
// the index and only this read denied it.
300+
//
301+
// What converges it is the registry's own materialization seam replayed
302+
// onto the served body (`materializeServedObjectOnto`, #8268), so the two
303+
// answers are ONE answer rather than two derivations that happen to
304+
// agree — which is why the assertion is written against the registry's
305+
// answer first and the literal second.
306+
expect(tenantIndexes(overlayBacked)).toEqual(tenantIndexes(registryBacked));
307+
expect(tenantIndexes(overlayBacked))
308+
.toEqual(multiTenant ? [{ fields: ['organization_id'] }] : []);
299309
});
300310

301311
it('the served correction never becomes a phantom customization', async () => {

0 commit comments

Comments
 (0)