From ca30220aa8e8387c43678656cf79011f136c1cab Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 13:36:32 +0000 Subject: [PATCH] fix(seed-loader): keep the per-org tenant stamp instead of resolving it as a natural key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-organization seed replay landed every row org-less, so a freshly created organization booted with data the tenant wall hides from everyone — including that organization's own owner. The stamp and the reference pass disagreed about what `organization_id` holds. The loader writes `config.organizationId` — the replay target's id. The reference pass then sees a field declared as a lookup to `sys_organization`, treats the value as a natural key, probes `sys_organization.name`, misses, and drops the field; a dropped reference takes the tenant attribution with it. The `id` fallback probe cannot rescue it: under replay every probe is AND-scoped with `organization_id = `, and `sys_organization` is the tenant table itself and carries no such column. The id SHAPE is what kept this hidden. `looksLikeInternalId` short-circuits UUID and ObjectId, so fixtures minting UUID org ids passed. Every organization better-auth creates — including the default org `ensureDefaultOrganization` bootstraps — is `org_`, which it does not recognise, so the defect fired on real deployments and nowhere else. The loader now remembers it wrote the stamp and skips resolution for that field only. A seed authoring `organization_id` itself still resolves, so naming an organization by natural key keeps working. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019TYoxKa8yFLiDDh7tkBqtu --- .changeset/seed-replay-tenant-stamp.md | 37 +++++++ packages/metadata-protocol/src/seed-loader.ts | 28 ++++++ .../src/seed-loader-org-stamp.test.ts | 98 +++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 .changeset/seed-replay-tenant-stamp.md create mode 100644 packages/objectql/src/seed-loader-org-stamp.test.ts diff --git a/.changeset/seed-replay-tenant-stamp.md b/.changeset/seed-replay-tenant-stamp.md new file mode 100644 index 0000000000..d536580ea5 --- /dev/null +++ b/.changeset/seed-replay-tenant-stamp.md @@ -0,0 +1,37 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +fix(seed-loader): the per-org tenant stamp is an id, not a natural key — stop +re-resolving it and dropping it + +In a multi-org deployment the SeedLoader's per-organization replay landed +**every row org-less**, so a freshly created organization booted with a CRM +whose tables held data nobody could see: the tenant wall (`organization_id = +`) hides a NULL-org row from all members, including the org's own +owner. + +The stamp and the reference pass disagreed about what `organization_id` holds. +The loader writes `config.organizationId` — the replay target's **id** — into +the record; the reference pass then sees a field declared as a lookup → +`sys_organization` and resolves its value as a **natural key**, probing +`sys_organization.name`. That misses, and a missed reference is dropped rather +than kept, taking the tenant attribution with it. The `id` fallback probe cannot +rescue it either: under replay every probe is AND-scoped with `organization_id = +`, and `sys_organization` — being the tenant table itself — carries +no such column, so that probe matches nothing by construction. + +What hid it for so long is the **id shape**. `looksLikeInternalId` recognises +UUID and Mongo ObjectId and short-circuits resolution for both, so any fixture +that minted UUID organization ids passed. Every organization better-auth +actually creates is `org_` — including the default organization +`ensureDefaultOrganization` bootstraps on first boot — and that shape is not +recognised. The defect therefore fired on real deployments and on nothing else. + +The loader now remembers that it wrote the stamp itself and skips resolution for +that one field. A seed that authors `organization_id` explicitly still goes +through resolution, so naming an organization by its natural key keeps working. + +Reported by `apps/ee-tenant-crm-showcase` in the cloud repo, which reproduces +the whole path end-to-end: two organizations over one database, each replaying +the artifact's seed datasets into its own private copy. diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index 91e557545d..f738e29e59 100644 --- a/packages/metadata-protocol/src/seed-loader.ts +++ b/packages/metadata-protocol/src/seed-loader.ts @@ -474,13 +474,41 @@ export class SeedLoaderService implements ISeedLoaderService { const tenantOrg = config.organizationId ?? (/^(sys_|cloud_|ai_)/.test(objectName) ? undefined : this.fallbackOrgId); + // Remember that WE wrote this value, so the reference pass below leaves it + // alone. `tenantOrg` is an ID by construction — the caller's target org, + // or a resolved `sys_organization.id` — never a natural key. But + // `organization_id` is declared as a lookup → `sys_organization`, so the + // pass would treat the id as a natural key, probe `sys_organization.name` + // for it, miss, and DROP the column: the row lands org-less and is then + // invisible to every member behind the tenant wall. + // + // The probe cannot rescue it either. `resolveFromDatabase` falls back to + // an `id` probe, but under per-tenant replay it AND-scopes every probe + // with `organization_id = ` — and `sys_organization`, being + // the tenant table itself, carries no such column, so that probe matches + // nothing by construction. + // + // Only better-auth-shaped ids (`org_msbubm8g3j35rgx0`) actually hit this: + // `looksLikeInternalId` recognises UUID/ObjectId and short-circuits those. + // Every organization better-auth creates — including the default org + // `ensureDefaultOrganization` bootstraps — carries the `org_` shape, so in + // a real multi-org deployment EVERY replayed row landed org-less, while + // fixtures that mint UUID org ids passed. That asymmetry is why this + // survived: see `apps/ee-tenant-crm-showcase` in the cloud repo, which + // reproduces it end-to-end. + let stampedTenantOrg = false; if (tenantOrg && record['organization_id'] == null) { record['organization_id'] = tenantOrg; + stampedTenantOrg = true; } // Resolve references let unresolvedRefError = false; for (const ref of objectRefs) { + // Never re-resolve the tenant stamp we just wrote (see above). A seed + // that authors `organization_id` ITSELF still goes through resolution, + // so naming an org by its natural key keeps working. + if (stampedTenantOrg && ref.field === 'organization_id') continue; const fieldValue = record[ref.field]; if (fieldValue === undefined || fieldValue === null) continue; diff --git a/packages/objectql/src/seed-loader-org-stamp.test.ts b/packages/objectql/src/seed-loader-org-stamp.test.ts new file mode 100644 index 0000000000..28eabec546 --- /dev/null +++ b/packages/objectql/src/seed-loader-org-stamp.test.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The SeedLoader's tenant stamp must survive the reference pass. +// +// `organization_id` is declared as a lookup → `sys_organization`, so it is a +// REFERENCE field as far as the loader's resolution pass is concerned. But the +// value the loader stamps into it (`config.organizationId`, the per-org replay +// target) is an ID, not a natural key. Resolving it as one probes +// `sys_organization.name` for an id, misses, and DROPS the column — the row +// lands org-less and is then invisible to every member behind the tenant wall. +// +// The `id` fallback probe cannot rescue it: under per-tenant replay every probe +// is AND-scoped with `organization_id = `, and `sys_organization` +// — the tenant table itself — has no such column. +// +// What kept this hidden is the ID SHAPE. `looksLikeInternalId` recognises UUID +// and Mongo ObjectId and short-circuits both, so any fixture minting UUID org +// ids passed. Every organization better-auth actually creates — including the +// default org `ensureDefaultOrganization` bootstraps — is `org_`, which +// it does not recognise. So the defect fired on real deployments only. + +import { describe, it, expect } from 'vitest'; +import { SeedLoaderService } from '@objectstack/metadata-protocol'; +import { SeedLoaderConfigSchema } from '@objectstack/spec/data'; + +/** + * Harness whose business object declares `organization_id` as the engine + * injects it — a lookup to `sys_organization` — which is what puts the stamp on + * the reference pass's path in the first place. + * + * `find` returns [] for every probe, standing in for the real miss: an org id + * matches neither `sys_organization.name` nor the tenant-scoped `id` probe. + */ +function harness() { + const inserted: Array<{ object: string; record: Record }> = []; + const engine = { + find: async () => [], + insert: async (object: string, record: Record) => { + inserted.push({ object, record }); + return { id: `${object}_${inserted.length}` }; + }, + update: async () => ({}), + }; + const metadata = { + getObject: async (name: string) => ({ + name, + fields: { + name: { type: 'text' }, + organization_id: { type: 'lookup', reference: 'sys_organization' }, + }, + }), + }; + const logger = { info() {}, warn() {}, error() {}, debug() {} }; + const svc = new SeedLoaderService(engine as never, metadata as never, logger as never); + return { svc, inserted }; +} + +const cfg = (over: Record = {}) => + SeedLoaderConfigSchema.parse({ mode: 'insert', ...over }); + +describe('SeedLoader tenant stamp survives the reference pass', () => { + it('keeps a better-auth-shaped organization id (the shape real deployments use)', async () => { + const { svc, inserted } = harness(); + const result = await svc.load({ + seeds: [{ object: 'project', records: [{ name: 'Apollo' }] }] as never, + config: cfg({ organizationId: 'org_msbubm8g3j35rgx0' }), + }); + + expect(inserted[0]?.record.organization_id).toBe('org_msbubm8g3j35rgx0'); + // A dropped stamp is reported as a reference error, never as a failed row — + // which is exactly why it went unnoticed: the seed summary reads clean. + expect(result.errors).toHaveLength(0); + expect(result.summary.totalReferencesDropped ?? 0).toBe(0); + }); + + it('keeps a UUID-shaped organization id too (the shape fixtures mint)', async () => { + const { svc, inserted } = harness(); + await svc.load({ + seeds: [{ object: 'project', records: [{ name: 'Apollo' }] }] as never, + config: cfg({ organizationId: '372e1c7b-493d-411a-9a92-faecdf7b3da9' }), + }); + expect(inserted[0]?.record.organization_id).toBe('372e1c7b-493d-411a-9a92-faecdf7b3da9'); + }); + + it('still RESOLVES an organization_id the seed itself authored as a natural key', async () => { + // The stamp is skipped only when the loader wrote it. A seed naming its org + // explicitly keeps going through resolution — so this one misses (the + // harness finds nothing) and is dropped, exactly as before the fix. + const { svc, inserted } = harness(); + await svc.load({ + seeds: [ + { object: 'project', records: [{ name: 'Apollo', organization_id: 'Acme Org' }] }, + ] as never, + config: cfg({ organizationId: 'org_msbubm8g3j35rgx0' }), + }); + expect(inserted[0]?.record.organization_id).toBeUndefined(); + }); +});