Skip to content

Commit e13fd91

Browse files
os-zhuangclaude
andauthored
fix(objectql,driver-mongodb): declare the tenant index in indexes[], so a registry-backed object stops reporting itself invalid (#6810) (#6812)
* fix(objectql,driver-mongodb): declare the tenant index in `indexes[]` (#6810) `applySystemFields` provisioned the injected `organization_id` column with `indexed: opts.multiTenant`. `indexed` is not a `FieldSchema` key — #2377 / ADR-0049 removed it because a field-level index flag built no index — and `FieldSchema` is a `strictObject`, so a field carrying it is rejected by name. `registerObject` runs `applySystemFields` before storing and `getItem('object', …)` serves that post-injection document, so the key reached `/meta`, where `decorateMetadataItem` re-parsed the served body and stamped `_diagnostics: { valid: false, errors: [{ path: 'fields.organization_id', code: 'unrecognized_keys' }] }` on every registry-backed object — both tenancy modes, both read exits. That is the channel Studio renders invalid-metadata banners from and an AI author reads to judge its own document, so the platform was reporting a defect on its own column and drowning real authoring errors. The tenant index is now declared in the object's `indexes[]`, where every other index in this system is declared: `{ fields: ['organization_id'] }` on a multi-tenant stack, nothing at all on a single-tenant one (absence is what `indexed: false` meant). `driver-mongodb` — the sole reader of the retired flag — reads declared indexes instead, generating the same index name it used to, so a re-synced collection finds its existing `idx_organization_id`. `driver-sql` already materialized `indexes[]`, so this is the first time the intent is enforced there at all. No `FieldSchema` change: re-declaring `indexed` would restore exactly the declared-but-unenforced key #2377 removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JE8Bbwb8qhau3yNtP3ftLJ * test(objectql): open the new fake engine's write verbs with the dispatch guards (#6810) `check:engine-double-contract` pins every engine double to ObjectQL's own `delete`/`update` dispatch predicates — a fake looser than the producer is how #4434 shipped a dead REST route with its suite green. The fake in `registry-tenant-index-declaration.test.ts` now routes both verbs through `assertEngineDeleteDispatch` / `assertEngineUpdateDispatch` from `@objectstack/metadata-core`, matching the pinned fake in `protocol-meta-effective-schema.test.ts` next to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JE8Bbwb8qhau3yNtP3ftLJ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6de592c commit e13fd91

12 files changed

Lines changed: 704 additions & 79 deletions
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/driver-mongodb": patch
4+
---
5+
6+
fix(objectql,driver-mongodb): declare the tenant index in `indexes[]`, so a registry-backed object stops reporting itself invalid (#6810)
7+
8+
`applySystemFields` provisioned the injected `organization_id` column with
9+
`indexed: opts.multiTenant`. `indexed` is **not a `FieldSchema` key**#2377 /
10+
ADR-0049 removed it because a field-level index flag built no index — and
11+
`FieldSchema` is a `strictObject`, so a field carrying it is rejected **by
12+
name**, with a purpose-written message.
13+
14+
`registerObject` runs `applySystemFields` *before* storing and
15+
`getItem('object', …)` serves that post-injection document, so the key travelled
16+
all the way out to `/meta`, where `decorateMetadataItem` re-parsed the served
17+
body and stamped the verdict on it. Measured on every registry-backed object, in
18+
**both** tenancy modes, at **both** read exits:
19+
20+
```
21+
_diagnostics: { valid: false,
22+
errors: [{ path: 'fields.organization_id', code: 'unrecognized_keys' }] }
23+
```
24+
25+
`_diagnostics` is what Studio renders invalid-metadata banners from and what an
26+
AI author reads to judge a document it produced. So the platform was reporting a
27+
defect on its own column — one the author never wrote and could not fix — and
28+
making the verdict useless as a signal on those objects, because a real
29+
authoring error was indistinguishable from this one.
30+
31+
**Two directions, both of them user-visible:**
32+
33+
- **The false `valid: false` verdict is gone.** A tenancy-enabled object
34+
registered through the real `SchemaRegistry` now reads back
35+
`_diagnostics: { valid: true }` at both `/meta` exits, in both tenancy modes.
36+
Nothing else about the served field changed — `type`, `reference`, and the
37+
governance keys that decide who may write it are byte-identical.
38+
- **The tenant index moved from a field-level flag to `indexes[]`**, the one
39+
surface an index is declared on in this system. On a multi-tenant stack the
40+
object now declares `{ fields: ['organization_id'] }`; on a single-tenant
41+
stack it declares **nothing** — the absence *is* what `indexed: false` used to
42+
say, since nothing filters by organization on an unwalled stack.
43+
44+
This is also the first time the intent is actually **enforced**. The sole reader
45+
of the old flag was one line in `driver-mongodb`; `driver-sql` — which every
46+
walled deployment runs — only ever materialized `indexes[]`, so the wall's
47+
hottest predicate ran unindexed no matter what the flag said. Expect the tenant
48+
index to now appear as ordinary index drift on existing SQL tables
49+
(`idx_<table>_organization_id`), created by `os migrate apply` or by the
50+
`autoMigrate: 'safe'` path in dev, like any other declared index.
51+
52+
`driver-mongodb` reads declared `indexes[]` in place of the retired flag. The
53+
generated index name matches the field-level convention already in that file
54+
(`idx_<fields>` / `idx_<fields>_unique`), so a re-synced collection finds its
55+
existing `idx_organization_id` rather than building a second index under a new
56+
name. Declarations are materialized over their columns **verbatim** at every
57+
`unique` scope, `'organization'` included — the same call the driver's
58+
field-level `unique` documents, because it implements no row-level tenancy and
59+
refuses to boot into a multi-tenant deployment (#3724).
60+
61+
No `FieldSchema` change: re-declaring `indexed` would restore exactly the
62+
declared-but-unenforced key #2377 removed.

packages/drivers/driver-mongodb/README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,14 +158,20 @@ try {
158158

159159
Schema sync creates collections and indexes:
160160

161+
Field-level `unique` and lookup fields index themselves; everything else is
162+
declared in the object's `indexes[]` — the one surface an index is declared on
163+
(a field-level `indexed` flag is not a `FieldSchema` key and never built an
164+
index, #2377 / #6810).
165+
161166
```typescript
162167
await driver.syncSchema('account', {
163168
name: 'account',
164169
fields: {
165170
name: { type: 'string', unique: true },
166-
email: { type: 'email', indexed: true },
171+
email: { type: 'email' },
167172
company_id: { type: 'lookup', reference_to: 'company' },
168173
},
174+
indexes: [{ fields: ['email'] }],
169175
});
170176
// Creates: idx_id_unique, idx_name_unique, idx_email, idx_company_id_lookup
171177
```

packages/drivers/driver-mongodb/src/mongodb-driver.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,9 +343,15 @@ describe.skipIf(!sharedMongod)('MongoDBDriver', () => {
343343
name: 'account',
344344
fields: {
345345
name: { type: 'string', unique: true },
346-
email: { type: 'email', indexed: true },
346+
email: { type: 'email' },
347347
company_id: { type: 'lookup', reference_to: 'company' },
348348
},
349+
// [#6810] `email` used to carry a field-level `indexed: true` here. That
350+
// was never a `FieldSchema` key (#2377 / ADR-0049); the index is
351+
// declared in `indexes[]`, where every other index in this system is
352+
// declared. Same resulting index name — the assertions are unchanged,
353+
// which is the point.
354+
indexes: [{ fields: ['email'] }],
349355
});
350356

351357
const db = driver.getDb();
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #6810 — `syncCollectionSchema` materializes the object's DECLARED `indexes[]`.
4+
//
5+
// This driver used to read a field-level `indexed` flag instead. That flag was
6+
// never a `FieldSchema` key (#2377 / ADR-0049 removed it, and `FieldSchema` is a
7+
// `strictObject` that rejects it by name), so its one remaining producer — the
8+
// kernel's `organization_id` injection — was stamping every registry-backed
9+
// object with a document the platform's own schema refused. The declaration
10+
// moved to `indexes[]`; this pins that the DDL outcome came with it, rather than
11+
// the fix quietly deleting an index the flag used to build.
12+
//
13+
// Driven against a fake `Db` on purpose: the `mongodb-memory-server` suite in
14+
// this package is OPT-IN (it downloads a real server binary, #5517), so a DDL
15+
// assertion parked there would not run on any ordinary CI lane — which is
16+
// exactly the lane that has to notice if this regresses.
17+
18+
import { describe, it, expect } from 'vitest';
19+
import type { Db } from 'mongodb';
20+
import { syncCollectionSchema } from './mongodb-schema.js';
21+
22+
interface CreatedIndex {
23+
spec: Record<string, unknown>;
24+
options: Record<string, unknown>;
25+
}
26+
27+
/**
28+
* The narrow slice of `Db` `syncCollectionSchema` touches, recording every
29+
* `createIndex` call in order. Nothing is stubbed beyond that slice — the
30+
* function under test runs verbatim.
31+
*/
32+
function fakeDb(existingCollections: string[] = []) {
33+
const created: CreatedIndex[] = [];
34+
const collectionsCreated: string[] = [];
35+
const db = {
36+
listCollections: ({ name }: { name: string }) => ({
37+
toArray: async () => (existingCollections.includes(name) ? [{ name }] : []),
38+
}),
39+
createCollection: async (name: string) => {
40+
collectionsCreated.push(name);
41+
},
42+
collection: () => ({
43+
createIndex: async (spec: Record<string, unknown>, options: Record<string, unknown>) => {
44+
created.push({ spec, options });
45+
},
46+
}),
47+
} as unknown as Db;
48+
return { db, created, collectionsCreated };
49+
}
50+
51+
/** Every index name the sync asked MongoDB to create, core indexes included. */
52+
const names = (created: CreatedIndex[]) => created.map((c) => c.options.name);
53+
54+
/** The one recorded creation for `name`, or `undefined`. */
55+
const byName = (created: CreatedIndex[], name: string) =>
56+
created.find((c) => c.options.name === name);
57+
58+
describe('#6810 — syncCollectionSchema materializes declared indexes[]', () => {
59+
it('creates the kernel-declared tenant index, byte-identical to what the retired flag built', async () => {
60+
// THE regression pin. Before #6810 this DDL came from
61+
// `organization_id: { …, indexed: true }`; it now comes from the object's
62+
// `indexes[]`. Same collection, same key spec, same index NAME — a
63+
// re-synced deployment finds its index already present rather than
64+
// building a second one under a new name.
65+
const { db, created } = fakeDb();
66+
await syncCollectionSchema(db, 'lead', {
67+
name: 'lead',
68+
fields: {
69+
first_name: { type: 'text' },
70+
organization_id: { type: 'lookup' },
71+
},
72+
indexes: [{ fields: ['organization_id'] }],
73+
});
74+
75+
const tenant = byName(created, 'idx_organization_id');
76+
expect(tenant).toBeDefined();
77+
expect(tenant!.spec).toEqual({ organization_id: 1 });
78+
// A plain lookup index, never a constraint.
79+
expect(tenant!.options.unique).toBeUndefined();
80+
});
81+
82+
it('declares no tenant index when the object declares none', async () => {
83+
// The single-tenant half: `multiTenant: false` now declares NOTHING rather
84+
// than a `false` flag, so absence has to stay absence here.
85+
const { db, created } = fakeDb();
86+
await syncCollectionSchema(db, 'lead', {
87+
name: 'lead',
88+
fields: { first_name: { type: 'text' }, organization_id: { type: 'lookup' } },
89+
});
90+
91+
expect(names(created)).not.toContain('idx_organization_id');
92+
// The core set is untouched by any of this.
93+
expect(names(created)).toEqual(['idx_id_unique', 'idx_created_at', 'idx_updated_at']);
94+
});
95+
96+
it('honours an explicit index name and a multi-column declaration', async () => {
97+
const { db, created } = fakeDb();
98+
await syncCollectionSchema(db, 'lead', {
99+
name: 'lead',
100+
fields: { organization_id: { type: 'lookup' }, code: { type: 'text' } },
101+
indexes: [{ name: 'lead_scope_idx', fields: ['organization_id', 'code'] }],
102+
});
103+
104+
const idx = byName(created, 'lead_scope_idx');
105+
expect(idx).toBeDefined();
106+
// Key order follows the declaration — a compound index is order-sensitive.
107+
expect(Object.keys(idx!.spec)).toEqual(['organization_id', 'code']);
108+
});
109+
110+
it('materializes a declared unique index, at every scope, over the columns VERBATIM', async () => {
111+
// `'organization'` is NOT scoped up with a tenant key part here, and that is
112+
// deliberate — the same call `FieldDef.unique` documents in the source.
113+
// This driver implements no row-level tenancy and refuses to boot into a
114+
// multi-tenant deployment (#3724), so a `(tenant, field)` index would
115+
// advertise an isolation it does not deliver.
116+
for (const scope of [true, 'global', 'organization'] as const) {
117+
const { db, created } = fakeDb();
118+
await syncCollectionSchema(db, 'lead', {
119+
name: 'lead',
120+
fields: { organization_id: { type: 'lookup' }, code: { type: 'text' } },
121+
indexes: [{ fields: ['code'], unique: scope }],
122+
});
123+
124+
const idx = byName(created, 'idx_code_unique');
125+
expect(idx, String(scope)).toBeDefined();
126+
expect(idx!.spec).toEqual({ code: 1 });
127+
expect(idx!.options.unique).toBe(true);
128+
expect(names(created)).not.toContain('idx_organization_id_code_unique');
129+
}
130+
});
131+
132+
it('a declared index and a field-level `unique` on the same column converge on ONE index', async () => {
133+
// Why the generated name mirrors the field-level convention: two routes
134+
// asking for the same constraint must land on the same name, or MongoDB
135+
// sees two indexes over one key spec.
136+
const { db, created } = fakeDb();
137+
await syncCollectionSchema(db, 'lead', {
138+
name: 'lead',
139+
fields: { email: { type: 'email', unique: true } },
140+
indexes: [{ fields: ['email'], unique: true }],
141+
});
142+
143+
expect(names(created).filter((n) => n === 'idx_email_unique')).toHaveLength(2);
144+
const [a, b] = created.filter((c) => c.options.name === 'idx_email_unique');
145+
expect(a.spec).toEqual(b.spec);
146+
expect(a.options).toEqual(b.options);
147+
});
148+
149+
it('skips a declaration with no usable fields rather than emitting empty DDL', async () => {
150+
const { db, created } = fakeDb();
151+
await syncCollectionSchema(db, 'lead', {
152+
name: 'lead',
153+
fields: { code: { type: 'text' } },
154+
indexes: [{ fields: [] }, { name: 'ghost' } as { name: string }],
155+
});
156+
157+
expect(names(created)).toEqual(['idx_id_unique', 'idx_created_at', 'idx_updated_at']);
158+
});
159+
});

packages/drivers/driver-mongodb/src/mongodb-schema.ts

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,37 @@ interface FieldDef {
3333
* `SqlDriver.uniqueIndexesFromFields`.
3434
*/
3535
unique?: boolean | 'global';
36-
indexed?: boolean;
3736
required?: boolean;
3837
reference_to?: string;
3938
multiple?: boolean;
4039
}
4140

41+
/**
42+
* A declared object-level index — `IndexSchema` in @objectstack/spec, narrowed
43+
* to what this driver materializes.
44+
*
45+
* [#6810] This is the ONE surface an index is declared on. The field-level
46+
* `indexed` flag this driver used to read alongside it was never a
47+
* `FieldSchema` key: #2377 / ADR-0049 removed it because a field-level index
48+
* flag built no index, and `FieldSchema` — a `strictObject` — rejects it by
49+
* name. The single producer still emitting it was the kernel's
50+
* `organization_id` injection, which therefore stamped every registry-backed
51+
* object with a document its own schema refused. That declaration moved to
52+
* `indexes[]`; nothing else in the repo ever read the flag, so it is gone.
53+
*/
54+
interface IndexDef {
55+
name?: string;
56+
fields?: string[];
57+
unique?: boolean | 'global' | 'organization';
58+
}
59+
4260
/**
4361
* ObjectStack object definition (subset needed for schema sync).
4462
*/
4563
interface ObjectDef {
4664
name: string;
4765
fields?: Record<string, FieldDef>;
66+
indexes?: IndexDef[];
4867
}
4968

5069
/**
@@ -53,8 +72,9 @@ interface ObjectDef {
5372
* - Creates the collection if it doesn't exist
5473
* - Creates a unique index on `id`
5574
* - Creates indexes on `created_at` and `updated_at`
56-
* - Creates indexes for fields marked `unique` or `indexed`
75+
* - Creates indexes for fields marked `unique`
5776
* - Creates indexes on lookup (reference) fields
77+
* - Creates the object's DECLARED `indexes[]` (#6810)
5878
*/
5979
export async function syncCollectionSchema(
6080
db: Db,
@@ -84,11 +104,6 @@ export async function syncCollectionSchema(
84104
spec: { [fieldName]: 1 },
85105
options: { unique: true, sparse: true, name: `idx_${fieldName}_unique` },
86106
});
87-
} else if (field.indexed) {
88-
indexOps.push({
89-
spec: { [fieldName]: 1 },
90-
options: { name: `idx_${fieldName}` },
91-
});
92107
}
93108

94109
// Lookup + user (a lookup specialized to sys_user) fields get an index for
@@ -106,6 +121,37 @@ export async function syncCollectionSchema(
106121
}
107122
}
108123

124+
// Declared object-level indexes (#6810) — the surface `indexes[]`, which is
125+
// where every other index in this system is declared and where the kernel now
126+
// declares the tenant index on `organization_id`.
127+
//
128+
// The generated name is `idx_<fields>` / `idx_<fields>_unique`, matching the
129+
// field-level convention above so the two routes converge on ONE index rather
130+
// than racing to create two with different options on the same column set
131+
// (Mongo index names are per-collection, so no table qualifier is needed —
132+
// unlike `SqlDriver`'s `buildIndexName`, which is why neither driver takes a
133+
// name from the declaration when the author left it out).
134+
//
135+
// Every `unique` scope materializes the columns VERBATIM, `'organization'`
136+
// included. That is the same call `FieldDef.unique` documents above and for
137+
// the same reason: this driver implements no row-level tenancy at all and
138+
// refuses to boot into a multi-tenant deployment (#3724), so prepending a
139+
// tenant key part would advertise an isolation it does not deliver.
140+
for (const idx of schema.indexes ?? []) {
141+
const fields = (idx.fields ?? []).filter((f) => typeof f === 'string' && f.length > 0);
142+
if (fields.length === 0) continue;
143+
const unique = Boolean(idx.unique);
144+
const spec: Record<string, 1> = {};
145+
for (const f of fields) spec[f] = 1;
146+
indexOps.push({
147+
spec: spec as IndexSpecification,
148+
options: {
149+
...(unique ? { unique: true, sparse: true } : {}),
150+
name: idx.name ?? `idx_${fields.join('_')}${unique ? '_unique' : ''}`,
151+
},
152+
});
153+
}
154+
109155
// Create indexes (idempotent — MongoDB ignores duplicates)
110156
for (const { spec, options } of indexOps) {
111157
try {

0 commit comments

Comments
 (0)