rip-lang 3.15.0 → 3.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,816 @@
1
+ // Schema runtime fragment: validate (universal — browser + server)
2
+ //
3
+ // This file is the source of truth for one slice of the schema runtime.
4
+ // Edit here, then run `bun run build:schema-runtime` to regenerate
5
+ // `src/schema/runtime.generated.js`. Tests pin the public surface via
6
+ // test/schema/errors.test.js, test/schema/modes.test.js, and the source
7
+ // schema test suite.
8
+ //
9
+ // Fragments are concatenated INSIDE one shared IIFE wrapper at build time.
10
+ // They share scope; references like `__SchemaRegistry` resolve to bindings
11
+ // defined in earlier-included fragments. Editor tooling (LSP / lint) may
12
+ // not recognize cross-fragment references — that is expected; behavior is
13
+ // pinned by the test suite.
14
+
15
+ /* eslint-disable no-undef, no-unused-vars */
16
+ class SchemaError extends Error {
17
+ constructor(issues, schemaName, schemaKind) {
18
+ super(__schemaFormatIssues(issues, schemaName));
19
+ this.name = 'SchemaError';
20
+ this.issues = issues;
21
+ this.schemaName = schemaName || null;
22
+ this.schemaKind = schemaKind || null;
23
+ }
24
+ }
25
+
26
+ function __schemaFormatIssues(issues, name) {
27
+ if (!issues || !issues.length) return 'SchemaError';
28
+ const head = name ? name + ': ' : '';
29
+ return head + issues.map(i => i.message || i.error || 'invalid').join('; ');
30
+ }
31
+
32
+ const __SCHEMA_RESERVED_STATIC = new Set([
33
+ 'parse','safe','ok','find','findMany','where','all','first','count','create','toSQL',
34
+ ]);
35
+ const __SCHEMA_RESERVED_INSTANCE = new Set([
36
+ 'save','destroy','reload','ok','errors','toJSON',
37
+ ]);
38
+ const __SCHEMA_RESERVED = new Set([...__SCHEMA_RESERVED_STATIC, ...__SCHEMA_RESERVED_INSTANCE]);
39
+
40
+ const __schemaTypes = {
41
+ string: v => typeof v === 'string',
42
+ number: v => typeof v === 'number' && !Number.isNaN(v),
43
+ integer: v => Number.isInteger(v),
44
+ boolean: v => typeof v === 'boolean',
45
+ date: v => v instanceof Date && !Number.isNaN(v.getTime()),
46
+ datetime: v => v instanceof Date && !Number.isNaN(v.getTime()),
47
+ email: v => typeof v === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
48
+ url: v => typeof v === 'string' && /^https?:\/\/.+/.test(v),
49
+ uuid: v => typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v),
50
+ phone: v => typeof v === 'string' && /^[\d\s\-+()]+$/.test(v),
51
+ zip: v => typeof v === 'string' && /^\d{5}(-\d{4})?$/.test(v),
52
+ text: v => typeof v === 'string',
53
+ json: v => v !== undefined,
54
+ any: () => true,
55
+ };
56
+
57
+ function __schemaCheckValue(v, typeName) {
58
+ const check = __schemaTypes[typeName];
59
+ return check ? check(v) : true;
60
+ }
61
+
62
+ function __schemaValidateValue(v, typeName) {
63
+ const prim = __schemaTypes[typeName];
64
+ if (prim) {
65
+ return prim(v) ? null : [{field: '', error: 'type', message: 'must be ' + typeName}];
66
+ }
67
+ const subDef = __SchemaRegistry.get(typeName);
68
+ if (!subDef) return null;
69
+ if (subDef.kind === 'enum') {
70
+ const errs = subDef._validateEnum(v, true);
71
+ return errs.length ? [{field: '', error: 'enum', message: errs[0].message}] : null;
72
+ }
73
+ if (subDef.kind === 'mixin') {
74
+ return [{field: '', error: 'type', message: ':mixin ' + typeName + ' is not usable as a field type'}];
75
+ }
76
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) {
77
+ return [{field: '', error: 'type', message: 'must be a ' + typeName + ' object'}];
78
+ }
79
+ const subErrs = subDef._validateFields(v, true);
80
+ return subErrs.length ? subErrs : null;
81
+ }
82
+
83
+ function __schemaJoinField(head, child) {
84
+ if (!child) return head;
85
+ return head + (child.startsWith('[') ? child : '.' + child);
86
+ }
87
+
88
+ function __schemaRewriteMessage(joinedField, childField, childMessage) {
89
+ if (!childField) return joinedField + ' ' + childMessage;
90
+ if (childMessage.startsWith(childField)) {
91
+ return joinedField + childMessage.slice(childField.length);
92
+ }
93
+ return joinedField + ': ' + childMessage;
94
+ }
95
+
96
+ function __schemaSnake(s) { return s.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(); }
97
+
98
+ function __schemaCamel(col) { return String(col).replace(/_([a-z])/g, (_, c) => c.toUpperCase()); }
99
+
100
+ const __SchemaRegistry = {
101
+ _entries: new Map(),
102
+ register(def) {
103
+ // Named schemas of any kind land here. Relations look up :model,
104
+ // @mixin Name looks up :mixin. Algebra (.extend etc.) accepts :shape
105
+ // and derived shapes. Kind is checked at lookup time.
106
+ if (!def.name) return;
107
+ // Most recent registration wins. Recompilation produces a fresh
108
+ // __SchemaDef with the same name; the registry rebinds. Cross-
109
+ // module name collisions should be avoided — schema names are
110
+ // app-global identifiers for relation resolution.
111
+ this._entries.set(def.name, { def, kind: def.kind });
112
+ },
113
+ get(name) {
114
+ const entry = this._entries.get(name);
115
+ return entry ? entry.def : null;
116
+ },
117
+ getKind(name, kind) {
118
+ const entry = this._entries.get(name);
119
+ return entry && entry.kind === kind ? entry.def : null;
120
+ },
121
+ has(name) { return this._entries.has(name); },
122
+ reset() { this._entries.clear(); },
123
+ };
124
+
125
+ class __SchemaDef {
126
+ constructor(desc) {
127
+ this._desc = desc;
128
+ this.kind = desc.kind;
129
+ this.name = desc.name || null;
130
+ this._norm = null;
131
+ this._klass = null;
132
+ this._sourceModel = null;
133
+ }
134
+
135
+ _normalize() {
136
+ if (this._norm) return this._norm;
137
+
138
+ const fields = new Map();
139
+ const methods = new Map();
140
+ const computed = new Map();
141
+ const derived = new Map();
142
+ const hooks = new Map();
143
+ const directives = [];
144
+ const enumMembers = new Map();
145
+ const relations = new Map();
146
+ const ensures = [];
147
+ let timestamps = false;
148
+ let softDelete = false;
149
+
150
+ const collision = (n, where) => {
151
+ throw new SchemaError(
152
+ [{field: n, error: 'collision', message: n + ' collides with ' + where}],
153
+ this.name, this.kind);
154
+ };
155
+ const noteCollision = (n) => {
156
+ if (fields.has(n)) collision(n, 'field');
157
+ if (methods.has(n)) collision(n, 'method');
158
+ if (computed.has(n)) collision(n, 'computed');
159
+ if (hooks.has(n)) collision(n, 'hook');
160
+ if (relations.has(n)) collision(n, 'relation');
161
+ if (this.kind === 'model' && __SCHEMA_RESERVED.has(n)) collision(n, 'reserved ORM name');
162
+ };
163
+
164
+ for (const e of this._desc.entries) {
165
+ switch (e.tag) {
166
+ case 'field':
167
+ noteCollision(e.name);
168
+ fields.set(e.name, {
169
+ name: e.name,
170
+ required: e.modifiers.includes('!'),
171
+ unique: e.modifiers.includes('#'),
172
+ optional: e.modifiers.includes('?'),
173
+ typeName: e.typeName,
174
+ literals: e.literals || null,
175
+ array: e.array === true,
176
+ constraints: e.constraints || null,
177
+ transform: e.transform || null,
178
+ });
179
+ break;
180
+ case 'method':
181
+ noteCollision(e.name);
182
+ methods.set(e.name, e.fn);
183
+ break;
184
+ case 'computed':
185
+ noteCollision(e.name);
186
+ computed.set(e.name, e.fn);
187
+ break;
188
+ case 'derived':
189
+ noteCollision(e.name);
190
+ derived.set(e.name, e.fn);
191
+ break;
192
+ case 'hook':
193
+ if (hooks.has(e.name)) collision(e.name, 'duplicate hook');
194
+ hooks.set(e.name, e.fn);
195
+ break;
196
+ case 'directive': {
197
+ directives.push({ name: e.name, args: e.args || [] });
198
+ // @mixin is recorded but further handling is deferred to the
199
+ // post-pass so we can dedupe diamond includes and detect
200
+ // cycles with a full expansion stack. All other directives
201
+ // get their relation / timestamps / softDelete processing now.
202
+ if (e.name === 'mixin') break;
203
+ if (e.name === 'timestamps') timestamps = true;
204
+ if (e.name === 'softDelete') softDelete = true;
205
+ const rel = __schemaNormalizeDirectiveRelation(e, this.name);
206
+ if (rel) {
207
+ noteCollision(rel.accessor);
208
+ relations.set(rel.accessor, rel);
209
+ }
210
+ break;
211
+ }
212
+ case 'enum-member':
213
+ enumMembers.set(e.name, e.value !== undefined ? e.value : e.name);
214
+ break;
215
+ case 'ensure':
216
+ // @ensure entries are schema-level invariants (cross-field
217
+ // predicates). Declaration order is preserved so diagnostics
218
+ // come out in the order authored.
219
+ ensures.push({ message: e.message, fn: e.fn });
220
+ break;
221
+ }
222
+ }
223
+
224
+ // @mixin expansion (Phase 5). Depth-first, dedupes diamond includes
225
+ // in the same host expansion, detects cycles with full chain.
226
+ if (this.kind === 'model' || this.kind === 'shape' || this.kind === 'input' ||
227
+ this.kind === 'mixin') {
228
+ __schemaExpandMixins(this, fields, directives, {
229
+ stack: [this.name || '<anon>'],
230
+ seen: new Set([this.name || '<anon>']),
231
+ onCollision: (name, src) => collision(name, 'mixin-included field from ' + src),
232
+ });
233
+ }
234
+
235
+ // Add implicit primary key for :model unless a field already marked primary.
236
+ const primaryKey = 'id';
237
+ const tableName = this.kind === 'model' ? __schemaTableName(this.name) : null;
238
+
239
+ this._norm = {
240
+ fields, methods, computed, derived, hooks, directives, enumMembers, relations,
241
+ ensures,
242
+ timestamps, softDelete, primaryKey, tableName,
243
+ };
244
+ return this._norm;
245
+ }
246
+
247
+ // Run eager-derived entries (!>) — one pass, in declaration order.
248
+ //
249
+ // Invariants worth keeping in mind here:
250
+ // - Fires at parse/safe time AND at DB hydrate time (declared fields
251
+ // are populated by then in both paths).
252
+ // - NOT re-run on field mutation — the value is materialized once at
253
+ // instance creation and stays. Use ~> for live recomputation.
254
+ // - Stored as own enumerable properties, so they round-trip through
255
+ // Object.keys and JSON.stringify. Excluded from DB persistence by
256
+ // _getSaveableData (writes declared fields only).
257
+ // - Thrown errors propagate. parse() wraps them into SchemaError
258
+ // before surfacing; safe() captures into {error: 'derived'}
259
+ // issues; hydrate lets them crash fast as data-integrity signals.
260
+
261
+ _applyEagerDerived(inst) {
262
+ const norm = this._normalize();
263
+ if (!norm.derived.size) return;
264
+ for (const [n, fn] of norm.derived) {
265
+ const v = fn.call(inst);
266
+ Object.defineProperty(inst, n, {
267
+ value: v, enumerable: true, writable: true, configurable: true,
268
+ });
269
+ }
270
+ }
271
+
272
+ // Run '@ensure' predicates — schema-level cross-field invariants —
273
+ // against a fully-typed, fully-defaulted data object. Returns [] if
274
+ // all pass, or an array of {field: '', error: 'ensure', message}
275
+ // issues for every failing predicate.
276
+ //
277
+ // Naming: '_applyEnsures' mirrors '_applyTransforms' and
278
+ // '_applyEagerDerived' — runtime method name matches the directive
279
+ // it services. The industry term for this pattern is 'refinement'
280
+ // (Zod's '.refine', design-by-contract postconditions); in Rip the
281
+ // user-visible name is '@ensure' and the code tracks that.
282
+ //
283
+ // Semantics:
284
+ // - Truthy return → pass; falsy → fail with the declared message.
285
+ // - Thrown exception → fail with the declared message (the thrown
286
+ // error's own message is used only if the @ensure declared no
287
+ // message, which can't happen via the parser since message is
288
+ // required — but downstream code-built defs might omit it).
289
+ // - All @ensures run; declaration order preserved in output.
290
+ // - Caller short-circuits: per-field validation errors skip this
291
+ // step entirely (predicates assume field types are correct).
292
+ // - Skipped on _hydrate — trusted DB data bypasses @ensures.
293
+
294
+ _applyEnsures(data) {
295
+ const norm = this._normalize();
296
+ if (!norm.ensures.length) return [];
297
+ const errs = [];
298
+ for (const r of norm.ensures) {
299
+ let ok = false;
300
+ try {
301
+ ok = !!r.fn(data);
302
+ } catch (e) {
303
+ errs.push({
304
+ field: '', error: 'ensure',
305
+ message: r.message || e?.message || 'ensure failed',
306
+ });
307
+ continue;
308
+ }
309
+ if (!ok) {
310
+ errs.push({
311
+ field: '', error: 'ensure',
312
+ message: r.message || 'ensure failed',
313
+ });
314
+ }
315
+ }
316
+ return errs;
317
+ }
318
+
319
+ _getClass() {
320
+ if (this._klass) return this._klass;
321
+ const norm = this._normalize();
322
+ const name = this.name || 'Schema';
323
+ const def = this;
324
+
325
+ const fieldNames = [...norm.fields.keys()];
326
+ const klass = ({[name]: class {
327
+ constructor(data, persisted = false) {
328
+ // Internal state is non-enumerable so Object.keys(inst) lists
329
+ // only declared fields that received a value.
330
+ Object.defineProperty(this, '_dirty', { value: new Set(), enumerable: false, writable: false, configurable: true });
331
+ Object.defineProperty(this, '_persisted', { value: persisted === true, enumerable: false, writable: true, configurable: true });
332
+ Object.defineProperty(this, '_snapshot', { value: null, enumerable: false, writable: true, configurable: true });
333
+ if (data && typeof data === 'object') {
334
+ for (const k of fieldNames) {
335
+ if (k in data && data[k] !== undefined) this[k] = data[k];
336
+ }
337
+ }
338
+ }
339
+ }})[name];
340
+
341
+ for (const [n, fn] of norm.methods) {
342
+ Object.defineProperty(klass.prototype, n, {
343
+ value: fn, writable: true, enumerable: false, configurable: true,
344
+ });
345
+ }
346
+ for (const [n, fn] of norm.computed) {
347
+ Object.defineProperty(klass.prototype, n, {
348
+ get: fn, enumerable: false, configurable: true,
349
+ });
350
+ }
351
+
352
+ // Relation methods: user.organization(). Accepts no args; returns
353
+ // a promise to a target-model instance (or array for has_many).
354
+ for (const [acc, rel] of norm.relations) {
355
+ Object.defineProperty(klass.prototype, acc, {
356
+ enumerable: false, configurable: true,
357
+ value: async function() { return __schemaResolveRelation(def, this, rel); },
358
+ });
359
+ }
360
+
361
+ // Instance ORM methods — only for :model kind.
362
+ if (this.kind === 'model') {
363
+ Object.defineProperty(klass.prototype, 'save', {
364
+ enumerable: false, configurable: true, writable: true,
365
+ value: async function() { return __schemaSave(def, this); },
366
+ });
367
+ Object.defineProperty(klass.prototype, 'destroy', {
368
+ enumerable: false, configurable: true, writable: true,
369
+ value: async function() { return __schemaDestroy(def, this); },
370
+ });
371
+ Object.defineProperty(klass.prototype, 'ok', {
372
+ enumerable: false, configurable: true, writable: true,
373
+ value: function() { return def._validateFields(this, false); },
374
+ });
375
+ Object.defineProperty(klass.prototype, 'errors', {
376
+ enumerable: false, configurable: true, writable: true,
377
+ value: function() { return def._validateFields(this, true); },
378
+ });
379
+ // toJSON mirrors the instance's own enumerable properties, which by
380
+ // construction are: the primary key, declared fields, @timestamps
381
+ // columns, @softDelete timestamp, @belongs_to FK columns, and any
382
+ // !> eager-derived fields. Internal state (_dirty, _persisted,
383
+ // _snapshot) is defined non-enumerable; methods and ~> computed
384
+ // getters live on the prototype. So iterating own keys picks up
385
+ // exactly the user-facing wire shape without special-casing each
386
+ // category — and stays correct when new implicit columns get added
387
+ // to the runtime.
388
+ Object.defineProperty(klass.prototype, 'toJSON', {
389
+ enumerable: false, configurable: true, writable: true,
390
+ value: function() {
391
+ const out = {};
392
+ for (const k of Object.keys(this)) out[k] = this[k];
393
+ return out;
394
+ },
395
+ });
396
+ }
397
+
398
+ this._klass = klass;
399
+ return klass;
400
+ }
401
+
402
+ _hydrate(columns, row) {
403
+ // DB rows are trusted: hydrate into a class instance without
404
+ // revalidating. Column names arrive snake_case; declared fields live
405
+ // under their camelCase names, and implicit columns (id, created_at,
406
+ // updated_at, relation FKs) surface under their camelCase equivalents.
407
+ // Each snake_case column name also aliases the camelCase property via
408
+ // a non-enumerable accessor so order.user_id and order.userId read
409
+ // the same slot — useful when DB column names leak into user code
410
+ // via raw SQL helpers.
411
+ const data = {};
412
+ for (let i = 0; i < columns.length; i++) {
413
+ data[__schemaCamel(columns[i].name)] = row[i];
414
+ }
415
+ const k = this._getClass();
416
+ const inst = new k(data, true);
417
+ for (const key of Object.keys(data)) {
418
+ if (!(key in inst)) {
419
+ Object.defineProperty(inst, key, {
420
+ value: data[key], enumerable: true, writable: true, configurable: true,
421
+ });
422
+ }
423
+ }
424
+ for (let i = 0; i < columns.length; i++) {
425
+ const snake = columns[i].name;
426
+ const camel = __schemaCamel(snake);
427
+ if (snake !== camel && !(snake in inst)) {
428
+ Object.defineProperty(inst, snake, {
429
+ enumerable: false, configurable: true,
430
+ get() { return this[camel]; },
431
+ set(v) { this[camel] = v; },
432
+ });
433
+ }
434
+ }
435
+ // Eager-derived fields re-run on hydrate — they're not persisted
436
+ // and must be re-computed from the declared fields now present.
437
+ this._applyEagerDerived(inst);
438
+ return inst;
439
+ }
440
+
441
+ _validateFields(data, collect) {
442
+ const norm = this._normalize();
443
+ const errors = collect ? [] : null;
444
+ for (const [n, f] of norm.fields) {
445
+ const v = data == null ? undefined : data[n];
446
+ if (v === undefined || v === null) {
447
+ if (f.required) {
448
+ if (!collect) return false;
449
+ errors.push({field: n, error: 'required', message: n + ' is required'});
450
+ }
451
+ continue;
452
+ }
453
+ if (f.array) {
454
+ if (!Array.isArray(v)) {
455
+ if (!collect) return false;
456
+ errors.push({field: n, error: 'type', message: n + ' must be an array'});
457
+ continue;
458
+ }
459
+ let bad = false;
460
+ for (let i = 0; i < v.length; i++) {
461
+ const issues = __schemaValidateValue(v[i], f.typeName);
462
+ if (issues) {
463
+ if (!collect) return false;
464
+ const head = n + '[' + i + ']';
465
+ for (const e of issues) {
466
+ const joined = __schemaJoinField(head, e.field);
467
+ errors.push({
468
+ field: joined,
469
+ error: e.error,
470
+ message: __schemaRewriteMessage(joined, e.field, e.message),
471
+ });
472
+ }
473
+ bad = true;
474
+ }
475
+ }
476
+ if (bad) continue;
477
+ } else if (f.typeName === 'literal-union') {
478
+ if (!f.literals.includes(v)) {
479
+ if (!collect) return false;
480
+ errors.push({field: n, error: 'enum', message: n + ' must be one of ' + f.literals.map(l => JSON.stringify(l)).join(', ')});
481
+ continue;
482
+ }
483
+ } else {
484
+ const issues = __schemaValidateValue(v, f.typeName);
485
+ if (issues) {
486
+ if (!collect) return false;
487
+ for (const e of issues) {
488
+ const joined = __schemaJoinField(n, e.field);
489
+ errors.push({
490
+ field: joined,
491
+ error: e.error,
492
+ message: __schemaRewriteMessage(joined, e.field, e.message),
493
+ });
494
+ }
495
+ continue;
496
+ }
497
+ }
498
+ // Apply constraint checks.
499
+ const c = f.constraints;
500
+ if (c) {
501
+ if (typeof v === 'string') {
502
+ if (c.min != null && v.length < c.min) { if (!collect) return false; errors.push({field: n, error: 'min', message: n + ' must be at least ' + c.min + ' chars'}); }
503
+ if (c.max != null && v.length > c.max) { if (!collect) return false; errors.push({field: n, error: 'max', message: n + ' must be at most ' + c.max + ' chars'}); }
504
+ if (c.regex && !c.regex.test(v)) { if (!collect) return false; errors.push({field: n, error: 'pattern', message: n + ' is invalid'}); }
505
+ } else if (typeof v === 'number') {
506
+ if (c.min != null && v < c.min) { if (!collect) return false; errors.push({field: n, error: 'min', message: n + ' must be >= ' + c.min}); }
507
+ if (c.max != null && v > c.max) { if (!collect) return false; errors.push({field: n, error: 'max', message: n + ' must be <= ' + c.max}); }
508
+ }
509
+ }
510
+ }
511
+ return collect ? errors : true;
512
+ }
513
+
514
+ _applyDefaults(data) {
515
+ const norm = this._normalize();
516
+ for (const [n, f] of norm.fields) {
517
+ if ((data[n] === undefined || data[n] === null) && f.constraints?.default !== undefined) {
518
+ const d = f.constraints.default;
519
+ data[n] = (typeof d === 'object' && d !== null && !(d instanceof RegExp))
520
+ ? structuredClone(d) : d;
521
+ }
522
+ }
523
+ return data;
524
+ }
525
+
526
+ // Inline field transforms run once during parse (and safe/ok), never
527
+ // during DB hydrate. Each transform receives the whole raw input
528
+ // object as 'it'; its return value becomes the field's candidate
529
+ // value before default + validation. Transform errors surface as
530
+ // {error: 'transform'} issues on the final result.
531
+
532
+ _applyTransforms(raw, working) {
533
+ const norm = this._normalize();
534
+ const errors = [];
535
+ for (const [n, f] of norm.fields) {
536
+ if (!f.transform) continue;
537
+ try {
538
+ working[n] = f.transform(raw);
539
+ } catch (e) {
540
+ errors.push({field: n, error: 'transform', message: e?.message || String(e)});
541
+ }
542
+ }
543
+ return errors;
544
+ }
545
+
546
+ _validateEnum(data, collect) {
547
+ const norm = this._normalize();
548
+ for (const [n, v] of norm.enumMembers) {
549
+ if (data === n || data === v) return collect ? [] : true;
550
+ }
551
+ if (!collect) return false;
552
+ const members = [...norm.enumMembers.keys()].join(', ');
553
+ return [{field: '', error: 'enum', message: (this.name || 'enum') + ' expected one of: ' + members}];
554
+ }
555
+
556
+ _materializeEnum(data) {
557
+ const norm = this._normalize();
558
+ for (const [n, v] of norm.enumMembers) {
559
+ if (data === n || data === v) return v;
560
+ }
561
+ return data;
562
+ }
563
+
564
+ // Canonical field parse pipeline — run per-field in declaration order,
565
+ // then an after-fields pass for eager-derived. This is the SINGLE
566
+ // source of truth for parse-time field semantics; _hydrate bypasses
567
+ // steps 1-5 entirely (DB rows arrive canonical) and picks up at step 7.
568
+ //
569
+ // 1. Obtain raw candidate — transform(raw) if declared, else raw[name]
570
+ // 2. Apply default — if candidate missing/undefined
571
+ // 3. Required check — optional/required/nullability
572
+ // 4. Type validation — primitive / literal-union / array
573
+ // 5. Constraint checks — range, regex, attrs
574
+ // 6. Assign to instance — own enumerable property
575
+ // 7. Eager-derived pass — run !> entries in declaration order
576
+ //
577
+ // Transforms (step 1) run on parse/safe/ok only. Hydrate skips them
578
+ // because DB columns already hold the canonical values. Eager-derived
579
+ // (step 7) fires on BOTH paths so hydrated instances have the same
580
+ // shape as parsed ones.
581
+
582
+ parse(data) {
583
+ if (this.kind === 'mixin') {
584
+ throw new Error(":mixin schema '" + (this.name || 'anon') + "' is not instantiable");
585
+ }
586
+ if (this.kind === 'enum') {
587
+ const errs = this._validateEnum(data, true);
588
+ if (errs.length) throw new SchemaError(errs, this.name, this.kind);
589
+ return this._materializeEnum(data);
590
+ }
591
+ const raw = data || {};
592
+ const working = { ...raw };
593
+ const transformErrors = this._applyTransforms(raw, working);
594
+ this._applyDefaults(working);
595
+ const errs = transformErrors.concat(this._validateFields(working, true));
596
+ if (errs.length) throw new SchemaError(errs, this.name, this.kind);
597
+ // @ensure runs AFTER per-field validation so predicates can
598
+ // assume declared fields are typed and defaulted. A field-level
599
+ // failure short-circuits: we never reach this line with errs.
600
+ const ensureErrs = this._applyEnsures(working);
601
+ if (ensureErrs.length) throw new SchemaError(ensureErrs, this.name, this.kind);
602
+ const klass = this._getClass();
603
+ const inst = new klass(working, false);
604
+ this._applyEagerDerived(inst);
605
+ return inst;
606
+ }
607
+
608
+ safe(data) {
609
+ if (this.kind === 'mixin') {
610
+ return {ok: false, value: null, errors: [{field: '', error: 'mixin', message: 'not instantiable'}]};
611
+ }
612
+ if (this.kind === 'enum') {
613
+ const errs = this._validateEnum(data, true);
614
+ if (errs.length) return {ok: false, value: null, errors: errs};
615
+ return {ok: true, value: this._materializeEnum(data), errors: null};
616
+ }
617
+ const raw = data || {};
618
+ const working = { ...raw };
619
+ const transformErrors = this._applyTransforms(raw, working);
620
+ this._applyDefaults(working);
621
+ const errs = transformErrors.concat(this._validateFields(working, true));
622
+ if (errs.length) return {ok: false, value: null, errors: errs};
623
+ const ensureErrs = this._applyEnsures(working);
624
+ if (ensureErrs.length) return {ok: false, value: null, errors: ensureErrs};
625
+ const klass = this._getClass();
626
+ const inst = new klass(working, false);
627
+ try { this._applyEagerDerived(inst); }
628
+ catch (e) {
629
+ return {ok: false, value: null, errors: [{field: '', error: 'derived', message: e?.message || String(e)}]};
630
+ }
631
+ return {ok: true, value: inst, errors: null};
632
+ }
633
+
634
+ ok(data) {
635
+ if (this.kind === 'mixin') return false;
636
+ if (this.kind === 'enum') return this._validateEnum(data, false);
637
+ const raw = data || {};
638
+ const working = { ...raw };
639
+ const transformErrors = this._applyTransforms(raw, working);
640
+ if (transformErrors.length) return false;
641
+ this._applyDefaults(working);
642
+ if (!this._validateFields(working, false)) return false;
643
+ // Per-field validation passed — @ensure predicates are the final gate.
644
+ return this._applyEnsures(working).length === 0;
645
+ }
646
+
647
+ // ---- :model static ORM methods --------------------------------------------
648
+
649
+ pick(...keys) {
650
+ return __schemaDerive(this, (src) => {
651
+ const names = __schemaFlatten(keys);
652
+ const out = new Map();
653
+ for (const k of names) {
654
+ if (!src.has(k)) throw new Error("pick: unknown field '" + k + "' on " + (this.name || 'schema'));
655
+ out.set(k, src.get(k));
656
+ }
657
+ return out;
658
+ });
659
+ }
660
+
661
+ omit(...keys) {
662
+ return __schemaDerive(this, (src) => {
663
+ const drop = new Set(__schemaFlatten(keys));
664
+ const out = new Map();
665
+ for (const [k, v] of src) if (!drop.has(k)) out.set(k, v);
666
+ return out;
667
+ });
668
+ }
669
+
670
+ partial() {
671
+ return __schemaDerive(this, (src) => {
672
+ const out = new Map();
673
+ for (const [k, v] of src) out.set(k, { ...v, required: false });
674
+ return out;
675
+ });
676
+ }
677
+
678
+ required(...keys) {
679
+ return __schemaDerive(this, (src) => {
680
+ const req = new Set(__schemaFlatten(keys));
681
+ const out = new Map();
682
+ for (const [k, v] of src) out.set(k, { ...v, required: req.has(k) ? true : v.required });
683
+ return out;
684
+ });
685
+ }
686
+
687
+ extend(other) {
688
+ if (!(other instanceof __SchemaDef)) {
689
+ throw new Error('extend(): argument must be a schema value');
690
+ }
691
+ return __schemaDerive(this, (src) => {
692
+ const merged = new Map(src);
693
+ const otherFields = other._normalize().fields;
694
+ for (const [k, v] of otherFields) {
695
+ if (merged.has(k)) {
696
+ throw new Error("extend(): field '" + k + "' collides between " + (this.name || 'schema') + " and " + (other.name || 'other'));
697
+ }
698
+ merged.set(k, v);
699
+ }
700
+ return merged;
701
+ });
702
+ }
703
+ }
704
+
705
+ function __schemaFlatten(keys) {
706
+ const out = [];
707
+ for (const k of keys) {
708
+ if (typeof k === 'symbol') out.push(Symbol.keyFor(k) || k.description);
709
+ else if (Array.isArray(k)) for (const kk of k) out.push(typeof kk === 'symbol' ? (Symbol.keyFor(kk) || kk.description) : kk);
710
+ else out.push(k);
711
+ }
712
+ return out;
713
+ }
714
+
715
+ function __schemaDerive(source, transform) {
716
+ const src = source._normalize().fields;
717
+ const derivedFields = transform(src);
718
+ const entries = [];
719
+ for (const [, f] of derivedFields) {
720
+ const mods = [];
721
+ if (f.required) mods.push('!');
722
+ if (f.unique) mods.push('#');
723
+ if (f.optional && !f.required) mods.push('?');
724
+ entries.push({
725
+ tag: 'field', name: f.name, modifiers: mods,
726
+ typeName: f.typeName, array: f.array,
727
+ literals: f.literals || null,
728
+ constraints: f.constraints,
729
+ transform: f.transform || null,
730
+ });
731
+ }
732
+ const name = (source.name || 'Schema') + 'Derived';
733
+ const derived = new __SchemaDef({ kind: 'shape', name, entries });
734
+ // sourceModel propagates through chained algebra. Tooling can follow
735
+ // the chain back to the original :model for projection hints.
736
+ derived._sourceModel = source._sourceModel || (source.kind === 'model' ? source : null);
737
+ return derived;
738
+ }
739
+
740
+ function __schemaNormalizeDirectiveRelation(directive, ownerModel) {
741
+ const args = directive.args;
742
+ if (!args || !args.length) return null;
743
+ const a = args[0];
744
+ const name = directive.name;
745
+ if (name === 'belongs_to') {
746
+ const targetLc = a.target[0].toLowerCase() + a.target.slice(1);
747
+ return { kind: 'belongsTo', target: a.target, accessor: targetLc, foreignKey: __schemaFkName(a.target), optional: !!a.optional };
748
+ }
749
+ if (name === 'has_one' || name === 'one') {
750
+ const targetLc = a.target[0].toLowerCase() + a.target.slice(1);
751
+ return { kind: 'hasOne', target: a.target, accessor: targetLc, foreignKey: __schemaFkName(ownerModel), optional: !!a.optional };
752
+ }
753
+ if (name === 'has_many' || name === 'many') {
754
+ const targetLc = a.target[0].toLowerCase() + a.target.slice(1);
755
+ return { kind: 'hasMany', target: a.target, accessor: __schemaPluralize(targetLc), foreignKey: __schemaFkName(ownerModel), optional: !!a.optional };
756
+ }
757
+ return null;
758
+ }
759
+
760
+ function __schemaExpandMixins(host, fields, directives, ctx) {
761
+ for (const d of directives) {
762
+ if (d.name !== 'mixin' || !d.args || !d.args[0]) continue;
763
+ const target = d.args[0].target;
764
+ if (!target) continue;
765
+ if (ctx.stack.includes(target)) {
766
+ throw new SchemaError(
767
+ [{field: '', error: 'mixin-cycle', message: 'mixin cycle: ' + ctx.stack.concat(target).join(' -> ')}],
768
+ host.name, host.kind);
769
+ }
770
+ if (ctx.seen.has(target)) continue;
771
+ const mx = __SchemaRegistry.getKind(target, 'mixin');
772
+ if (!mx) {
773
+ throw new SchemaError(
774
+ [{field: '', error: 'mixin-missing', message: 'unknown mixin: ' + target}],
775
+ host.name, host.kind);
776
+ }
777
+ ctx.seen.add(target);
778
+ ctx.stack.push(target);
779
+ // Recurse into nested mixins first (depth-first).
780
+ const childDirectives = mx._desc.entries.filter(e => e.tag === 'directive' && e.name === 'mixin')
781
+ .map(e => ({ name: e.name, args: e.args || [] }));
782
+ __schemaExpandMixins(host, fields, childDirectives, ctx);
783
+ // Then contribute the mixin's own fields.
784
+ for (const e of mx._desc.entries) {
785
+ if (e.tag !== 'field') continue;
786
+ if (fields.has(e.name)) {
787
+ throw new SchemaError(
788
+ [{field: e.name, error: 'mixin-collision', message: e.name + ' from mixin ' + target + ' collides with existing field'}],
789
+ host.name, host.kind);
790
+ }
791
+ fields.set(e.name, {
792
+ name: e.name,
793
+ required: e.modifiers.includes('!'),
794
+ unique: e.modifiers.includes('#'),
795
+ optional: e.modifiers.includes('?'),
796
+ typeName: e.typeName,
797
+ literals: e.literals || null,
798
+ array: e.array === true,
799
+ constraints: e.constraints || null,
800
+ transform: e.transform || null,
801
+ });
802
+ }
803
+ ctx.stack.pop();
804
+ }
805
+ }
806
+
807
+ function __schema(descriptor) {
808
+ const def = new __SchemaDef(descriptor);
809
+ // Every user-declared named schema lands in the registry so
810
+ // nested-typed fields (address! Address, items! OrderItem[],
811
+ // role! Role) can resolve their type reference at validate time.
812
+ // Algebra-derived schemas (.pick/.omit/.partial/…) bypass this
813
+ // factory so their synthetic names don't shadow the source.
814
+ if (def.name) __SchemaRegistry.register(def);
815
+ return def;
816
+ }