Skip to content

Commit c584e21

Browse files
authored
fix(objectql): stamp the tenant org-key onto un-pinned business seed rows (#2376)
1 parent 638f472 commit c584e21

2 files changed

Lines changed: 136 additions & 7 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// The SeedLoader stamps business seed rows with the tenant's organization key so
4+
// they don't vanish under strict org-scoping. When the caller pins no org (an
5+
// in-process publish has no active user session — the AI build agent's publish
6+
// path), the loader adopts the tenant's SOLE organization as a fallback. A
7+
// `sys_`/platform seed never takes the fallback (those stay global). Zero or
8+
// many orgs → leave rows org-less (genuinely ambiguous → historical behavior).
9+
10+
import { describe, it, expect } from 'vitest';
11+
import { SeedLoaderService } from './seed-loader';
12+
import { SeedLoaderConfigSchema } from '@objectstack/spec/data';
13+
14+
function harness(orgRows: Array<{ id: string }>) {
15+
const inserted: Array<{ object: string; record: Record<string, unknown> }> = [];
16+
const engine = {
17+
// sys_organization is the org-count probe; everything else (ref lookups) is empty.
18+
find: async (object: string) => (object === 'sys_organization' ? orgRows : []),
19+
insert: async (object: string, record: Record<string, unknown>) => {
20+
inserted.push({ object, record });
21+
return { id: `${object}_${inserted.length}` };
22+
},
23+
update: async () => ({}),
24+
};
25+
const metadata = {
26+
// A single text field → no lookup/master_detail references to resolve.
27+
getObject: async (name: string) => ({ name, fields: { name: { type: 'text' } } }),
28+
};
29+
const logger = { info() {}, warn() {}, error() {}, debug() {} };
30+
const svc = new SeedLoaderService(engine as never, metadata as never, logger as never);
31+
return { svc, inserted };
32+
}
33+
34+
const cfg = (over: Record<string, unknown> = {}) =>
35+
SeedLoaderConfigSchema.parse({ mode: 'insert', ...over });
36+
37+
describe('SeedLoader org-key fallback (un-pinned publish)', () => {
38+
it('stamps the SOLE organization onto un-pinned BUSINESS seed rows', async () => {
39+
const { svc, inserted } = harness([{ id: 'org_only' }]);
40+
await svc.load({
41+
seeds: [{ object: 'project', records: [{ name: 'Apollo' }] }] as never,
42+
config: cfg(),
43+
});
44+
expect(inserted[0]?.record.organization_id).toBe('org_only');
45+
});
46+
47+
it('does NOT take the fallback for sys_/platform seeds (they stay global)', async () => {
48+
const { svc, inserted } = harness([{ id: 'org_only' }]);
49+
await svc.load({
50+
seeds: [{ object: 'sys_widget_pref', records: [{ name: 'X' }] }] as never,
51+
config: cfg(),
52+
});
53+
expect(inserted[0]?.record.organization_id).toBeUndefined();
54+
});
55+
56+
it('leaves rows org-less when the tenant org is ambiguous (≠ exactly one)', async () => {
57+
const { svc, inserted } = harness([{ id: 'a' }, { id: 'b' }]);
58+
await svc.load({
59+
seeds: [{ object: 'project', records: [{ name: 'Apollo' }] }] as never,
60+
config: cfg(),
61+
});
62+
expect(inserted[0]?.record.organization_id).toBeUndefined();
63+
});
64+
65+
it('leaves rows org-less when there is no organization at all', async () => {
66+
const { svc, inserted } = harness([]);
67+
await svc.load({
68+
seeds: [{ object: 'project', records: [{ name: 'Apollo' }] }] as never,
69+
config: cfg(),
70+
});
71+
expect(inserted[0]?.record.organization_id).toBeUndefined();
72+
});
73+
74+
it('an explicitly pinned org still wins over the fallback path', async () => {
75+
const { svc, inserted } = harness([{ id: 'sole_ignored' }]);
76+
await svc.load({
77+
seeds: [{ object: 'project', records: [{ name: 'Apollo' }] }] as never,
78+
config: cfg({ organizationId: 'org_pinned' }),
79+
});
80+
expect(inserted[0]?.record.organization_id).toBe('org_pinned');
81+
});
82+
});

packages/objectql/src/seed-loader.ts

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ export class SeedLoaderService implements ISeedLoaderService {
4141
private engine: IDataEngine;
4242
private metadata: IMetadataService;
4343
private logger: Logger;
44+
/**
45+
* Tenant org to stamp BUSINESS seed rows with when the caller pinned no
46+
* explicit `config.organizationId` (resolved per {@link resolveSoleOrganizationId}).
47+
* Set once per {@link load}; never applied to `sys_`/`cloud_`/`ai_` platform
48+
* seeds (those stay intentionally global/cross-tenant).
49+
*/
50+
private fallbackOrgId?: string;
4451

4552
constructor(engine: IDataEngine, metadata: IMetadataService, logger: Logger) {
4653
this.engine = engine;
@@ -58,6 +65,16 @@ export class SeedLoaderService implements ISeedLoaderService {
5865
const allErrors: ReferenceResolutionError[] = [];
5966
const allResults: SeedLoadResult[] = [];
6067

68+
// When the caller pinned no target org (an in-process publish has no active
69+
// user session — the AI build agent's publish path), BUSINESS seed rows
70+
// would land `organization_id = NULL` and then vanish under strict
71+
// org-scoping. If the tenant has exactly ONE organization, adopt it as a
72+
// fallback so business seeds carry the tenant key like a normal write.
73+
// Zero/many orgs → leave unset (genuinely ambiguous → keep the historical
74+
// global/cross-tenant behavior; the publisher must scope explicitly).
75+
this.fallbackOrgId =
76+
config.organizationId == null ? await this.resolveSoleOrganizationId() : undefined;
77+
6178
// 1. Filter datasets by environment
6279
const datasets = this.filterByEnv(request.seeds, config.env);
6380

@@ -265,13 +282,17 @@ export class SeedLoaderService implements ISeedLoaderService {
265282
}
266283
const record = { ...(seedResult.value as Record<string, unknown>) };
267284

268-
// Per-tenant tagging: when a target org is set, stamp every
269-
// seeded row with it (unless the record itself already supplies
270-
// an explicit organization_id — respect dataset author overrides).
271-
// Skipped objects that don't declare `organization_id` will have
272-
// the extra key silently ignored by the engine.
273-
if (config.organizationId && record['organization_id'] == null) {
274-
record['organization_id'] = config.organizationId;
285+
// Per-tenant tagging: stamp every seeded row with the target org — the
286+
// caller's explicit `config.organizationId`, or (when none was pinned) the
287+
// single-org fallback for BUSINESS objects only. A `sys_`/`cloud_`/`ai_`
288+
// platform seed never takes the fallback: those stay global/cross-tenant.
289+
// A record that supplies its own `organization_id` always wins; objects
290+
// without the column ignore the extra key at the engine.
291+
const tenantOrg =
292+
config.organizationId ??
293+
(/^(sys_|cloud_|ai_)/.test(objectName) ? undefined : this.fallbackOrgId);
294+
if (tenantOrg && record['organization_id'] == null) {
295+
record['organization_id'] = tenantOrg;
275296
}
276297

277298
// Resolve references
@@ -436,6 +457,32 @@ export class SeedLoaderService implements ISeedLoaderService {
436457
// Internal: Reference Resolution
437458
// ==========================================================================
438459

460+
/**
461+
* Best-effort resolve the tenant's SOLE organization id — used to stamp
462+
* business seed rows when the caller pinned no `config.organizationId` (an
463+
* in-process publish has no active user session). A fresh env has exactly one
464+
* org, so its seeds should carry it like a normal write instead of landing
465+
* org-less (→ invisible under strict org-scoping). Returns undefined when
466+
* there are zero or several orgs (genuinely ambiguous — keep the historical
467+
* global/cross-tenant NULL) or when `sys_organization` is absent.
468+
*/
469+
private async resolveSoleOrganizationId(): Promise<string | undefined> {
470+
try {
471+
const rows = await this.engine.find('sys_organization', {
472+
fields: ['id'],
473+
limit: 2,
474+
context: { isSystem: true },
475+
} as any);
476+
if (Array.isArray(rows) && rows.length === 1) {
477+
const id = (rows[0] as { id?: unknown; _id?: unknown })?.id ?? (rows[0] as { _id?: unknown })?._id;
478+
return id ? String(id) : undefined;
479+
}
480+
} catch {
481+
// sys_organization may not exist (single-tenant runtime) — ignore.
482+
}
483+
return undefined;
484+
}
485+
439486
private async resolveFromDatabase(
440487
targetObject: string,
441488
targetField: string,

0 commit comments

Comments
 (0)