From 8704b6e597eb956ec56cca6e605e824b2fbb31d0 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 27 Jun 2026 22:25:50 +0800 Subject: [PATCH] fix(objectql): stamp the tenant org-key onto un-pinned business seed rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A published seed loaded in-process (the AI build agent's publish path) has no active user session, so `config.organizationId` is unset and the SQL driver's per-write org injection never fires — business seed rows land with `organization_id = NULL`. Under strict org-scoping the data API filters with `WHERE organization_id = ` (a NULL row matches nobody), so every list / kanban / read of the freshly-built app comes back empty even though the rows physically exist. (Metadata + dashboard datasets read via a system, non-org-scoped path, so the app shell and dashboard counts render — only the business data vanishes, which reads as "I built it and it's empty".) Fix: when no org is pinned, the SeedLoader resolves the tenant's SOLE organization and stamps BUSINESS seed rows with it — the same tenant key a normal create injects from the session. `sys_`/`cloud_`/`ai_` platform seeds never take the fallback (they stay intentionally global/cross-tenant); zero or several orgs leave rows org-less (genuinely ambiguous → unchanged behavior); an explicitly pinned `organizationId` still wins for every object. Also makes `os.org.id` resolvable in seed CEL on the un-pinned path (the fallback flows into the seed identity's `org`). Tests: src/seed-loader-org-fallback.test.ts — sole-org stamp, sys_ skip, ambiguous/no-org no-op, explicit-org precedence. Existing seed-loader + protocol suites green (no regression). Co-Authored-By: Claude Opus 4.8 --- .../src/seed-loader-org-fallback.test.ts | 82 +++++++++++++++++++ packages/objectql/src/seed-loader.ts | 61 ++++++++++++-- 2 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 packages/objectql/src/seed-loader-org-fallback.test.ts diff --git a/packages/objectql/src/seed-loader-org-fallback.test.ts b/packages/objectql/src/seed-loader-org-fallback.test.ts new file mode 100644 index 0000000000..2c412dc5e7 --- /dev/null +++ b/packages/objectql/src/seed-loader-org-fallback.test.ts @@ -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 }> = []; + 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) => { + 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 = {}) => + 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'); + }); +}); diff --git a/packages/objectql/src/seed-loader.ts b/packages/objectql/src/seed-loader.ts index f4312d42f4..16ded64dd2 100644 --- a/packages/objectql/src/seed-loader.ts +++ b/packages/objectql/src/seed-loader.ts @@ -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; @@ -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); @@ -265,13 +282,17 @@ export class SeedLoaderService implements ISeedLoaderService { } const record = { ...(seedResult.value as Record) }; - // 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 @@ -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 { + 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,