rip-lang 3.16.0 → 3.16.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.
Files changed (57) hide show
  1. package/README.md +3 -4
  2. package/bin/rip +201 -18
  3. package/bin/rip-schema +175 -0
  4. package/docs/AGENTS.md +1 -1
  5. package/docs/RIP-APP.md +200 -19
  6. package/docs/RIP-DUCKDB.md +64 -1
  7. package/docs/RIP-INTRO.md +4 -4
  8. package/docs/RIP-LANG.md +36 -38
  9. package/docs/RIP-SCHEMA.md +1204 -364
  10. package/docs/RIP-TYPES.md +74 -103
  11. package/docs/demo/README.md +4 -3
  12. package/docs/dist/rip.js +4195 -966
  13. package/docs/dist/rip.min.js +1161 -284
  14. package/docs/dist/rip.min.js.br +0 -0
  15. package/docs/example/index.json +7 -7
  16. package/docs/example/index.json.br +0 -0
  17. package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
  18. package/docs/extensions/vscode/print/print-latest.vsix +0 -0
  19. package/docs/extensions/vscode/rip/index.html +2 -1
  20. package/docs/extensions/vscode/rip/rip-0.5.15.vsix +0 -0
  21. package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
  22. package/docs/extensions/vscode/rip/vscode-rip-0.6.0.vsix +0 -0
  23. package/docs/index.html +1 -1
  24. package/docs/ui/bundle.json +55 -55
  25. package/docs/ui/bundle.json.br +0 -0
  26. package/docs/ui/hljs-rip.js +1 -1
  27. package/docs/ui/index.html +1 -1
  28. package/package.json +15 -7
  29. package/rip-loader.js +59 -2
  30. package/src/AGENTS.md +43 -12
  31. package/src/browser.js +52 -11
  32. package/src/compiler.js +538 -80
  33. package/src/components.js +488 -48
  34. package/src/dts.js +80 -50
  35. package/src/grammar/README.md +29 -170
  36. package/src/grammar/grammar.rip +17 -12
  37. package/src/grammar/solar.rip +4 -17
  38. package/src/lexer.js +82 -32
  39. package/src/parser.js +229 -229
  40. package/src/schema/dts.js +328 -54
  41. package/src/schema/loader-server.js +2 -1
  42. package/src/schema/runtime-browser-stubs.js +20 -9
  43. package/src/schema/runtime-ddl.js +161 -44
  44. package/src/schema/runtime-migrate.js +681 -0
  45. package/src/schema/runtime-orm.js +698 -54
  46. package/src/schema/runtime-validate.js +808 -24
  47. package/src/schema/runtime.generated.js +2395 -135
  48. package/src/schema/schema.js +1054 -94
  49. package/src/typecheck.js +1610 -127
  50. package/src/types.js +90 -6
  51. package/src/grammar/lunar.rip +0 -2412
  52. /package/docs/demo/{components → routes}/_layout.rip +0 -0
  53. /package/docs/demo/{components → routes}/about.rip +0 -0
  54. /package/docs/demo/{components → routes}/card.rip +0 -0
  55. /package/docs/demo/{components → routes}/counter.rip +0 -0
  56. /package/docs/demo/{components → routes}/index.rip +0 -0
  57. /package/docs/demo/{components → routes}/todos.rip +0 -0
@@ -30,11 +30,20 @@ function __schemaFormatIssues(issues, name) {
30
30
  }
31
31
 
32
32
  const __SCHEMA_RESERVED_STATIC = new Set([
33
- 'parse','safe','ok','find','findMany','where','all','first','count','create','toSQL',
33
+ 'parse','array','safe','ok','parseAsync','safeAsync','okAsync','toJSONSchema',
34
+ 'find','findMany','where','all','first','count','create','toSQL',
35
+ 'includes','upsert','insertMany','updateAll','deleteAll','withDeleted','onlyDeleted',
36
+ 'unscoped',
37
+ ]);
38
+ // Names a @scope may not take: the model statics above plus the
39
+ // builder-only chain methods — scopes install on both surfaces.
40
+ const __SCHEMA_SCOPE_RESERVED = new Set([
41
+ ...__SCHEMA_RESERVED_STATIC,
42
+ 'limit','offset','order','orderBy',
34
43
  ]);
35
44
  const __SCHEMA_RESERVED_INSTANCE = new Set([
36
- 'save','destroy','reload','ok','errors','toJSON','savedChanges','markDirty',
37
- '_saving',
45
+ 'save','destroy','restore','reload','ok','errors','toJSON','savedChanges','markDirty',
46
+ '_saving','_relMemo',
38
47
  ]);
39
48
  // Implicit columns owned by directive-driven runtime behavior. Declaring
40
49
  // them as user fields would either shadow the runtime API (savedChanges /
@@ -71,6 +80,64 @@ function __schemaCheckValue(v, typeName) {
71
80
  return check ? check(v) : true;
72
81
  }
73
82
 
