Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/tenant-index-author-declared-column.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
'@objectstack/objectql': patch
---

Declare the multi-tenant tenant-scope index whenever an object carries `organization_id` — whether the platform provisioned that column or the author declared it (#8459)

On a multi-tenant deployment `SecurityPlugin`'s tenant layer AND-composes `organization_id = <org>` onto essentially every read of a tenant-scoped object, and the platform declares `indexes: [{ fields: ['organization_id'] }]` so that predicate is served by an index. That declaration was gated on the column being the platform's own injected definition, byte-for-byte. An author who declared their own `organization_id` — adding a label, making it required, pointing it at their own org table — kept their column and silently lost the index on it: the deployment's hottest predicate running unindexed, reached by an additive-looking authoring move that removed a guarantee the author never knew they held. Isolation still held; it was slow, not wrong, which is why it went unreported.

The condition is lifted off the index half only. Unchanged: the platform still never overwrites an author-declared `organization_id`; an object that declares its own single-column tenant index still gets none from the platform (the opt-out for a different index shape); a single-tenant deployment still declares no tenant index at all; and an object that opts out of the tenant column (`systemFields: false`, `systemFields.tenant: false`, `tenancy.enabled: false`, `managedBy: 'better-auth'`) still gets neither column nor index. The declared column's TYPE is not inspected — a `text` org code is indexed too.

**DDL-bearing on the next `syncSchema`** for deployments that carry author-declared `organization_id` columns: the driver will create an index it did not create before. Index creation is additive and idempotent — no data migration, no column change, and re-running it is a no-op.
49 changes: 49 additions & 0 deletions packages/objectql/src/meta-object-tenant-index-roundtrip.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -279,6 +279,55 @@ describe('[#8375] the write path takes back the tenant index the read added (#43
expect(host.storedBody()!.indexes).toEqual(authored.indexes);
});

it('[#8459] round-trips an AUTHOR-DECLARED organization_id — stamp on, strip off', async () => {
// The combination that could not arise before #8459: the read now stamps
// the tenant index on an object whose `organization_id` the AUTHOR
// declared, so the write path owes the counterpart on that object too —
// and it is not a separate implementation to write. The strip re-stamps
// the remainder through `provisionTenantScopeIndex` ITSELF, so widening
// the stamp widens the strip in the same edit; this measures that it
// actually did, rather than asserting it from the code shape.
//
// Two cycles and the STORED ROW, for the reason the head of this file
// gives: one cycle read at the served document cannot separate a strip
// that is bounded from one that never fires.
const authored = {
...clone(AUTHORED),
fields: {
...clone(AUTHORED).fields,
// Not byte-identical to `TENANT_SCOPE_FIELD_DEF` — the author's
// own shape, which is what withheld the index before this card.
organization_id: { type: 'lookup', reference: 'sys_organization', label: 'Org' },
},
};
const host = await seed(true, authored);
const firstStored = host.storedBody()!;
// Preconditions, both load-bearing: the author's row carries no index,
// and the column stored is the author's own (the strip that removes
// INJECTED columns must not have taken it — it is not the platform's).
expect(firstStored.indexes).toBeUndefined();
expect(firstStored.fields.organization_id).toEqual(authored.fields.organization_id);

for (const cycle of [1, 2]) {
const item = await served(host);
expect(item.indexes, `cycle ${cycle} served`).toEqual([PLATFORM_TENANT_INDEX]);
// The author's column travels out unchanged beside the index the
// platform added — the field half of the ruling, on the served body.
expect(item.fields.organization_id, `cycle ${cycle} column`)
.toEqual(authored.fields.organization_id);

await host.protocol.saveMetaItem({
type: 'object', name: AUTHORED.name, item,
} as never);

// The row is where a missing strip would bake the platform's entry
// in — into `sys_metadata.metadata`, its checksum and every history
// diff (#4326).
expect(host.storedBody()!.indexes, `cycle ${cycle} stored`).toBeUndefined();
expect(host.storedBody(), `cycle ${cycle} body`).toEqual(firstStored);
}
});

it('adds and strips NOTHING on an object that opts out of the tenant column', async () => {
// The stamp is gated on the spec's own derivation, not on the
// deployment flag alone: `systemFields.tenant: false` withholds the
Expand Down
Loading
Loading