Skip to content

Commit b5c2321

Browse files
zhuangjianguoclaude
andcommitted
fix(seed-loader): keep the per-org tenant stamp instead of resolving it as a natural key
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 = <target org>`, 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_<base36>`, 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TYoxKa8yFLiDDh7tkBqtu
1 parent 868718e commit b5c2321

3 files changed

Lines changed: 163 additions & 0 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(seed-loader): the per-org tenant stamp is an id, not a natural key — stop
6+
re-resolving it and dropping it
7+
8+
In a multi-org deployment the SeedLoader's per-organization replay landed
9+
**every row org-less**, so a freshly created organization booted with a CRM
10+
whose tables held data nobody could see: the tenant wall (`organization_id =
11+
<active org>`) hides a NULL-org row from all members, including the org's own
12+
owner.
13+
14+
The stamp and the reference pass disagreed about what `organization_id` holds.
15+
The loader writes `config.organizationId` — the replay target's **id** — into
16+
the record; the reference pass then sees a field declared as a lookup →
17+
`sys_organization` and resolves its value as a **natural key**, probing
18+
`sys_organization.name`. That misses, and a missed reference is dropped rather
19+
than kept, taking the tenant attribution with it. The `id` fallback probe cannot
20+
rescue it either: under replay every probe is AND-scoped with `organization_id =
21+
<target org>`, and `sys_organization` — being the tenant table itself — carries
22+
no such column, so that probe matches nothing by construction.
23+
24+
What hid it for so long is the **id shape**. `looksLikeInternalId` recognises
25+
UUID and Mongo ObjectId and short-circuits resolution for both, so any fixture
26+
that minted UUID organization ids passed. Every organization better-auth
27+
actually creates is `org_<base36>` — including the default organization
28+
`ensureDefaultOrganization` bootstraps on first boot — and that shape is not
29+
recognised. The defect therefore fired on real deployments and on nothing else.
30+
31+
The loader now remembers that it wrote the stamp itself and skips resolution for
32+
that one field. A seed that authors `organization_id` explicitly still goes
33+
through resolution, so naming an organization by its natural key keeps working.
34+
35+
Reported by `apps/ee-tenant-crm-showcase` in the cloud repo, which reproduces
36+
the whole path end-to-end: two organizations over one database, each replaying
37+
the artifact's seed datasets into its own private copy.