83
+ // Strict coercion tables for the `~type` marker — "coerce, then
84
+ // validate". Deliberately narrow: `~integer` rejects "12.5" and NaN,
85
+ // `~boolean` accepts exactly six tokens, `~date` accepts ISO-8601
86
+ // strings and finite epoch numbers. A failed coercion is
87
+ // {error: 'coerce'}, distinct from {error: 'type'} — the value LOOKED
88
+ // like wire data but didn't convert, which is a different user mistake
89
+ // than sending the wrong shape entirely.
90
+ const __SCHEMA_COERCERS = {
91
+ integer(v) {
92
+ if (typeof v === 'number') return Number.isInteger(v) ? { ok: true, value: v } : { ok: false };
93
+ if (typeof v === 'string' && /^[+-]?\d+$/.test(v.trim())) return { ok: true, value: parseInt(v.trim(), 10) };
94
+ return { ok: false };
95
+ },
96
+ number(v) {
97
+ if (typeof v === 'number') return Number.isNaN(v) ? { ok: false } : { ok: true, value: v };
98
+ if (typeof v === 'string' && /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(v.trim())) {
99
+ return { ok: true, value: Number(v.trim()) };
100
+ }
101
+ return { ok: false };
102
+ },
103
+ boolean(v) {
104
+ if (typeof v === 'boolean') return { ok: true, value: v };
105
+ if (v === 'true' || v === '1' || v === 1) return { ok: true, value: true };
106
+ if (v === 'false' || v === '0' || v === 0) return { ok: true, value: false };
107
+ return { ok: false };
108
+ },
109
+ date(v) {
110
+ if (v instanceof Date) return Number.isNaN(v.getTime()) ? { ok: false } : { ok: true, value: v };
111
+ if (typeof v === 'number' && Number.isFinite(v)) return { ok: true, value: new Date(v) };
112
+ if (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}/.test(v)) {
113
+ const d = new Date(v);
114
+ if (!Number.isNaN(d.getTime())) return { ok: true, value: d };
115
+ }
116
+ return { ok: false };
117
+ },
118
+ };
119
+ __SCHEMA_COERCERS.datetime = __SCHEMA_COERCERS.date;
120
+
121
+ // Named-coercer registry for the `~:name` field syntax. A coercer is a
122
+ // function (wireValue) → coercedValue, where null/undefined/false means
123
+ // "didn't convert" → {error: 'coerce'}. @rip-lang/server registers its
124
+ // entire read() validator vocabulary (id, money, ssn, phone, name,
125
+ // date, …) here at module load, so every wire normalizer that works in
126
+ // `read 'x', 'ssn'` also works as `x? ~:ssn` in a schema. Apps register
127
+ // their own via schema.registerCoercer.
128
+ //
129
+ // opts.raw — pass the value through un-stringified (validators that
130
+ // operate on arrays/objects, e.g. the server's array/hash/json).
131
+ const __schemaNamedCoercers = new Map();
132
+
133
+ function __schemaRegisterCoercer(name, fn, opts) {
134
+ if (typeof name !== 'string' || typeof fn !== 'function') {
135
+ throw new Error('schema.registerCoercer(name, fn, opts?): name string and fn required');
136
+ }
137
+ __schemaNamedCoercers.set(name, { fn, raw: opts?.raw === true });
138
+ return fn;
139
+ }
140
+
74
141
  function __schemaValidateValue(v, typeName) {
75
142
  const prim = __schemaTypes[typeName];
76
143
  if (prim) {
@@ -85,6 +152,12 @@ function __schemaValidateValue(v, typeName) {
85
152
  if (subDef.kind === 'mixin') {
86
153
  return [{field: '', error: 'type', message: ':mixin ' + typeName + ' is not usable as a field type'}];
87
154
  }
155
+ if (subDef.kind === 'union') {
156
+ const r = subDef._unionResolve(v);
157
+ if (r.issue) return [r.issue];
158
+ const memberErrs = r.def._validateFields(v, true);
159
+ return memberErrs.length ? memberErrs : null;
160
+ }
88
161
  if (v === null || typeof v !== 'object' || Array.isArray(v)) {
89
162
  return [{field: '', error: 'type', message: 'must be a ' + typeName + ' object'}];
90
163
  }
@@ -158,6 +231,21 @@ function __schemaSnapshot(norm, inst) {
158
231
  return snap;
159
232
  }
160
233
 
234
+ // Relation memo — caches resolved relation values per instance under
235
+ // the accessor name. Lazily created and non-enumerable so it never
236
+ // shows up in Object.keys / JSON.stringify. Written by the relation
237
+ // accessors (on first resolve) and by the eager-loading preloader
238
+ // (.includes), read by the accessors.
239
+ function __schemaRelMemoSet(inst, acc, v) {
240
+ if (!inst._relMemo) {
241
+ Object.defineProperty(inst, '_relMemo', {
242
+ value: new Map(), enumerable: false, writable: false, configurable: true,
243
+ });
244
+ }
245
+ inst._relMemo.set(acc, v);
246
+ return v;
247
+ }
248
+
161
249
  // SameValue-Zero: like ===, except NaN equals NaN. Used by the dirty
162
250
  // check so a persisted NaN doesn't trigger a wasted UPDATE on every
163
251
  // save. Distinguishes from Object.is by treating +0/-0 as equal, which
@@ -166,17 +254,74 @@ function __schemaSameValue(a, b) {
166
254
  return a === b || (a !== a && b !== b);
167
255
  }
168
256
 
257
+ // Structural signature of a declaration — name-shape only, function
258
+ // bodies excluded. Two registrations with the same signature are the
259
+ // same declaration arriving twice (double import via symlinked paths,
260
+ // re-eval of an unchanged module) and rebind silently. Different
261
+ // signatures under the same name are a real collision and throw,
262
+ // unless `__SchemaRegistry.replace` is set (dev/HMR semantics).
263
+ function __schemaSignature(def) {
264
+ // Constraints may hold RegExp values; JSON.stringify would erase them
265
+ // to {} and miss a real difference, so stringify them explicitly.
266
+ const safe = (v) => JSON.stringify(v ?? null, (k, x) =>
267
+ x instanceof RegExp ? String(x) : (typeof x === 'function' ? '<fn>' : x));
268
+ const parts = [def.kind];
269
+ for (const e of def._desc.entries || []) {
270
+ switch (e.tag) {
271
+ case 'field':
272
+ parts.push('f:' + e.name + ':' + (e.typeName || '') +
273
+ (e.array ? '[]' : '') + ':' + (e.modifiers || []).join('') +
274
+ (e.literals ? ':' + e.literals.join(',') : '') +
275
+ ':' + safe(e.constraints) + ':' + safe(e.attrs) + (e.coerce ? ':~' + (e.coercer || '') : '') +
276
+ (e.transform ? ':t' : ''));
277
+ break;
278
+ case 'enum-member':
279
+ parts.push('e:' + e.name + '=' + String(e.value));
280
+ break;
281
+ case 'directive':
282
+ parts.push('d:' + e.name + ':' + safe(e.args));
283
+ break;
284
+ case 'ensure':
285
+ parts.push('n:' + (e.message || ''));
286
+ break;
287
+ default:
288
+ // method / computed / derived / hook — name identity only.
289
+ parts.push(e.tag + ':' + (e.name || ''));
290
+ }
291
+ }
292
+ return parts.join('|');
293
+ }
294
+
169
295
  const __SchemaRegistry = {
170
296
  _entries: new Map(),
297
+ // Dev/HMR escape hatch: when true, re-registering a name rebinds
298
+ // unconditionally (pre-hardening "last loaded wins" semantics). Dev
299
+ // servers and test harnesses set it; production code should not.
300
+ replace: false,
171
301
  register(def) {
172
302
  // Named schemas of any kind land here. Relations look up :model,
173
303
  // @mixin Name looks up :mixin. Algebra (.extend etc.) accepts :shape
174
304
  // and derived shapes. Kind is checked at lookup time.
175
305
  if (!def.name) return;
176
- // Most recent registration wins. Recompilation produces a fresh
177
- // __SchemaDef with the same name; the registry rebinds. Cross-
178
- // module name collisions should be avoided — schema names are
179
- // app-global identifiers for relation resolution.
306
+ const existing = this._entries.get(def.name);
307
+ if (existing && existing.def !== def && !this.replace) {
308
+ // Identical declarations (same structural signature) rebind
309
+ // silently the same module arriving twice is not a conflict.
310
+ // Anything else is the "two different models, one name" footgun:
311
+ // relation / @mixin resolution is name-keyed and app-global, so
312
+ // silently letting the last one win corrupts resolution. Throw.
313
+ if (__schemaSignature(existing.def) !== __schemaSignature(def)) {
314
+ throw new SchemaError(
315
+ [{
316
+ field: def.name, error: 'collision',
317
+ message: "schema name '" + def.name + "' is already registered with a different definition. " +
318
+ "Schema names are app-global (they resolve relations and @mixin references), so two different " +
319
+ "schemas cannot share one name. Rename one of them — or, for dev/HMR reload semantics, set " +
320
+ "__SchemaRegistry.replace = true before re-evaluating modules.",
321
+ }],
322
+ def.name, def.kind);
323
+ }
324
+ }
180
325
  this._entries.set(def.name, { def, kind: def.kind });
181
326
  },
182
327
  get(name) {
@@ -189,6 +334,24 @@ const __SchemaRegistry = {
189
334
  },
190
335
  has(name) { return this._entries.has(name); },
191
336
  reset() { this._entries.clear(); },
337
+ // Run `fn` against a fresh, empty registry; restore the parent
338
+ // registry afterward (success, throw, or async rejection). Replaces
339
+ // ad-hoc reset() in tests and makes schema-declaring test blocks
340
+ // safe to run without leaking registrations into each other.
341
+ scope(fn) {
342
+ const saved = this._entries;
343
+ this._entries = new Map();
344
+ const restore = () => { this._entries = saved; };
345
+ try {
346
+ const r = fn();
347
+ if (r && typeof r.then === 'function') return r.finally(restore);
348
+ restore();
349
+ return r;
350
+ } catch (e) {
351
+ restore();
352
+ throw e;
353
+ }
354
+ },
192
355
  };
193
356
 
194
357
  class __SchemaDef {
@@ -199,6 +362,25 @@ class __SchemaDef {
199
362
  this._norm = null;
200
363
  this._klass = null;
201
364
  this._sourceModel = null;
365
+ this._unionPlanCache = null;
366
+ // Per-schema adapter (`schema :model, on: analytics`). null → the
367
+ // process-global adapter. Resolved per ORM call by the orm fragment.
368
+ this._adapter = desc.adapter || null;
369
+ // Install @scope statics eagerly so `User.active()` works as the
370
+ // very first call on the model (normalization hasn't run yet at
371
+ // that point; the scope invocation itself triggers it, which also
372
+ // fires the duplicate / reserved-name collision checks). Prototype
373
+ // methods win on name conflicts (`in` sees the prototype chain),
374
+ // and normalize rejects those names anyway.
375
+ for (const e of desc.entries || []) {
376
+ if (e.tag !== 'scope' || (e.name in this)) continue;
377
+ const self = this;
378
+ const sfn = e.fn;
379
+ Object.defineProperty(this, e.name, {
380
+ enumerable: false, configurable: true,
381
+ value: function(...args) { return __schemaInvokeScope(self, null, sfn, args); },
382
+ });
383
+ }
202
384
  }
203
385
 
204
386
  _normalize() {
@@ -213,6 +395,8 @@ class __SchemaDef {
213
395
  const enumMembers = new Map();
214
396
  const relations = new Map();
215
397
  const ensures = [];
398
+ const scopes = new Map();
399
+ let defaultScope = null;
216
400
  let timestamps = false;
217
401
  let softDelete = false;
218
402
 
@@ -252,12 +436,15 @@ class __SchemaDef {
252
436
  fields.set(e.name, {
253
437
  name: e.name,
254
438
  required: e.modifiers.includes('!'),
255
- unique: e.modifiers.includes('#'),
439
+ unique: e.unique === true,
256
440
  optional: e.modifiers.includes('?'),
257
441
  typeName: e.typeName,
258
442
  literals: e.literals || null,
259
443
  array: e.array === true,
444
+ coerce: e.coerce === true,
445
+ coercer: e.coercer || null,
260
446
  constraints: e.constraints || null,
447
+ attrs: e.attrs || null,
261
448
  transform: e.transform || null,
262
449
  });
263
450
  break;
@@ -299,8 +486,32 @@ class __SchemaDef {
299
486
  case 'ensure':
300
487
  // @ensure entries are schema-level invariants (cross-field
301
488
  // predicates). Declaration order is preserved so diagnostics
302
- // come out in the order authored.
303
- ensures.push({ message: e.message, fn: e.fn });
489
+ // come out in the order authored. `field` attributes the
490
+ // failure to a specific input; `async` marks an @ensure!
491
+ // refinement (the schema becomes async-validating).
492
+ ensures.push({
493
+ message: e.message,
494
+ field: e.field || '',
495
+ async: e.async === true,
496
+ fn: e.fn,
497
+ });
498
+ break;
499
+ case 'scope':
500
+ // Scopes live in the STATIC namespace (model + builder), not
501
+ // the instance namespace — a field `active` and a scope
502
+ // `:active` coexist by design. Collisions are checked against
503
+ // other scopes and the reserved static/builder names.
504
+ if (scopes.has(e.name)) collision(e.name, 'scope');
505
+ if (__SCHEMA_SCOPE_RESERVED.has(e.name)) collision(e.name, 'reserved query API name');
506
+ scopes.set(e.name, e.fn);
507
+ break;
508
+ case 'defaultScope':
509
+ if (defaultScope) {
510
+ throw new SchemaError(
511
+ [{field: '', error: 'collision', message: 'only one @defaultScope per model'}],
512
+ this.name, this.kind);
513
+ }
514
+ defaultScope = e.fn;
304
515
  break;
305
516
  }
306
517
  }
@@ -320,14 +531,98 @@ class __SchemaDef {
320
531
  const primaryKey = 'id';
321
532
  const tableName = this.kind === 'model' ? __schemaTableName(this.name) : null;
322
533
 
534
+ // `@tableWas old_name` — table-rename annotation for the differ.
535
+ let tableWas = null;
536
+ for (const d of directives) {
537
+ if (d.name === 'tableWas' && d.args?.[0]?.name) tableWas = d.args[0].name;
538
+ }
539
+
540
+ // :union metadata — discriminator field + constituent names.
541
+ let unionOn = null;
542
+ const unionMembers = [];
543
+ if (this.kind === 'union') {
544
+ for (const d of directives) {
545
+ if (d.name === 'on' && d.args?.[0]?.field) unionOn = d.args[0].field;
546
+ }
547
+ for (const e of this._desc.entries) {
548
+ if (e.tag === 'union-member') unionMembers.push(e.name);
549
+ }
550
+ }
551
+
323
552
  this._norm = {
324
553
  fields, methods, computed, derived, hooks, directives, enumMembers, relations,
325
- ensures,
326
- timestamps, softDelete, primaryKey, tableName,
554
+ ensures, scopes, defaultScope,
555
+ hasAsyncEnsures: ensures.some(r => r.async),
556
+ timestamps, softDelete, primaryKey, tableName, tableWas,
557
+ unionOn, unionMembers,
327
558
  };
328
559
  return this._norm;
329
560
  }
330
561
 
562
+ // ---- :union dispatch -------------------------------------------------------
563
+ //
564
+ // Built lazily on first parse (consistent with registry resolution):
565
+ // resolves every constituent from the registry, reads its declared
566
+ // discriminator literals, and compiles a value → constituent map for
567
+ // O(1) dispatch. Collisions and shapeless discriminators fail here
568
+ // with the constituent names in the message.
569
+
570
+ _unionPlan() {
571
+ if (this._unionPlanCache) return this._unionPlanCache;
572
+ const norm = this._normalize();
573
+ const disc = norm.unionOn;
574
+ if (this.kind !== 'union' || !disc) {
575
+ throw new Error("schema: '" + (this.name || 'anon') + "' is not a :union");
576
+ }
577
+ const map = new Map();
578
+ const members = [];
579
+ for (const name of norm.unionMembers) {
580
+ const def = __SchemaRegistry.get(name);
581
+ if (!def) {
582
+ throw new SchemaError(
583
+ [{field: '', error: 'union', message: 'unknown union constituent: ' + name + ' (import the file that declares it)'}],
584
+ this.name, this.kind);
585
+ }
586
+ members.push(def);
587
+ const f = def._normalize().fields.get(disc);
588
+ if (!f || f.typeName !== 'literal-union' || !f.literals?.length) {
589
+ throw new SchemaError(
590
+ [{field: disc, error: 'union', message: name + " must declare '" + disc +
591
+ "' as a string-literal type (e.g. " + disc + '! "click") to join union ' + (this.name || '')}],
592
+ this.name, this.kind);
593
+ }
594
+ for (const lit of f.literals) {
595
+ if (map.has(lit)) {
596
+ throw new SchemaError(
597
+ [{field: disc, error: 'union', message: 'duplicate discriminator value ' + JSON.stringify(lit) +
598
+ ' in ' + (map.get(lit).name || 'anon') + ' and ' + name}],
599
+ this.name, this.kind);
600
+ }
601
+ map.set(lit, def);
602
+ }
603
+ }
604
+ const plan = {
605
+ disc, map,
606
+ expected: [...map.keys()].join(' | '),
607
+ hasAsyncEnsures: members.some(d => d._normalize().hasAsyncEnsures),
608
+ };
609
+ this._unionPlanCache = plan;
610
+ return plan;
611
+ }
612
+
613
+ // Resolve a raw value to its constituent def, or produce the issue.
614
+ _unionResolve(data) {
615
+ const plan = this._unionPlan();
616
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
617
+ return { issue: {field: plan.disc, error: 'union', message: 'expected an object with ' + plan.disc} };
618
+ }
619
+ const def = plan.map.get(data[plan.disc]);
620
+ if (!def) {
621
+ return { issue: {field: plan.disc, error: 'union', message: 'expected one of ' + plan.expected} };
622
+ }
623
+ return { def };
624
+ }
625
+
331
626
  // Run eager-derived entries (!>) — one pass, in declaration order.
332
627
  //
333
628
  // Invariants worth keeping in mind here:
@@ -385,14 +680,14 @@ class __SchemaDef {
385
680
  ok = !!r.fn(data);
386
681
  } catch (e) {
387
682
  errs.push({
388
- field: '', error: 'ensure',
683
+ field: r.field || '', error: 'ensure',
389
684
  message: r.message || e?.message || 'ensure failed',
390
685
  });
391
686
  continue;
392
687
  }
393
688
  if (!ok) {
394
689
  errs.push({
395
- field: '', error: 'ensure',
690
+ field: r.field || '', error: 'ensure',
396
691
  message: r.message || 'ensure failed',
397
692
  });
398
693
  }
@@ -400,6 +695,53 @@ class __SchemaDef {
400
695
  return errs;
401
696
  }
402
697
 
698
+ // Async-aware refinement pass for parseAsync/safeAsync/okAsync. Sync
699
+ // refinements run first (cheap before expensive); async refinements
700
+ // then run CONCURRENTLY (Promise.all) — preserving the no-short-
701
+ // circuit rule. Issues come out in declaration order regardless of
702
+ // completion order. A rejected async predicate counts as failure
703
+ // with the declared message, same as a thrown sync one.
704
+ async _applyEnsuresAsync(data) {
705
+ const norm = this._normalize();
706
+ if (!norm.ensures.length) return [];
707
+ const results = [];
708
+ const pending = [];
709
+ norm.ensures.forEach((r, idx) => {
710
+ const issue = () => ({
711
+ field: r.field || '', error: 'ensure',
712
+ message: r.message || 'ensure failed',
713
+ });
714
+ if (r.async) {
715
+ pending.push((async () => {
716
+ let ok = false;
717
+ try { ok = !!(await r.fn(data)); } catch { ok = false; }
718
+ if (!ok) results.push({ idx, issue: issue() });
719
+ })());
720
+ } else {
721
+ let ok = false;
722
+ try { ok = !!r.fn(data); } catch { ok = false; }
723
+ if (!ok) results.push({ idx, issue: issue() });
724
+ }
725
+ });
726
+ await Promise.all(pending);
727
+ results.sort((a, b) => a.idx - b.idx);
728
+ return results.map(r => r.issue);
729
+ }
730
+
731
+ // A schema with ≥1 @ensure! is async-validating: the sync entry
732
+ // points refuse loudly rather than sometimes-returning a promise.
733
+ _assertSyncValidatable(api) {
734
+ const async = this.kind === 'union'
735
+ ? this._unionPlan().hasAsyncEnsures
736
+ : this._normalize().hasAsyncEnsures;
737
+ if (async) {
738
+ throw new Error(
739
+ "schema '" + (this.name || 'anon') + "' has async refinements (@ensure!" +
740
+ (this.kind === 'union' ? ' in a constituent' : '') + "); " +
741
+ '.' + api + '() is sync. Use parseAsync!/safeAsync!/okAsync! instead.');
742
+ }
743
+ }
744
+
403
745
  _getClass() {
404
746
  if (this._klass) return this._klass;
405
747
  const norm = this._normalize();
@@ -447,12 +789,23 @@ class __SchemaDef {
447
789
  });
448
790
  }
449
791
 
450
- // Relation methods: user.organization(). Accepts no args; returns
451
- // a promise to a target-model instance (or array for has_many).
792
+ // Relation methods: user.organization(). Accepts an optional opts
793
+ // object; returns a promise to a target-model instance (or array
794
+ // for has_many). Results memoize per instance — the second call
795
+ // resolves from cache with no query, and eager loading (.includes)
796
+ // fills the same memo so preloaded relations are free. Pass
797
+ // {reload: true} to bust the memo and re-query.
452
798
  for (const [acc, rel] of norm.relations) {
453
799
  Object.defineProperty(klass.prototype, acc, {
454
800
  enumerable: false, configurable: true,
455
- value: async function() { return __schemaResolveRelation(def, this, rel); },
801
+ value: async function(opts) {
802
+ if (!(opts && opts.reload === true) && this._relMemo && this._relMemo.has(acc)) {
803
+ return this._relMemo.get(acc);
804
+ }
805
+ const v = await __schemaResolveRelation(def, this, rel);
806
+ __schemaRelMemoSet(this, acc, v);
807
+ return v;
808
+ },
456
809
  });
457
810
  }
458
811
 
@@ -462,9 +815,17 @@ class __SchemaDef {
462
815
  enumerable: false, configurable: true, writable: true,
463
816
  value: async function() { return __schemaSave(def, this); },
464
817
  });
818
+ // destroy() honors @softDelete (UPDATE deleted_at) by default;
819
+ // destroy(hard: true) forces a real DELETE. Hooks fire either way.
465
820
  Object.defineProperty(klass.prototype, 'destroy', {
466
821
  enumerable: false, configurable: true, writable: true,
467
- value: async function() { return __schemaDestroy(def, this); },
822
+ value: async function(opts) { return __schemaDestroy(def, this, opts); },
823
+ });
824
+ // restore() un-deletes a soft-deleted row (deleted_at = NULL).
825
+ // Throws on models without @softDelete.
826
+ Object.defineProperty(klass.prototype, 'restore', {
827
+ enumerable: false, configurable: true, writable: true,
828
+ value: async function() { return __schemaRestore(def, this); },
468
829
  });
469
830
  Object.defineProperty(klass.prototype, 'ok', {
470
831
  enumerable: false, configurable: true, writable: true,
@@ -582,10 +943,37 @@ class __SchemaDef {
582
943
  return inst;
583
944
  }
584
945
 
585
- _validateFields(data, collect) {
946
+ // Coerce ISO date strings to Date for date/datetime fields. Over JSON a
947
+ // date is a string, so a value crossing the wire (or any `.parse` of
948
+ // external input) arrives as a string; this lets it validate and be stored
949
+ // as a Date. Runs on parse/safe only — hydrate gets canonical DB values.
950
+ _coerceDates(working) {
951
+ const norm = this._normalize();
952
+ // Only ISO-shaped strings (`YYYY-MM-DD` optionally followed by a time) are
953
+ // coerced. `new Date(v)` is otherwise lax — `new Date("5")` is a valid
954
+ // Date — which would let clearly-bad input slip past a date field as a
955
+ // bogus Date instead of failing validation. Array-of-date fields coerce
956
+ // element-wise.
957
+ const isoShaped = (s) => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}([T ].*)?$/.test(s);
958
+ const toDate = (s) => { const d = new Date(s); return Number.isNaN(d.getTime()) ? s : d; };
959
+ for (const [n, f] of norm.fields) {
960
+ if (f.typeName !== 'date' && f.typeName !== 'datetime') continue;
961
+ const v = working[n];
962
+ if (f.array && Array.isArray(v)) {
963
+ working[n] = v.map(el => isoShaped(el) ? toDate(el) : el);
964
+ } else if (isoShaped(v)) {
965
+ working[n] = toDate(v);
966
+ }
967
+ }
968
+ }
969
+
970
+ _validateFields(data, collect, skip) {
586
971
  const norm = this._normalize();
587
972
  const errors = collect ? [] : null;
588
973
  for (const [n, f] of norm.fields) {
974
+ // Fields whose coercion already failed carry a {error: 'coerce'}
975
+ // issue; re-checking the uncoerced value would double-report.
976
+ if (skip && skip.has(n)) continue;
589
977
  const v = data == null ? undefined : data[n];
590
978
  if (v === undefined || v === null) {
591
979
  if (f.required) {
@@ -687,6 +1075,54 @@ class __SchemaDef {
687
1075
  return errors;
688
1076
  }
689
1077
 
1078
+ // `~type` coercions — part of pipeline step 1 (obtain raw candidate):
1079
+ // the wire value converts through the strict coercion table before
1080
+ // defaults and validation, so range checks see the coerced value.
1081
+ // Failed coercions collect {error: 'coerce'} issues and the field
1082
+ // lands in `failed` so per-field validation doesn't double-report
1083
+ // the same problem as a type error. Skipped on hydrate (rows arrive
1084
+ // canonical), like transforms. Mutually exclusive with transforms at
1085
+ // compile time.
1086
+ _applyCoercions(working, failed) {
1087
+ const norm = this._normalize();
1088
+ const errors = [];
1089
+ for (const [n, f] of norm.fields) {
1090
+ if (!f.coerce) continue;
1091
+ const v = working[n];
1092
+ if (v === undefined || v === null) continue;
1093
+ if (f.coercer) {
1094
+ // `~:name` — registry lookup. A missing coercer is a CONFIG
1095
+ // error (the package that provides it wasn't loaded), not a
1096
+ // validation failure — fail loud.
1097
+ const entry = __schemaNamedCoercers.get(f.coercer);
1098
+ if (!entry) {
1099
+ throw new Error(
1100
+ "schema: no coercer registered for '~:" + f.coercer + "' (field '" + n + "' on " +
1101
+ (this.name || 'anon') + "). Import '@rip-lang/validate' (browser-safe; registers the " +
1102
+ "whole vocabulary) or register it with schema.registerCoercer('" + f.coercer + "', fn).");
1103
+ }
1104
+ const input = entry.raw ? v : String(v).trim();
1105
+ let out;
1106
+ try { out = entry.fn(input); } catch { out = null; }
1107
+ if (out === null || out === undefined || out === false) {
1108
+ errors.push({field: n, error: 'coerce', message: n + ' is not a valid ' + f.coercer});
1109
+ failed.add(n);
1110
+ } else {
1111
+ working[n] = out;
1112
+ }
1113
+ continue;
1114
+ }
1115
+ const r = __SCHEMA_COERCERS[f.typeName] ? __SCHEMA_COERCERS[f.typeName](v) : { ok: false };
1116
+ if (r.ok) {
1117
+ working[n] = r.value;
1118
+ } else {
1119
+ errors.push({field: n, error: 'coerce', message: n + ' cannot be coerced to ' + f.typeName});
1120
+ failed.add(n);
1121
+ }
1122
+ }
1123
+ return errors;
1124
+ }
1125
+
690
1126
  _validateEnum(data, collect) {
691
1127
  const norm = this._normalize();
692
1128
  for (const [n, v] of norm.enumMembers) {
@@ -727,16 +1163,27 @@ class __SchemaDef {
727
1163
  if (this.kind === 'mixin') {
728
1164
  throw new Error(":mixin schema '" + (this.name || 'anon') + "' is not instantiable");
729
1165
  }
1166
+ if (this.kind === 'union') {
1167
+ const plan = this._unionPlan();
1168
+ if (plan.hasAsyncEnsures) this._assertSyncValidatable('parse');
1169
+ const r = this._unionResolve(data);
1170
+ if (r.issue) throw new SchemaError([r.issue], this.name, this.kind);
1171
+ return r.def.parse(data);
1172
+ }
730
1173
  if (this.kind === 'enum') {
731
1174
  const errs = this._validateEnum(data, true);
732
1175
  if (errs.length) throw new SchemaError(errs, this.name, this.kind);
733
1176
  return this._materializeEnum(data);
734
1177
  }
1178
+ this._assertSyncValidatable('parse');
735
1179
  const raw = data || {};
736
1180
  const working = { ...raw };
1181
+ const failed = new Set();
737
1182
  const transformErrors = this._applyTransforms(raw, working);
1183
+ const coerceErrors = this._applyCoercions(working, failed);
738
1184
  this._applyDefaults(working);
739
- const errs = transformErrors.concat(this._validateFields(working, true));
1185
+ this._coerceDates(working);
1186
+ const errs = transformErrors.concat(coerceErrors, this._validateFields(working, true, failed));
740
1187
  if (errs.length) throw new SchemaError(errs, this.name, this.kind);
741
1188
  // @ensure runs AFTER per-field validation so predicates can
742
1189
  // assume declared fields are typed and defaulted. A field-level
@@ -749,20 +1196,93 @@ class __SchemaDef {
749
1196
  return inst;
750
1197
  }
751
1198
 
1199
+ // Array combinator: `Schema.array` is a list-of-this schema exposing the
1200
+ // same validation family (parse/safe/ok + async). The common list-endpoint
1201
+ // case is `Schema.array.parse(api.get!('x').json!())` → Out[]. A non-array
1202
+ // input fails fast with a descriptive error naming what it got — so passing
1203
+ // an enveloped `{ items: [...] }` whole, a typo'd key, or a changed server
1204
+ // contract surfaces clearly at the boundary. Item failures aggregate, each
1205
+ // issue tagged with its `[index]` so a bad element is locatable.
1206
+ get array() {
1207
+ const elem = this;
1208
+ const notArray = (data) => {
1209
+ const got = data === null ? 'null'
1210
+ : data === undefined ? 'undefined'
1211
+ : typeof data === 'object' ? ('an object with keys [' + Object.keys(data).join(', ') + ']')
1212
+ : typeof data;
1213
+ return { field: '', error: 'not_array', message: 'expected an array, received ' + got };
1214
+ };
1215
+ const collect = (results) => {
1216
+ const value = [];
1217
+ const errors = [];
1218
+ results.forEach((r, i) => {
1219
+ if (r.ok) value.push(r.value);
1220
+ else for (const e of r.errors) {
1221
+ errors.push({ ...e, field: '[' + i + ']' + (e.field ? '.' + e.field : '') });
1222
+ }
1223
+ });
1224
+ return { value, errors };
1225
+ };
1226
+ return {
1227
+ parse(data) {
1228
+ if (!Array.isArray(data)) throw new SchemaError([notArray(data)], elem.name, elem.kind);
1229
+ const { value, errors } = collect(data.map((x) => elem.safe(x)));
1230
+ if (errors.length) throw new SchemaError(errors, elem.name, elem.kind);
1231
+ return value;
1232
+ },
1233
+ safe(data) {
1234
+ if (!Array.isArray(data)) return { ok: false, value: null, errors: [notArray(data)] };
1235
+ const { value, errors } = collect(data.map((x) => elem.safe(x)));
1236
+ return errors.length ? { ok: false, value: null, errors } : { ok: true, value, errors: null };
1237
+ },
1238
+ ok(data) {
1239
+ return Array.isArray(data) && data.every((x) => elem.ok(x));
1240
+ },
1241
+ async parseAsync(data) {
1242
+ if (!Array.isArray(data)) throw new SchemaError([notArray(data)], elem.name, elem.kind);
1243
+ const { value, errors } = collect(await Promise.all(data.map((x) => elem.safeAsync(x))));
1244
+ if (errors.length) throw new SchemaError(errors, elem.name, elem.kind);
1245
+ return value;
1246
+ },
1247
+ async safeAsync(data) {
1248
+ if (!Array.isArray(data)) return { ok: false, value: null, errors: [notArray(data)] };
1249
+ const { value, errors } = collect(await Promise.all(data.map((x) => elem.safeAsync(x))));
1250
+ return errors.length ? { ok: false, value: null, errors } : { ok: true, value, errors: null };
1251
+ },
1252
+ async okAsync(data) {
1253
+ return Array.isArray(data) && (await Promise.all(data.map((x) => elem.okAsync(x)))).every(Boolean);
1254
+ },
1255
+ toJSONSchema() {
1256
+ return { type: 'array', items: elem.toJSONSchema() };
1257
+ },
1258
+ };
1259
+ }
1260
+
752
1261
  safe(data) {
753
1262
  if (this.kind === 'mixin') {
754
1263
  return {ok: false, value: null, errors: [{field: '', error: 'mixin', message: 'not instantiable'}]};
755
1264
  }
1265
+ if (this.kind === 'union') {
1266
+ const plan = this._unionPlan();
1267
+ if (plan.hasAsyncEnsures) this._assertSyncValidatable('safe');
1268
+ const r = this._unionResolve(data);
1269
+ if (r.issue) return {ok: false, value: null, errors: [r.issue]};
1270
+ return r.def.safe(data);
1271
+ }
756
1272
  if (this.kind === 'enum') {
757
1273
  const errs = this._validateEnum(data, true);
758
1274
  if (errs.length) return {ok: false, value: null, errors: errs};
759
1275
  return {ok: true, value: this._materializeEnum(data), errors: null};
760
1276
  }
1277
+ this._assertSyncValidatable('safe');
761
1278
  const raw = data || {};
762
1279
  const working = { ...raw };
1280
+ const failed = new Set();
763
1281
  const transformErrors = this._applyTransforms(raw, working);
1282
+ const coerceErrors = this._applyCoercions(working, failed);
764
1283
  this._applyDefaults(working);
765
- const errs = transformErrors.concat(this._validateFields(working, true));
1284
+ this._coerceDates(working);
1285
+ const errs = transformErrors.concat(coerceErrors, this._validateFields(working, true, failed));
766
1286
  if (errs.length) return {ok: false, value: null, errors: errs};
767
1287
  const ensureErrs = this._applyEnsures(working);
768
1288
  if (ensureErrs.length) return {ok: false, value: null, errors: ensureErrs};
@@ -777,17 +1297,89 @@ class __SchemaDef {
777
1297
 
778
1298
  ok(data) {
779
1299
  if (this.kind === 'mixin') return false;
1300
+ if (this.kind === 'union') {
1301
+ const plan = this._unionPlan();
1302
+ if (plan.hasAsyncEnsures) this._assertSyncValidatable('ok');
1303
+ const r = this._unionResolve(data);
1304
+ return r.issue ? false : r.def.ok(data);
1305
+ }
780
1306
  if (this.kind === 'enum') return this._validateEnum(data, false);
1307
+ this._assertSyncValidatable('ok');
781
1308
  const raw = data || {};
782
1309
  const working = { ...raw };
1310
+ const failed = new Set();
783
1311
  const transformErrors = this._applyTransforms(raw, working);
784
1312
  if (transformErrors.length) return false;
1313
+ if (this._applyCoercions(working, failed).length) return false;
785
1314
  this._applyDefaults(working);
1315
+ this._coerceDates(working);
786
1316
  if (!this._validateFields(working, false)) return false;
787
1317
  // Per-field validation passed — @ensure predicates are the final gate.
788
1318
  return this._applyEnsures(working).length === 0;
789
1319
  }
790
1320
 
1321
+ // Async validation entry points. Work on EVERY schema (sync-only
1322
+ // schemas just resolve immediately); REQUIRED when the schema has
1323
+ // @ensure! refinements. The dammit operator gives the idiomatic
1324
+ // form: `SignupInput.parseAsync! raw`.
1325
+ async _runPipelineAsync(data) {
1326
+ const raw = data || {};
1327
+ const working = { ...raw };
1328
+ const failed = new Set();
1329
+ const transformErrors = this._applyTransforms(raw, working);
1330
+ const coerceErrors = this._applyCoercions(working, failed);
1331
+ this._applyDefaults(working);
1332
+ this._coerceDates(working);
1333
+ const errs = transformErrors.concat(coerceErrors, this._validateFields(working, true, failed));
1334
+ if (errs.length) return { errors: errs };
1335
+ const ensureErrs = await this._applyEnsuresAsync(working);
1336
+ if (ensureErrs.length) return { errors: ensureErrs };
1337
+ return { working };
1338
+ }
1339
+
1340
+ async parseAsync(data) {
1341
+ if (this.kind === 'mixin') {
1342
+ throw new Error(":mixin schema '" + (this.name || 'anon') + "' is not instantiable");
1343
+ }
1344
+ if (this.kind === 'union') {
1345
+ const r = this._unionResolve(data);
1346
+ if (r.issue) throw new SchemaError([r.issue], this.name, this.kind);
1347
+ return r.def.parseAsync(data);
1348
+ }
1349
+ if (this.kind === 'enum') return this.parse(data);
1350
+ const r = await this._runPipelineAsync(data);
1351
+ if (r.errors) throw new SchemaError(r.errors, this.name, this.kind);
1352
+ const klass = this._getClass();
1353
+ const inst = new klass(r.working, false);
1354
+ this._applyEagerDerived(inst);
1355
+ return inst;
1356
+ }
1357
+
1358
+ async safeAsync(data) {
1359
+ if (this.kind === 'mixin') {
1360
+ return {ok: false, value: null, errors: [{field: '', error: 'mixin', message: 'not instantiable'}]};
1361
+ }
1362
+ if (this.kind === 'union') {
1363
+ const r = this._unionResolve(data);
1364
+ if (r.issue) return {ok: false, value: null, errors: [r.issue]};
1365
+ return r.def.safeAsync(data);
1366
+ }
1367
+ if (this.kind === 'enum') return this.safe(data);
1368
+ const r = await this._runPipelineAsync(data);
1369
+ if (r.errors) return {ok: false, value: null, errors: r.errors};
1370
+ const klass = this._getClass();
1371
+ const inst = new klass(r.working, false);
1372
+ try { this._applyEagerDerived(inst); }
1373
+ catch (e) {
1374
+ return {ok: false, value: null, errors: [{field: '', error: 'derived', message: e?.message || String(e)}]};
1375
+ }
1376
+ return {ok: true, value: inst, errors: null};
1377
+ }
1378
+
1379
+ async okAsync(data) {
1380
+ return (await this.safeAsync(data)).ok;
1381
+ }
1382
+
791
1383
  // ---- :model static ORM methods --------------------------------------------
792
1384
 
793
1385
  pick(...keys) {
@@ -832,6 +1424,9 @@ class __SchemaDef {
832
1424
  if (!(other instanceof __SchemaDef)) {
833
1425
  throw new Error('extend(): argument must be a schema value');
834
1426
  }
1427
+ if (other.kind === 'union') {
1428
+ throw new Error('extend(): :union schemas have no fields to merge');
1429
+ }
835
1430
  return __schemaDerive(this, (src) => {
836
1431
  const merged = new Map(src);
837
1432
  const otherFields = other._normalize().fields;
@@ -846,6 +1441,159 @@ class __SchemaDef {
846
1441
  }
847
1442
  }
848
1443
 
1444
+ // ---- JSON Schema export (draft 2020-12) -------------------------------------
1445
+ //
1446
+ // One declaration → wire contract. Field types map per the table in the
1447
+ // language reference; nested registry schemas become `$ref`s collected
1448
+ // under `$defs` (cycle-safe); enums map to `enum`, unions to `oneOf` +
1449
+ // an OpenAPI-style `discriminator`. Transforms and refinements have no
1450
+ // executable JSON Schema equivalent — they export as `description`
1451
+ // annotations rather than being silently dropped or approximated.
1452
+
1453
+ const __SCHEMA_JSON_TYPES = {
1454
+ string: () => ({ type: 'string' }),
1455
+ text: () => ({ type: 'string' }),
1456
+ email: () => ({ type: 'string', format: 'email' }),
1457
+ url: () => ({ type: 'string', format: 'uri' }),
1458
+ uuid: () => ({ type: 'string', format: 'uuid' }),
1459
+ phone: () => ({ type: 'string', pattern: '^[\\d\\s\\-+()]+$' }),
1460
+ zip: () => ({ type: 'string', pattern: '^\\d{5}(-\\d{4})?$' }),
1461
+ number: () => ({ type: 'number' }),
1462
+ integer: () => ({ type: 'integer' }),
1463
+ boolean: () => ({ type: 'boolean' }),
1464
+ date: () => ({ type: 'string', format: 'date' }),
1465
+ datetime: () => ({ type: 'string', format: 'date-time' }),
1466
+ json: () => ({}),
1467
+ any: () => ({}),
1468
+ };
1469
+
1470
+ function __schemaFieldJSONSchema(f, ctx) {
1471
+ let s;
1472
+ if (f.typeName === 'literal-union' && f.literals?.length) {
1473
+ s = f.literals.length === 1 ? { const: f.literals[0] } : { enum: [...f.literals] };
1474
+ } else if (__SCHEMA_JSON_TYPES[f.typeName]) {
1475
+ s = __SCHEMA_JSON_TYPES[f.typeName]();
1476
+ } else {
1477
+ const sub = __SchemaRegistry.get(f.typeName);
1478
+ if (sub) {
1479
+ s = __schemaJSONSchemaRef(sub, ctx);
1480
+ } else {
1481
+ s = {}; // unknown identifier — permissive, matching the validator
1482
+ }
1483
+ }
1484
+ const c = f.constraints;
1485
+ if (c && !f.array) {
1486
+ const stringish = s.type === 'string';
1487
+ const numeric = s.type === 'number' || s.type === 'integer';
1488
+ if (stringish) {
1489
+ if (c.min != null) s.minLength = c.min;
1490
+ if (c.max != null) s.maxLength = c.max;
1491
+ if (c.regex) s.pattern = c.regex.source;
1492
+ } else if (numeric) {
1493
+ if (c.min != null) s.minimum = c.min;
1494
+ if (c.max != null) s.maximum = c.max;
1495
+ }
1496
+ }
1497
+ if (f.array) {
1498
+ const items = s;
1499
+ s = { type: 'array', items };
1500
+ if (c) {
1501
+ if (c.min != null) s.minItems = c.min;
1502
+ if (c.max != null) s.maxItems = c.max;
1503
+ }
1504
+ }
1505
+ if (c && c.default !== undefined) s.default = c.default;
1506
+ if (f.coerce) {
1507
+ s.description = ((s.description ? s.description + ' ' : '') +
1508
+ 'Coerced from wire data (' + (f.coercer ? '~:' + f.coercer : '~' + f.typeName) + ').').trim();
1509
+ }
1510
+ if (f.transform) {
1511
+ s.description = ((s.description ? s.description + ' ' : '') +
1512
+ 'Derived via transform; the raw input may use different keys.').trim();
1513
+ }
1514
+ return s;
1515
+ }
1516
+
1517
+ // A registry schema used as a field type / union constituent becomes a
1518
+ // `$ref` into `$defs`, expanding each named schema exactly once.
1519
+ function __schemaJSONSchemaRef(def, ctx) {
1520
+ const name = def.name || 'Anon';
1521
+ if (!ctx.defs.has(name) && !ctx.expanding.has(name)) {
1522
+ ctx.expanding.add(name);
1523
+ ctx.defs.set(name, null); // reserve slot to keep insertion order
1524
+ ctx.defs.set(name, __schemaJSONSchemaBody(def, ctx));
1525
+ ctx.expanding.delete(name);
1526
+ }
1527
+ return { $ref: '#/$defs/' + name };
1528
+ }
1529
+
1530
+ function __schemaJSONSchemaBody(def, ctx) {
1531
+ const norm = def._normalize();
1532
+
1533
+ if (def.kind === 'enum') {
1534
+ return { enum: [...new Set(norm.enumMembers.values())] };
1535
+ }
1536
+
1537
+ if (def.kind === 'union') {
1538
+ const plan = def._unionPlan();
1539
+ const oneOf = norm.unionMembers.map(name => {
1540
+ const member = __SchemaRegistry.get(name);
1541
+ return member ? __schemaJSONSchemaRef(member, ctx) : {};
1542
+ });
1543
+ return {
1544
+ oneOf,
1545
+ discriminator: { propertyName: plan.disc },
1546
+ };
1547
+ }
1548
+
1549
+ // Fielded kinds: object schema. A field is required on the wire only
1550
+ // when it's `!`-marked AND has no default (defaults apply before the
1551
+ // required check, so a defaulted field can never fail required).
1552
+ const properties = {};
1553
+ const required = [];
1554
+ for (const [n, f] of norm.fields) {
1555
+ properties[n] = __schemaFieldJSONSchema(f, ctx);
1556
+ if (f.required && f.constraints?.default === undefined) required.push(n);
1557
+ }
1558
+ // :model wire shapes include the DB-managed columns toJSON() carries.
1559
+ if (def.kind === 'model') {
1560
+ properties[norm.primaryKey] = { type: 'integer' };
1561
+ for (const [, rel] of norm.relations) {
1562
+ if (rel.kind !== 'belongsTo') continue;
1563
+ properties[__schemaCamel(rel.foreignKey)] = rel.optional
1564
+ ? { type: ['integer', 'null'] }
1565
+ : { type: 'integer' };
1566
+ }
1567
+ if (norm.timestamps) {
1568
+ properties.createdAt = { type: 'string', format: 'date-time' };
1569
+ properties.updatedAt = { type: 'string', format: 'date-time' };
1570
+ }
1571
+ if (norm.softDelete) {
1572
+ properties.deletedAt = { type: ['string', 'null'], format: 'date-time' };
1573
+ }
1574
+ }
1575
+ const out = { type: 'object', properties };
1576
+ if (required.length) out.required = required;
1577
+ if (norm.ensures.length) {
1578
+ out.description = 'Refinements (not expressible in JSON Schema): ' +
1579
+ norm.ensures.map(r => r.message).join('; ') + '.';
1580
+ }
1581
+ return out;
1582
+ }
1583
+
1584
+ __SchemaDef.prototype.toJSONSchema = function () {
1585
+ const ctx = { defs: new Map(), expanding: new Set() };
1586
+ // The root schema expands inline; only REFERENCED schemas go to $defs.
1587
+ const root = __schemaJSONSchemaBody(this, ctx);
1588
+ root.$schema = 'https://json-schema.org/draft/2020-12/schema';
1589
+ if (this.name) root.title = this.name;
1590
+ if (ctx.defs.size) {
1591
+ root.$defs = {};
1592
+ for (const [k, v] of ctx.defs) root.$defs[k] = v;
1593
+ }
1594
+ return root;
1595
+ };
1596
+
849
1597
  function __schemaFlatten(keys) {
850
1598
  const out = [];
851
1599
  for (const k of keys) {
@@ -856,20 +1604,53 @@ function __schemaFlatten(keys) {
856
1604
  return out;
857
1605
  }
858
1606
 
1607
+ // The full projectable column set of a schema: declared fields plus the
1608
+ // columns a :model manages implicitly — the `id` primary key, `@timestamps`
1609
+ // (createdAt/updatedAt), `@softDelete` (deletedAt), and `@belongs_to` FK
1610
+ // columns. Algebra (.pick/.omit/.partial/.required) operates over THIS set so
1611
+ // a client projection can include `id`/`createdAt` even though they aren't
1612
+ // declared fields. Non-model kinds have no implicit columns — declared only.
1613
+ function __schemaProjectableFields(def) {
1614
+ const norm = def._normalize();
1615
+ const out = new Map(norm.fields);
1616
+ if (def.kind !== 'model') return out;
1617
+ const col = (name, typeName, required) => {
1618
+ if (!out.has(name)) out.set(name, {
1619
+ name, required: !!required, unique: false, optional: !required,
1620
+ typeName, literals: null, array: false, constraints: null, transform: null,
1621
+ });
1622
+ };
1623
+ col(norm.primaryKey, 'integer', true);
1624
+ if (norm.timestamps) { col('createdAt', 'datetime', true); col('updatedAt', 'datetime', true); }
1625
+ if (norm.softDelete) col('deletedAt', 'datetime', false);
1626
+ for (const [, rel] of norm.relations) {
1627
+ if (rel.kind === 'belongsTo') col(__schemaCamel(rel.foreignKey), 'integer', !rel.optional);
1628
+ }
1629
+ return out;
1630
+ }
1631
+
859
1632
  function __schemaDerive(source, transform) {
860
- const src = source._normalize().fields;
1633
+ // Algebra on a union has no obviously-right semantics (distribute?
1634
+ // intersect?) — deferring is honest. Derive from a constituent.
1635
+ if (source.kind === 'union') {
1636
+ throw new Error('schema algebra (.pick/.omit/.partial/.required/.extend) is not supported on :union — derive from a constituent schema instead');
1637
+ }
1638
+ const src = __schemaProjectableFields(source);
861
1639
  const derivedFields = transform(src);
862
1640
  const entries = [];
863
1641
  for (const [, f] of derivedFields) {
864
1642
  const mods = [];
865
1643
  if (f.required) mods.push('!');
866
- if (f.unique) mods.push('#');
867
1644
  if (f.optional && !f.required) mods.push('?');
868
1645
  entries.push({
869
1646
  tag: 'field', name: f.name, modifiers: mods,
1647
+ unique: f.unique === true,
870
1648
  typeName: f.typeName, array: f.array,
871
1649
  literals: f.literals || null,
1650
+ coerce: f.coerce === true,
1651
+ coercer: f.coercer || null,
872
1652
  constraints: f.constraints,
1653
+ attrs: f.attrs || null,
873
1654
  transform: f.transform || null,
874
1655
  });
875
1656
  }
@@ -935,12 +1716,15 @@ function __schemaExpandMixins(host, fields, directives, ctx) {
935
1716
  fields.set(e.name, {
936
1717
  name: e.name,
937
1718
  required: e.modifiers.includes('!'),
938
- unique: e.modifiers.includes('#'),
1719
+ unique: e.unique === true,
939
1720
  optional: e.modifiers.includes('?'),
940
1721
  typeName: e.typeName,
941
1722
  literals: e.literals || null,
942
1723
  array: e.array === true,
1724
+ coerce: e.coerce === true,
1725
+ coercer: e.coercer || null,
943
1726
  constraints: e.constraints || null,
1727
+ attrs: e.attrs || null,
944
1728
  transform: e.transform || null,
945
1729
  });
946
1730
  }