diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 5ff4b1b20..1b3441828 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1487,6 +1487,25 @@ export class HttpDispatcher { ...(organizationId ? { organizationId } : {}), ...(body?.actor ? { actor: body.actor } : {}), }); + // Publishing a `seed` draft is what actually loads its + // rows. Best-effort + idempotent (upsert): apply every + // just-published seed now so the data is live the moment + // the user clicks publish. A seed-load failure NEVER + // fails the publish — it is surfaced under `seedApplied`. + try { + const seedNames = ((result as any)?.published ?? []) + .filter((p: any) => p?.type === 'seed') + .map((p: any) => p.name as string); + if (seedNames.length > 0) { + (result as any).seedApplied = await this.applyPublishedSeeds( + seedNames, + organizationId, + _context, + ); + } + } catch (e: any) { + (result as any).seedApplied = { success: false, error: e?.message ?? 'seed apply failed' }; + } return { handled: true, response: this.success(result) }; } catch (e: any) { return { handled: true, response: this.error(e.message, e.statusCode || 500) }; @@ -1715,6 +1734,68 @@ export class HttpDispatcher { * Physical database addressing (database_url, database_driver, etc.) * is stored directly on the sys_environment row. */ + /** + * Apply just-published `seed` metadata: load each seed's rows into its + * target object so publishing a seed draft makes the data live (the runtime + * counterpart to staging it). Reads each seed body via the protocol, then + * runs the {@link SeedLoaderService} for the active org. Best-effort and + * idempotent (upsert) — callers must never let this fail the publish. + * + * Lives at the runtime layer (not in the objectql publish primitive) + * because the seed loader needs the data engine + metadata service, which + * objectql cannot depend on without a layering cycle. + */ + private async applyPublishedSeeds( + names: string[], + organizationId: string | undefined, + _context: HttpProtocolContext, + ): Promise<{ success: boolean; inserted?: number; updated?: number; errors?: unknown[]; error?: string }> { + const protocol: any = await this.resolveService('protocol'); + const metadata: any = await this.getService(CoreServiceName.enum.metadata); + const ql: any = await this.resolveService('objectql'); + if (!protocol || typeof protocol.getMetaItem !== 'function' || !ql || !metadata) { + return { success: false, error: 'seed apply: required services unavailable' }; + } + const datasets: any[] = []; + for (const name of names) { + try { + const item: any = await protocol.getMetaItem({ + type: 'seed', + name, + ...(organizationId ? { organizationId } : {}), + }); + // getMetaItem returns the item body directly; tolerate a + // wrapper ({metadata|body}) just in case. + const seed = item?.object && Array.isArray(item?.records) + ? item + : (item?.metadata ?? item?.body); + if (seed?.object && Array.isArray(seed?.records)) datasets.push(seed); + } catch { + /* skip an unreadable seed; keep applying the rest */ + } + } + if (datasets.length === 0) return { success: true, inserted: 0, updated: 0 }; + + const { SeedLoaderService } = await import('./seed-loader.js'); + const { SeedLoaderRequestSchema } = await import('@objectstack/spec/data'); + const loader = new SeedLoaderService(ql, metadata, (this as any).logger ?? console); + const request = SeedLoaderRequestSchema.parse({ + datasets, + config: { + defaultMode: 'upsert', + multiPass: true, + ...(organizationId ? { organizationId } : {}), + }, + }); + const r = await loader.load(request); + return { + success: r.success, + inserted: r.summary.totalInserted, + updated: r.summary.totalUpdated, + errors: r.errors, + }; + } + /** * Resolve the calling user id from the request session, if any. * Returns `undefined` for anonymous calls or when auth is not wired up. diff --git a/packages/spec/src/data/dataset.zod.ts b/packages/spec/src/data/dataset.zod.ts index 89516b988..48bf0036a 100644 --- a/packages/spec/src/data/dataset.zod.ts +++ b/packages/spec/src/data/dataset.zod.ts @@ -59,6 +59,23 @@ export const DatasetSchema = lazySchema(() => z.object({ records: z.array(z.record(z.string(), z.unknown())).describe('Data records'), })); +/** + * Seed metadata-type schema — the runtime-draftable, publishable form of + * fixture / initialization data (the `seed` metadata type). + * + * It shares the {@link DatasetSchema} shape today (object + externalId + mode + + * env + records), but is exported under its own name so the `seed` metadata + * type can evolve independently of the `dataset` name, which ADR reserves for a + * future data-analysis capability. Authoring tools (incl. the AI metadata + * assistant) stage `type: 'seed'` drafts against this schema; publishing the + * draft is what applies the rows (runtime SeedLoaderService). + */ +export const SeedSchema = DatasetSchema; + +/** A seed metadata item (same shape as {@link Dataset}). */ +export type Seed = z.infer; +export type SeedInput = z.input; + /** Parsed/output type — all defaults are applied (env, mode, externalId always present) */ export type Dataset = z.infer; diff --git a/packages/spec/src/kernel/metadata-plugin.test.ts b/packages/spec/src/kernel/metadata-plugin.test.ts index 8a776e4d2..9dd4196f8 100644 --- a/packages/spec/src/kernel/metadata-plugin.test.ts +++ b/packages/spec/src/kernel/metadata-plugin.test.ts @@ -18,7 +18,7 @@ describe('MetadataPluginProtocol', () => { describe('MetadataTypeSchema', () => { it('should accept all built-in metadata types', () => { const types = [ - 'object', 'field', 'trigger', 'validation', 'hook', + 'object', 'field', 'trigger', 'validation', 'hook', 'seed', 'view', 'page', 'dashboard', 'app', 'action', 'report', 'flow', // ADR-0020: `workflow` retired as a metadata type 'datasource', 'translation', 'router', 'function', 'service', @@ -40,6 +40,19 @@ describe('MetadataPluginProtocol', () => { it('should reject `approval` as a metadata type (ADR-0019: approval is a flow node)', () => { expect(() => MetadataTypeSchema.parse('approval')).toThrow(); }); + + it('registers `seed` as a runtime-draftable data type applied on publish', () => { + // accepted by the type enum + expect(MetadataTypeSchema.parse('seed')).toBe('seed'); + // present in the default registry with the right capabilities + const entry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === 'seed'); + expect(entry).toBeDefined(); + expect(entry?.domain).toBe('data'); + expect(entry?.allowRuntimeCreate).toBe(true); // AI/authors can stage seed drafts + // loads after objects/fields so referenced schema already exists + const objectOrder = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === 'object')?.loadOrder ?? 0; + expect((entry?.loadOrder ?? 0)).toBeGreaterThan(objectOrder); + }); }); describe('MetadataTypeRegistryEntrySchema', () => { diff --git a/packages/spec/src/kernel/metadata-plugin.zod.ts b/packages/spec/src/kernel/metadata-plugin.zod.ts index 73f733a88..2e1a14428 100644 --- a/packages/spec/src/kernel/metadata-plugin.zod.ts +++ b/packages/spec/src/kernel/metadata-plugin.zod.ts @@ -76,6 +76,7 @@ export const MetadataTypeSchema = lazySchema(() => z.enum([ 'trigger', // Data-layer event triggers (TriggerSchema) 'validation', // Validation rules (ValidationSchema) 'hook', // Data hooks (HookSchema) + 'seed', // Seed/fixture data — runtime-draftable; publishing applies it (SeedSchema) // UI Protocol 'view', // List/form views (ViewSchema) @@ -594,6 +595,14 @@ export const DEFAULT_METADATA_TYPE_REGISTRY: MetadataTypeRegistryEntry[] = [ { type: 'trigger', label: 'Trigger', filePatterns: ['**/*.trigger.ts', '**/*.trigger.yml'], supportsOverlay: false, allowOrgOverride: false, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 30, domain: 'data' }, { type: 'validation', label: 'Validation Rule', filePatterns: ['**/*.validation.ts', '**/*.validation.yml'], supportsOverlay: false, allowOrgOverride: false, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 30, domain: 'data' }, { type: 'hook', label: 'Hook', filePatterns: ['**/*.hook.ts', '**/*.hook.yml'], supportsOverlay: false, allowOrgOverride: false, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 30, domain: 'data' }, + // `seed`: fixture / initialization data (SeedSchema = object + records + mode + + // externalId). Runtime-draftable so the AI (and any author) can stage seed + // rows as a DRAFT (ADR-0033) and PUBLISH them like any other artifact; + // publishing a seed draft is what actually loads the rows (runtime applies it + // via SeedLoaderService — see runtime publish-drafts handler). loadOrder is + // last in the data domain so every referenced object/field already exists. + // NOTE: distinct from the (analytics-bound) `dataset` name — see ADR. + { type: 'seed', label: 'Seed Data', description: 'Fixture / initialization data applied on publish', filePatterns: ['**/*.seed.ts', '**/*.seed.yml', '**/*.seed.json'], supportsOverlay: false, allowOrgOverride: false, allowRuntimeCreate: true, supportsVersioning: true, executionPinned: false, loadOrder: 95, domain: 'data' }, // UI Protocol // `view/page/dashboard/action/report`: UI artifacts benefit from version diff --git a/packages/spec/src/kernel/metadata-type-schemas.ts b/packages/spec/src/kernel/metadata-type-schemas.ts index 50b0f20ce..e0a5faaab 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.ts @@ -32,6 +32,7 @@ import { ObjectSchema } from '../data/object.zod'; import { HookSchema } from '../data/hook.zod'; import { ValidationRuleSchema } from '../data/validation.zod'; import { DatasourceSchema } from '../data/datasource.zod'; +import { SeedSchema } from '../data/dataset.zod'; import { ViewSchema } from '../ui/view.zod'; import { PageSchema } from '../ui/page.zod'; @@ -68,6 +69,7 @@ const BUILTIN_METADATA_TYPE_SCHEMAS: Partial> = field: FieldSchema, hook: HookSchema, validation: ValidationRuleSchema, + seed: SeedSchema, // fixture/init data; runtime-draftable, applied on publish // `trigger` — no standalone Zod schema yet; falls back to raw-JSON // editor until the data-trigger spec lands.