packages/metadata-protocol/src/seed-loader.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,13 +474,41 @@ export class SeedLoaderService implements ISeedLoaderService {
474474
const tenantOrg =
475475
config.organizationId ??
476476
(/^(sys_|cloud_|ai_)/.test(objectName) ? undefined : this.fallbackOrgId);
477+
// Remember that WE wrote this value, so the reference pass below leaves it
478+
// alone. `tenantOrg` is an ID by construction — the caller's target org,
479+
// or a resolved `sys_organization.id` — never a natural key. But
480+
// `organization_id` is declared as a lookup → `sys_organization`, so the
481+
// pass would treat the id as a natural key, probe `sys_organization.name`
482+
// for it, miss, and DROP the column: the row lands org-less and is then
483+
// invisible to every member behind the tenant wall.
484+
//
485+
// The probe cannot rescue it either. `resolveFromDatabase` falls back to
486+
// an `id` probe, but under per-tenant replay it AND-scopes every probe
487+
// with `organization_id = <target org>` — and `sys_organization`, being
488+
// the tenant table itself, carries no such column, so that probe matches
489+
// nothing by construction.
490+
//
491+
// Only better-auth-shaped ids (`org_msbubm8g3j35rgx0`) actually hit this:
492+
// `looksLikeInternalId` recognises UUID/ObjectId and short-circuits those.
493+
// Every organization better-auth creates — including the default org
494+
// `ensureDefaultOrganization` bootstraps — carries the `org_` shape, so in
495+
// a real multi-org deployment EVERY replayed row landed org-less, while
496+
// fixtures that mint UUID org ids passed. That asymmetry is why this
497+
// survived: see `apps/ee-tenant-crm-showcase` in the cloud repo, which
498+
// reproduces it end-to-end.
499+
let stampedTenantOrg = false;
477500
if (tenantOrg && record['organization_id'] == null) {
478501
record['organization_id'] = tenantOrg;
502+
stampedTenantOrg = true;
479503
}
480504

481505
// Resolve references
482506
let unresolvedRefError = false;
483507
for (const ref of objectRefs) {
508+
// Never re-resolve the tenant stamp we just wrote (see above). A seed
509+
// that authors `organization_id` ITSELF still goes through resolution,
510+
// so naming an org by its natural key keeps working.
511+
if (stampedTenantOrg && ref.field === 'organization_id') continue;
484512
const fieldValue = record[ref.field];
485513
if (fieldValue === undefined || fieldValue === null) continue;
486514

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// The SeedLoader's tenant stamp must survive the reference pass.
4+
//
5+
// `organization_id` is declared as a lookup → `sys_organization`, so it is a
6+
// REFERENCE field as far as the loader's resolution pass is concerned. But the
7+
// value the loader stamps into it (`config.organizationId`, the per-org replay
8+
// target) is an ID, not a natural key. Resolving it as one probes
9+
// `sys_organization.name` for an id, misses, and DROPS the column — the row
10+
// lands org-less and is then invisible to every member behind the tenant wall.
11+
//
12+
// The `id` fallback probe cannot rescue it: under per-tenant replay every probe
13+
// is AND-scoped with `organization_id = <target org>`, and `sys_organization`
14+
// — the tenant table itself — has no such column.
15+
//
16+
// What kept this hidden is the ID SHAPE. `looksLikeInternalId` recognises UUID
17+
// and Mongo ObjectId and short-circuits both, so any fixture minting UUID org
18+
// ids passed. Every organization better-auth actually creates — including the
19+
// default org `ensureDefaultOrganization` bootstraps — is `org_<base36>`, which
20+
// it does not recognise. So the defect fired on real deployments only.
21+
22+
import { describe, it, expect } from 'vitest';
23+
import { SeedLoaderService } from '@objectstack/metadata-protocol';
24+
import { SeedLoaderConfigSchema } from '@objectstack/spec/data';
25+
26+
/**
27+
* Harness whose business object declares `organization_id` as the engine
28+
* injects it — a lookup to `sys_organization` — which is what puts the stamp on
29+
* the reference pass's path in the first place.
30+
*
31+
* `find` returns [] for every probe, standing in for the real miss: an org id
32+
* matches neither `sys_organization.name` nor the tenant-scoped `id` probe.
33+
*/
34+
function harness() {
35+
const inserted: Array<{ object: string; record: Record<string, unknown> }> = [];
36+
const engine = {
37+
find: async () => [],
38+
insert: async (object: string, record: Record<string, unknown>) => {
39+
inserted.push({ object, record });
40+
return { id: `${object}_${inserted.length}` };
41+
},
42+
update: async () => ({}),
43+
};
44+
const metadata = {
45+
getObject: async (name: string) => ({
46+
name,
47+
fields: {
48+
name: { type: 'text' },
49+
organization_id: { type: 'lookup', reference: 'sys_organization' },
50+
},
51+
}),
52+
};
53+
const logger = { info() {}, warn() {}, error() {}, debug() {} };
54+
const svc = new SeedLoaderService(engine as never, metadata as never, logger as never);
55+
return { svc, inserted };
56+
}
57+
58+
const cfg = (over: Record<string, unknown> = {}) =>
59+
SeedLoaderConfigSchema.parse({ mode: 'insert', ...over });
60+
61+
describe('SeedLoader tenant stamp survives the reference pass', () => {
62+
it('keeps a better-auth-shaped organization id (the shape real deployments use)', async () => {
63+
const { svc, inserted } = harness();
64+
const result = await svc.load({
65+
seeds: [{ object: 'project', records: [{ name: 'Apollo' }] }] as never,
66+
config: cfg({ organizationId: 'org_msbubm8g3j35rgx0' }),
67+
});
68+
69+
expect(inserted[0]?.record.organization_id).toBe('org_msbubm8g3j35rgx0');
70+
// A dropped stamp is reported as a reference error, never as a failed row —
71+
// which is exactly why it went unnoticed: the seed summary reads clean.
72+
expect(result.errors).toHaveLength(0);
73+
expect(result.summary.totalReferencesDropped ?? 0).toBe(0);
74+
});
75+
76+
it('keeps a UUID-shaped organization id too (the shape fixtures mint)', async () => {
77+
const { svc, inserted } = harness();
78+
await svc.load({
79+
seeds: [{ object: 'project', records: [{ name: 'Apollo' }] }] as never,
80+
config: cfg({ organizationId: '372e1c7b-493d-411a-9a92-faecdf7b3da9' }),
81+
});
82+
expect(inserted[0]?.record.organization_id).toBe('372e1c7b-493d-411a-9a92-faecdf7b3da9');
83+
});
84+
85+
it('still RESOLVES an organization_id the seed itself authored as a natural key', async () => {
86+
// The stamp is skipped only when the loader wrote it. A seed naming its org
87+
// explicitly keeps going through resolution — so this one misses (the
88+
// harness finds nothing) and is dropped, exactly as before the fix.
89+
const { svc, inserted } = harness();
90+
await svc.load({
91+
seeds: [
92+
{ object: 'project', records: [{ name: 'Apollo', organization_id: 'Acme Org' }] },
93+
] as never,
94+
config: cfg({ organizationId: 'org_msbubm8g3j35rgx0' }),
95+
});
96+
expect(inserted[0]?.record.organization_id).toBeUndefined();
97+
});
98+
});

0 commit comments

Comments
 (0)