-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathseed.zod.ts
More file actions
136 lines (123 loc) · 5.42 KB
/
Copy pathseed.zod.ts
File metadata and controls
136 lines (123 loc) · 5.42 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
// 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 (or fields) 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.
*
* A single field name matches on that one column. A **composite** natural key
* — a list of field names — is required for objects with no single-field
* natural key, most commonly a **join / junction table** keyed by both of its
* foreign keys (e.g. `externalId: ['team', 'project']`). Without it such a
* dataset can only run `mode: 'insert'`, which re-inserts every row on each
* replay boot and duplicates the table (framework#3434). The reference fields
* are matched by their RESOLVED ids, so a composite of foreign keys dedupes
* correctly across restarts.
*/
externalId: z
.union([z.string(), z.array(z.string()).min(1)])
.default('name')
.describe('Field (or composite list of fields) matched for the 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 });
}