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
82 changes: 82 additions & 0 deletions packages/objectql/src/seed-loader-org-fallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// The SeedLoader stamps business seed rows with the tenant's organization key so
// they don't vanish under strict org-scoping. When the caller pins no org (an
// in-process publish has no active user session — the AI build agent's publish
// path), the loader adopts the tenant's SOLE organization as a fallback. A
// `sys_`/platform seed never takes the fallback (those stay global). Zero or
// many orgs → leave rows org-less (genuinely ambiguous → historical behavior).

import { describe, it, expect } from 'vitest';
import { SeedLoaderService } from './seed-loader';
import { SeedLoaderConfigSchema } from '@objectstack/spec/data';

function harness(orgRows: Array<{ id: string }>) {
const inserted: Array<{ object: string; record: Record<string, unknown> }> = [];
const engine = {
// sys_organization is the org-count probe; everything else (ref lookups) is empty.
find: async (object: string) => (object === 'sys_organization' ? orgRows : []),
insert: async (object: string, record: Record<string, unknown>) => {
inserted.push({ object, record });
return { id: `${object}_${inserted.length}` };
},
update: async () => ({}),
};
const metadata = {
// A single text field → no lookup/master_detail references to resolve.
getObject: async (name: string) => ({ name, fields: { name: { type: 'text' } } }),
};
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 org-key fallback (un-pinned publish)', () => {
it('stamps the SOLE organization onto un-pinned BUSINESS seed rows', async () => {
const { svc, inserted } = harness([{ id: 'org_only' }]);
await svc.load({
seeds: [{ object: 'project', records: [{ name: 'Apollo' }] }] as never,
config: cfg(),
});
expect(inserted[0]?.record.organization_id).toBe('org_only');
});

it('does NOT take the fallback for sys_/platform seeds (they stay global)', async () => {
const { svc, inserted } = harness([{ id: 'org_only' }]);
await svc.load({
seeds: [{ object: 'sys_widget_pref', records: [{ name: 'X' }] }] as never,
config: cfg(),
});
expect(inserted[0]?.record.organization_id).toBeUndefined();
});

it('leaves rows org-less when the tenant org is ambiguous (≠ exactly one)', async () => {
const { svc, inserted } = harness([{ id: 'a' }, { id: 'b' }]);
await svc.load({
seeds: [{ object: 'project', records: [{ name: 'Apollo' }] }] as never,
config: cfg(),
});
expect(inserted[0]?.record.organization_id).toBeUndefined();
});

it('leaves rows org-less when there is no organization at all', async () => {
const { svc, inserted } = harness([]);
await svc.load({
seeds: [{ object: 'project', records: [{ name: 'Apollo' }] }] as never,
config: cfg(),
});
expect(inserted[0]?.record.organization_id).toBeUndefined();
});

it('an explicitly pinned org still wins over the fallback path', async () => {
const { svc, inserted } = harness([{ id: 'sole_ignored' }]);
await svc.load({
seeds: [{ object: 'project', records: [{ name: 'Apollo' }] }] as never,
config: cfg({ organizationId: 'org_pinned' }),
});
expect(inserted[0]?.record.organization_id).toBe('org_pinned');
});
});
61 changes: 54 additions & 7 deletions packages/objectql/src/seed-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ export class SeedLoaderService implements ISeedLoaderService {
private engine: IDataEngine;
private metadata: IMetadataService;
private logger: Logger;
/**
* Tenant org to stamp BUSINESS seed rows with when the caller pinned no
* explicit `config.organizationId` (resolved per {@link resolveSoleOrganizationId}).
* Set once per {@link load}; never applied to `sys_`/`cloud_`/`ai_` platform
* seeds (those stay intentionally global/cross-tenant).
*/
private fallbackOrgId?: string;

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

// When the caller pinned no target org (an in-process publish has no active
// user session — the AI build agent's publish path), BUSINESS seed rows
// would land `organization_id = NULL` and then vanish under strict
// org-scoping. If the tenant has exactly ONE organization, adopt it as a
// fallback so business seeds carry the tenant key like a normal write.
// Zero/many orgs → leave unset (genuinely ambiguous → keep the historical
// global/cross-tenant behavior; the publisher must scope explicitly).
this.fallbackOrgId =
config.organizationId == null ? await this.resolveSoleOrganizationId() : undefined;

// 1. Filter datasets by environment
const datasets = this.filterByEnv(request.seeds, config.env);

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

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

// Resolve references
Expand Down Expand Up @@ -436,6 +457,32 @@ export class SeedLoaderService implements ISeedLoaderService {
// Internal: Reference Resolution
// ==========================================================================

/**
* Best-effort resolve the tenant's SOLE organization id — used to stamp
* business seed rows when the caller pinned no `config.organizationId` (an
* in-process publish has no active user session). A fresh env has exactly one
* org, so its seeds should carry it like a normal write instead of landing
* org-less (→ invisible under strict org-scoping). Returns undefined when
* there are zero or several orgs (genuinely ambiguous — keep the historical
* global/cross-tenant NULL) or when `sys_organization` is absent.
*/
private async resolveSoleOrganizationId(): Promise<string | undefined> {
try {
const rows = await this.engine.find('sys_organization', {
fields: ['id'],
limit: 2,
context: { isSystem: true },
} as any);
if (Array.isArray(rows) && rows.length === 1) {
const id = (rows[0] as { id?: unknown; _id?: unknown })?.id ?? (rows[0] as { _id?: unknown })?._id;
return id ? String(id) : undefined;
}
} catch {
// sys_organization may not exist (single-tenant runtime) — ignore.
}
return undefined;
}

private async resolveFromDatabase(
targetObject: string,
targetField: string,
Expand Down