-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathseed-loader.zod.ts
More file actions
416 lines (339 loc) · 16.8 KB
/
Copy pathseed-loader.zod.ts
File metadata and controls
416 lines (339 loc) · 16.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { z } from 'zod';
import { SeedSchema, SeedMode } from './seed.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.
*/
import { lazySchema } from '../shared/lazy-schema';
export const ReferenceResolutionSchema = lazySchema(() => 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, master_detail, or user) */
fieldType: z.enum(['lookup', 'master_detail', 'user']).describe('Relationship field type'),
}).describe('Describes how a field reference is resolved during seed loading'));
export type ReferenceResolution = z.infer<typeof ReferenceResolutionSchema>;
// ==========================================================================
// 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 = lazySchema(() => 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<typeof ObjectDependencyNodeSchema>;
// ==========================================================================
// 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 = lazySchema(() => 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<typeof ObjectDependencyGraphSchema>;
// ==========================================================================
// 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 = lazySchema(() => 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<typeof ReferenceResolutionErrorSchema>;
// ==========================================================================
// 4b. Seed Identity (os.user / os.org binding)
// ==========================================================================
/**
* Identity bound to `os.user` / `os.org` when the loader evaluates CEL
* expressions embedded in seed records (e.g. `owner_id: cel\`os.user.id\``).
*
* The seed loader populates the formula evaluation context from this, so
* identity-derived seed values resolve to the installer/system identity
* instead of failing. The canonical producer is the runtime's deterministic
* system user (`usr_system`), ensured before seeding so this always resolves
* — even on a fresh boot before the first human sign-up.
*
* Defined eagerly (only primitive fields, no circular refs) so it can be
* referenced from the lazily-built config schema below.
*/
export const SeedIdentitySchema = z.object({
/**
* Subject bound to `os.user` in seed CEL. Typically the deterministic
* system user (`usr_system`) or the promoted platform admin.
*/
user: z.object({
id: z.string().min(1),
role: z.string().optional(),
email: z.string().optional(),
}).optional().describe('Subject bound to os.user in seed CEL expressions'),
/**
* Organization bound to `os.org` in seed CEL. During per-tenant replay
* the loader defaults this to `organizationId` when omitted.
*/
org: z.object({
id: z.string().min(1),
tier: z.string().optional(),
}).optional().describe('Organization bound to os.org in seed CEL expressions'),
}).describe('Identity context for resolving os.user / os.org in seed CEL values');
export type SeedIdentity = z.infer<typeof SeedIdentitySchema>;
// ==========================================================================
// 5. Seed Loader Configuration
// ==========================================================================
/**
* Configuration for the seed data loader.
* Controls behavior for reference resolution, error handling, and validation.
*/
export const SeedLoaderConfigSchema = lazySchema(() => 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: SeedMode.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'),
/**
* Target organization for per-tenant seed loading.
*
* When set, the loader:
* - Injects `organization_id: <id>` into every record before write
* (unless the record already supplies one).
* - Scopes existing-record lookups (`loadExistingRecords`,
* `resolveFromDatabase`) to `organization_id = <id>`, so `upsert`
* mode finds the per-org copy rather than another tenant's row.
*
* Use this from a `sys_organization` insert hook to give every new
* tenant a private copy of the artifact-shipped demo data (Salesforce
* sandbox style). Omit for cross-tenant / platform-wide seeds (e.g.
* `sys_permission_set`) — those rows should have `organization_id`
* NULL and are not filtered by the default tenant RLS.
*/
organizationId: z.string().min(1).optional()
.describe('Target organization id for per-tenant seed replay'),
/**
* Identity bound to `os.user` / `os.org` when evaluating CEL expressions
* embedded in seed records (e.g. `owner_id: cel\`os.user.id\``,
* `organization_id: cel\`os.org.id\``).
*
* When omitted, `os.user` / `os.org` are simply unbound — and any record
* that references them fails loudly (the record is dropped with an
* actionable error rather than persisting the raw Expression envelope into
* the column). The runtime supplies a deterministic system user here so
* identity-derived seeds resolve even before the first human sign-up.
*/
identity: SeedIdentitySchema.optional()
.describe('Identity bound to os.user / os.org when resolving CEL seed values'),
}).describe('Seed data loader configuration'));
export type SeedLoaderConfig = z.infer<typeof SeedLoaderConfigSchema>;
/** Input type — all fields with defaults are optional */
export type SeedLoaderConfigInput = z.input<typeof SeedLoaderConfigSchema>;
// ==========================================================================
// 6. Per-Object Load Result
// ==========================================================================
/**
* Result of loading a single object's dataset.
*/
export const SeedLoadResultSchema = lazySchema(() => z.object({
/** Target object name */
object: z.string().describe('Object that was loaded'),
/** Import mode used */
mode: SeedMode.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 SeedLoadResult = z.infer<typeof SeedLoadResultSchema>;
// ==========================================================================
// 7. Seed Loader Result
// ==========================================================================
/**
* Complete result of a seed loading operation.
* Aggregates all per-object results and provides summary statistics.
*/
export const SeedLoaderResultSchema = lazySchema(() => 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(SeedLoadResultSchema).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<typeof SeedLoaderResultSchema>;
// ==========================================================================
// 8. Seed Loader Request
// ==========================================================================
/**
* Input request for the seed loader.
* Combines datasets with loader configuration.
*/
export const SeedLoaderRequestSchema = lazySchema(() => z.object({
/** Seeds to load */
seeds: z.array(SeedSchema).min(1).describe('Seeds to load'),
/** Loader configuration */
config: SeedLoaderConfigSchema.default(() => SeedLoaderConfigSchema.parse({})).describe('Loader configuration'),
}).describe('Seed loader request with datasets and configuration'));
export type SeedLoaderRequest = z.infer<typeof SeedLoaderRequestSchema>;
/** Input type — config defaults are optional */
export type SeedLoaderRequestInput = z.input<typeof SeedLoaderRequestSchema>;