From 6404084b484ed51d2b3d37745b261240109dd6aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 04:16:22 +0000 Subject: [PATCH 1/2] Initial plan From d6efa2a55a61a692639ce7e210b4aada549b1faa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 04:23:54 +0000 Subject: [PATCH 2/2] feat(data): add seed loader protocol with relationship resolution & dependency ordering Add seed-loader.zod.ts with Zod schemas for: - ReferenceResolutionSchema: field reference resolution via externalId - ObjectDependencyNodeSchema/GraphSchema: topological sort for insert order - ReferenceResolutionErrorSchema: actionable error reporting - SeedLoaderConfigSchema: dry-run, multi-pass, batch, transaction config - DatasetLoadResultSchema: per-object load statistics - SeedLoaderResultSchema: complete load result with summary - SeedLoaderRequestSchema: request combining datasets + config Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/spec/src/data/index.ts | 3 + packages/spec/src/data/seed-loader.test.ts | 665 +++++++++++++++++++++ packages/spec/src/data/seed-loader.zod.ts | 342 +++++++++++ 3 files changed, 1010 insertions(+) create mode 100644 packages/spec/src/data/seed-loader.test.ts create mode 100644 packages/spec/src/data/seed-loader.zod.ts diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index 7b1fde6af..e1b2fd271 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -14,6 +14,9 @@ export * from './driver-nosql.zod'; export * from './dataset.zod'; +// Seed Loader Protocol (Relationship Resolution & Dependency Ordering) +export * from './seed-loader.zod'; + // Document Management Protocol export * from './document.zod'; diff --git a/packages/spec/src/data/seed-loader.test.ts b/packages/spec/src/data/seed-loader.test.ts new file mode 100644 index 000000000..fd2e2bdb6 --- /dev/null +++ b/packages/spec/src/data/seed-loader.test.ts @@ -0,0 +1,665 @@ +import { describe, it, expect } from 'vitest'; +import { + ReferenceResolutionSchema, + ObjectDependencyNodeSchema, + ObjectDependencyGraphSchema, + ReferenceResolutionErrorSchema, + SeedLoaderConfigSchema, + DatasetLoadResultSchema, + SeedLoaderResultSchema, + SeedLoaderRequestSchema, +} from './seed-loader.zod'; + +// ========================================================================== +// ReferenceResolutionSchema +// ========================================================================== + +describe('ReferenceResolutionSchema', () => { + it('should accept a valid lookup reference', () => { + const ref = { + field: 'account_id', + targetObject: 'account', + targetField: 'name', + fieldType: 'lookup' as const, + }; + const parsed = ReferenceResolutionSchema.parse(ref); + expect(parsed.field).toBe('account_id'); + expect(parsed.targetObject).toBe('account'); + expect(parsed.fieldType).toBe('lookup'); + }); + + it('should accept a master_detail reference', () => { + const ref = { + field: 'project_id', + targetObject: 'project', + fieldType: 'master_detail' as const, + }; + const parsed = ReferenceResolutionSchema.parse(ref); + expect(parsed.targetField).toBe('name'); // default + expect(parsed.fieldType).toBe('master_detail'); + }); + + it('should default targetField to name', () => { + const ref = ReferenceResolutionSchema.parse({ + field: 'owner', + targetObject: 'user', + fieldType: 'lookup', + }); + expect(ref.targetField).toBe('name'); + }); + + it('should reject invalid target object name', () => { + expect(() => ReferenceResolutionSchema.parse({ + field: 'account_id', + targetObject: 'InvalidName', + fieldType: 'lookup', + })).toThrow(); + }); + + it('should reject invalid field type', () => { + expect(() => ReferenceResolutionSchema.parse({ + field: 'account_id', + targetObject: 'account', + fieldType: 'foreign_key', + })).toThrow(); + }); + + it('should reject missing required fields', () => { + expect(() => ReferenceResolutionSchema.parse({})).toThrow(); + expect(() => ReferenceResolutionSchema.parse({ field: 'x' })).toThrow(); + }); +}); + +// ========================================================================== +// ObjectDependencyNodeSchema +// ========================================================================== + +describe('ObjectDependencyNodeSchema', () => { + it('should accept an object with no dependencies', () => { + const node = ObjectDependencyNodeSchema.parse({ + object: 'country', + dependsOn: [], + references: [], + }); + expect(node.dependsOn).toHaveLength(0); + }); + + it('should accept an object with dependencies', () => { + const node = ObjectDependencyNodeSchema.parse({ + object: 'contact', + dependsOn: ['account', 'user'], + references: [ + { field: 'account_id', targetObject: 'account', fieldType: 'lookup' }, + { field: 'owner', targetObject: 'user', fieldType: 'lookup' }, + ], + }); + expect(node.dependsOn).toEqual(['account', 'user']); + expect(node.references).toHaveLength(2); + }); + + it('should reject invalid object name', () => { + expect(() => ObjectDependencyNodeSchema.parse({ + object: 'Invalid', + dependsOn: [], + references: [], + })).toThrow(); + }); +}); + +// ========================================================================== +// ObjectDependencyGraphSchema +// ========================================================================== + +describe('ObjectDependencyGraphSchema', () => { + it('should accept a simple linear dependency graph', () => { + const graph = ObjectDependencyGraphSchema.parse({ + nodes: [ + { object: 'country', dependsOn: [], references: [] }, + { object: 'account', dependsOn: ['country'], references: [ + { field: 'country_id', targetObject: 'country', fieldType: 'lookup' }, + ]}, + { object: 'contact', dependsOn: ['account'], references: [ + { field: 'account_id', targetObject: 'account', fieldType: 'master_detail' }, + ]}, + ], + insertOrder: ['country', 'account', 'contact'], + }); + expect(graph.insertOrder).toEqual(['country', 'account', 'contact']); + expect(graph.circularDependencies).toEqual([]); + }); + + it('should accept a graph with circular dependencies', () => { + const graph = ObjectDependencyGraphSchema.parse({ + nodes: [ + { object: 'project', dependsOn: ['task'], references: [ + { field: 'lead_task', targetObject: 'task', fieldType: 'lookup' }, + ]}, + { object: 'task', dependsOn: ['project'], references: [ + { field: 'project_id', targetObject: 'project', fieldType: 'master_detail' }, + ]}, + ], + insertOrder: ['project', 'task'], + circularDependencies: [['project', 'task', 'project']], + }); + expect(graph.circularDependencies).toHaveLength(1); + }); + + it('should default circularDependencies to empty array', () => { + const graph = ObjectDependencyGraphSchema.parse({ + nodes: [], + insertOrder: [], + }); + expect(graph.circularDependencies).toEqual([]); + }); +}); + +// ========================================================================== +// ReferenceResolutionErrorSchema +// ========================================================================== + +describe('ReferenceResolutionErrorSchema', () => { + it('should accept a complete resolution error', () => { + const error = ReferenceResolutionErrorSchema.parse({ + sourceObject: 'contact', + field: 'account_id', + targetObject: 'account', + targetField: 'name', + attemptedValue: 'Nonexistent Corp', + recordIndex: 3, + message: 'No account found with name "Nonexistent Corp". Check that the referenced record exists in the account object.', + }); + expect(error.sourceObject).toBe('contact'); + expect(error.attemptedValue).toBe('Nonexistent Corp'); + expect(error.recordIndex).toBe(3); + }); + + it('should accept numeric attempted values', () => { + const error = ReferenceResolutionErrorSchema.parse({ + sourceObject: 'order_item', + field: 'product_id', + targetObject: 'product', + targetField: 'code', + attemptedValue: 12345, + recordIndex: 0, + message: 'No product found with code "12345"', + }); + expect(error.attemptedValue).toBe(12345); + }); + + it('should reject negative record index', () => { + expect(() => ReferenceResolutionErrorSchema.parse({ + sourceObject: 'contact', + field: 'account_id', + targetObject: 'account', + targetField: 'name', + attemptedValue: 'test', + recordIndex: -1, + message: 'error', + })).toThrow(); + }); + + it('should reject missing required fields', () => { + expect(() => ReferenceResolutionErrorSchema.parse({})).toThrow(); + expect(() => ReferenceResolutionErrorSchema.parse({ + sourceObject: 'contact', + })).toThrow(); + }); +}); + +// ========================================================================== +// SeedLoaderConfigSchema +// ========================================================================== + +describe('SeedLoaderConfigSchema', () => { + it('should apply all defaults', () => { + const config = SeedLoaderConfigSchema.parse({}); + expect(config.dryRun).toBe(false); + expect(config.haltOnError).toBe(false); + expect(config.multiPass).toBe(true); + expect(config.defaultMode).toBe('upsert'); + expect(config.batchSize).toBe(1000); + expect(config.transaction).toBe(false); + expect(config.env).toBeUndefined(); + }); + + it('should accept dry-run configuration', () => { + const config = SeedLoaderConfigSchema.parse({ + dryRun: true, + haltOnError: true, + }); + expect(config.dryRun).toBe(true); + expect(config.haltOnError).toBe(true); + }); + + it('should accept environment filter', () => { + const config = SeedLoaderConfigSchema.parse({ env: 'dev' }); + expect(config.env).toBe('dev'); + }); + + it('should accept custom batch size', () => { + const config = SeedLoaderConfigSchema.parse({ batchSize: 500 }); + expect(config.batchSize).toBe(500); + }); + + it('should accept transaction mode', () => { + const config = SeedLoaderConfigSchema.parse({ transaction: true }); + expect(config.transaction).toBe(true); + }); + + it('should reject invalid environment', () => { + expect(() => SeedLoaderConfigSchema.parse({ env: 'staging' })).toThrow(); + }); + + it('should reject batch size less than 1', () => { + expect(() => SeedLoaderConfigSchema.parse({ batchSize: 0 })).toThrow(); + }); + + it('should accept all valid dataset modes as default', () => { + const modes = ['insert', 'update', 'upsert', 'replace', 'ignore'] as const; + modes.forEach(mode => { + const config = SeedLoaderConfigSchema.parse({ defaultMode: mode }); + expect(config.defaultMode).toBe(mode); + }); + }); + + it('should accept multiPass disabled', () => { + const config = SeedLoaderConfigSchema.parse({ multiPass: false }); + expect(config.multiPass).toBe(false); + }); +}); + +// ========================================================================== +// DatasetLoadResultSchema +// ========================================================================== + +describe('DatasetLoadResultSchema', () => { + it('should accept a successful load result', () => { + const result = DatasetLoadResultSchema.parse({ + object: 'account', + mode: 'upsert', + inserted: 5, + updated: 2, + skipped: 0, + errored: 0, + total: 7, + referencesResolved: 3, + referencesDeferred: 0, + }); + expect(result.inserted).toBe(5); + expect(result.updated).toBe(2); + expect(result.errors).toEqual([]); + }); + + it('should accept a result with errors', () => { + const result = DatasetLoadResultSchema.parse({ + object: 'contact', + mode: 'upsert', + inserted: 3, + updated: 0, + skipped: 0, + errored: 2, + total: 5, + referencesResolved: 1, + referencesDeferred: 0, + errors: [ + { + sourceObject: 'contact', + field: 'account_id', + targetObject: 'account', + targetField: 'name', + attemptedValue: 'Missing Corp', + recordIndex: 2, + message: 'No account found with name "Missing Corp"', + }, + ], + }); + expect(result.errored).toBe(2); + expect(result.errors).toHaveLength(1); + }); + + it('should accept a result with deferred references', () => { + const result = DatasetLoadResultSchema.parse({ + object: 'task', + mode: 'insert', + inserted: 10, + updated: 0, + skipped: 0, + errored: 0, + total: 10, + referencesResolved: 5, + referencesDeferred: 3, + }); + expect(result.referencesDeferred).toBe(3); + }); + + it('should default errors to empty array', () => { + const result = DatasetLoadResultSchema.parse({ + object: 'product', + mode: 'insert', + inserted: 1, + updated: 0, + skipped: 0, + errored: 0, + total: 1, + referencesResolved: 0, + referencesDeferred: 0, + }); + expect(result.errors).toEqual([]); + }); + + it('should reject negative counts', () => { + expect(() => DatasetLoadResultSchema.parse({ + object: 'test', + mode: 'upsert', + inserted: -1, + updated: 0, + skipped: 0, + errored: 0, + total: 0, + referencesResolved: 0, + referencesDeferred: 0, + })).toThrow(); + }); +}); + +// ========================================================================== +// SeedLoaderResultSchema +// ========================================================================== + +describe('SeedLoaderResultSchema', () => { + it('should accept a successful seed loader result', () => { + const result = SeedLoaderResultSchema.parse({ + success: true, + dryRun: false, + dependencyGraph: { + nodes: [ + { object: 'country', dependsOn: [], references: [] }, + { object: 'account', dependsOn: ['country'], references: [ + { field: 'country_id', targetObject: 'country', fieldType: 'lookup' }, + ]}, + ], + insertOrder: ['country', 'account'], + }, + results: [ + { + object: 'country', + mode: 'upsert', + inserted: 3, + updated: 0, + skipped: 0, + errored: 0, + total: 3, + referencesResolved: 0, + referencesDeferred: 0, + }, + { + object: 'account', + mode: 'upsert', + inserted: 5, + updated: 0, + skipped: 0, + errored: 0, + total: 5, + referencesResolved: 5, + referencesDeferred: 0, + }, + ], + errors: [], + summary: { + objectsProcessed: 2, + totalRecords: 8, + totalInserted: 8, + totalUpdated: 0, + totalSkipped: 0, + totalErrored: 0, + totalReferencesResolved: 5, + totalReferencesDeferred: 0, + circularDependencyCount: 0, + durationMs: 150, + }, + }); + expect(result.success).toBe(true); + expect(result.summary.objectsProcessed).toBe(2); + expect(result.summary.totalReferencesResolved).toBe(5); + }); + + it('should accept a dry-run result', () => { + const result = SeedLoaderResultSchema.parse({ + success: true, + dryRun: true, + dependencyGraph: { + nodes: [], + insertOrder: [], + }, + results: [], + errors: [], + summary: { + objectsProcessed: 0, + totalRecords: 0, + totalInserted: 0, + totalUpdated: 0, + totalSkipped: 0, + totalErrored: 0, + totalReferencesResolved: 0, + totalReferencesDeferred: 0, + circularDependencyCount: 0, + durationMs: 10, + }, + }); + expect(result.dryRun).toBe(true); + }); + + it('should accept a result with errors', () => { + const result = SeedLoaderResultSchema.parse({ + success: false, + dryRun: false, + dependencyGraph: { + nodes: [{ object: 'contact', dependsOn: ['account'], references: [ + { field: 'account_id', targetObject: 'account', fieldType: 'lookup' }, + ]}], + insertOrder: ['account', 'contact'], + }, + results: [{ + object: 'contact', + mode: 'upsert', + inserted: 0, + updated: 0, + skipped: 0, + errored: 1, + total: 1, + referencesResolved: 0, + referencesDeferred: 0, + errors: [{ + sourceObject: 'contact', + field: 'account_id', + targetObject: 'account', + targetField: 'name', + attemptedValue: 'Ghost Corp', + recordIndex: 0, + message: 'No account found with name "Ghost Corp"', + }], + }], + errors: [{ + sourceObject: 'contact', + field: 'account_id', + targetObject: 'account', + targetField: 'name', + attemptedValue: 'Ghost Corp', + recordIndex: 0, + message: 'No account found with name "Ghost Corp"', + }], + summary: { + objectsProcessed: 1, + totalRecords: 1, + totalInserted: 0, + totalUpdated: 0, + totalSkipped: 0, + totalErrored: 1, + totalReferencesResolved: 0, + totalReferencesDeferred: 0, + circularDependencyCount: 0, + durationMs: 50, + }, + }); + expect(result.success).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].attemptedValue).toBe('Ghost Corp'); + }); + + it('should accept a result with circular dependencies', () => { + const result = SeedLoaderResultSchema.parse({ + success: true, + dryRun: false, + dependencyGraph: { + nodes: [ + { object: 'project', dependsOn: ['task'], references: [ + { field: 'lead_task', targetObject: 'task', fieldType: 'lookup' }, + ]}, + { object: 'task', dependsOn: ['project'], references: [ + { field: 'project_id', targetObject: 'project', fieldType: 'master_detail' }, + ]}, + ], + insertOrder: ['project', 'task'], + circularDependencies: [['project', 'task', 'project']], + }, + results: [ + { + object: 'project', + mode: 'upsert', + inserted: 2, + updated: 0, + skipped: 0, + errored: 0, + total: 2, + referencesResolved: 0, + referencesDeferred: 2, + }, + { + object: 'task', + mode: 'upsert', + inserted: 5, + updated: 0, + skipped: 0, + errored: 0, + total: 5, + referencesResolved: 5, + referencesDeferred: 0, + }, + ], + errors: [], + summary: { + objectsProcessed: 2, + totalRecords: 7, + totalInserted: 7, + totalUpdated: 0, + totalSkipped: 0, + totalErrored: 0, + totalReferencesResolved: 5, + totalReferencesDeferred: 2, + circularDependencyCount: 1, + durationMs: 200, + }, + }); + expect(result.summary.circularDependencyCount).toBe(1); + expect(result.summary.totalReferencesDeferred).toBe(2); + }); + + it('should reject missing required fields', () => { + expect(() => SeedLoaderResultSchema.parse({})).toThrow(); + }); +}); + +// ========================================================================== +// SeedLoaderRequestSchema +// ========================================================================== + +describe('SeedLoaderRequestSchema', () => { + it('should accept a minimal request with defaults', () => { + const request = SeedLoaderRequestSchema.parse({ + datasets: [ + { object: 'country', records: [{ name: 'United States', code: 'US' }] }, + ], + }); + expect(request.datasets).toHaveLength(1); + expect(request.config.dryRun).toBe(false); + expect(request.config.defaultMode).toBe('upsert'); + }); + + it('should accept a request with full configuration', () => { + const request = SeedLoaderRequestSchema.parse({ + datasets: [ + { + object: 'account', + externalId: 'code', + mode: 'upsert', + records: [{ code: 'ACC001', name: 'Acme Corp' }], + }, + { + object: 'contact', + records: [{ name: 'John Doe', account_id: 'Acme Corp' }], + }, + ], + config: { + dryRun: true, + haltOnError: true, + multiPass: true, + batchSize: 500, + env: 'dev', + }, + }); + expect(request.datasets).toHaveLength(2); + expect(request.config.dryRun).toBe(true); + expect(request.config.env).toBe('dev'); + }); + + it('should reject empty datasets', () => { + expect(() => SeedLoaderRequestSchema.parse({ + datasets: [], + })).toThrow(); + }); + + it('should reject request without datasets', () => { + expect(() => SeedLoaderRequestSchema.parse({})).toThrow(); + }); + + it('should handle CRM seed data scenario', () => { + const request = SeedLoaderRequestSchema.parse({ + datasets: [ + { + object: 'industry', + externalId: 'code', + mode: 'upsert', + records: [ + { code: 'tech', name: 'Technology' }, + { code: 'finance', name: 'Finance' }, + ], + }, + { + object: 'account', + externalId: 'name', + mode: 'upsert', + records: [ + { name: 'Acme Corp', industry: 'tech' }, + { name: 'Beta Inc', industry: 'finance' }, + ], + }, + { + object: 'contact', + externalId: 'email', + mode: 'upsert', + records: [ + { email: 'john@acme.com', name: 'John', account_id: 'Acme Corp' }, + { email: 'jane@beta.com', name: 'Jane', account_id: 'Beta Inc' }, + ], + }, + ], + config: { + multiPass: true, + defaultMode: 'upsert', + }, + }); + expect(request.datasets).toHaveLength(3); + expect(request.datasets[0].externalId).toBe('code'); + expect(request.datasets[2].externalId).toBe('email'); + }); +}); diff --git a/packages/spec/src/data/seed-loader.zod.ts b/packages/spec/src/data/seed-loader.zod.ts new file mode 100644 index 000000000..c8c012d86 --- /dev/null +++ b/packages/spec/src/data/seed-loader.zod.ts @@ -0,0 +1,342 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { DatasetSchema, DatasetMode } from './dataset.zod'; + +/** + * # Seed Loader Protocol + * + * Defines the schemas for metadata-driven seed data loading with automatic + * relationship resolution, dependency ordering, and multi-pass insertion. + * + * ## Architecture Alignment + * - **Salesforce Data Loader**: External ID-based upsert with relationship resolution + * - **ServiceNow**: Sys ID and display value mapping during import + * - **Airtable**: Linked record resolution via display names + * + * ## Loading Flow + * ``` + * 1. Build object dependency graph from field metadata (lookup/master_detail) + * 2. Topological sort → determine insert order (parents before children) + * 3. Pass 1: Insert/upsert records, resolve references via externalId + * 4. Pass 2: Fill deferred references (circular/delayed dependencies) + * 5. Validate & report unresolved references + * 6. Return structured result with per-object stats + * ``` + */ + +// ========================================================================== +// 1. Reference Resolution +// ========================================================================== + +/** + * Describes how a single field reference should be resolved during seed loading. + * + * When a lookup/master_detail field value is not an internal ID, the loader + * attempts to match it against the target object's externalId field. + */ +export const ReferenceResolutionSchema = z.object({ + /** The field name on the source object (e.g., 'account_id') */ + field: z.string().describe('Source field name containing the reference value'), + + /** The target object being referenced (e.g., 'account') */ + targetObject: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Target object name (snake_case)'), + + /** + * The field on the target object used to match the reference value. + * Defaults to the target object's externalId (usually 'name'). + */ + targetField: z.string().default('name').describe('Field on target object used for matching'), + + /** The field type that triggered this resolution (lookup or master_detail) */ + fieldType: z.enum(['lookup', 'master_detail']).describe('Relationship field type'), +}).describe('Describes how a field reference is resolved during seed loading'); + +export type ReferenceResolution = z.infer; + +// ========================================================================== +// 2. Object Dependency Node +// ========================================================================== + +/** + * Represents a single object in the dependency graph. + * Built from object metadata by inspecting lookup/master_detail fields. + */ +export const ObjectDependencyNodeSchema = z.object({ + /** Object machine name */ + object: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Object name (snake_case)'), + + /** + * Objects that this object depends on (via lookup/master_detail fields). + * These must be loaded before this object. + */ + dependsOn: z.array(z.string()).describe('Objects this object depends on'), + + /** + * Field-level reference details for each dependency. + * Maps field name → reference resolution info. + */ + references: z.array(ReferenceResolutionSchema).describe('Field-level reference details'), +}).describe('Object node in the seed data dependency graph'); + +export type ObjectDependencyNode = z.infer; + +// ========================================================================== +// 3. Object Dependency Graph +// ========================================================================== + +/** + * The complete object dependency graph for seed data loading. + * Used to determine topological insert order and detect circular dependencies. + */ +export const ObjectDependencyGraphSchema = z.object({ + /** All object nodes in the graph */ + nodes: z.array(ObjectDependencyNodeSchema).describe('All objects in the dependency graph'), + + /** + * Topologically sorted object names for insertion order. + * Parent objects appear before child objects. + */ + insertOrder: z.array(z.string()).describe('Topologically sorted insert order'), + + /** + * Circular dependency chains detected in the graph. + * Each chain is an array of object names forming a cycle. + * When present, the loader must use a multi-pass strategy. + * + * @example [['project', 'task', 'project']] + */ + circularDependencies: z.array(z.array(z.string())).default([]) + .describe('Circular dependency chains (e.g., [["a", "b", "a"]])'), +}).describe('Complete object dependency graph for seed data loading'); + +export type ObjectDependencyGraph = z.infer; + +// ========================================================================== +// 4. Reference Resolution Error +// ========================================================================== + +/** + * Actionable error for a failed reference resolution. + * Provides all context needed to diagnose and fix the broken reference. + * + * Aligns with Salesforce Data Loader error reporting patterns: + * field name, target object, attempted value, and reason. + */ +export const ReferenceResolutionErrorSchema = z.object({ + /** The source object containing the broken reference */ + sourceObject: z.string().describe('Object with the broken reference'), + + /** The field containing the unresolved value */ + field: z.string().describe('Field name with unresolved reference'), + + /** The target object that was searched */ + targetObject: z.string().describe('Target object searched for the reference'), + + /** The externalId field used for matching on the target object */ + targetField: z.string().describe('ExternalId field used for matching'), + + /** The value that could not be resolved */ + attemptedValue: z.unknown().describe('Value that failed to resolve'), + + /** The index of the record in the dataset's records array */ + recordIndex: z.number().int().min(0).describe('Index of the record in the dataset'), + + /** Human-readable error message */ + message: z.string().describe('Human-readable error description'), +}).describe('Actionable error for a failed reference resolution'); + +export type ReferenceResolutionError = z.infer; + +// ========================================================================== +// 5. Seed Loader Configuration +// ========================================================================== + +/** + * Configuration for the seed data loader. + * Controls behavior for reference resolution, error handling, and validation. + */ +export const SeedLoaderConfigSchema = z.object({ + /** + * Dry-run mode: validate all references without writing data. + * Surfaces broken references before any mutations occur. + * @default false + */ + dryRun: z.boolean().default(false) + .describe('Validate references without writing data'), + + /** + * Whether to halt on the first reference resolution error. + * When false, collects all errors and continues loading. + * @default false + */ + haltOnError: z.boolean().default(false) + .describe('Stop on first reference resolution error'), + + /** + * Enable multi-pass loading for circular dependencies. + * Pass 1: Insert records with null for circular references. + * Pass 2: Update records to fill deferred references. + * @default true + */ + multiPass: z.boolean().default(true) + .describe('Enable multi-pass loading for circular dependencies'), + + /** + * Default dataset mode when not specified per-dataset. + * @default 'upsert' + */ + defaultMode: DatasetMode.default('upsert') + .describe('Default conflict resolution strategy'), + + /** + * Maximum number of records to process in a single batch. + * Controls memory usage for large datasets. + * @default 1000 + */ + batchSize: z.number().int().min(1).default(1000) + .describe('Maximum records per batch insert/upsert'), + + /** + * Whether to wrap the entire load operation in a transaction. + * When true, all-or-nothing semantics apply. + * @default false + */ + transaction: z.boolean().default(false) + .describe('Wrap entire load in a transaction (all-or-nothing)'), + + /** + * Environment filter. Only datasets matching this environment are loaded. + * When not specified, all datasets are loaded regardless of env scope. + */ + env: z.enum(['prod', 'dev', 'test']).optional() + .describe('Only load datasets matching this environment'), +}).describe('Seed data loader configuration'); + +export type SeedLoaderConfig = z.infer; + +/** Input type — all fields with defaults are optional */ +export type SeedLoaderConfigInput = z.input; + +// ========================================================================== +// 6. Per-Object Load Result +// ========================================================================== + +/** + * Result of loading a single object's dataset. + */ +export const DatasetLoadResultSchema = z.object({ + /** Target object name */ + object: z.string().describe('Object that was loaded'), + + /** Import mode used */ + mode: DatasetMode.describe('Import mode used'), + + /** Number of records successfully inserted */ + inserted: z.number().int().min(0).describe('Records inserted'), + + /** Number of records successfully updated (upsert matched existing) */ + updated: z.number().int().min(0).describe('Records updated'), + + /** Number of records skipped (mode: ignore, or already exists) */ + skipped: z.number().int().min(0).describe('Records skipped'), + + /** Number of records with errors */ + errored: z.number().int().min(0).describe('Records with errors'), + + /** Total records in the dataset */ + total: z.number().int().min(0).describe('Total records in dataset'), + + /** Number of references resolved via externalId */ + referencesResolved: z.number().int().min(0).describe('References resolved via externalId'), + + /** Number of references deferred to pass 2 (circular dependencies) */ + referencesDeferred: z.number().int().min(0).describe('References deferred to second pass'), + + /** Reference resolution errors for this object */ + errors: z.array(ReferenceResolutionErrorSchema).default([]) + .describe('Reference resolution errors'), +}).describe('Result of loading a single dataset'); + +export type DatasetLoadResult = z.infer; + +// ========================================================================== +// 7. Seed Loader Result +// ========================================================================== + +/** + * Complete result of a seed loading operation. + * Aggregates all per-object results and provides summary statistics. + */ +export const SeedLoaderResultSchema = z.object({ + /** Whether the overall load operation succeeded */ + success: z.boolean().describe('Overall success status'), + + /** Was this a dry-run (validation only, no writes)? */ + dryRun: z.boolean().describe('Whether this was a dry-run'), + + /** The dependency graph used for ordering */ + dependencyGraph: ObjectDependencyGraphSchema.describe('Object dependency graph'), + + /** Per-object load results, in the order they were processed */ + results: z.array(DatasetLoadResultSchema).describe('Per-object load results'), + + /** All reference resolution errors across all objects */ + errors: z.array(ReferenceResolutionErrorSchema).describe('All reference resolution errors'), + + /** Summary statistics */ + summary: z.object({ + /** Total objects processed */ + objectsProcessed: z.number().int().min(0).describe('Total objects processed'), + + /** Total records across all objects */ + totalRecords: z.number().int().min(0).describe('Total records across all objects'), + + /** Total records inserted */ + totalInserted: z.number().int().min(0).describe('Total records inserted'), + + /** Total records updated */ + totalUpdated: z.number().int().min(0).describe('Total records updated'), + + /** Total records skipped */ + totalSkipped: z.number().int().min(0).describe('Total records skipped'), + + /** Total records with errors */ + totalErrored: z.number().int().min(0).describe('Total records with errors'), + + /** Total references resolved via externalId */ + totalReferencesResolved: z.number().int().min(0).describe('Total references resolved'), + + /** Total references deferred to second pass */ + totalReferencesDeferred: z.number().int().min(0).describe('Total references deferred'), + + /** Number of circular dependency chains detected */ + circularDependencyCount: z.number().int().min(0).describe('Circular dependency chains detected'), + + /** Duration of the load operation in milliseconds */ + durationMs: z.number().min(0).describe('Load duration in milliseconds'), + }).describe('Summary statistics'), +}).describe('Complete seed loader result'); + +export type SeedLoaderResult = z.infer; + +// ========================================================================== +// 8. Seed Loader Request +// ========================================================================== + +/** + * Input request for the seed loader. + * Combines datasets with loader configuration. + */ +export const SeedLoaderRequestSchema = z.object({ + /** Datasets to load */ + datasets: z.array(DatasetSchema).min(1).describe('Datasets to load'), + + /** Loader configuration */ + config: z.preprocess((val) => val ?? {}, SeedLoaderConfigSchema).describe('Loader configuration'), +}).describe('Seed loader request with datasets and configuration'); + +export type SeedLoaderRequest = z.infer; + +/** Input type — config defaults are optional */ +export type SeedLoaderRequestInput = z.input;