-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathseed.zod.ts
More file actions
124 lines (111 loc) · 4.74 KB
/
Copy pathseed.zod.ts
File metadata and controls
124 lines (111 loc) · 4.74 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
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { z } from 'zod';
/**
* Seed Import Strategy
* Defines how the engine handles existing records when a seed is applied.
*/
import { lazySchema } from '../shared/lazy-schema';
export const SeedMode = z.enum([
'insert', // Try to insert, fail on duplicate
'update', // Only update found records, ignore new
'upsert', // Create new or Update existing (Standard)
'replace', // Delete ALL records in object then insert (Dangerous - use for cache tables)
'ignore' // Try to insert, silently skip duplicates
]);
/**
* Seed Schema (Seed Data / Fixtures)
*
* Standardized format for transporting initialization data. Used for:
* 1. System Bootstrapping (Admin accounts, Standard Roles)
* 2. Reference Data (Countries, Currencies)
* 3. Demo / sample data (incl. AI-authored, applied on publish)
*
* This is the shape of the runtime-draftable `seed` metadata type: an author
* (or the AI metadata assistant) stages a `seed` draft against this schema and
* its rows load when the draft is published. Named `Seed` (not `Dataset`) so
* the `dataset` name stays reserved for the ADR-0021 analytics semantic layer.
*/
export const SeedSchema = lazySchema(() => z.object({
/**
* Target Object
* The machine name of the object to populate.
*/
object: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Target Object Name'),
/**
* Idempotency Key (The "Upsert" Key)
* The field used to check if a record already exists.
* Best Practice: Use a natural key like 'code', 'slug', 'username' or 'external_id'.
* Standard: 'id' is rarely used for portable seed data — prefer natural keys.
*/
externalId: z.string().default('name').describe('Field match for uniqueness check'),
/**
* Import Strategy
*/
mode: SeedMode.default('upsert').describe('Conflict resolution strategy'),
/**
* Environment Scope
* - 'all': Always load
* - 'dev': Only for development/demo
* - 'test': Only for CI/CD tests
*/
env: z.array(z.enum(['prod', 'dev', 'test'])).default(['prod', 'dev', 'test']).describe('Applicable environments'),
/**
* The Payload
* Array of raw JSON objects matching the Object Schema.
*/
records: z.array(z.record(z.string(), z.unknown())).describe('Data records'),
}));
/** Parsed/output type — all defaults are applied (env, mode, externalId always present) */
export type Seed = z.infer<typeof SeedSchema>;
/** Input type — fields with defaults (env, mode, externalId) are optional */
export type SeedInput = z.input<typeof SeedSchema>;
export type SeedImportMode = z.infer<typeof SeedMode>;
/**
* Per-field value type for a seed record.
*
* Reference fields (`lookup` / `master_detail`) are resolved during seeding by
* matching the value against the target record's externalId — so the value MUST
* be the plain natural-key string (e.g. `account: 'Acme Corp'`), or `null`.
* Passing a wrapper object like `account: { externalId: 'Acme Corp' }` does NOT
* resolve: the loader skips non-string reference values, the raw object reaches
* the SQL driver, and on update it crashes with "SQLite3 can only bind numbers,
* strings, bigints, buffers, and null" (silently masked on an always-empty
* `:memory:` DB, fatal-looking on a persistent one). Constrain those fields to
* `string | null` at compile time; every other field stays `unknown`.
*/
type SeedFieldValue<TFieldDef> =
TFieldDef extends { type: 'lookup' | 'master_detail' } ? string | null : unknown;
/** Shape of a single seed record, derived from the object's field definitions. */
type SeedRecord<TFields> = {
[K in keyof TFields]?: SeedFieldValue<TFields[K]>;
};
/**
* Type-safe factory for creating seed definitions.
* Infers valid field keys from the object definition passed in,
* so typos in record field names are caught at compile time. Reference
* fields (lookup/master_detail) are additionally constrained to the
* natural-key string the loader resolves — see {@link SeedFieldValue}.
*
* @example
* ```ts
* export const leadSeed = defineSeed(Lead, {
* externalId: 'email',
* records: [
* { first_name: 'Alice', lead_source: 'web' }, // ✅ type-checked
* { source: 'web' }, // ❌ compile error (unknown field)
* { first_name: 'Bob', account: 'Acme Corp' }, // ✅ reference by natural key
* { first_name: 'Bob', account: { externalId: 'Acme Corp' } }, // ❌ object not allowed
* ],
* });
* ```
*/
export function defineSeed<
const TObj extends { name: string; fields: Record<string, unknown> }
>(
objectDef: TObj,
config: Omit<SeedInput, 'object' | 'records'> & {
records: Array<SeedRecord<TObj['fields']>>;
}
): Seed {
return SeedSchema.parse({ ...config, object: objectDef.name });
}