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
37 changes: 37 additions & 0 deletions .changeset/seed-replay-tenant-stamp.md
Original file line number Diff line number Diff line change
@@ -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 =
<active org>`) 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 =
<target org>`, 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_<base36>` — 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.
28 changes: 28 additions & 0 deletions packages/metadata-protocol/src/seed-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <target org>` — 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;

Expand Down
98 changes: 98 additions & 0 deletions packages/objectql/src/seed-loader-org-stamp.test.ts
Original file line number Diff line number Diff line change
@@ -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 = <target org>`, 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_<base36>`, 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<string, unknown> }> = [];
const engine = {
find: async () => [],
insert: async (object: string, record: Record<string, unknown>) => {
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<string, unknown> = {}) =>
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();
});
});
Loading