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
@@ -7,6 +7,7 @@
7
7
  // src/schema/runtime-db-naming.js (server + migration)
8
8
  // src/schema/runtime-orm.js (server + migration)
9
9
  // src/schema/runtime-ddl.js (migration only)
10
+ // src/schema/runtime-migrate.js (migration only)
10
11
  // src/schema/runtime-browser-stubs.js (browser only)
11
12
  //
12
13
  // CI: bun scripts/build-schema-runtime.js --check fails if this file
@@ -47,17 +48,53 @@ var { __schema, SchemaError, __SchemaRegistry, __schemaSetAdapter } = (function(
47
48
 
48
49
  `;
49
50
  export const SCHEMA_RUNTIME_WRAPPER_TAIL = `
50
- // __schemaSetAdapter is server/migration-only. In validate or browser
51
- // modes it doesn't exist; export an undefined slot so destructure works.
51
+ // __schemaSetAdapter / __schemaTransaction are server/migration-only.
52
+ // In validate mode they don't exist (browser mode stubs transaction);
53
+ // export undefined / throwing slots so destructure works everywhere.
52
54
  const __schemaSetAdapterExport = typeof __schemaSetAdapter !== 'undefined'
53
55
  ? __schemaSetAdapter
54
56
  : undefined;
57
+ const __schemaTransactionExport = typeof __schemaTransaction !== 'undefined'
58
+ ? __schemaTransaction
59
+ : function() {
60
+ throw new Error('schema.transaction() requires the server schema runtime (validate-only runtime loaded).');
61
+ };
62
+ // Migration surface is migration-mode-only; export throwing slots in
63
+ // other modes so the namespace shape is stable everywhere.
64
+ const __schemaMigrationStub = (api) => function() {
65
+ throw new Error('schema.' + api + '() requires the migration schema runtime (CLI / rip schema); it is not part of the ' +
66
+ 'validate/browser/server runtime modes.');
67
+ };
68
+ // User-facing namespace: schema.transaction! -> ... in Rip source
69
+ // resolves through this object (installed as a global alongside the
70
+ // other Rip stdlib globals; ??= keeps user overrides intact).
71
+ const __schemaConnectExport = typeof __schemaConnect !== 'undefined'
72
+ ? __schemaConnect
73
+ : function() {
74
+ throw new Error('schema.connect() requires the server schema runtime (validate/browser-only runtime loaded).');
75
+ };
76
+ const schemaNamespace = {
77
+ transaction: __schemaTransactionExport,
78
+ connect: __schemaConnectExport,
79
+ registerCoercer: __schemaRegisterCoercer,
80
+ plan: typeof __schemaPlan !== 'undefined' ? __schemaPlan : __schemaMigrationStub('plan'),
81
+ status: typeof __schemaStatus !== 'undefined' ? __schemaStatus : __schemaMigrationStub('status'),
82
+ make: typeof __schemaMake !== 'undefined' ? __schemaMake : __schemaMigrationStub('make'),
83
+ migrate: typeof __schemaMigrate !== 'undefined' ? __schemaMigrate : __schemaMigrationStub('migrate'),
84
+ introspect: typeof __schemaIntrospect !== 'undefined' ? __schemaIntrospect : __schemaMigrationStub('introspect'),
85
+ };
55
86
  const exports = {
56
87
  __schema, SchemaError, __SchemaRegistry,
57
88
  __schemaSetAdapter: __schemaSetAdapterExport,
89
+ __schemaTransaction: __schemaTransactionExport,
90
+ __schemaRegisterCoercer,
91
+ schema: schemaNamespace,
58
92
  __version: 1,
59
93
  };
60
- if (typeof globalThis !== 'undefined') globalThis.__ripSchema = exports;
94
+ if (typeof globalThis !== 'undefined') {
95
+ globalThis.__ripSchema = exports;
96
+ globalThis.schema ??= schemaNamespace;
97
+ }
61
98
  return exports;
62
99
  })();
63
100
 
@@ -81,11 +118,20 @@ function __schemaFormatIssues(issues, name) {
81
118
  }
82
119
 
83
120
  const __SCHEMA_RESERVED_STATIC = new Set([
84
- 'parse','safe','ok','find','findMany','where','all','first','count','create','toSQL',
121
+ 'parse','array','safe','ok','parseAsync','safeAsync','okAsync','toJSONSchema',
122
+ 'find','findMany','where','all','first','count','create','toSQL',
123
+ 'includes','upsert','insertMany','updateAll','deleteAll','withDeleted','onlyDeleted',
124
+ 'unscoped',
125
+ ]);
126
+ // Names a @scope may not take: the model statics above plus the
127
+ // builder-only chain methods — scopes install on both surfaces.
128
+ const __SCHEMA_SCOPE_RESERVED = new Set([
129
+ ...__SCHEMA_RESERVED_STATIC,
130
+ 'limit','offset','order','orderBy',
85
131
  ]);
86
132
  const __SCHEMA_RESERVED_INSTANCE = new Set([
87
- 'save','destroy','reload','ok','errors','toJSON','savedChanges','markDirty',
88
- '_saving',
133
+ 'save','destroy','restore','reload','ok','errors','toJSON','savedChanges','markDirty',
134
+ '_saving','_relMemo',
89
135
  ]);
90
136
  // Implicit columns owned by directive-driven runtime behavior. Declaring
91
137
  // them as user fields would either shadow the runtime API (savedChanges /
@@ -122,6 +168,64 @@ function __schemaCheckValue(v, typeName) {
122
168
  return check ? check(v) : true;
123
169
  }
124
170
 
171
+ // Strict coercion tables for the \`~type\` marker — "coerce, then
172
+ // validate". Deliberately narrow: \`~integer\` rejects "12.5" and NaN,
173
+ // \`~boolean\` accepts exactly six tokens, \`~date\` accepts ISO-8601
174
+ // strings and finite epoch numbers. A failed coercion is
175
+ // {error: 'coerce'}, distinct from {error: 'type'} — the value LOOKED
176
+ // like wire data but didn't convert, which is a different user mistake
177
+ // than sending the wrong shape entirely.
178
+ const __SCHEMA_COERCERS = {
179
+ integer(v) {
180
+ if (typeof v === 'number') return Number.isInteger(v) ? { ok: true, value: v } : { ok: false };
181
+ if (typeof v === 'string' && /^[+-]?\\d+$/.test(v.trim())) return { ok: true, value: parseInt(v.trim(), 10) };
182
+ return { ok: false };
183
+ },
184
+ number(v) {
185
+ if (typeof v === 'number') return Number.isNaN(v) ? { ok: false } : { ok: true, value: v };
186
+ if (typeof v === 'string' && /^[+-]?(\\d+\\.?\\d*|\\.\\d+)([eE][+-]?\\d+)?$/.test(v.trim())) {
187
+ return { ok: true, value: Number(v.trim()) };
188
+ }
189
+ return { ok: false };
190
+ },
191
+ boolean(v) {
192
+ if (typeof v === 'boolean') return { ok: true, value: v };
193
+ if (v === 'true' || v === '1' || v === 1) return { ok: true, value: true };
194
+ if (v === 'false' || v === '0' || v === 0) return { ok: true, value: false };
195
+ return { ok: false };
196
+ },
197
+ date(v) {
198
+ if (v instanceof Date) return Number.isNaN(v.getTime()) ? { ok: false } : { ok: true, value: v };
199
+ if (typeof v === 'number' && Number.isFinite(v)) return { ok: true, value: new Date(v) };
200
+ if (typeof v === 'string' && /^\\d{4}-\\d{2}-\\d{2}/.test(v)) {
201
+ const d = new Date(v);
202
+ if (!Number.isNaN(d.getTime())) return { ok: true, value: d };
203
+ }
204
+ return { ok: false };
205
+ },
206
+ };
207
+ __SCHEMA_COERCERS.datetime = __SCHEMA_COERCERS.date;
208
+
209
+ // Named-coercer registry for the \`~:name\` field syntax. A coercer is a
210
+ // function (wireValue) → coercedValue, where null/undefined/false means
211
+ // "didn't convert" → {error: 'coerce'}. @rip-lang/server registers its
212
+ // entire read() validator vocabulary (id, money, ssn, phone, name,
213
+ // date, …) here at module load, so every wire normalizer that works in
214
+ // \`read 'x', 'ssn'\` also works as \`x? ~:ssn\` in a schema. Apps register
215
+ // their own via schema.registerCoercer.
216
+ //
217
+ // opts.raw — pass the value through un-stringified (validators that
218
+ // operate on arrays/objects, e.g. the server's array/hash/json).
219
+ const __schemaNamedCoercers = new Map();
220
+
221
+ function __schemaRegisterCoercer(name, fn, opts) {
222
+ if (typeof name !== 'string' || typeof fn !== 'function') {
223
+ throw new Error('schema.registerCoercer(name, fn, opts?): name string and fn required');
224
+ }
225
+ __schemaNamedCoercers.set(name, { fn, raw: opts?.raw === true });
226
+ return fn;
227
+ }
228
+
125
229
  function __schemaValidateValue(v, typeName) {
126
230
  const prim = __schemaTypes[typeName];
127
231
  if (prim) {
@@ -136,6 +240,12 @@ function __schemaValidateValue(v, typeName) {
136
240
  if (subDef.kind === 'mixin') {
137
241
  return [{field: '', error: 'type', message: ':mixin ' + typeName + ' is not usable as a field type'}];
138
242
  }
243
+ if (subDef.kind === 'union') {
244
+ const r = subDef._unionResolve(v);
245
+ if (r.issue) return [r.issue];
246
+ const memberErrs = r.def._validateFields(v, true);
247
+ return memberErrs.length ? memberErrs : null;
248
+ }
139
249
  if (v === null || typeof v !== 'object' || Array.isArray(v)) {
140
250
  return [{field: '', error: 'type', message: 'must be a ' + typeName + ' object'}];
141
251
  }
@@ -209,6 +319,21 @@ function __schemaSnapshot(norm, inst) {
209
319
  return snap;
210
320
  }
211
321
 
322
+ // Relation memo — caches resolved relation values per instance under
323
+ // the accessor name. Lazily created and non-enumerable so it never
324
+ // shows up in Object.keys / JSON.stringify. Written by the relation
325
+ // accessors (on first resolve) and by the eager-loading preloader
326
+ // (.includes), read by the accessors.
327
+ function __schemaRelMemoSet(inst, acc, v) {
328
+ if (!inst._relMemo) {
329
+ Object.defineProperty(inst, '_relMemo', {
330
+ value: new Map(), enumerable: false, writable: false, configurable: true,
331
+ });
332
+ }
333
+ inst._relMemo.set(acc, v);
334
+ return v;
335
+ }
336
+
212
337
  // SameValue-Zero: like ===, except NaN equals NaN. Used by the dirty
213
338
  // check so a persisted NaN doesn't trigger a wasted UPDATE on every
214
339
  // save. Distinguishes from Object.is by treating +0/-0 as equal, which
@@ -217,17 +342,74 @@ function __schemaSameValue(a, b) {
217
342
  return a === b || (a !== a && b !== b);
218
343
  }
219
344
 
345
+ // Structural signature of a declaration — name-shape only, function
346
+ // bodies excluded. Two registrations with the same signature are the
347
+ // same declaration arriving twice (double import via symlinked paths,
348
+ // re-eval of an unchanged module) and rebind silently. Different
349
+ // signatures under the same name are a real collision and throw,
350
+ // unless \`__SchemaRegistry.replace\` is set (dev/HMR semantics).
351
+ function __schemaSignature(def) {
352
+ // Constraints may hold RegExp values; JSON.stringify would erase them
353
+ // to {} and miss a real difference, so stringify them explicitly.
354
+ const safe = (v) => JSON.stringify(v ?? null, (k, x) =>
355
+ x instanceof RegExp ? String(x) : (typeof x === 'function' ? '<fn>' : x));
356
+ const parts = [def.kind];
357
+ for (const e of def._desc.entries || []) {
358
+ switch (e.tag) {
359
+ case 'field':
360
+ parts.push('f:' + e.name + ':' + (e.typeName || '') +
361
+ (e.array ? '[]' : '') + ':' + (e.modifiers || []).join('') +
362
+ (e.literals ? ':' + e.literals.join(',') : '') +
363
+ ':' + safe(e.constraints) + ':' + safe(e.attrs) + (e.coerce ? ':~' + (e.coercer || '') : '') +
364
+ (e.transform ? ':t' : ''));
365
+ break;
366
+ case 'enum-member':
367
+ parts.push('e:' + e.name + '=' + String(e.value));
368
+ break;
369
+ case 'directive':
370
+ parts.push('d:' + e.name + ':' + safe(e.args));
371
+ break;
372
+ case 'ensure':
373
+ parts.push('n:' + (e.message || ''));
374
+ break;
375
+ default:
376
+ // method / computed / derived / hook — name identity only.
377
+ parts.push(e.tag + ':' + (e.name || ''));
378
+ }
379
+ }
380
+ return parts.join('|');
381
+ }
382
+
220
383
  const __SchemaRegistry = {
221
384
  _entries: new Map(),
385
+ // Dev/HMR escape hatch: when true, re-registering a name rebinds
386
+ // unconditionally (pre-hardening "last loaded wins" semantics). Dev
387
+ // servers and test harnesses set it; production code should not.
388
+ replace: false,
222
389
  register(def) {
223
390
  // Named schemas of any kind land here. Relations look up :model,
224
391
  // @mixin Name looks up :mixin. Algebra (.extend etc.) accepts :shape
225
392
  // and derived shapes. Kind is checked at lookup time.
226
393
  if (!def.name) return;
227
- // Most recent registration wins. Recompilation produces a fresh
228
- // __SchemaDef with the same name; the registry rebinds. Cross-
229
- // module name collisions should be avoided — schema names are
230
- // app-global identifiers for relation resolution.
394
+ const existing = this._entries.get(def.name);
395
+ if (existing && existing.def !== def && !this.replace) {
396
+ // Identical declarations (same structural signature) rebind
397
+ // silently the same module arriving twice is not a conflict.
398
+ // Anything else is the "two different models, one name" footgun:
399
+ // relation / @mixin resolution is name-keyed and app-global, so
400
+ // silently letting the last one win corrupts resolution. Throw.
401
+ if (__schemaSignature(existing.def) !== __schemaSignature(def)) {
402
+ throw new SchemaError(
403
+ [{
404
+ field: def.name, error: 'collision',
405
+ message: "schema name '" + def.name + "' is already registered with a different definition. " +
406
+ "Schema names are app-global (they resolve relations and @mixin references), so two different " +
407
+ "schemas cannot share one name. Rename one of them — or, for dev/HMR reload semantics, set " +
408
+ "__SchemaRegistry.replace = true before re-evaluating modules.",
409
+ }],
410
+ def.name, def.kind);
411
+ }
412
+ }
231
413
  this._entries.set(def.name, { def, kind: def.kind });
232
414
  },
233
415
  get(name) {
@@ -240,6 +422,24 @@ const __SchemaRegistry = {
240
422
  },
241
423
  has(name) { return this._entries.has(name); },
242
424
  reset() { this._entries.clear(); },
425
+ // Run \`fn\` against a fresh, empty registry; restore the parent
426
+ // registry afterward (success, throw, or async rejection). Replaces
427
+ // ad-hoc reset() in tests and makes schema-declaring test blocks
428
+ // safe to run without leaking registrations into each other.
429
+ scope(fn) {
430
+ const saved = this._entries;
431
+ this._entries = new Map();
432
+ const restore = () => { this._entries = saved; };
433
+ try {
434
+ const r = fn();
435
+ if (r && typeof r.then === 'function') return r.finally(restore);
436
+ restore();
437
+ return r;
438
+ } catch (e) {
439
+ restore();
440
+ throw e;
441
+ }
442
+ },
243
443
  };
244
444
 
245
445
  class __SchemaDef {
@@ -250,6 +450,25 @@ class __SchemaDef {
250
450
  this._norm = null;
251
451
  this._klass = null;
252
452
  this._sourceModel = null;
453
+ this._unionPlanCache = null;
454
+ // Per-schema adapter (\`schema :model, on: analytics\`). null → the
455
+ // process-global adapter. Resolved per ORM call by the orm fragment.
456
+ this._adapter = desc.adapter || null;
457
+ // Install @scope statics eagerly so \`User.active()\` works as the
458
+ // very first call on the model (normalization hasn't run yet at
459
+ // that point; the scope invocation itself triggers it, which also
460
+ // fires the duplicate / reserved-name collision checks). Prototype
461
+ // methods win on name conflicts (\`in\` sees the prototype chain),
462
+ // and normalize rejects those names anyway.
463
+ for (const e of desc.entries || []) {
464
+ if (e.tag !== 'scope' || (e.name in this)) continue;
465
+ const self = this;
466
+ const sfn = e.fn;
467
+ Object.defineProperty(this, e.name, {
468
+ enumerable: false, configurable: true,
469
+ value: function(...args) { return __schemaInvokeScope(self, null, sfn, args); },
470
+ });
471
+ }
253
472
  }
254
473
 
255
474
  _normalize() {
@@ -264,6 +483,8 @@ class __SchemaDef {
264
483
  const enumMembers = new Map();
265
484
  const relations = new Map();
266
485
  const ensures = [];
486
+ const scopes = new Map();
487
+ let defaultScope = null;
267
488
  let timestamps = false;
268
489
  let softDelete = false;
269
490
 
@@ -303,12 +524,15 @@ class __SchemaDef {
303
524
  fields.set(e.name, {
304
525
  name: e.name,
305
526
  required: e.modifiers.includes('!'),
306
- unique: e.modifiers.includes('#'),
527
+ unique: e.unique === true,
307
528
  optional: e.modifiers.includes('?'),
308
529
  typeName: e.typeName,
309
530
  literals: e.literals || null,
310
531
  array: e.array === true,
532
+ coerce: e.coerce === true,
533
+ coercer: e.coercer || null,
311
534
  constraints: e.constraints || null,
535
+ attrs: e.attrs || null,
312
536
  transform: e.transform || null,
313
537
  });
314
538
  break;
@@ -350,8 +574,32 @@ class __SchemaDef {
350
574
  case 'ensure':
351
575
  // @ensure entries are schema-level invariants (cross-field
352
576
  // predicates). Declaration order is preserved so diagnostics
353
- // come out in the order authored.
354
- ensures.push({ message: e.message, fn: e.fn });
577
+ // come out in the order authored. \`field\` attributes the
578
+ // failure to a specific input; \`async\` marks an @ensure!
579
+ // refinement (the schema becomes async-validating).
580
+ ensures.push({
581
+ message: e.message,
582
+ field: e.field || '',
583
+ async: e.async === true,
584
+ fn: e.fn,
585
+ });
586
+ break;
587
+ case 'scope':
588
+ // Scopes live in the STATIC namespace (model + builder), not
589
+ // the instance namespace — a field \`active\` and a scope
590
+ // \`:active\` coexist by design. Collisions are checked against
591
+ // other scopes and the reserved static/builder names.
592
+ if (scopes.has(e.name)) collision(e.name, 'scope');
593
+ if (__SCHEMA_SCOPE_RESERVED.has(e.name)) collision(e.name, 'reserved query API name');
594
+ scopes.set(e.name, e.fn);
595
+ break;
596
+ case 'defaultScope':
597
+ if (defaultScope) {
598
+ throw new SchemaError(
599
+ [{field: '', error: 'collision', message: 'only one @defaultScope per model'}],
600
+ this.name, this.kind);
601
+ }
602
+ defaultScope = e.fn;
355
603
  break;
356
604
  }
357
605
  }
@@ -371,14 +619,98 @@ class __SchemaDef {
371
619
  const primaryKey = 'id';
372
620
  const tableName = this.kind === 'model' ? __schemaTableName(this.name) : null;
373
621
 
622
+ // \`@tableWas old_name\` — table-rename annotation for the differ.
623
+ let tableWas = null;
624
+ for (const d of directives) {
625
+ if (d.name === 'tableWas' && d.args?.[0]?.name) tableWas = d.args[0].name;
626
+ }
627
+
628
+ // :union metadata — discriminator field + constituent names.
629
+ let unionOn = null;
630
+ const unionMembers = [];
631
+ if (this.kind === 'union') {
632
+ for (const d of directives) {
633
+ if (d.name === 'on' && d.args?.[0]?.field) unionOn = d.args[0].field;
634
+ }
635
+ for (const e of this._desc.entries) {
636
+ if (e.tag === 'union-member') unionMembers.push(e.name);
637
+ }
638
+ }
639
+
374
640
  this._norm = {
375
641
  fields, methods, computed, derived, hooks, directives, enumMembers, relations,
376
- ensures,
377
- timestamps, softDelete, primaryKey, tableName,
642
+ ensures, scopes, defaultScope,
643
+ hasAsyncEnsures: ensures.some(r => r.async),
644
+ timestamps, softDelete, primaryKey, tableName, tableWas,
645
+ unionOn, unionMembers,
378
646
  };
379
647
  return this._norm;
380
648
  }
381
649
 
650
+ // ---- :union dispatch -------------------------------------------------------
651
+ //
652
+ // Built lazily on first parse (consistent with registry resolution):
653
+ // resolves every constituent from the registry, reads its declared
654
+ // discriminator literals, and compiles a value → constituent map for
655
+ // O(1) dispatch. Collisions and shapeless discriminators fail here
656
+ // with the constituent names in the message.
657
+
658
+ _unionPlan() {
659
+ if (this._unionPlanCache) return this._unionPlanCache;
660
+ const norm = this._normalize();
661
+ const disc = norm.unionOn;
662
+ if (this.kind !== 'union' || !disc) {
663
+ throw new Error("schema: '" + (this.name || 'anon') + "' is not a :union");
664
+ }
665
+ const map = new Map();
666
+ const members = [];
667
+ for (const name of norm.unionMembers) {
668
+ const def = __SchemaRegistry.get(name);
669
+ if (!def) {
670
+ throw new SchemaError(
671
+ [{field: '', error: 'union', message: 'unknown union constituent: ' + name + ' (import the file that declares it)'}],
672
+ this.name, this.kind);
673
+ }
674
+ members.push(def);
675
+ const f = def._normalize().fields.get(disc);
676
+ if (!f || f.typeName !== 'literal-union' || !f.literals?.length) {
677
+ throw new SchemaError(
678
+ [{field: disc, error: 'union', message: name + " must declare '" + disc +
679
+ "' as a string-literal type (e.g. " + disc + '! "click") to join union ' + (this.name || '')}],
680
+ this.name, this.kind);
681
+ }
682
+ for (const lit of f.literals) {
683
+ if (map.has(lit)) {
684
+ throw new SchemaError(
685
+ [{field: disc, error: 'union', message: 'duplicate discriminator value ' + JSON.stringify(lit) +
686
+ ' in ' + (map.get(lit).name || 'anon') + ' and ' + name}],
687
+ this.name, this.kind);
688
+ }
689
+ map.set(lit, def);
690
+ }
691
+ }
692
+ const plan = {
693
+ disc, map,
694
+ expected: [...map.keys()].join(' | '),
695
+ hasAsyncEnsures: members.some(d => d._normalize().hasAsyncEnsures),
696
+ };
697
+ this._unionPlanCache = plan;
698
+ return plan;
699
+ }
700
+
701
+ // Resolve a raw value to its constituent def, or produce the issue.
702
+ _unionResolve(data) {
703
+ const plan = this._unionPlan();
704
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
705
+ return { issue: {field: plan.disc, error: 'union', message: 'expected an object with ' + plan.disc} };
706
+ }
707
+ const def = plan.map.get(data[plan.disc]);
708
+ if (!def) {
709
+ return { issue: {field: plan.disc, error: 'union', message: 'expected one of ' + plan.expected} };
710
+ }
711
+ return { def };
712
+ }
713
+
382
714
  // Run eager-derived entries (!>) — one pass, in declaration order.
383
715
  //
384
716
  // Invariants worth keeping in mind here:
@@ -436,14 +768,14 @@ class __SchemaDef {
436
768
  ok = !!r.fn(data);
437
769
  } catch (e) {
438
770
  errs.push({
439
- field: '', error: 'ensure',
771
+ field: r.field || '', error: 'ensure',
440
772
  message: r.message || e?.message || 'ensure failed',
441
773
  });
442
774
  continue;
443
775
  }
444
776
  if (!ok) {
445
777
  errs.push({
446
- field: '', error: 'ensure',
778
+ field: r.field || '', error: 'ensure',
447
779
  message: r.message || 'ensure failed',
448
780
  });
449
781
  }
@@ -451,6 +783,53 @@ class __SchemaDef {
451
783
  return errs;
452
784
  }
453
785
 
786
+ // Async-aware refinement pass for parseAsync/safeAsync/okAsync. Sync
787
+ // refinements run first (cheap before expensive); async refinements
788
+ // then run CONCURRENTLY (Promise.all) — preserving the no-short-
789
+ // circuit rule. Issues come out in declaration order regardless of
790
+ // completion order. A rejected async predicate counts as failure
791
+ // with the declared message, same as a thrown sync one.
792
+ async _applyEnsuresAsync(data) {
793
+ const norm = this._normalize();
794
+ if (!norm.ensures.length) return [];
795
+ const results = [];
796
+ const pending = [];
797
+ norm.ensures.forEach((r, idx) => {
798
+ const issue = () => ({
799
+ field: r.field || '', error: 'ensure',
800
+ message: r.message || 'ensure failed',
801
+ });
802
+ if (r.async) {
803
+ pending.push((async () => {
804
+ let ok = false;
805
+ try { ok = !!(await r.fn(data)); } catch { ok = false; }
806
+ if (!ok) results.push({ idx, issue: issue() });
807
+ })());
808
+ } else {
809
+ let ok = false;
810
+ try { ok = !!r.fn(data); } catch { ok = false; }
811
+ if (!ok) results.push({ idx, issue: issue() });
812
+ }
813
+ });
814
+ await Promise.all(pending);
815
+ results.sort((a, b) => a.idx - b.idx);
816
+ return results.map(r => r.issue);
817
+ }
818
+
819
+ // A schema with ≥1 @ensure! is async-validating: the sync entry
820
+ // points refuse loudly rather than sometimes-returning a promise.
821
+ _assertSyncValidatable(api) {
822
+ const async = this.kind === 'union'
823
+ ? this._unionPlan().hasAsyncEnsures
824
+ : this._normalize().hasAsyncEnsures;
825
+ if (async) {
826
+ throw new Error(
827
+ "schema '" + (this.name || 'anon') + "' has async refinements (@ensure!" +
828
+ (this.kind === 'union' ? ' in a constituent' : '') + "); " +
829
+ '.' + api + '() is sync. Use parseAsync!/safeAsync!/okAsync! instead.');
830
+ }
831
+ }
832
+
454
833
  _getClass() {
455
834
  if (this._klass) return this._klass;
456
835
  const norm = this._normalize();
@@ -498,12 +877,23 @@ class __SchemaDef {
498
877
  });
499
878
  }
500
879
 
501
- // Relation methods: user.organization(). Accepts no args; returns
502
- // a promise to a target-model instance (or array for has_many).
880
+ // Relation methods: user.organization(). Accepts an optional opts
881
+ // object; returns a promise to a target-model instance (or array
882
+ // for has_many). Results memoize per instance — the second call
883
+ // resolves from cache with no query, and eager loading (.includes)
884
+ // fills the same memo so preloaded relations are free. Pass
885
+ // {reload: true} to bust the memo and re-query.
503
886
  for (const [acc, rel] of norm.relations) {
504
887
  Object.defineProperty(klass.prototype, acc, {
505
888
  enumerable: false, configurable: true,
506
- value: async function() { return __schemaResolveRelation(def, this, rel); },
889
+ value: async function(opts) {
890
+ if (!(opts && opts.reload === true) && this._relMemo && this._relMemo.has(acc)) {
891
+ return this._relMemo.get(acc);
892
+ }
893
+ const v = await __schemaResolveRelation(def, this, rel);
894
+ __schemaRelMemoSet(this, acc, v);
895
+ return v;
896
+ },
507
897
  });
508
898
  }
509
899
 
@@ -513,9 +903,17 @@ class __SchemaDef {
513
903
  enumerable: false, configurable: true, writable: true,
514
904
  value: async function() { return __schemaSave(def, this); },
515
905
  });
906
+ // destroy() honors @softDelete (UPDATE deleted_at) by default;
907
+ // destroy(hard: true) forces a real DELETE. Hooks fire either way.
516
908
  Object.defineProperty(klass.prototype, 'destroy', {
517
909
  enumerable: false, configurable: true, writable: true,
518
- value: async function() { return __schemaDestroy(def, this); },
910
+ value: async function(opts) { return __schemaDestroy(def, this, opts); },
911
+ });
912
+ // restore() un-deletes a soft-deleted row (deleted_at = NULL).
913
+ // Throws on models without @softDelete.
914
+ Object.defineProperty(klass.prototype, 'restore', {
915
+ enumerable: false, configurable: true, writable: true,
916
+ value: async function() { return __schemaRestore(def, this); },
519
917
  });
520
918
  Object.defineProperty(klass.prototype, 'ok', {
521
919
  enumerable: false, configurable: true, writable: true,
@@ -633,10 +1031,37 @@ class __SchemaDef {
633
1031
  return inst;
634
1032
  }
635
1033
 
636
- _validateFields(data, collect) {
1034
+ // Coerce ISO date strings to Date for date/datetime fields. Over JSON a
1035
+ // date is a string, so a value crossing the wire (or any \`.parse\` of
1036
+ // external input) arrives as a string; this lets it validate and be stored
1037
+ // as a Date. Runs on parse/safe only — hydrate gets canonical DB values.
1038
+ _coerceDates(working) {
1039
+ const norm = this._normalize();
1040
+ // Only ISO-shaped strings (\`YYYY-MM-DD\` optionally followed by a time) are
1041
+ // coerced. \`new Date(v)\` is otherwise lax — \`new Date("5")\` is a valid
1042
+ // Date — which would let clearly-bad input slip past a date field as a
1043
+ // bogus Date instead of failing validation. Array-of-date fields coerce
1044
+ // element-wise.
1045
+ const isoShaped = (s) => typeof s === 'string' && /^\\d{4}-\\d{2}-\\d{2}([T ].*)?$/.test(s);
1046
+ const toDate = (s) => { const d = new Date(s); return Number.isNaN(d.getTime()) ? s : d; };
1047
+ for (const [n, f] of norm.fields) {
1048
+ if (f.typeName !== 'date' && f.typeName !== 'datetime') continue;
1049
+ const v = working[n];
1050
+ if (f.array && Array.isArray(v)) {
1051
+ working[n] = v.map(el => isoShaped(el) ? toDate(el) : el);
1052
+ } else if (isoShaped(v)) {
1053
+ working[n] = toDate(v);
1054
+ }
1055
+ }
1056
+ }
1057
+
1058
+ _validateFields(data, collect, skip) {
637
1059
  const norm = this._normalize();
638
1060
  const errors = collect ? [] : null;
639
1061
  for (const [n, f] of norm.fields) {
1062
+ // Fields whose coercion already failed carry a {error: 'coerce'}
1063
+ // issue; re-checking the uncoerced value would double-report.
1064
+ if (skip && skip.has(n)) continue;
640
1065
  const v = data == null ? undefined : data[n];
641
1066
  if (v === undefined || v === null) {
642
1067
  if (f.required) {
@@ -738,6 +1163,54 @@ class __SchemaDef {
738
1163
  return errors;
739
1164
  }
740
1165
 
1166
+ // \`~type\` coercions — part of pipeline step 1 (obtain raw candidate):
1167
+ // the wire value converts through the strict coercion table before
1168
+ // defaults and validation, so range checks see the coerced value.
1169
+ // Failed coercions collect {error: 'coerce'} issues and the field
1170
+ // lands in \`failed\` so per-field validation doesn't double-report
1171
+ // the same problem as a type error. Skipped on hydrate (rows arrive
1172
+ // canonical), like transforms. Mutually exclusive with transforms at
1173
+ // compile time.
1174
+ _applyCoercions(working, failed) {
1175
+ const norm = this._normalize();
1176
+ const errors = [];
1177
+ for (const [n, f] of norm.fields) {
1178
+ if (!f.coerce) continue;
1179
+ const v = working[n];
1180
+ if (v === undefined || v === null) continue;
1181
+ if (f.coercer) {
1182
+ // \`~:name\` — registry lookup. A missing coercer is a CONFIG
1183
+ // error (the package that provides it wasn't loaded), not a
1184
+ // validation failure — fail loud.
1185
+ const entry = __schemaNamedCoercers.get(f.coercer);
1186
+ if (!entry) {
1187
+ throw new Error(
1188
+ "schema: no coercer registered for '~:" + f.coercer + "' (field '" + n + "' on " +
1189
+ (this.name || 'anon') + "). Import '@rip-lang/validate' (browser-safe; registers the " +
1190
+ "whole vocabulary) or register it with schema.registerCoercer('" + f.coercer + "', fn).");
1191
+ }
1192
+ const input = entry.raw ? v : String(v).trim();
1193
+ let out;
1194
+ try { out = entry.fn(input); } catch { out = null; }
1195
+ if (out === null || out === undefined || out === false) {
1196
+ errors.push({field: n, error: 'coerce', message: n + ' is not a valid ' + f.coercer});
1197
+ failed.add(n);
1198
+ } else {
1199
+ working[n] = out;
1200
+ }
1201
+ continue;
1202
+ }
1203
+ const r = __SCHEMA_COERCERS[f.typeName] ? __SCHEMA_COERCERS[f.typeName](v) : { ok: false };
1204
+ if (r.ok) {
1205
+ working[n] = r.value;
1206
+ } else {
1207
+ errors.push({field: n, error: 'coerce', message: n + ' cannot be coerced to ' + f.typeName});
1208
+ failed.add(n);
1209
+ }
1210
+ }
1211
+ return errors;
1212
+ }
1213
+
741
1214
  _validateEnum(data, collect) {
742
1215
  const norm = this._normalize();
743
1216
  for (const [n, v] of norm.enumMembers) {
@@ -778,16 +1251,27 @@ class __SchemaDef {
778
1251
  if (this.kind === 'mixin') {
779
1252
  throw new Error(":mixin schema '" + (this.name || 'anon') + "' is not instantiable");
780
1253
  }
1254
+ if (this.kind === 'union') {
1255
+ const plan = this._unionPlan();
1256
+ if (plan.hasAsyncEnsures) this._assertSyncValidatable('parse');
1257
+ const r = this._unionResolve(data);
1258
+ if (r.issue) throw new SchemaError([r.issue], this.name, this.kind);
1259
+ return r.def.parse(data);
1260
+ }
781
1261
  if (this.kind === 'enum') {
782
1262
  const errs = this._validateEnum(data, true);
783
1263
  if (errs.length) throw new SchemaError(errs, this.name, this.kind);
784
1264
  return this._materializeEnum(data);
785
1265
  }
1266
+ this._assertSyncValidatable('parse');
786
1267
  const raw = data || {};
787
1268
  const working = { ...raw };
1269
+ const failed = new Set();
788
1270
  const transformErrors = this._applyTransforms(raw, working);
1271
+ const coerceErrors = this._applyCoercions(working, failed);
789
1272
  this._applyDefaults(working);
790
- const errs = transformErrors.concat(this._validateFields(working, true));
1273
+ this._coerceDates(working);
1274
+ const errs = transformErrors.concat(coerceErrors, this._validateFields(working, true, failed));
791
1275
  if (errs.length) throw new SchemaError(errs, this.name, this.kind);
792
1276
  // @ensure runs AFTER per-field validation so predicates can
793
1277
  // assume declared fields are typed and defaulted. A field-level
@@ -800,20 +1284,93 @@ class __SchemaDef {
800
1284
  return inst;
801
1285
  }
802
1286
 
1287
+ // Array combinator: \`Schema.array\` is a list-of-this schema exposing the
1288
+ // same validation family (parse/safe/ok + async). The common list-endpoint
1289
+ // case is \`Schema.array.parse(api.get!('x').json!())\` → Out[]. A non-array
1290
+ // input fails fast with a descriptive error naming what it got — so passing
1291
+ // an enveloped \`{ items: [...] }\` whole, a typo'd key, or a changed server
1292
+ // contract surfaces clearly at the boundary. Item failures aggregate, each
1293
+ // issue tagged with its \`[index]\` so a bad element is locatable.
1294
+ get array() {
1295
+ const elem = this;
1296
+ const notArray = (data) => {
1297
+ const got = data === null ? 'null'
1298
+ : data === undefined ? 'undefined'
1299
+ : typeof data === 'object' ? ('an object with keys [' + Object.keys(data).join(', ') + ']')
1300
+ : typeof data;
1301
+ return { field: '', error: 'not_array', message: 'expected an array, received ' + got };
1302
+ };
1303
+ const collect = (results) => {
1304
+ const value = [];
1305
+ const errors = [];
1306
+ results.forEach((r, i) => {
1307
+ if (r.ok) value.push(r.value);
1308
+ else for (const e of r.errors) {
1309
+ errors.push({ ...e, field: '[' + i + ']' + (e.field ? '.' + e.field : '') });
1310
+ }
1311
+ });
1312
+ return { value, errors };
1313
+ };
1314
+ return {
1315
+ parse(data) {
1316
+ if (!Array.isArray(data)) throw new SchemaError([notArray(data)], elem.name, elem.kind);
1317
+ const { value, errors } = collect(data.map((x) => elem.safe(x)));
1318
+ if (errors.length) throw new SchemaError(errors, elem.name, elem.kind);
1319
+ return value;
1320
+ },
1321
+ safe(data) {
1322
+ if (!Array.isArray(data)) return { ok: false, value: null, errors: [notArray(data)] };
1323
+ const { value, errors } = collect(data.map((x) => elem.safe(x)));
1324
+ return errors.length ? { ok: false, value: null, errors } : { ok: true, value, errors: null };
1325
+ },
1326
+ ok(data) {
1327
+ return Array.isArray(data) && data.every((x) => elem.ok(x));
1328
+ },
1329
+ async parseAsync(data) {
1330
+ if (!Array.isArray(data)) throw new SchemaError([notArray(data)], elem.name, elem.kind);
1331
+ const { value, errors } = collect(await Promise.all(data.map((x) => elem.safeAsync(x))));
1332
+ if (errors.length) throw new SchemaError(errors, elem.name, elem.kind);
1333
+ return value;
1334
+ },
1335
+ async safeAsync(data) {
1336
+ if (!Array.isArray(data)) return { ok: false, value: null, errors: [notArray(data)] };
1337
+ const { value, errors } = collect(await Promise.all(data.map((x) => elem.safeAsync(x))));
1338
+ return errors.length ? { ok: false, value: null, errors } : { ok: true, value, errors: null };
1339
+ },
1340
+ async okAsync(data) {
1341
+ return Array.isArray(data) && (await Promise.all(data.map((x) => elem.okAsync(x)))).every(Boolean);
1342
+ },
1343
+ toJSONSchema() {
1344
+ return { type: 'array', items: elem.toJSONSchema() };
1345
+ },
1346
+ };
1347
+ }
1348
+
803
1349
  safe(data) {
804
1350
  if (this.kind === 'mixin') {
805
1351
  return {ok: false, value: null, errors: [{field: '', error: 'mixin', message: 'not instantiable'}]};
806
1352
  }
1353
+ if (this.kind === 'union') {
1354
+ const plan = this._unionPlan();
1355
+ if (plan.hasAsyncEnsures) this._assertSyncValidatable('safe');
1356
+ const r = this._unionResolve(data);
1357
+ if (r.issue) return {ok: false, value: null, errors: [r.issue]};
1358
+ return r.def.safe(data);
1359
+ }
807
1360
  if (this.kind === 'enum') {
808
1361
  const errs = this._validateEnum(data, true);
809
1362
  if (errs.length) return {ok: false, value: null, errors: errs};
810
1363
  return {ok: true, value: this._materializeEnum(data), errors: null};
811
1364
  }
1365
+ this._assertSyncValidatable('safe');
812
1366
  const raw = data || {};
813
1367
  const working = { ...raw };
1368
+ const failed = new Set();
814
1369
  const transformErrors = this._applyTransforms(raw, working);
1370
+ const coerceErrors = this._applyCoercions(working, failed);
815
1371
  this._applyDefaults(working);
816
- const errs = transformErrors.concat(this._validateFields(working, true));
1372
+ this._coerceDates(working);
1373
+ const errs = transformErrors.concat(coerceErrors, this._validateFields(working, true, failed));
817
1374
  if (errs.length) return {ok: false, value: null, errors: errs};
818
1375
  const ensureErrs = this._applyEnsures(working);
819
1376
  if (ensureErrs.length) return {ok: false, value: null, errors: ensureErrs};
@@ -828,17 +1385,89 @@ class __SchemaDef {
828
1385
 
829
1386
  ok(data) {
830
1387
  if (this.kind === 'mixin') return false;
1388
+ if (this.kind === 'union') {
1389
+ const plan = this._unionPlan();
1390
+ if (plan.hasAsyncEnsures) this._assertSyncValidatable('ok');
1391
+ const r = this._unionResolve(data);
1392
+ return r.issue ? false : r.def.ok(data);
1393
+ }
831
1394
  if (this.kind === 'enum') return this._validateEnum(data, false);
1395
+ this._assertSyncValidatable('ok');
832
1396
  const raw = data || {};
833
1397
  const working = { ...raw };
1398
+ const failed = new Set();
834
1399
  const transformErrors = this._applyTransforms(raw, working);
835
1400
  if (transformErrors.length) return false;
1401
+ if (this._applyCoercions(working, failed).length) return false;
836
1402
  this._applyDefaults(working);
1403
+ this._coerceDates(working);
837
1404
  if (!this._validateFields(working, false)) return false;
838
1405
  // Per-field validation passed — @ensure predicates are the final gate.
839
1406
  return this._applyEnsures(working).length === 0;
840
1407
  }
841
1408
 
1409
+ // Async validation entry points. Work on EVERY schema (sync-only
1410
+ // schemas just resolve immediately); REQUIRED when the schema has
1411
+ // @ensure! refinements. The dammit operator gives the idiomatic
1412
+ // form: \`SignupInput.parseAsync! raw\`.
1413
+ async _runPipelineAsync(data) {
1414
+ const raw = data || {};
1415
+ const working = { ...raw };
1416
+ const failed = new Set();
1417
+ const transformErrors = this._applyTransforms(raw, working);
1418
+ const coerceErrors = this._applyCoercions(working, failed);
1419
+ this._applyDefaults(working);
1420
+ this._coerceDates(working);
1421
+ const errs = transformErrors.concat(coerceErrors, this._validateFields(working, true, failed));
1422
+ if (errs.length) return { errors: errs };
1423
+ const ensureErrs = await this._applyEnsuresAsync(working);
1424
+ if (ensureErrs.length) return { errors: ensureErrs };
1425
+ return { working };
1426
+ }
1427
+
1428
+ async parseAsync(data) {
1429
+ if (this.kind === 'mixin') {
1430
+ throw new Error(":mixin schema '" + (this.name || 'anon') + "' is not instantiable");
1431
+ }
1432
+ if (this.kind === 'union') {
1433
+ const r = this._unionResolve(data);
1434
+ if (r.issue) throw new SchemaError([r.issue], this.name, this.kind);
1435
+ return r.def.parseAsync(data);
1436
+ }
1437
+ if (this.kind === 'enum') return this.parse(data);
1438
+ const r = await this._runPipelineAsync(data);
1439
+ if (r.errors) throw new SchemaError(r.errors, this.name, this.kind);
1440
+ const klass = this._getClass();
1441
+ const inst = new klass(r.working, false);
1442
+ this._applyEagerDerived(inst);
1443
+ return inst;
1444
+ }
1445
+
1446
+ async safeAsync(data) {
1447
+ if (this.kind === 'mixin') {
1448
+ return {ok: false, value: null, errors: [{field: '', error: 'mixin', message: 'not instantiable'}]};
1449
+ }
1450
+ if (this.kind === 'union') {
1451
+ const r = this._unionResolve(data);
1452
+ if (r.issue) return {ok: false, value: null, errors: [r.issue]};
1453
+ return r.def.safeAsync(data);
1454
+ }
1455
+ if (this.kind === 'enum') return this.safe(data);
1456
+ const r = await this._runPipelineAsync(data);
1457
+ if (r.errors) return {ok: false, value: null, errors: r.errors};
1458
+ const klass = this._getClass();
1459
+ const inst = new klass(r.working, false);
1460
+ try { this._applyEagerDerived(inst); }
1461
+ catch (e) {
1462
+ return {ok: false, value: null, errors: [{field: '', error: 'derived', message: e?.message || String(e)}]};
1463
+ }
1464
+ return {ok: true, value: inst, errors: null};
1465
+ }
1466
+
1467
+ async okAsync(data) {
1468
+ return (await this.safeAsync(data)).ok;
1469
+ }
1470
+
842
1471
  // ---- :model static ORM methods --------------------------------------------
843
1472
 
844
1473
  pick(...keys) {
@@ -883,6 +1512,9 @@ class __SchemaDef {
883
1512
  if (!(other instanceof __SchemaDef)) {
884
1513
  throw new Error('extend(): argument must be a schema value');
885
1514
  }
1515
+ if (other.kind === 'union') {
1516
+ throw new Error('extend(): :union schemas have no fields to merge');
1517
+ }
886
1518
  return __schemaDerive(this, (src) => {
887
1519
  const merged = new Map(src);
888
1520
  const otherFields = other._normalize().fields;
@@ -897,6 +1529,159 @@ class __SchemaDef {
897
1529
  }
898
1530
  }
899
1531
 
1532
+ // ---- JSON Schema export (draft 2020-12) -------------------------------------
1533
+ //
1534
+ // One declaration → wire contract. Field types map per the table in the
1535
+ // language reference; nested registry schemas become \`$ref\`s collected
1536
+ // under \`$defs\` (cycle-safe); enums map to \`enum\`, unions to \`oneOf\` +
1537
+ // an OpenAPI-style \`discriminator\`. Transforms and refinements have no
1538
+ // executable JSON Schema equivalent — they export as \`description\`
1539
+ // annotations rather than being silently dropped or approximated.
1540
+
1541
+ const __SCHEMA_JSON_TYPES = {
1542
+ string: () => ({ type: 'string' }),
1543
+ text: () => ({ type: 'string' }),
1544
+ email: () => ({ type: 'string', format: 'email' }),
1545
+ url: () => ({ type: 'string', format: 'uri' }),
1546
+ uuid: () => ({ type: 'string', format: 'uuid' }),
1547
+ phone: () => ({ type: 'string', pattern: '^[\\\\d\\\\s\\\\-+()]+$' }),
1548
+ zip: () => ({ type: 'string', pattern: '^\\\\d{5}(-\\\\d{4})?$' }),
1549
+ number: () => ({ type: 'number' }),
1550
+ integer: () => ({ type: 'integer' }),
1551
+ boolean: () => ({ type: 'boolean' }),
1552
+ date: () => ({ type: 'string', format: 'date' }),
1553
+ datetime: () => ({ type: 'string', format: 'date-time' }),
1554
+ json: () => ({}),
1555
+ any: () => ({}),
1556
+ };
1557
+
1558
+ function __schemaFieldJSONSchema(f, ctx) {
1559
+ let s;
1560
+ if (f.typeName === 'literal-union' && f.literals?.length) {
1561
+ s = f.literals.length === 1 ? { const: f.literals[0] } : { enum: [...f.literals] };
1562
+ } else if (__SCHEMA_JSON_TYPES[f.typeName]) {
1563
+ s = __SCHEMA_JSON_TYPES[f.typeName]();
1564
+ } else {
1565
+ const sub = __SchemaRegistry.get(f.typeName);
1566
+ if (sub) {
1567
+ s = __schemaJSONSchemaRef(sub, ctx);
1568
+ } else {
1569
+ s = {}; // unknown identifier — permissive, matching the validator
1570
+ }
1571
+ }
1572
+ const c = f.constraints;
1573
+ if (c && !f.array) {
1574
+ const stringish = s.type === 'string';
1575
+ const numeric = s.type === 'number' || s.type === 'integer';
1576
+ if (stringish) {
1577
+ if (c.min != null) s.minLength = c.min;
1578
+ if (c.max != null) s.maxLength = c.max;
1579
+ if (c.regex) s.pattern = c.regex.source;
1580
+ } else if (numeric) {
1581
+ if (c.min != null) s.minimum = c.min;
1582
+ if (c.max != null) s.maximum = c.max;
1583
+ }
1584
+ }
1585
+ if (f.array) {
1586
+ const items = s;
1587
+ s = { type: 'array', items };
1588
+ if (c) {
1589
+ if (c.min != null) s.minItems = c.min;
1590
+ if (c.max != null) s.maxItems = c.max;
1591
+ }
1592
+ }
1593
+ if (c && c.default !== undefined) s.default = c.default;
1594
+ if (f.coerce) {
1595
+ s.description = ((s.description ? s.description + ' ' : '') +
1596
+ 'Coerced from wire data (' + (f.coercer ? '~:' + f.coercer : '~' + f.typeName) + ').').trim();
1597
+ }
1598
+ if (f.transform) {
1599
+ s.description = ((s.description ? s.description + ' ' : '') +
1600
+ 'Derived via transform; the raw input may use different keys.').trim();
1601
+ }
1602
+ return s;
1603
+ }
1604
+
1605
+ // A registry schema used as a field type / union constituent becomes a
1606
+ // \`$ref\` into \`$defs\`, expanding each named schema exactly once.
1607
+ function __schemaJSONSchemaRef(def, ctx) {
1608
+ const name = def.name || 'Anon';
1609
+ if (!ctx.defs.has(name) && !ctx.expanding.has(name)) {
1610
+ ctx.expanding.add(name);
1611
+ ctx.defs.set(name, null); // reserve slot to keep insertion order
1612
+ ctx.defs.set(name, __schemaJSONSchemaBody(def, ctx));
1613
+ ctx.expanding.delete(name);
1614
+ }
1615
+ return { $ref: '#/$defs/' + name };
1616
+ }
1617
+
1618
+ function __schemaJSONSchemaBody(def, ctx) {
1619
+ const norm = def._normalize();
1620
+
1621
+ if (def.kind === 'enum') {
1622
+ return { enum: [...new Set(norm.enumMembers.values())] };
1623
+ }
1624
+
1625
+ if (def.kind === 'union') {
1626
+ const plan = def._unionPlan();
1627
+ const oneOf = norm.unionMembers.map(name => {
1628
+ const member = __SchemaRegistry.get(name);
1629
+ return member ? __schemaJSONSchemaRef(member, ctx) : {};
1630
+ });
1631
+ return {
1632
+ oneOf,
1633
+ discriminator: { propertyName: plan.disc },
1634
+ };
1635
+ }
1636
+
1637
+ // Fielded kinds: object schema. A field is required on the wire only
1638
+ // when it's \`!\`-marked AND has no default (defaults apply before the
1639
+ // required check, so a defaulted field can never fail required).
1640
+ const properties = {};
1641
+ const required = [];
1642
+ for (const [n, f] of norm.fields) {
1643
+ properties[n] = __schemaFieldJSONSchema(f, ctx);
1644
+ if (f.required && f.constraints?.default === undefined) required.push(n);
1645
+ }
1646
+ // :model wire shapes include the DB-managed columns toJSON() carries.
1647
+ if (def.kind === 'model') {
1648
+ properties[norm.primaryKey] = { type: 'integer' };
1649
+ for (const [, rel] of norm.relations) {
1650
+ if (rel.kind !== 'belongsTo') continue;
1651
+ properties[__schemaCamel(rel.foreignKey)] = rel.optional
1652
+ ? { type: ['integer', 'null'] }
1653
+ : { type: 'integer' };
1654
+ }
1655
+ if (norm.timestamps) {
1656
+ properties.createdAt = { type: 'string', format: 'date-time' };
1657
+ properties.updatedAt = { type: 'string', format: 'date-time' };
1658
+ }
1659
+ if (norm.softDelete) {
1660
+ properties.deletedAt = { type: ['string', 'null'], format: 'date-time' };
1661
+ }
1662
+ }
1663
+ const out = { type: 'object', properties };
1664
+ if (required.length) out.required = required;
1665
+ if (norm.ensures.length) {
1666
+ out.description = 'Refinements (not expressible in JSON Schema): ' +
1667
+ norm.ensures.map(r => r.message).join('; ') + '.';
1668
+ }
1669
+ return out;
1670
+ }
1671
+
1672
+ __SchemaDef.prototype.toJSONSchema = function () {
1673
+ const ctx = { defs: new Map(), expanding: new Set() };
1674
+ // The root schema expands inline; only REFERENCED schemas go to $defs.
1675
+ const root = __schemaJSONSchemaBody(this, ctx);
1676
+ root.$schema = 'https://json-schema.org/draft/2020-12/schema';
1677
+ if (this.name) root.title = this.name;
1678
+ if (ctx.defs.size) {
1679
+ root.$defs = {};
1680
+ for (const [k, v] of ctx.defs) root.$defs[k] = v;
1681
+ }
1682
+ return root;
1683
+ };
1684
+
900
1685
  function __schemaFlatten(keys) {
901
1686
  const out = [];
902
1687
  for (const k of keys) {
@@ -907,20 +1692,53 @@ function __schemaFlatten(keys) {
907
1692
  return out;
908
1693
  }
909
1694
 
1695
+ // The full projectable column set of a schema: declared fields plus the
1696
+ // columns a :model manages implicitly — the \`id\` primary key, \`@timestamps\`
1697
+ // (createdAt/updatedAt), \`@softDelete\` (deletedAt), and \`@belongs_to\` FK
1698
+ // columns. Algebra (.pick/.omit/.partial/.required) operates over THIS set so
1699
+ // a client projection can include \`id\`/\`createdAt\` even though they aren't
1700
+ // declared fields. Non-model kinds have no implicit columns — declared only.
1701
+ function __schemaProjectableFields(def) {
1702
+ const norm = def._normalize();
1703
+ const out = new Map(norm.fields);
1704
+ if (def.kind !== 'model') return out;
1705
+ const col = (name, typeName, required) => {
1706
+ if (!out.has(name)) out.set(name, {
1707
+ name, required: !!required, unique: false, optional: !required,
1708
+ typeName, literals: null, array: false, constraints: null, transform: null,
1709
+ });
1710
+ };
1711
+ col(norm.primaryKey, 'integer', true);
1712
+ if (norm.timestamps) { col('createdAt', 'datetime', true); col('updatedAt', 'datetime', true); }
1713
+ if (norm.softDelete) col('deletedAt', 'datetime', false);
1714
+ for (const [, rel] of norm.relations) {
1715
+ if (rel.kind === 'belongsTo') col(__schemaCamel(rel.foreignKey), 'integer', !rel.optional);
1716
+ }
1717
+ return out;
1718
+ }
1719
+
910
1720
  function __schemaDerive(source, transform) {
911
- const src = source._normalize().fields;
1721
+ // Algebra on a union has no obviously-right semantics (distribute?
1722
+ // intersect?) — deferring is honest. Derive from a constituent.
1723
+ if (source.kind === 'union') {
1724
+ throw new Error('schema algebra (.pick/.omit/.partial/.required/.extend) is not supported on :union — derive from a constituent schema instead');
1725
+ }
1726
+ const src = __schemaProjectableFields(source);
912
1727
  const derivedFields = transform(src);
913
1728
  const entries = [];
914
1729
  for (const [, f] of derivedFields) {
915
1730
  const mods = [];
916
1731
  if (f.required) mods.push('!');
917
- if (f.unique) mods.push('#');
918
1732
  if (f.optional && !f.required) mods.push('?');
919
1733
  entries.push({
920
1734
  tag: 'field', name: f.name, modifiers: mods,
1735
+ unique: f.unique === true,
921
1736
  typeName: f.typeName, array: f.array,
922
1737
  literals: f.literals || null,
1738
+ coerce: f.coerce === true,
1739
+ coercer: f.coercer || null,
923
1740
  constraints: f.constraints,
1741
+ attrs: f.attrs || null,
924
1742
  transform: f.transform || null,
925
1743
  });
926
1744
  }
@@ -986,12 +1804,15 @@ function __schemaExpandMixins(host, fields, directives, ctx) {
986
1804
  fields.set(e.name, {
987
1805
  name: e.name,
988
1806
  required: e.modifiers.includes('!'),
989
- unique: e.modifiers.includes('#'),
1807
+ unique: e.unique === true,
990
1808
  optional: e.modifiers.includes('?'),
991
1809
  typeName: e.typeName,
992
1810
  literals: e.literals || null,
993
1811
  array: e.array === true,
1812
+ coerce: e.coerce === true,
1813
+ coercer: e.coercer || null,
994
1814
  constraints: e.constraints || null,
1815
+ attrs: e.attrs || null,
995
1816
  transform: e.transform || null,
996
1817
  });
997
1818
  }
@@ -1030,20 +1851,91 @@ function __schemaTableName(model) { return __schemaPluralize(__schemaSnake(model
1030
1851
 
1031
1852
  function __schemaFkName(model) { return __schemaSnake(model) + '_id'; }
1032
1853
  `;
1033
- export const SCHEMA_ORM_RUNTIME = `function __schemaDefaultAdapter() {
1034
- const url = (typeof process !== 'undefined' && process.env?.DB_URL) || 'http://localhost:9494';
1854
+ export const SCHEMA_ORM_RUNTIME = `// ---- Adapter (Contract v2) -------------------------------------------------
1855
+ //
1856
+ // \`query(sql, params) → {columns, data, rowCount}\` is the only REQUIRED
1857
+ // method. v2 adds optional capabilities that the runtime feature-detects:
1858
+ //
1859
+ // begin(options?) → TxHandle transactions (schema.transaction!)
1860
+ // TxHandle: { query(sql, params), commit(), rollback() }
1861
+ // capabilities: { tx: true, ... } truthful self-report (informational)
1862
+ //
1863
+ // Calling a feature whose method is absent throws a clear error — never
1864
+ // a silent fallback.
1865
+ //
1866
+ // The default adapter talks to a duckdb-harbor server (or any
1867
+ // /sql-compatible server). Configuration via env:
1868
+ //
1869
+ // RIP_DB_URL base URL (default http://127.0.0.1:9494; legacy DB_URL
1870
+ // honored as a fallback)
1871
+ // RIP_DB_TOKEN bearer token, required when harbor runs authenticated
1872
+ //
1873
+ // Transactions use harbor's session protocol: POST /sql/sessions/new
1874
+ // pins a DB session (connection) so BEGIN/COMMIT survive across
1875
+ // requests; statements carry the sessionId; the session is destroyed
1876
+ // after COMMIT/ROLLBACK. Harbor enforces an idle TTL, so an abandoned
1877
+ // transaction auto-rolls-back server-side.
1878
+ function __schemaDefaultAdapter(overrides) {
1879
+ const env = (typeof process !== 'undefined' && process.env) || {};
1880
+ const base = () => String(
1881
+ overrides?.url || env.RIP_DB_URL || env.DB_URL || 'http://127.0.0.1:9494').replace(/\\/+$/, '');
1882
+ const headers = () => {
1883
+ const h = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
1884
+ const token = overrides?.token || env.RIP_DB_TOKEN;
1885
+ if (token) h['Authorization'] = 'Bearer ' + token;
1886
+ return h;
1887
+ };
1888
+ async function post(path, body) {
1889
+ const res = await fetch(base() + path, {
1890
+ method: 'POST', headers: headers(), body: JSON.stringify(body),
1891
+ });
1892
+ let data;
1893
+ try { data = await res.json(); } catch { data = {}; }
1894
+ if (!res.ok || data.ok === false || data.error) {
1895
+ const err = new Error(data.error || ('db request failed: ' + res.status + ' ' + (res.statusText || '')));
1896
+ if (data.errorCode) err.code = data.errorCode;
1897
+ if (data.errorDetails) err.details = data.errorDetails;
1898
+ err.httpStatus = res.status;
1899
+ throw err;
1900
+ }
1901
+ return data;
1902
+ }
1035
1903
  return {
1036
1904
  async query(sql, params) {
1037
- const body = params && params.length ? { sql, params } : { sql };
1038
- const res = await fetch(url + '/sql', {
1039
- method: 'POST',
1040
- headers: { 'Content-Type': 'application/json' },
1041
- body: JSON.stringify(body),
1042
- });
1043
- const data = await res.json();
1044
- if (data.error) throw new Error(data.error);
1045
- return data;
1046
- }
1905
+ return post('/sql', params && params.length ? { sql, params } : { sql });
1906
+ },
1907
+ async begin(options) {
1908
+ let session;
1909
+ try {
1910
+ session = await post('/sql/sessions/new', {});
1911
+ } catch (e) {
1912
+ if (e && e.httpStatus === 403) {
1913
+ e.message = 'transactions need a harbor DB session, and harbor denied session creation ' +
1914
+ '(authz policy __HARBOR_ADMIN__:sessions:create). Allow it in your harbor_authorization_function ' +
1915
+ 'or set harbor_allow_admin_without_authz = true on a trusted deployment. Original error: ' + e.message;
1916
+ }
1917
+ throw e;
1918
+ }
1919
+ const sessionId = session.sessionId;
1920
+ const run = (sql, params) =>
1921
+ post('/sql', params && params.length ? { sql, params, sessionId } : { sql, sessionId });
1922
+ const drop = async () => {
1923
+ // Best-effort: harbor's idle TTL reaps abandoned sessions, so a
1924
+ // failed DELETE only delays cleanup, never leaks a transaction.
1925
+ try {
1926
+ await fetch(base() + '/sql/sessions/' + sessionId, { method: 'DELETE', headers: headers() });
1927
+ } catch {}
1928
+ };
1929
+ await run('BEGIN');
1930
+ return {
1931
+ query: run,
1932
+ async commit() { await run('COMMIT'); await drop(); },
1933
+ async rollback() {
1934
+ try { await run('ROLLBACK'); } finally { await drop(); }
1935
+ },
1936
+ };
1937
+ },
1938
+ capabilities: { tx: true },
1047
1939
  };
1048
1940
  }
1049
1941
 
@@ -1051,6 +1943,205 @@ let __schemaAdapter = __schemaDefaultAdapter();
1051
1943
 
1052
1944
  function __schemaSetAdapter(a) { __schemaAdapter = a; }
1053
1945
 
1946
+ // Resolve the adapter for a def: its own \`on:\` adapter, else the
1947
+ // process-global one. Migration internals pass def = null → global.
1948
+ function __schemaAdapterFor(def) {
1949
+ return (def && def._adapter) || __schemaAdapter;
1950
+ }
1951
+
1952
+ // Build a NEW adapter value (without installing it globally) — the
1953
+ // counterpart of \`schema :model, on: analytics\`:
1954
+ //
1955
+ // analytics = schema.connect url: env.ANALYTICS_URL, token: env.ANALYTICS_TOKEN
1956
+ // Event = schema :model, on: analytics
1957
+ //
1958
+ // Same duckdb-harbor contract as the default adapter (query + begin +
1959
+ // capabilities), pinned to an explicit URL/token instead of env vars.
1960
+ function __schemaConnect(opts) {
1961
+ const o = typeof opts === 'string' ? { url: opts } : (opts || {});
1962
+ if (!o.url) throw new Error("schema.connect({url, token?}): a url is required");
1963
+ return __schemaDefaultAdapter({ url: o.url, token: o.token });
1964
+ }
1965
+
1966
+ // ---- Transactions ----------------------------------------------------------
1967
+ //
1968
+ // schema.transaction! -> propagates ambiently: every ORM
1969
+ // user = User.create! ... call inside the block routes
1970
+ // Order.create! userId: user.id, ... through the transaction's handle
1971
+ // user via AsyncLocalStorage. Model code
1972
+ // is unchanged inside the block.
1973
+ //
1974
+ // Block throws → ROLLBACK, exception propagates, afterRollback hooks fire.
1975
+ // Block returns → COMMIT, value returned, afterCommit hooks fire.
1976
+ // Nested call → joins the ambient transaction (Active Record's default;
1977
+ // DuckDB has no SAVEPOINT, so nested-as-independent-unit
1978
+ // cannot be honored on the primary backend).
1979
+ //
1980
+ // The ALS instance is created lazily on first use — \`node:async_hooks\`
1981
+ // exists in Bun and Node, and the import never runs in browser bundles
1982
+ // (this fragment is server/migration-only).
1983
+ let __schemaTxALS = null;
1984
+
1985
+ // The ALS store is a Map<adapter, txContext> — each adapter gets its
1986
+ // own slot, so a transaction pinned to one adapter never captures ORM
1987
+ // calls bound to another (cross-adapter atomicity is impossible and
1988
+ // the runtime never pretends otherwise), and an inner transaction on a
1989
+ // second adapter doesn't shadow the outer one.
1990
+ function __schemaTxStore(adapter) {
1991
+ if (!__schemaTxALS) return null;
1992
+ const map = __schemaTxALS.getStore();
1993
+ return (map && map.get(adapter)) || null;
1994
+ }
1995
+
1996
+ // The single SQL funnel. Every ORM-issued statement flows through here:
1997
+ // it resolves the def's adapter, routes to that adapter's ambient
1998
+ // transaction handle when one exists, and translates DB constraint
1999
+ // violations into structured SchemaErrors.
2000
+ async function __schemaRunSQL(def, sql, params) {
2001
+ const adapter = __schemaAdapterFor(def);
2002
+ const tx = __schemaTxStore(adapter);
2003
+ try {
2004
+ return await (tx ? tx.handle.query(sql, params) : adapter.query(sql, params));
2005
+ } catch (e) {
2006
+ throw __schemaTranslateDBError(e, def);
2007
+ }
2008
+ }
2009
+
2010
+ async function __schemaTransaction(optsOrFn, maybeFn) {
2011
+ const fn = typeof optsOrFn === 'function' ? optsOrFn : maybeFn;
2012
+ const opts = typeof optsOrFn === 'function' ? {} : (optsOrFn || {});
2013
+ if (typeof fn !== 'function') {
2014
+ throw new Error('schema.transaction(fn): expected a function (got ' + typeof fn + ')');
2015
+ }
2016
+ // \`on:\` pins the transaction to a specific adapter (per-schema
2017
+ // adapters); default is the process-global one.
2018
+ const adapter = opts.on || __schemaAdapter;
2019
+
2020
+ // Nested transaction on the SAME adapter joins the ambient one — the
2021
+ // inner block's writes commit or roll back with the outer transaction.
2022
+ // A nested transaction on a DIFFERENT adapter is independent.
2023
+ if (__schemaTxStore(adapter)) return fn();
2024
+
2025
+ if (typeof adapter.begin !== 'function') {
2026
+ throw new Error(
2027
+ 'schema.transaction(): the configured adapter does not support transactions ' +
2028
+ '(no begin() method; see Adapter Contract v2). Install an adapter with begin() ' +
2029
+ 'or use the default @rip-lang/db adapter against duckdb-harbor.');
2030
+ }
2031
+ if (!__schemaTxALS) {
2032
+ const { AsyncLocalStorage } = await import('node:async_hooks');
2033
+ __schemaTxALS = new AsyncLocalStorage();
2034
+ }
2035
+
2036
+ const handle = await adapter.begin(opts);
2037
+ // \`after\` collects {def, inst} for every save/destroy that completed
2038
+ // inside the transaction on a model declaring afterCommit/afterRollback.
2039
+ const store = { adapter, handle, after: [] };
2040
+ // Copy-on-run: other adapters' ambient contexts stay visible inside
2041
+ // the block; only this adapter's slot is (re)bound.
2042
+ const nextMap = new Map(__schemaTxALS.getStore() || []);
2043
+ nextMap.set(adapter, store);
2044
+ let result;
2045
+ try {
2046
+ result = await __schemaTxALS.run(nextMap, fn);
2047
+ } catch (err) {
2048
+ try { await handle.rollback(); } catch {}
2049
+ await __schemaFlushTxHooks(store, 'afterRollback');
2050
+ throw err;
2051
+ }
2052
+ await handle.commit();
2053
+ // afterCommit runs OUTSIDE the transaction — this is where emails,
2054
+ // webhooks, and cache invalidation belong (they must never observe
2055
+ // uncommitted state). Exceptions here propagate to the caller but
2056
+ // cannot roll anything back: the COMMIT already happened.
2057
+ await __schemaFlushTxHooks(store, 'afterCommit');
2058
+ return result;
2059
+ }
2060
+
2061
+ async function __schemaFlushTxHooks(store, hookName) {
2062
+ // Dedupe by instance: a row saved twice in one transaction gets one
2063
+ // callback, matching Active Record's after_commit semantics.
2064
+ const seen = new Set();
2065
+ for (const entry of store.after) {
2066
+ if (seen.has(entry.inst)) continue;
2067
+ seen.add(entry.inst);
2068
+ await __schemaRunHook(entry.def, entry.inst, hookName);
2069
+ }
2070
+ }
2071
+
2072
+ // Queue an instance's commit-time hooks on the ambient transaction for
2073
+ // ITS adapter. Returns true when queued; false means "no ambient tx —
2074
+ // fire now".
2075
+ function __schemaEnqueueTxHook(def, inst) {
2076
+ const tx = __schemaTxStore(__schemaAdapterFor(def));
2077
+ if (!tx) return false;
2078
+ tx.after.push({ def, inst });
2079
+ return true;
2080
+ }
2081
+
2082
+ // ---- Constraint-violation translation --------------------------------------
2083
+ //
2084
+ // The ORM wraps every adapter call (via __schemaRunSQL). Errors that are
2085
+ // recognizably DB constraint violations are translated into SchemaError
2086
+ // so a \`save!\` that trips a UNIQUE index fails the same way a \`save!\`
2087
+ // that trips a validator does: structured {field, error, message} issues.
2088
+ // Unrecognized errors propagate untouched. The original error is kept
2089
+ // as \`.cause\` for debugging.
2090
+ //
2091
+ // Recognition is message-pattern based (DuckDB first). Deliberately NOT
2092
+ // added: \`validates_uniqueness_of\`-style pre-checks — they race. The DB
2093
+ // constraint is the check; translation makes it ergonomic.
2094
+ function __schemaTranslateDBError(e, def) {
2095
+ const msg = (e && e.message) || '';
2096
+ const issue = __schemaConstraintIssue(msg);
2097
+ if (!issue) return e;
2098
+ const err = new SchemaError([issue], def ? def.name : null, def ? def.kind : null);
2099
+ err.cause = e;
2100
+ return err;
2101
+ }
2102
+
2103
+ function __schemaConstraintIssue(msg) {
2104
+ let m;
2105
+ // DuckDB: Constraint Error: Duplicate key "email: a@b.c" violates unique constraint ...
2106
+ // (also "violates primary key constraint")
2107
+ m = msg.match(/[Dd]uplicate key "([A-Za-z0-9_]+):[^"]*" violates (?:unique|primary key) constraint/);
2108
+ if (m || /violates unique constraint/i.test(msg)) {
2109
+ const field = m ? __schemaCamel(m[1]) : '';
2110
+ return { field, error: 'unique', message: (field || 'value') + ' already taken' };
2111
+ }
2112
+ // DuckDB: Constraint Error: NOT NULL constraint failed: users.name
2113
+ m = msg.match(/NOT NULL constraint failed:\\s*(?:[A-Za-z0-9_]+\\.)?([A-Za-z0-9_]+)/i);
2114
+ if (m) {
2115
+ const field = __schemaCamel(m[1]);
2116
+ return { field, error: 'required', message: field + ' is required' };
2117
+ }
2118
+ // DuckDB: Constraint Error: Violates foreign key constraint because ... "user_id: 99" ...
2119
+ if (/[Vv]iolates foreign key constraint/.test(msg)) {
2120
+ m = msg.match(/"([A-Za-z0-9_]+):[^"]*"/);
2121
+ const field = m ? __schemaCamel(m[1]) : '';
2122
+ return { field, error: 'reference', message: (field || 'reference') + ' refers to a missing or still-referenced record' };
2123
+ }
2124
+ // DuckDB: Constraint Error: CHECK constraint failed: <table>
2125
+ if (/CHECK constraint failed/i.test(msg)) {
2126
+ return { field: '', error: 'check', message: msg };
2127
+ }
2128
+ return null;
2129
+ }
2130
+
2131
+ // ---- Query builder ----------------------------------------------------------
2132
+
2133
+ // Run a scope body with \`this\` bound to a query builder. \`builder\` is
2134
+ // null when invoked from a model static (User.active()) — a fresh
2135
+ // builder is created; chained scope calls pass the existing builder.
2136
+ // Scopes conventionally return the builder (\`@where\` does), but a body
2137
+ // that returns something else falls back to the builder so chains never
2138
+ // break on a stray trailing expression.
2139
+ function __schemaInvokeScope(def, builder, fn, args) {
2140
+ const q = builder || new __SchemaQuery(def);
2141
+ const out = fn.apply(q, args);
2142
+ return out instanceof __SchemaQuery ? out : q;
2143
+ }
2144
+
1054
2145
  class __SchemaQuery {
1055
2146
  constructor(def, opts = {}) {
1056
2147
  this._def = def;
@@ -1059,8 +2150,27 @@ class __SchemaQuery {
1059
2150
  this._limit = null;
1060
2151
  this._offset = null;
1061
2152
  this._order = null;
1062
- this._includeDeleted = opts.includeDeleted === true;
1063
- }
2153
+ this._includes = [];
2154
+ this._unscoped = false;
2155
+ this._defaultScopeApplied = false;
2156
+ // Soft-delete filter mode: 'live' (default), 'all' (.withDeleted),
2157
+ // 'deleted' (.onlyDeleted). Pre-v2 \`includeDeleted\` option maps to 'all'.
2158
+ this._deleted = opts.includeDeleted === true ? 'all' : 'live';
2159
+ // Per-model scopes install as own methods so chains compose in any
2160
+ // order: User.where(role: 'admin').active().since(d). Builder
2161
+ // method names win on collision (normalize rejects those anyway).
2162
+ const scopes = def._normalize().scopes;
2163
+ if (scopes && scopes.size) {
2164
+ for (const [sname, sfn] of scopes) {
2165
+ if (!(sname in this)) {
2166
+ Object.defineProperty(this, sname, {
2167
+ enumerable: false, configurable: true,
2168
+ value: (...args) => __schemaInvokeScope(def, this, sfn, args),
2169
+ });
2170
+ }
2171
+ }
2172
+ }
2173
+ }
1064
2174
  where(cond, ...params) {
1065
2175
  if (typeof cond === 'string') {
1066
2176
  this._clauses.push(cond);
@@ -1070,6 +2180,9 @@ class __SchemaQuery {
1070
2180
  const col = __schemaSnake(k);
1071
2181
  if (v === null || v === undefined) {
1072
2182
  this._clauses.push('"' + col + '" IS NULL');
2183
+ } else if (Array.isArray(v)) {
2184
+ this._clauses.push('"' + col + '" IN (' + v.map(() => '?').join(', ') + ')');
2185
+ this._params.push(...v);
1073
2186
  } else {
1074
2187
  this._clauses.push('"' + col + '" = ?');
1075
2188
  this._params.push(v);
@@ -1082,12 +2195,34 @@ class __SchemaQuery {
1082
2195
  offset(n) { this._offset = n; return this; }
1083
2196
  order(spec) { this._order = spec; return this; }
1084
2197
  orderBy(spec) { return this.order(spec); }
2198
+ includes(...specs) {
2199
+ this._includes.push(...__schemaNormalizeIncludes(specs));
2200
+ return this;
2201
+ }
2202
+ withDeleted() { this._deleted = 'all'; return this; }
2203
+ onlyDeleted() { this._deleted = 'deleted'; return this; }
2204
+ unscoped() { this._unscoped = true; return this; }
2205
+ // @defaultScope applies lazily at terminal time (all/count/updateAll/
2206
+ // deleteAll) so .unscoped() works no matter where it appears in the
2207
+ // chain, and so the default's clauses never double-apply.
2208
+ _applyDefaultScope() {
2209
+ if (this._unscoped || this._defaultScopeApplied) return;
2210
+ this._defaultScopeApplied = true;
2211
+ const fn = this._def._normalize().defaultScope;
2212
+ if (fn) fn.call(this);
2213
+ }
2214
+ _whereParts(norm) {
2215
+ const where = [...this._clauses];
2216
+ if (norm.softDelete) {
2217
+ if (this._deleted === 'live') where.push('"deleted_at" IS NULL');
2218
+ else if (this._deleted === 'deleted') where.push('"deleted_at" IS NOT NULL');
2219
+ }
2220
+ return where;
2221
+ }
1085
2222
  _buildSQL() {
1086
2223
  const n = this._def._normalize();
1087
- const table = n.tableName;
1088
- const parts = ['SELECT * FROM "' + table + '"'];
1089
- const where = [...this._clauses];
1090
- if (!this._includeDeleted && n.softDelete) where.push('"deleted_at" IS NULL');
2224
+ const parts = ['SELECT * FROM "' + n.tableName + '"'];
2225
+ const where = this._whereParts(n);
1091
2226
  if (where.length) parts.push('WHERE ' + where.join(' AND '));
1092
2227
  if (this._order) parts.push('ORDER BY ' + this._order);
1093
2228
  if (this._limit != null) parts.push('LIMIT ' + this._limit);
@@ -1095,9 +2230,16 @@ class __SchemaQuery {
1095
2230
  return parts.join(' ');
1096
2231
  }
1097
2232
  async all() {
2233
+ this._applyDefaultScope();
1098
2234
  const sql = this._buildSQL();
1099
- const res = await __schemaAdapter.query(sql, this._params);
1100
- return (res.data || []).map(row => this._def._hydrate(res.columns, row));
2235
+ const res = await __schemaRunSQL(this._def, sql, this._params);
2236
+ const instances = (res.data || []).map(row => this._def._hydrate(res.columns, row));
2237
+ // Eager loading: batched second queries (WHERE fk IN (...)) that
2238
+ // fill the relation memos. Never changes the root result set.
2239
+ if (this._includes.length && instances.length) {
2240
+ await __schemaPreload(this._def, instances, this._includes);
2241
+ }
2242
+ return instances;
1101
2243
  }
1102
2244
  async first() {
1103
2245
  this._limit = 1;
@@ -1105,14 +2247,136 @@ class __SchemaQuery {
1105
2247
  return arr[0] || null;
1106
2248
  }
1107
2249
  async count() {
2250
+ this._applyDefaultScope();
1108
2251
  const n = this._def._normalize();
1109
2252
  const parts = ['SELECT COUNT(*) FROM "' + n.tableName + '"'];
1110
- const where = [...this._clauses];
1111
- if (!this._includeDeleted && n.softDelete) where.push('"deleted_at" IS NULL');
2253
+ const where = this._whereParts(n);
1112
2254
  if (where.length) parts.push('WHERE ' + where.join(' AND '));
1113
- const res = await __schemaAdapter.query(parts.join(' '), this._params);
2255
+ const res = await __schemaRunSQL(this._def, parts.join(' '), this._params);
1114
2256
  return res.data?.[0]?.[0] || 0;
1115
2257
  }
2258
+ // One UPDATE statement for every matching row. Bypasses validation
2259
+ // and per-instance hooks — the name says "all", the docs say "raw".
2260
+ // Returns the adapter's reported row count when available.
2261
+ async updateAll(values) {
2262
+ this._applyDefaultScope();
2263
+ const n = this._def._normalize();
2264
+ const keys = values && typeof values === 'object' ? Object.keys(values) : [];
2265
+ if (!keys.length) throw new Error('updateAll: requires at least one column to set');
2266
+ const sets = [];
2267
+ const params = [];
2268
+ for (const k of keys) {
2269
+ const name = __schemaCamel(k);
2270
+ const field = n.fields.get(name);
2271
+ sets.push('"' + __schemaSnake(k) + '" = ?');
2272
+ params.push(__schemaSerialize(values[k], field));
2273
+ }
2274
+ if (n.timestamps) {
2275
+ sets.push('"updated_at" = ?');
2276
+ params.push(new Date().toISOString());
2277
+ }
2278
+ const where = this._whereParts(n);
2279
+ let sql = 'UPDATE "' + n.tableName + '" SET ' + sets.join(', ');
2280
+ if (where.length) sql += ' WHERE ' + where.join(' AND ');
2281
+ const res = await __schemaRunSQL(this._def, sql, [...params, ...this._params]);
2282
+ return res.rowCount ?? res.rows ?? null;
2283
+ }
2284
+ // One statement for every matching row. Soft-delete aware: on a
2285
+ // @softDelete model this is an UPDATE setting deleted_at; on a hard
2286
+ // model it's a real DELETE. Bypasses per-instance hooks (bulk path).
2287
+ async deleteAll() {
2288
+ this._applyDefaultScope();
2289
+ const n = this._def._normalize();
2290
+ const where = this._whereParts(n);
2291
+ let sql, params;
2292
+ if (n.softDelete && this._deleted === 'live') {
2293
+ sql = 'UPDATE "' + n.tableName + '" SET "deleted_at" = ?';
2294
+ params = [new Date().toISOString(), ...this._params];
2295
+ } else {
2296
+ sql = 'DELETE FROM "' + n.tableName + '"';
2297
+ params = this._params;
2298
+ }
2299
+ if (where.length) sql += ' WHERE ' + where.join(' AND ');
2300
+ const res = await __schemaRunSQL(this._def, sql, params);
2301
+ return res.rowCount ?? res.rows ?? null;
2302
+ }
2303
+ }
2304
+
2305
+ // ---- Eager loading ----------------------------------------------------------
2306
+
2307
+ // Normalize .includes arguments into [{name, children}] trees. Accepts
2308
+ // :symbols, strings, arrays, and nested maps to any depth:
2309
+ // .includes(:orders)
2310
+ // .includes(:author, comments: :author)
2311
+ function __schemaNormalizeIncludes(specs) {
2312
+ const out = [];
2313
+ for (const s of specs) {
2314
+ if (s == null) continue;
2315
+ if (typeof s === 'symbol') out.push({ name: Symbol.keyFor(s) || s.description, children: [] });
2316
+ else if (typeof s === 'string') out.push({ name: s, children: [] });
2317
+ else if (Array.isArray(s)) out.push(...__schemaNormalizeIncludes(s));
2318
+ else if (typeof s === 'object') {
2319
+ for (const [k, v] of Object.entries(s)) {
2320
+ out.push({ name: k, children: __schemaNormalizeIncludes([v]) });
2321
+ }
2322
+ }
2323
+ }
2324
+ return out;
2325
+ }
2326
+
2327
+ // Batched preload: one query per relation per nesting level
2328
+ // (\`WHERE fk IN (?, …)\`), never JOINs — no row duplication, uniform
2329
+ // across belongs_to / has_one / has_many. Results land in the relation
2330
+ // memo, so \`user.orders!\` resolves from cache with no query. Invisible
2331
+ // to call sites: preloading is purely a performance fact.
2332
+ async function __schemaPreload(def, instances, specs) {
2333
+ if (!instances.length || !specs.length) return;
2334
+ const norm = def._normalize();
2335
+ for (const spec of specs) {
2336
+ const rel = norm.relations.get(spec.name);
2337
+ if (!rel) {
2338
+ throw new Error(
2339
+ "schema: includes('" + spec.name + "') — no such relation on " + (def.name || 'model') +
2340
+ '. Declared relations: ' + ([...norm.relations.keys()].join(', ') || '(none)'));
2341
+ }
2342
+ const target = __SchemaRegistry.get(rel.target);
2343
+ if (!target) throw new Error('schema: unknown relation target "' + rel.target + '" from ' + (def.name || 'anon'));
2344
+ const children = [];
2345
+ if (rel.kind === 'belongsTo') {
2346
+ const fkCamel = __schemaCamel(rel.foreignKey);
2347
+ const ids = [...new Set(instances.map(i => i[fkCamel]).filter(v => v != null))];
2348
+ const rows = ids.length ? await target.findMany(ids) : [];
2349
+ const pk = target._normalize().primaryKey;
2350
+ const byId = new Map(rows.map(r => [r[pk], r]));
2351
+ for (const inst of instances) {
2352
+ const v = inst[fkCamel] != null ? (byId.get(inst[fkCamel]) ?? null) : null;
2353
+ __schemaRelMemoSet(inst, spec.name, v);
2354
+ if (v && !children.includes(v)) children.push(v);
2355
+ }
2356
+ } else {
2357
+ const pk = norm.primaryKey;
2358
+ const fkCamel = __schemaCamel(rel.foreignKey);
2359
+ const ids = [...new Set(instances.map(i => i[pk]).filter(v => v != null))];
2360
+ let rows = [];
2361
+ if (ids.length) {
2362
+ rows = await new __SchemaQuery(target)
2363
+ .where('"' + rel.foreignKey + '" IN (' + ids.map(() => '?').join(', ') + ')', ...ids)
2364
+ .all();
2365
+ }
2366
+ const groups = new Map();
2367
+ for (const r of rows) {
2368
+ const k = r[fkCamel];
2369
+ if (!groups.has(k)) groups.set(k, []);
2370
+ groups.get(k).push(r);
2371
+ children.push(r);
2372
+ }
2373
+ for (const inst of instances) {
2374
+ const g = groups.get(inst[pk]) || [];
2375
+ __schemaRelMemoSet(inst, spec.name, rel.kind === 'hasOne' ? (g[0] ?? null) : g);
2376
+ }
2377
+ }
2378
+ if (spec.children.length) await __schemaPreload(target, children, spec.children);
2379
+ }
1116
2380
  }
1117
2381
 
1118
2382
  async function __schemaResolveRelation(def, inst, rel) {
@@ -1132,11 +2396,26 @@ async function __schemaResolveRelation(def, inst, rel) {
1132
2396
  return null;
1133
2397
  }
1134
2398
 
2399
+ // ---- Save / destroy ----------------------------------------------------------
2400
+
1135
2401
  async function __schemaRunHook(def, inst, name) {
1136
2402
  const fn = def._normalize().hooks.get(name);
1137
2403
  if (fn) await fn.call(inst);
1138
2404
  }
1139
2405
 
2406
+ // After a successful save/destroy: queue afterCommit/afterRollback on
2407
+ // the ambient transaction, or fire afterCommit immediately when no
2408
+ // transaction is open (AR semantics: outside a tx, "commit" is the
2409
+ // statement itself). Only models that declare one of the two hooks pay
2410
+ // any cost here.
2411
+ async function __schemaSettleTxHooks(def, inst) {
2412
+ const hooks = def._normalize().hooks;
2413
+ if (!hooks.has('afterCommit') && !hooks.has('afterRollback')) return;
2414
+ if (!__schemaEnqueueTxHook(def, inst)) {
2415
+ await __schemaRunHook(def, inst, 'afterCommit');
2416
+ }
2417
+ }
2418
+
1140
2419
  async function __schemaSave(def, inst) {
1141
2420
  // Re-entry guard. Same-instance re-entry into save() — typically a
1142
2421
  // hook on this very instance calling .save() on \`this\` — would race
@@ -1199,24 +2478,9 @@ async function __schemaSave(def, inst) {
1199
2478
  }
1200
2479
  }
1201
2480
  const sql = 'INSERT INTO "' + norm.tableName + '" (' + cols.join(', ') + ') VALUES (' + placeholders.join(', ') + ') RETURNING *';
1202
- const res = await __schemaAdapter.query(sql, values);
2481
+ const res = await __schemaRunSQL(def, sql, values);
1203
2482
  if (res.data?.[0] && res.columns) {
1204
- for (let i = 0; i < res.columns.length; i++) {
1205
- const snake = res.columns[i].name;
1206
- const key = __schemaCamel(snake);
1207
- if (!(key in inst)) {
1208
- Object.defineProperty(inst, key, { value: res.data[0][i], enumerable: true, writable: true, configurable: true });
1209
- } else {
1210
- inst[key] = res.data[0][i];
1211
- }
1212
- if (snake !== key && !(snake in inst)) {
1213
- Object.defineProperty(inst, snake, {
1214
- enumerable: false, configurable: true,
1215
- get() { return this[key]; },
1216
- set(v) { this[key] = v; },
1217
- });
1218
- }
1219
- }
2483
+ __schemaAbsorbRow(inst, res.columns, res.data[0]);
1220
2484
  }
1221
2485
  // Now that the RETURNING columns (id, @timestamps, FKs) are on the
1222
2486
  // instance, !> eager-derived fields can see them. Mirrors the hydrate
@@ -1364,7 +2628,7 @@ async function __schemaSave(def, inst) {
1364
2628
  }
1365
2629
  values.push(wherePk);
1366
2630
  const sql = 'UPDATE "' + norm.tableName + '" SET ' + sets.join(', ') + ' WHERE "' + pk + '" = ?';
1367
- await __schemaAdapter.query(sql, values);
2631
+ await __schemaRunSQL(def, sql, values);
1368
2632
  inst._snapshot = nextSnap;
1369
2633
  }
1370
2634
  }
@@ -1373,6 +2637,7 @@ async function __schemaSave(def, inst) {
1373
2637
  if (isNew) await __schemaRunHook(def, inst, 'afterCreate');
1374
2638
  else await __schemaRunHook(def, inst, 'afterUpdate');
1375
2639
  await __schemaRunHook(def, inst, 'afterSave');
2640
+ await __schemaSettleTxHooks(def, inst);
1376
2641
  return inst;
1377
2642
 
1378
2643
  } finally {
@@ -1380,19 +2645,59 @@ async function __schemaSave(def, inst) {
1380
2645
  }
1381
2646
  }
1382
2647
 
1383
- async function __schemaDestroy(def, inst) {
2648
+ // Absorb a RETURNING row onto an instance: camelCase canonical own
2649
+ // properties plus non-enumerable snake_case aliases. Shared by the
2650
+ // INSERT path, upsert, and restore.
2651
+ function __schemaAbsorbRow(inst, columns, row) {
2652
+ for (let i = 0; i < columns.length; i++) {
2653
+ const snake = columns[i].name;
2654
+ const key = __schemaCamel(snake);
2655
+ if (!(key in inst)) {
2656
+ Object.defineProperty(inst, key, { value: row[i], enumerable: true, writable: true, configurable: true });
2657
+ } else {
2658
+ inst[key] = row[i];
2659
+ }
2660
+ if (snake !== key && !(snake in inst)) {
2661
+ Object.defineProperty(inst, snake, {
2662
+ enumerable: false, configurable: true,
2663
+ get() { return this[key]; },
2664
+ set(v) { this[key] = v; },
2665
+ });
2666
+ }
2667
+ }
2668
+ }
2669
+
2670
+ async function __schemaDestroy(def, inst, opts) {
1384
2671
  if (!inst._persisted) return inst;
1385
2672
  const norm = def._normalize();
2673
+ const hard = opts && opts.hard === true;
1386
2674
  await __schemaRunHook(def, inst, 'beforeDestroy');
1387
- if (norm.softDelete) {
2675
+ if (norm.softDelete && !hard) {
1388
2676
  const now = new Date().toISOString();
1389
- await __schemaAdapter.query('UPDATE "' + norm.tableName + '" SET "deleted_at" = ? WHERE "' + norm.primaryKey + '" = ?', [now, inst[norm.primaryKey]]);
2677
+ await __schemaRunSQL(def, 'UPDATE "' + norm.tableName + '" SET "deleted_at" = ? WHERE "' + norm.primaryKey + '" = ?', [now, inst[norm.primaryKey]]);
1390
2678
  inst.deletedAt = now;
1391
2679
  } else {
1392
- await __schemaAdapter.query('DELETE FROM "' + norm.tableName + '" WHERE "' + norm.primaryKey + '" = ?', [inst[norm.primaryKey]]);
2680
+ await __schemaRunSQL(def, 'DELETE FROM "' + norm.tableName + '" WHERE "' + norm.primaryKey + '" = ?', [inst[norm.primaryKey]]);
1393
2681
  inst._persisted = false;
1394
2682
  }
1395
2683
  await __schemaRunHook(def, inst, 'afterDestroy');
2684
+ await __schemaSettleTxHooks(def, inst);
2685
+ return inst;
2686
+ }
2687
+
2688
+ // Soft-delete recovery: UPDATE ... SET deleted_at = NULL. Fires the
2689
+ // update lifecycle (beforeUpdate/afterUpdate) like Active Record's
2690
+ // touch-style writes. Only meaningful on @softDelete models.
2691
+ async function __schemaRestore(def, inst) {
2692
+ const norm = def._normalize();
2693
+ if (!norm.softDelete) {
2694
+ throw new Error('schema: restore() requires @softDelete on ' + (def.name || 'model'));
2695
+ }
2696
+ if (!inst._persisted) return inst;
2697
+ await __schemaRunHook(def, inst, 'beforeUpdate');
2698
+ await __schemaRunSQL(def, 'UPDATE "' + norm.tableName + '" SET "deleted_at" = NULL WHERE "' + norm.primaryKey + '" = ?', [inst[norm.primaryKey]]);
2699
+ inst.deletedAt = null;
2700
+ await __schemaRunHook(def, inst, 'afterUpdate');
1396
2701
  return inst;
1397
2702
  }
1398
2703
 
@@ -1403,20 +2708,27 @@ function __schemaSerialize(v, field) {
1403
2708
  return v;
1404
2709
  }
1405
2710
 
1406
- // ORM prototype augmentations — added to __SchemaDef
2711
+ // ---- ORM prototype augmentations — added to __SchemaDef ----------------------
1407
2712
 
1408
2713
  __SchemaDef.prototype.find = async function (id) {
1409
2714
  this._assertModel('find');
2715
+ // Routed through the builder so find honors the same filters as every
2716
+ // other read: the @softDelete \`deleted_at IS NULL\` filter and the
2717
+ // model's @defaultScope (Active Record semantics — default_scope
2718
+ // applies to find). \`User.unscoped().where(id: …).first!\` is the
2719
+ // escape hatch.
1410
2720
  const norm = this._normalize();
1411
- const soft = norm.softDelete ? ' AND "deleted_at" IS NULL' : '';
1412
- const sql = 'SELECT * FROM "' + norm.tableName + '" WHERE "' + norm.primaryKey + '" = ?' + soft + ' LIMIT 1';
1413
- const res = await __schemaAdapter.query(sql, [id]);
1414
- // Harbor returns rowCount (not the legacy \`rows\` alias). Treat both
1415
- // as authoritative so the runtime works against any /sql adapter
1416
- // that has a row-count field, regardless of which name it uses.
1417
- const n = res.rowCount ?? res.rows;
1418
- if (!n || !res.data?.[0]) return null;
1419
- return this._hydrate(res.columns, res.data[0]);
2721
+ return new __SchemaQuery(this).where({ [norm.primaryKey]: id }).first();
2722
+ };
2723
+
2724
+ __SchemaDef.prototype.findMany = async function (ids) {
2725
+ this._assertModel('findMany');
2726
+ if (!Array.isArray(ids)) throw new Error('schema: findMany(ids) expects an array');
2727
+ if (!ids.length) return [];
2728
+ const norm = this._normalize();
2729
+ return new __SchemaQuery(this)
2730
+ .where('"' + norm.primaryKey + '" IN (' + ids.map(() => '?').join(', ') + ')', ...ids)
2731
+ .all();
1420
2732
  };
1421
2733
 
1422
2734
  __SchemaDef.prototype.where = function (cond, ...params) {
@@ -1424,6 +2736,26 @@ __SchemaDef.prototype.where = function (cond, ...params) {
1424
2736
  return new __SchemaQuery(this).where(cond, ...params);
1425
2737
  };
1426
2738
 
2739
+ __SchemaDef.prototype.includes = function (...specs) {
2740
+ this._assertModel('includes');
2741
+ return new __SchemaQuery(this).includes(...specs);
2742
+ };
2743
+
2744
+ __SchemaDef.prototype.withDeleted = function () {
2745
+ this._assertModel('withDeleted');
2746
+ return new __SchemaQuery(this).withDeleted();
2747
+ };
2748
+
2749
+ __SchemaDef.prototype.onlyDeleted = function () {
2750
+ this._assertModel('onlyDeleted');
2751
+ return new __SchemaQuery(this).onlyDeleted();
2752
+ };
2753
+
2754
+ __SchemaDef.prototype.unscoped = function () {
2755
+ this._assertModel('unscoped');
2756
+ return new __SchemaQuery(this).unscoped();
2757
+ };
2758
+
1427
2759
  __SchemaDef.prototype.all = function () {
1428
2760
  this._assertModel('all');
1429
2761
  return new __SchemaQuery(this).all();
@@ -1462,6 +2794,139 @@ __SchemaDef.prototype.create = async function (data) {
1462
2794
  return inst;
1463
2795
  };
1464
2796
 
2797
+ // INSERT ... ON CONFLICT (target) DO UPDATE SET ... RETURNING *.
2798
+ //
2799
+ // User.upsert! {email: "a@b.c", name: "Alice"}, on: :email
2800
+ //
2801
+ // Validates the row and fires beforeValidation / beforeSave / afterSave.
2802
+ // beforeCreate/beforeUpdate do NOT fire — the runtime cannot know which
2803
+ // branch the database took. The conflict target accepts a :symbol,
2804
+ // string, or array of either (composite targets).
2805
+ __SchemaDef.prototype.upsert = async function (data, opts) {
2806
+ this._assertModel('upsert');
2807
+ const norm = this._normalize();
2808
+ const on = opts && (opts.on ?? opts.conflict);
2809
+ if (on == null) throw new Error("schema: upsert(data, on: :column) requires a conflict target");
2810
+ const targets = (Array.isArray(on) ? on : [on]).map(t =>
2811
+ __schemaSnake(typeof t === 'symbol' ? (Symbol.keyFor(t) || t.description) : String(t)));
2812
+
2813
+ const klass = this._getClass();
2814
+ const canonical = {};
2815
+ if (data && typeof data === 'object') {
2816
+ for (const k of Object.keys(data)) canonical[__schemaCamel(k)] = data[k];
2817
+ }
2818
+ const inst = new klass(this._applyDefaults(canonical), false);
2819
+ for (const [k, v] of Object.entries(canonical)) {
2820
+ if (!(k in inst)) {
2821
+ Object.defineProperty(inst, k, { value: v, enumerable: true, writable: true, configurable: true });
2822
+ }
2823
+ }
2824
+
2825
+ await __schemaRunHook(this, inst, 'beforeValidation');
2826
+ const errs = this._validateFields(inst, true);
2827
+ if (errs.length) throw new SchemaError(errs, this.name, this.kind);
2828
+ await __schemaRunHook(this, inst, 'afterValidation');
2829
+ await __schemaRunHook(this, inst, 'beforeSave');
2830
+
2831
+ const cols = [], placeholders = [], values = [];
2832
+ for (const [n, f] of norm.fields) {
2833
+ const v = inst[n];
2834
+ if (v == null) continue;
2835
+ cols.push(__schemaSnake(n));
2836
+ placeholders.push('?');
2837
+ values.push(__schemaSerialize(v, f));
2838
+ }
2839
+ for (const [, rel] of norm.relations) {
2840
+ if (rel.kind !== 'belongsTo') continue;
2841
+ const v = inst[__schemaCamel(rel.foreignKey)];
2842
+ if (v != null) {
2843
+ cols.push(rel.foreignKey);
2844
+ placeholders.push('?');
2845
+ values.push(v);
2846
+ }
2847
+ }
2848
+ if (!cols.length) throw new Error('schema: upsert() requires at least one column');
2849
+ const updateCols = cols.filter(c => !targets.includes(c));
2850
+ let conflict = ' ON CONFLICT (' + targets.map(t => '"' + t + '"').join(', ') + ')';
2851
+ if (updateCols.length) {
2852
+ const sets = updateCols.map(c => '"' + c + '" = EXCLUDED."' + c + '"');
2853
+ if (norm.timestamps) sets.push('"updated_at" = CURRENT_TIMESTAMP');
2854
+ conflict += ' DO UPDATE SET ' + sets.join(', ');
2855
+ } else {
2856
+ conflict += ' DO NOTHING';
2857
+ }
2858
+ const sql = 'INSERT INTO "' + norm.tableName + '" (' + cols.map(c => '"' + c + '"').join(', ') + ')' +
2859
+ ' VALUES (' + placeholders.join(', ') + ')' + conflict + ' RETURNING *';
2860
+ const res = await __schemaRunSQL(this, sql, values);
2861
+ if (res.data?.[0] && res.columns) __schemaAbsorbRow(inst, res.columns, res.data[0]);
2862
+ this._applyEagerDerived(inst);
2863
+ inst._snapshot = __schemaSnapshot(norm, inst);
2864
+ inst._persisted = true;
2865
+ await __schemaRunHook(this, inst, 'afterSave');
2866
+ await __schemaSettleTxHooks(this, inst);
2867
+ return inst;
2868
+ };
2869
+
2870
+ // Bulk insert: validates EVERY row first (collecting all failures into
2871
+ // one SchemaError, issues prefixed \`[i].field\`, before any SQL), then
2872
+ // issues one multi-VALUES INSERT ... RETURNING *. Per-instance hooks
2873
+ // are deliberately skipped — this is the bulk path; use create! in a
2874
+ // loop when hooks matter. Returns hydrated instances.
2875
+ __SchemaDef.prototype.insertMany = async function (rows) {
2876
+ this._assertModel('insertMany');
2877
+ if (!Array.isArray(rows)) throw new Error('schema: insertMany(rows) expects an array');
2878
+ if (!rows.length) return [];
2879
+ const norm = this._normalize();
2880
+
2881
+ const canonicalRows = [];
2882
+ const allErrs = [];
2883
+ for (let i = 0; i < rows.length; i++) {
2884
+ const canonical = {};
2885
+ const data = rows[i];
2886
+ if (data && typeof data === 'object') {
2887
+ for (const k of Object.keys(data)) canonical[__schemaCamel(k)] = data[k];
2888
+ }
2889
+ this._applyDefaults(canonical);
2890
+ for (const e of this._validateFields(canonical, true)) {
2891
+ allErrs.push({
2892
+ field: '[' + i + ']' + (e.field ? '.' + e.field : ''),
2893
+ error: e.error,
2894
+ message: '[' + i + '] ' + e.message,
2895
+ });
2896
+ }
2897
+ canonicalRows.push(canonical);
2898
+ }
2899
+ if (allErrs.length) throw new SchemaError(allErrs, this.name, this.kind);
2900
+
2901
+ // Column set = union of written columns across rows (missing values
2902
+ // insert as NULL / column default).
2903
+ const colSet = new Set();
2904
+ for (const row of canonicalRows) {
2905
+ for (const [n] of norm.fields) if (row[n] != null) colSet.add(n);
2906
+ for (const [, rel] of norm.relations) {
2907
+ if (rel.kind !== 'belongsTo') continue;
2908
+ if (row[__schemaCamel(rel.foreignKey)] != null) colSet.add(__schemaCamel(rel.foreignKey));
2909
+ }
2910
+ }
2911
+ const colNames = [...colSet];
2912
+ if (!colNames.length) throw new Error('schema: insertMany() requires at least one column');
2913
+ const values = [];
2914
+ const tuples = [];
2915
+ for (const row of canonicalRows) {
2916
+ const slots = [];
2917
+ for (const n of colNames) {
2918
+ slots.push('?');
2919
+ values.push(__schemaSerialize(row[n] ?? null, norm.fields.get(n)));
2920
+ }
2921
+ tuples.push('(' + slots.join(', ') + ')');
2922
+ }
2923
+ const sql = 'INSERT INTO "' + norm.tableName + '" (' +
2924
+ colNames.map(n => '"' + __schemaSnake(n) + '"').join(', ') + ') VALUES ' +
2925
+ tuples.join(', ') + ' RETURNING *';
2926
+ const res = await __schemaRunSQL(this, sql, values);
2927
+ return (res.data || []).map(row => this._hydrate(res.columns, row));
2928
+ };
2929
+
1465
2930
  __SchemaDef.prototype._assertModel = function (api) {
1466
2931
  if (this.kind !== 'model') {
1467
2932
  throw new Error('schema: .' + api + '() is :model-only (got :' + this.kind + ')');
@@ -1480,21 +2945,53 @@ export const SCHEMA_DDL_RUNTIME = `const __SCHEMA_SQL_TYPES = {
1480
2945
  url: 'VARCHAR', uuid: 'UUID', phone: 'VARCHAR', zip: 'VARCHAR', json: 'JSON', any: 'JSON',
1481
2946
  };
1482
2947
 
1483
- function __schemaToSQL(def, options) {
1484
- const opts = options || {};
1485
- const { dropFirst = false, header } = opts;
1486
- const norm = def._normalize();
1487
- const blocks = [];
1488
- if (header) blocks.push(header);
2948
+ // ---- Canonical table spec ----------------------------------------------------
2949
+ //
2950
+ // The DDL emitter's internal model, exposed (roadmap §2.2). One structure
2951
+ // serves both directions: \`_tableSpec()\` builds it from Layer 2 metadata,
2952
+ // \`__schemaIntrospect()\` (migrate fragment) builds the same shape from the
2953
+ // deployed database, and the differ operates on two values of the same type.
2954
+ //
2955
+ // {
2956
+ // name, // table name
2957
+ // sequence: { name, start } | null, // auto-id sequence
2958
+ // primaryKey, // pk column name
2959
+ // columns: [{ name, type, notNull, unique, default, primary?, was? }],
2960
+ // indexes: [{ name, columns: [..], unique }],
2961
+ // foreignKeys: [{ column, refTable, refColumn }],
2962
+ // tableWas, // rename annotation or null
2963
+ // }
2964
+ //
2965
+ // \`type\` is the RENDER type (VARCHAR(100) keeps its length hint); the
2966
+ // differ compares via __schemaTypeKey, which normalizes away parts DuckDB
2967
+ // doesn't persist. \`default\` is the rendered SQL default string or null.
1489
2968
 
2969
+ function __schemaColumnSpec(name, field) {
2970
+ let base = __SCHEMA_SQL_TYPES[field.typeName] || 'VARCHAR';
2971
+ if (field.array) base = 'JSON';
2972
+ if (base === 'VARCHAR' && field.constraints?.max != null) {
2973
+ base = 'VARCHAR(' + field.constraints.max + ')';
2974
+ }
2975
+ return {
2976
+ name: __schemaSnake(name),
2977
+ type: base,
2978
+ notNull: field.required === true,
2979
+ unique: field.unique === true,
2980
+ default: field.constraints?.default !== undefined
2981
+ ? __schemaSQLDefault(field.constraints.default) : null,
2982
+ was: field.attrs?.was || null,
2983
+ };
2984
+ }
2985
+
2986
+ __SchemaDef.prototype._tableSpec = function (options) {
2987
+ this._assertModel('_tableSpec');
2988
+ const opts = options || {};
2989
+ const norm = this._normalize();
1490
2990
  const table = norm.tableName;
1491
2991
  const seq = table + '_seq';
1492
- if (dropFirst) {
1493
- blocks.push('DROP TABLE IF EXISTS ' + table + ' CASCADE;\\nDROP SEQUENCE IF EXISTS ' + seq + ';');
1494
- }
1495
2992
 
1496
2993
  // Sequence seed: explicit option wins over @idStart directive wins over 1.
1497
- // DuckDB 1.5.2 does not implement ALTER SEQUENCE ... RESTART WITH N, so the
2994
+ // DuckDB 1.5.x does not implement ALTER SEQUENCE ... RESTART WITH N, so the
1498
2995
  // baseline has to be set at creation — hence the knob lives here, not in a
1499
2996
  // post-create migration.
1500
2997
  let idStart = 1;
@@ -1511,61 +3008,146 @@ function __schemaToSQL(def, options) {
1511
3008
  }
1512
3009
 
1513
3010
  const columns = [];
1514
- const indexes = [];
1515
- columns.push(' ' + norm.primaryKey + " INTEGER PRIMARY KEY DEFAULT nextval('" + seq + "')");
1516
-
3011
+ columns.push({
3012
+ name: norm.primaryKey, type: 'INTEGER',
3013
+ notNull: true, unique: false, primary: true,
3014
+ default: "nextval('" + seq + "')", was: null,
3015
+ });
1517
3016
  for (const [n, f] of norm.fields) {
1518
- columns.push(__schemaColumnDDL(n, f));
1519
- if (f.unique) {
1520
- indexes.push('CREATE UNIQUE INDEX idx_' + table + '_' + __schemaSnake(n) + ' ON ' + table + ' ("' + __schemaSnake(n) + '");');
1521
- }
3017
+ columns.push(__schemaColumnSpec(n, f));
1522
3018
  }
1523
3019
 
3020
+ const foreignKeys = [];
3021
+ const notes = [];
1524
3022
  for (const [, rel] of norm.relations) {
1525
3023
  if (rel.kind !== 'belongsTo') continue;
1526
- const refTable = __schemaTableName(rel.target);
1527
- const notNull = rel.optional ? '' : ' NOT NULL';
1528
- columns.push(' ' + rel.foreignKey + ' INTEGER' + notNull + ' REFERENCES ' + refTable + '(id)');
3024
+ columns.push({
3025
+ name: rel.foreignKey, type: 'INTEGER',
3026
+ notNull: !rel.optional, unique: false, default: null, was: null,
3027
+ });
3028
+ // A relation whose target lives on a DIFFERENT adapter cannot carry
3029
+ // a database FK constraint — the referenced table is in another
3030
+ // database. The accessor still works (it's just a second query);
3031
+ // the DDL suppresses the constraint with a note.
3032
+ const targetDef = __SchemaRegistry.get(rel.target);
3033
+ const crossAdapter = targetDef &&
3034
+ (targetDef._adapter || null) !== (this._adapter || null);
3035
+ if (crossAdapter) {
3036
+ notes.push('-- NOTE: ' + rel.foreignKey + ' references ' + __schemaTableName(rel.target) +
3037
+ '(id) on a different adapter; FK constraint suppressed (cross-database constraints are impossible)');
3038
+ continue;
3039
+ }
3040
+ foreignKeys.push({
3041
+ column: rel.foreignKey,
3042
+ refTable: __schemaTableName(rel.target),
3043
+ refColumn: 'id',
3044
+ });
1529
3045
  }
1530
3046
 
1531
3047
  if (norm.timestamps) {
1532
- columns.push(' created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP');
1533
- columns.push(' updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP');
3048
+ columns.push({ name: 'created_at', type: 'TIMESTAMP', notNull: false, unique: false, default: 'CURRENT_TIMESTAMP', was: null });
3049
+ columns.push({ name: 'updated_at', type: 'TIMESTAMP', notNull: false, unique: false, default: 'CURRENT_TIMESTAMP', was: null });
1534
3050
  }
1535
3051
  if (norm.softDelete) {
1536
- columns.push(' deleted_at TIMESTAMP');
3052
+ columns.push({ name: 'deleted_at', type: 'TIMESTAMP', notNull: false, unique: false, default: null, was: null });
1537
3053
  }
1538
3054
 
1539
- // @index directives
3055
+ // Index names are derived from their column set (\`idx_<table>_<cols>\`),
3056
+ // so two declarations on the same columns collide. That's always a
3057
+ // redundant/contradictory schema (a \`@unique\` index already serves as an
3058
+ // index for those columns) — reject it loudly rather than emit duplicate
3059
+ // CREATE INDEX statements.
3060
+ const indexes = [];
3061
+ const indexByName = new Map();
3062
+ const addIndex = (ix) => {
3063
+ if (indexByName.has(ix.name)) {
3064
+ throw new Error(
3065
+ \`Table '\${table}': duplicate index '\${ix.name}' on (\${ix.columns.join(', ')}). \` +
3066
+ \`Those columns are declared unique/indexed more than once — a '@unique' already \` +
3067
+ \`creates an index, so remove the redundant '@unique'/'@index' declaration.\`);
3068
+ }
3069
+ indexByName.set(ix.name, ix);
3070
+ indexes.push(ix);
3071
+ };
3072
+ for (const [n, f] of norm.fields) {
3073
+ if (!f.unique) continue;
3074
+ const col = __schemaSnake(n);
3075
+ addIndex({ name: 'idx_' + table + '_' + col, columns: [col], unique: true });
3076
+ }
1540
3077
  for (const d of norm.directives) {
1541
- if (d.name !== 'index') continue;
3078
+ if (d.name !== 'index' && d.name !== 'unique') continue;
1542
3079
  const ixArgs = d.args?.[0] || {};
1543
- const fields = (ixArgs.fields || []).map(__schemaSnake);
1544
- if (!fields.length) continue;
1545
- const u = ixArgs.unique ? 'UNIQUE ' : '';
1546
- indexes.push('CREATE ' + u + 'INDEX idx_' + table + '_' + fields.join('_') + ' ON ' + table + ' (' + fields.map(f => '"' + f + '"').join(', ') + ');');
3080
+ const cols = (ixArgs.fields || []).map(__schemaSnake);
3081
+ if (!cols.length) continue;
3082
+ addIndex({ name: 'idx_' + table + '_' + cols.join('_'), columns: cols, unique: d.name === 'unique' });
1547
3083
  }
1548
3084
 
1549
- blocks.push('CREATE SEQUENCE ' + seq + ' START ' + idStart + ';');
1550
- blocks.push('CREATE TABLE ' + table + ' (\\n' + columns.join(',\\n') + '\\n);');
1551
- if (indexes.length) blocks.push(indexes.join('\\n'));
3085
+ return {
3086
+ name: table,
3087
+ sequence: { name: seq, start: idStart },
3088
+ primaryKey: norm.primaryKey,
3089
+ columns, indexes, foreignKeys, notes,
3090
+ tableWas: norm.tableWas || null,
3091
+ };
3092
+ };
1552
3093
 
1553
- return blocks.join('\\n\\n') + '\\n';
3094
+ // ---- DDL rendering ------------------------------------------------------------
3095
+
3096
+ // Render one column line for CREATE TABLE — also used by the differ's
3097
+ // ADD COLUMN steps (minus the parts DuckDB can't ALTER in).
3098
+ function __schemaRenderColumn(spec, col, fkByColumn) {
3099
+ const parts = [' ' + col.name + ' ' + col.type];
3100
+ if (col.primary) {
3101
+ parts[0] = ' ' + col.name + ' ' + col.type + ' PRIMARY KEY';
3102
+ } else {
3103
+ if (col.notNull) parts.push('NOT NULL');
3104
+ // Uniqueness is emitted as a single named index (\`idx_<table>_<col>\`),
3105
+ // never as an inline column \`UNIQUE\`. Inline UNIQUE created a second,
3106
+ // auto-named index the migrate differ's fold (__schemaFoldSpec) can't
3107
+ // normalize; the named index is what ADD COLUMN and introspection
3108
+ // already round-trip through. \`col.unique\` stays the canonical spec
3109
+ // flag — it drives the index below and the differ — it just no longer
3110
+ // renders here.
3111
+ }
3112
+ const fk = fkByColumn ? fkByColumn.get(col.name) : null;
3113
+ if (fk) parts.push('REFERENCES ' + fk.refTable + '(' + fk.refColumn + ')');
3114
+ if (col.default != null) parts.push('DEFAULT ' + col.default);
3115
+ return parts.join(' ');
1554
3116
  }
1555
3117
 
1556
- function __schemaColumnDDL(name, field) {
1557
- let base = __SCHEMA_SQL_TYPES[field.typeName] || 'VARCHAR';
1558
- if (field.array) base = 'JSON';
1559
- if (base === 'VARCHAR' && field.constraints?.max != null) {
1560
- base = 'VARCHAR(' + field.constraints.max + ')';
3118
+ function __schemaRenderIndex(spec, ix) {
3119
+ const u = ix.unique ? 'UNIQUE ' : '';
3120
+ return 'CREATE ' + u + 'INDEX ' + ix.name + ' ON ' + spec.name +
3121
+ ' (' + ix.columns.map(c => '"' + c + '"').join(', ') + ');';
3122
+ }
3123
+
3124
+ // Render the CREATE SEQUENCE / CREATE TABLE / CREATE INDEX blocks for a
3125
+ // table spec. toSQL() joins these; the differ's ADD TABLE step reuses them.
3126
+ function __schemaRenderCreate(spec) {
3127
+ const blocks = [];
3128
+ const fkByColumn = new Map(spec.foreignKeys.map(fk => [fk.column, fk]));
3129
+ if (spec.sequence) {
3130
+ blocks.push('CREATE SEQUENCE ' + spec.sequence.name + ' START ' + spec.sequence.start + ';');
1561
3131
  }
1562
- const parts = [' ' + __schemaSnake(name) + ' ' + base];
1563
- if (field.required) parts.push('NOT NULL');
1564
- if (field.unique) parts.push('UNIQUE');
1565
- if (field.constraints?.default !== undefined) {
1566
- parts.push('DEFAULT ' + __schemaSQLDefault(field.constraints.default));
3132
+ const lines = spec.columns.map(c => __schemaRenderColumn(spec, c, fkByColumn));
3133
+ blocks.push('CREATE TABLE ' + spec.name + ' (\\n' + lines.join(',\\n') + '\\n);');
3134
+ const ix = spec.indexes.map(i => __schemaRenderIndex(spec, i));
3135
+ if (ix.length) blocks.push(ix.join('\\n'));
3136
+ if (spec.notes && spec.notes.length) blocks.push(spec.notes.join('\\n'));
3137
+ return blocks;
3138
+ }
3139
+
3140
+ function __schemaToSQL(def, options) {
3141
+ const opts = options || {};
3142
+ const { dropFirst = false, header } = opts;
3143
+ const spec = def._tableSpec(opts);
3144
+ const blocks = [];
3145
+ if (header) blocks.push(header);
3146
+ if (dropFirst) {
3147
+ blocks.push('DROP TABLE IF EXISTS ' + spec.name + ' CASCADE;\\nDROP SEQUENCE IF EXISTS ' + spec.sequence.name + ';');
1567
3148
  }
1568
- return parts.join(' ');
3149
+ blocks.push(...__schemaRenderCreate(spec));
3150
+ return blocks.join('\\n\\n') + '\\n';
1569
3151
  }
1570
3152
 
1571
3153
  function __schemaSQLDefault(v) {
@@ -1584,6 +3166,673 @@ __SchemaDef.prototype.toSQL = function (options) {
1584
3166
  return __schemaToSQL(this, options);
1585
3167
  };
1586
3168
  `;
3169
+ export const SCHEMA_MIGRATE_RUNTIME = `// ---- Schema evolution: introspect → diff → status / make / migrate ----------
3170
+ //
3171
+ // \`toSQL()\` solves greenfield CREATE; this fragment solves evolution:
3172
+ // diff the declared models against the deployed database and emit ALTER
3173
+ // migrations, with history, checksums, and destructive-change gates.
3174
+ //
3175
+ // schema.plan() → classified diff steps (pure, no files)
3176
+ // schema.status(opts) → { steps, applied, pending, mismatched }
3177
+ // schema.make(name, opts) → write migrations/NNNN_name.sql from the diff
3178
+ // schema.migrate(opts) → apply pending migration files in order
3179
+ // schema.introspect() → DeployedSchema (canonical table specs)
3180
+ //
3181
+ // Migration FILES are plain SQL — numbered, hand-editable, checked into
3182
+ // git. The generator writes them; humans may amend them; migrate()
3183
+ // applies them and records (version, name, checksum, applied_at) in the
3184
+ // \`_rip_migrations\` table. A checksum mismatch on an applied file aborts
3185
+ // (someone edited history) unless {repair: true} re-records checksums.
3186
+
3187
+ const __SCHEMA_MIGRATIONS_TABLE = '_rip_migrations';
3188
+
3189
+ // ---- Row materializer ---------------------------------------------------------
3190
+
3191
+ function __schemaMigrateRows(res) {
3192
+ const cols = (res.columns || []).map(c => c.name);
3193
+ return (res.data || []).map(row => {
3194
+ const obj = {};
3195
+ for (let i = 0; i < cols.length; i++) obj[cols[i]] = row[i];
3196
+ return obj;
3197
+ });
3198
+ }
3199
+
3200
+ // ---- Introspection ------------------------------------------------------------
3201
+
3202
+ // Build the DeployedSchema — an array of canonical table specs in the
3203
+ // same shape \`_tableSpec()\` produces — from the live database. Uses the
3204
+ // adapter's \`introspect()\` capability when present (Contract v2);
3205
+ // otherwise falls back to DuckDB catalog queries through \`query()\`.
3206
+ async function __schemaIntrospect() {
3207
+ if (typeof __schemaAdapter.introspect === 'function') {
3208
+ return await __schemaAdapter.introspect();
3209
+ }
3210
+ const q = (sql) => __schemaRunSQL(null, sql, []);
3211
+ const [tablesRes, columnsRes, constraintsRes, indexesRes, sequencesRes] = [
3212
+ await q("SELECT table_name FROM information_schema.tables WHERE table_schema = 'main' AND table_type = 'BASE TABLE'"),
3213
+ await q("SELECT table_name, column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_schema = 'main' ORDER BY table_name, ordinal_position"),
3214
+ await q("SELECT table_name, constraint_type, constraint_column_names, constraint_text FROM duckdb_constraints() WHERE schema_name = 'main'"),
3215
+ await q("SELECT table_name, index_name, is_unique, expressions FROM duckdb_indexes() WHERE schema_name = 'main'"),
3216
+ await q("SELECT sequence_name, start_value FROM duckdb_sequences() WHERE schema_name = 'main'"),
3217
+ ];
3218
+
3219
+ const tables = new Map();
3220
+ for (const r of __schemaMigrateRows(tablesRes)) {
3221
+ if (r.table_name === __SCHEMA_MIGRATIONS_TABLE) continue;
3222
+ tables.set(r.table_name, {
3223
+ name: r.table_name,
3224
+ sequence: null,
3225
+ primaryKey: null,
3226
+ columns: [],
3227
+ indexes: [],
3228
+ foreignKeys: [],
3229
+ tableWas: null,
3230
+ });
3231
+ }
3232
+
3233
+ for (const r of __schemaMigrateRows(columnsRes)) {
3234
+ const t = tables.get(r.table_name);
3235
+ if (!t) continue;
3236
+ t.columns.push({
3237
+ name: r.column_name,
3238
+ type: r.data_type,
3239
+ notNull: r.is_nullable === 'NO',
3240
+ unique: false,
3241
+ default: r.column_default != null && r.column_default !== '' ? r.column_default : null,
3242
+ was: null,
3243
+ });
3244
+ }
3245
+
3246
+ // constraint_column_names arrives as a JSON array over harbor, or as a
3247
+ // "[a, b]" string from other transports. Normalize to string[].
3248
+ const listOf = (v) => {
3249
+ if (Array.isArray(v)) return v.map(String);
3250
+ if (typeof v === 'string') {
3251
+ const inner = v.replace(/^\\[/, '').replace(/\\]$/, '').trim();
3252
+ return inner ? inner.split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')) : [];
3253
+ }
3254
+ return [];
3255
+ };
3256
+
3257
+ for (const r of __schemaMigrateRows(constraintsRes)) {
3258
+ const t = tables.get(r.table_name);
3259
+ if (!t) continue;
3260
+ const cols = listOf(r.constraint_column_names);
3261
+ if (r.constraint_type === 'PRIMARY KEY' && cols.length === 1) {
3262
+ t.primaryKey = cols[0];
3263
+ const col = t.columns.find(c => c.name === cols[0]);
3264
+ if (col) col.primary = true;
3265
+ } else if (r.constraint_type === 'UNIQUE' && cols.length === 1) {
3266
+ const col = t.columns.find(c => c.name === cols[0]);
3267
+ if (col) col.unique = true;
3268
+ } else if (r.constraint_type === 'FOREIGN KEY') {
3269
+ const m = String(r.constraint_text || '').match(/FOREIGN KEY\\s*\\(([^)]+)\\)\\s*REFERENCES\\s+(\\S+?)\\s*\\(([^)]+)\\)/i);
3270
+ if (m) {
3271
+ t.foreignKeys.push({ column: m[1].trim(), refTable: m[2].trim(), refColumn: m[3].trim() });
3272
+ } else if (cols.length === 1) {
3273
+ t.foreignKeys.push({ column: cols[0], refTable: null, refColumn: null });
3274
+ }
3275
+ }
3276
+ }
3277
+
3278
+ for (const r of __schemaMigrateRows(indexesRes)) {
3279
+ const t = tables.get(r.table_name);
3280
+ if (!t) continue;
3281
+ t.indexes.push({
3282
+ name: r.index_name,
3283
+ columns: listOf(r.expressions),
3284
+ unique: r.is_unique === true || r.is_unique === 'true',
3285
+ });
3286
+ }
3287
+
3288
+ for (const r of __schemaMigrateRows(sequencesRes)) {
3289
+ // Attach by the \`<table>_seq\` naming convention the DDL emitter uses.
3290
+ const tableName = String(r.sequence_name).replace(/_seq$/, '');
3291
+ const t = tables.get(tableName);
3292
+ if (t && String(r.sequence_name).endsWith('_seq')) {
3293
+ t.sequence = { name: r.sequence_name, start: Number(r.start_value) };
3294
+ }
3295
+ }
3296
+
3297
+ return { tables: [...tables.values()] };
3298
+ }
3299
+
3300
+ // Canonical declared schema: one table spec per registered :model.
3301
+ function __schemaCanonicalDeclared() {
3302
+ const tables = [];
3303
+ for (const [, entry] of __SchemaRegistry._entries) {
3304
+ if (entry.kind !== 'model') continue;
3305
+ tables.push(entry.def._tableSpec());
3306
+ }
3307
+ return { tables };
3308
+ }
3309
+
3310
+ // ---- Comparison normalizers ----------------------------------------------------
3311
+
3312
+ // DuckDB does not persist VARCHAR length hints, and reports several type
3313
+ // aliases under canonical names. Compare under those equivalences.
3314
+ const __SCHEMA_TYPE_ALIASES = {
3315
+ 'TEXT': 'VARCHAR', 'CHARACTER VARYING': 'VARCHAR', 'CHAR': 'VARCHAR', 'BPCHAR': 'VARCHAR', 'STRING': 'VARCHAR',
3316
+ 'INT': 'INTEGER', 'INT4': 'INTEGER', 'SIGNED': 'INTEGER',
3317
+ 'INT8': 'BIGINT', 'LONG': 'BIGINT',
3318
+ 'FLOAT8': 'DOUBLE', 'DOUBLE PRECISION': 'DOUBLE',
3319
+ 'BOOL': 'BOOLEAN', 'LOGICAL': 'BOOLEAN',
3320
+ 'DATETIME': 'TIMESTAMP', 'TIMESTAMP WITHOUT TIME ZONE': 'TIMESTAMP',
3321
+ };
3322
+
3323
+ function __schemaTypeKey(t) {
3324
+ let k = String(t || '').toUpperCase().replace(/\\(.*\\)\\s*$/, '').trim();
3325
+ return __SCHEMA_TYPE_ALIASES[k] || k;
3326
+ }
3327
+
3328
+ // Tolerant default comparison: deployed defaults round-trip through the
3329
+ // catalog with cosmetic differences (CAST wrappers, now() for
3330
+ // CURRENT_TIMESTAMP, case). Don't emit ALTERs for representation noise.
3331
+ function __schemaDefaultKey(d) {
3332
+ if (d == null) return '';
3333
+ let s = String(d).trim();
3334
+ const cast = s.match(/^CAST\\s*\\(\\s*(.*?)\\s+AS\\s+[A-Za-z0-9_ ()]+\\)$/i);
3335
+ if (cast) s = cast[1].trim();
3336
+ s = s.toLowerCase();
3337
+ if (s === 'now()' || s === 'current_timestamp()' || s === 'get_current_timestamp()') s = 'current_timestamp';
3338
+ return s;
3339
+ }
3340
+
3341
+ // Fold the \`#\`-modifier pattern: a UNIQUE column plus its auto-named
3342
+ // single-column unique index (\`idx_<table>_<col>\`) count as ONE fact —
3343
+ // the column's unique flag. Applies to both sides so the differ never
3344
+ // sees the pair as two separate diffs.
3345
+ function __schemaFoldSpec(spec) {
3346
+ const columnsByName = new Map(spec.columns.map(c => [c.name, c]));
3347
+ const indexes = [];
3348
+ for (const ix of spec.indexes) {
3349
+ const autoName = ix.columns.length === 1 && ix.name === 'idx_' + spec.name + '_' + ix.columns[0];
3350
+ if (ix.unique && autoName) {
3351
+ const col = columnsByName.get(ix.columns[0]);
3352
+ if (col) { col.unique = true; continue; }
3353
+ }
3354
+ indexes.push(ix);
3355
+ }
3356
+ return { ...spec, indexes };
3357
+ }
3358
+
3359
+ // ---- The differ -----------------------------------------------------------------
3360
+ //
3361
+ // Returns classified steps:
3362
+ //
3363
+ // { table, kind, class: 'safe' | 'lossy' | 'destructive' | 'blocked',
3364
+ // sql: [statements/comments], notes: [strings] }
3365
+ //
3366
+ // Classes gate generation (\`make\` refuses lossy/destructive without the
3367
+ // matching allow flag, and refuses \`blocked\` outright); the printed plan
3368
+ // always shows everything.
3369
+ //
3370
+ // DuckDB ALTER constraints shape several decisions:
3371
+ // - ADD COLUMN cannot carry NOT NULL / UNIQUE / REFERENCES → required
3372
+ // adds become add + (backfill) + SET NOT NULL; unique adds get a
3373
+ // separate CREATE UNIQUE INDEX; FK constraints cannot be added to an
3374
+ // existing table at all (note emitted).
3375
+ // - A table referenced by another table's FOREIGN KEY is frozen for
3376
+ // everything except ADD COLUMN and index DDL ("Dependency Error:
3377
+ // cannot alter entry") — even DROP TABLE … CASCADE is refused.
3378
+ // Steps that hit this wall classify as \`blocked\`: the change
3379
+ // requires dropping/rebuilding the referencing tables around it.
3380
+ // - No SAVEPOINT / ALTER SEQUENCE RESTART → sequence-start drift is a
3381
+ // note, not a step.
3382
+
3383
+ // Step kinds DuckDB executes even when the table is FK-referenced.
3384
+ const __SCHEMA_UNBLOCKED_KINDS = new Set([
3385
+ 'create-table', 'add-column', 'create-index', 'drop-index', 'note-fk',
3386
+ ]);
3387
+
3388
+ // Mark steps that DuckDB will refuse because the target table is
3389
+ // referenced by other tables' FOREIGN KEYs.
3390
+ function __schemaApplyFkBlocks(steps, deployed) {
3391
+ const referencedBy = new Map(); // table → [child.fkColumn, ...]
3392
+ for (const t of deployed.tables) {
3393
+ for (const fk of t.foreignKeys) {
3394
+ if (!fk.refTable) continue;
3395
+ if (!referencedBy.has(fk.refTable)) referencedBy.set(fk.refTable, []);
3396
+ referencedBy.get(fk.refTable).push(t.name + '.' + fk.column);
3397
+ }
3398
+ }
3399
+ for (const s of steps) {
3400
+ if (__SCHEMA_UNBLOCKED_KINDS.has(s.kind)) continue;
3401
+ const refs = referencedBy.get(s.table) ||
3402
+ (s.kind === 'rename-table' && s.sql[0] ? referencedBy.get((s.sql[0].match(/^ALTER TABLE (\\S+) RENAME TO/) || [])[1]) : null);
3403
+ if (!refs || !refs.length) continue;
3404
+ s.class = 'blocked';
3405
+ s.notes.push(
3406
+ 'DuckDB refuses this ALTER while ' + refs.join(', ') + ' reference(s) this table ' +
3407
+ '("Dependency Error"). Rebuild the referencing table(s) around this change, or ' +
3408
+ 'apply it manually with the referencing tables dropped and recreated.');
3409
+ }
3410
+ return steps;
3411
+ }
3412
+
3413
+ function __schemaDiff(declared, deployed) {
3414
+ const steps = [];
3415
+ const dTables = new Map(declared.tables.map(t => [t.name, __schemaFoldSpec(t)]));
3416
+ const pTables = new Map(deployed.tables.map(t => [t.name, __schemaFoldSpec(t)]));
3417
+
3418
+ // Table renames first: declared table missing from deployed, with a
3419
+ // @tableWas pointing at a deployed table that no declared model claims.
3420
+ for (const [name, d] of dTables) {
3421
+ if (pTables.has(name) || !d.tableWas) continue;
3422
+ const old = pTables.get(d.tableWas);
3423
+ if (old && !dTables.has(d.tableWas)) {
3424
+ steps.push({
3425
+ table: name, kind: 'rename-table', class: 'safe',
3426
+ sql: ['ALTER TABLE ' + d.tableWas + ' RENAME TO ' + name + ';'],
3427
+ notes: ["@tableWas " + d.tableWas + " can be removed once this migration lands"],
3428
+ });
3429
+ pTables.delete(d.tableWas);
3430
+ pTables.set(name, { ...old, name });
3431
+ }
3432
+ }
3433
+
3434
+ // Matched tables next: column / index / FK diffs. Alters run BEFORE
3435
+ // create-table steps on purpose — a new child table's FOREIGN KEY
3436
+ // freezes its parent the moment it exists, so a migration that both
3437
+ // alters \`orders\` and creates \`invoices REFERENCES orders\` must alter
3438
+ // first.
3439
+ for (const [name, d] of dTables) {
3440
+ const p = pTables.get(name);
3441
+ if (!p) continue;
3442
+ __schemaDiffTable(d, p, steps);
3443
+ }
3444
+
3445
+ // New tables.
3446
+ for (const [name, d] of dTables) {
3447
+ if (pTables.has(name)) continue;
3448
+ steps.push({
3449
+ table: name, kind: 'create-table', class: 'safe',
3450
+ sql: __schemaRenderCreate(d),
3451
+ notes: [],
3452
+ });
3453
+ }
3454
+
3455
+ // Dropped tables (deployed but not declared) — the "someone ran manual
3456
+ // SQL" detector doubles as the model-deletion path. Destructive.
3457
+ for (const [name, p] of pTables) {
3458
+ if (dTables.has(name)) continue;
3459
+ const sql = ['DROP TABLE ' + name + ';'];
3460
+ if (p.sequence) sql.push('DROP SEQUENCE ' + p.sequence.name + ';');
3461
+ steps.push({ table: name, kind: 'drop-table', class: 'destructive', sql, notes: [] });
3462
+ }
3463
+
3464
+ return __schemaApplyFkBlocks(steps, deployed);
3465
+ }
3466
+
3467
+ function __schemaDiffTable(d, p, steps) {
3468
+ const t = d.name;
3469
+ const dCols = new Map(d.columns.map(c => [c.name, c]));
3470
+ const pCols = new Map(p.columns.map(c => [c.name, c]));
3471
+
3472
+ // Column renames: declared column missing from deployed whose \`was\`
3473
+ // names a deployed column that no declared column claims.
3474
+ for (const [name, col] of dCols) {
3475
+ if (pCols.has(name) || !col.was) continue;
3476
+ const old = pCols.get(col.was);
3477
+ if (old && !dCols.has(col.was)) {
3478
+ steps.push({
3479
+ table: t, kind: 'rename-column', class: 'safe',
3480
+ sql: ['ALTER TABLE ' + t + ' RENAME COLUMN ' + col.was + ' TO ' + name + ';'],
3481
+ notes: ['{was: "' + col.was + '"} on ' + name + ' can be removed once this migration lands'],
3482
+ });
3483
+ pCols.delete(col.was);
3484
+ pCols.set(name, { ...old, name });
3485
+ }
3486
+ }
3487
+
3488
+ // Added columns.
3489
+ for (const [name, col] of dCols) {
3490
+ if (pCols.has(name)) continue;
3491
+ const sql = [];
3492
+ const notes = [];
3493
+ let cls = 'safe';
3494
+ // DuckDB: ADD COLUMN cannot carry constraints. DEFAULT is allowed
3495
+ // (and backfills existing rows), so add with the default when one
3496
+ // is declared, then tighten with SET NOT NULL.
3497
+ let add = 'ALTER TABLE ' + t + ' ADD COLUMN ' + name + ' ' + col.type;
3498
+ if (col.default != null) add += ' DEFAULT ' + col.default;
3499
+ sql.push(add + ';');
3500
+ if (col.notNull) {
3501
+ if (col.default == null) {
3502
+ sql.push("-- TODO: backfill " + t + "." + name + " before SET NOT NULL (required column, no default)");
3503
+ }
3504
+ sql.push('ALTER TABLE ' + t + ' ALTER COLUMN ' + name + ' SET NOT NULL;');
3505
+ }
3506
+ if (col.unique) {
3507
+ sql.push('CREATE UNIQUE INDEX idx_' + t + '_' + name + ' ON ' + t + ' ("' + name + '");');
3508
+ }
3509
+ const fk = d.foreignKeys.find(f => f.column === name);
3510
+ if (fk) {
3511
+ notes.push('DuckDB cannot add FOREIGN KEY constraints to an existing table; ' +
3512
+ name + ' -> ' + fk.refTable + '(' + fk.refColumn + ') is unenforced until the table is recreated');
3513
+ }
3514
+ steps.push({ table: t, kind: 'add-column', class: cls, sql, notes });
3515
+ }
3516
+
3517
+ // Dropped columns.
3518
+ for (const [name] of pCols) {
3519
+ if (dCols.has(name)) continue;
3520
+ steps.push({
3521
+ table: t, kind: 'drop-column', class: 'destructive',
3522
+ sql: ['ALTER TABLE ' + t + ' DROP COLUMN ' + name + ';'],
3523
+ notes: [],
3524
+ });
3525
+ }
3526
+
3527
+ // Altered columns.
3528
+ for (const [name, dc] of dCols) {
3529
+ const pc = pCols.get(name);
3530
+ if (!pc) continue;
3531
+ if (dc.primary || pc.primary) continue; // pk shape is fixed (INTEGER + nextval)
3532
+ if (__schemaTypeKey(dc.type) !== __schemaTypeKey(pc.type)) {
3533
+ steps.push({
3534
+ table: t, kind: 'alter-type', class: 'lossy',
3535
+ sql: ['ALTER TABLE ' + t + ' ALTER COLUMN ' + name + ' TYPE ' + dc.type + ';'],
3536
+ notes: [pc.type + ' -> ' + dc.type + ' casts existing values; rows that cannot cast will fail the migration'],
3537
+ });
3538
+ }
3539
+ if (dc.notNull !== pc.notNull) {
3540
+ if (dc.notNull) {
3541
+ steps.push({
3542
+ table: t, kind: 'set-not-null', class: 'lossy',
3543
+ sql: ['ALTER TABLE ' + t + ' ALTER COLUMN ' + name + ' SET NOT NULL;'],
3544
+ notes: ['fails if existing rows hold NULLs — backfill first'],
3545
+ });
3546
+ } else {
3547
+ steps.push({
3548
+ table: t, kind: 'drop-not-null', class: 'safe',
3549
+ sql: ['ALTER TABLE ' + t + ' ALTER COLUMN ' + name + ' DROP NOT NULL;'],
3550
+ notes: [],
3551
+ });
3552
+ }
3553
+ }
3554
+ if (__schemaDefaultKey(dc.default) !== __schemaDefaultKey(pc.default)) {
3555
+ steps.push({
3556
+ table: t, kind: 'alter-default', class: 'safe',
3557
+ sql: [dc.default != null
3558
+ ? 'ALTER TABLE ' + t + ' ALTER COLUMN ' + name + ' SET DEFAULT ' + dc.default + ';'
3559
+ : 'ALTER TABLE ' + t + ' ALTER COLUMN ' + name + ' DROP DEFAULT;'],
3560
+ notes: [],
3561
+ });
3562
+ }
3563
+ if (dc.unique !== pc.unique) {
3564
+ if (dc.unique) {
3565
+ steps.push({
3566
+ table: t, kind: 'add-unique', class: 'lossy',
3567
+ sql: ['CREATE UNIQUE INDEX idx_' + t + '_' + name + ' ON ' + t + ' ("' + name + '");'],
3568
+ notes: ['fails if existing rows hold duplicates'],
3569
+ });
3570
+ } else {
3571
+ steps.push({
3572
+ table: t, kind: 'drop-unique', class: 'safe',
3573
+ sql: ['DROP INDEX IF EXISTS idx_' + t + '_' + name + ';'],
3574
+ notes: ['a UNIQUE declared inline in CREATE TABLE cannot be dropped by index name; recreate the table if this fails'],
3575
+ });
3576
+ }
3577
+ }
3578
+ }
3579
+
3580
+ // Index diffs (auto-unique indexes already folded into column flags).
3581
+ const dIdx = new Map(d.indexes.map(i => [i.name, i]));
3582
+ const pIdx = new Map(p.indexes.map(i => [i.name, i]));
3583
+ for (const [name, ix] of dIdx) {
3584
+ const ex = pIdx.get(name);
3585
+ if (ex && ex.unique === ix.unique &&
3586
+ ex.columns.join(',') === ix.columns.join(',')) continue;
3587
+ const sql = [];
3588
+ if (ex) sql.push('DROP INDEX ' + name + ';');
3589
+ sql.push(__schemaRenderIndex(d, ix));
3590
+ steps.push({
3591
+ table: t, kind: 'create-index', class: ix.unique ? 'lossy' : 'safe',
3592
+ sql,
3593
+ notes: ix.unique ? ['unique index creation fails if existing rows hold duplicates'] : [],
3594
+ });
3595
+ }
3596
+ for (const [name] of pIdx) {
3597
+ if (dIdx.has(name)) continue;
3598
+ steps.push({
3599
+ table: t, kind: 'drop-index', class: 'safe',
3600
+ sql: ['DROP INDEX ' + name + ';'],
3601
+ notes: [],
3602
+ });
3603
+ }
3604
+
3605
+ // FK diffs are notes only — DuckDB has no ALTER TABLE ADD/DROP CONSTRAINT.
3606
+ const pFks = new Set(p.foreignKeys.map(f => f.column));
3607
+ for (const fk of d.foreignKeys) {
3608
+ if (pFks.has(fk.column) || !pCols.has(fk.column)) continue;
3609
+ steps.push({
3610
+ table: t, kind: 'note-fk', class: 'safe',
3611
+ sql: ['-- NOTE: ' + t + '.' + fk.column + ' should reference ' + fk.refTable + '(' + fk.refColumn + ') ' +
3612
+ 'but DuckDB cannot add FK constraints to an existing table'],
3613
+ notes: [],
3614
+ });
3615
+ }
3616
+ }
3617
+
3618
+ // ---- Plan rendering --------------------------------------------------------------
3619
+
3620
+ function __schemaRenderPlan(steps) {
3621
+ const lines = [];
3622
+ for (const s of steps) {
3623
+ lines.push('-- [' + s.class + '] ' + s.kind + ' ' + s.table);
3624
+ for (const n of s.notes) lines.push('-- ' + n);
3625
+ lines.push(...s.sql);
3626
+ lines.push('');
3627
+ }
3628
+ return lines.join('\\n');
3629
+ }
3630
+
3631
+ // ---- Migration files & history ------------------------------------------------------
3632
+
3633
+ const __SCHEMA_MIGRATION_FILE_RE = /^(\\d{4,})_(.+)\\.sql$/;
3634
+
3635
+ async function __schemaMigrationFiles(dir) {
3636
+ const fs = await import('node:fs');
3637
+ const path = await import('node:path');
3638
+ const crypto = await import('node:crypto');
3639
+ if (!fs.existsSync(dir)) return [];
3640
+ const out = [];
3641
+ for (const f of fs.readdirSync(dir).sort()) {
3642
+ const m = f.match(__SCHEMA_MIGRATION_FILE_RE);
3643
+ if (!m) continue;
3644
+ const content = fs.readFileSync(path.join(dir, f), 'utf8');
3645
+ out.push({
3646
+ version: m[1],
3647
+ name: m[2],
3648
+ file: path.join(dir, f),
3649
+ checksum: crypto.createHash('sha256').update(content).digest('hex'),
3650
+ content,
3651
+ });
3652
+ }
3653
+ return out;
3654
+ }
3655
+
3656
+ async function __schemaAppliedMigrations() {
3657
+ try {
3658
+ const res = await __schemaRunSQL(null,
3659
+ 'SELECT version, name, checksum, applied_at FROM ' + __SCHEMA_MIGRATIONS_TABLE + ' ORDER BY version', []);
3660
+ return __schemaMigrateRows(res);
3661
+ } catch (e) {
3662
+ // History table doesn't exist yet — nothing applied. Anything else
3663
+ // (connection refused, auth) should propagate.
3664
+ if (/does not exist|Catalog Error/i.test(e?.message || '')) return [];
3665
+ throw e;
3666
+ }
3667
+ }
3668
+
3669
+ async function __schemaEnsureMigrationsTable() {
3670
+ await __schemaRunSQL(null,
3671
+ 'CREATE TABLE IF NOT EXISTS ' + __SCHEMA_MIGRATIONS_TABLE +
3672
+ ' (version VARCHAR PRIMARY KEY, name VARCHAR, checksum VARCHAR, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)', []);
3673
+ }
3674
+
3675
+ // Split a migration file into statements: ';' terminates, except inside
3676
+ // single-quoted strings; \`--\` line comments pass through attached to the
3677
+ // following statement (so a leading TODO comment is visible in errors but
3678
+ // never executed alone). Pure-comment / empty fragments are dropped.
3679
+ function __schemaSplitStatements(sql) {
3680
+ const out = [];
3681
+ let cur = '';
3682
+ let inString = false;
3683
+ let inComment = false;
3684
+ for (let i = 0; i < sql.length; i++) {
3685
+ const ch = sql[i];
3686
+ if (inComment) {
3687
+ cur += ch;
3688
+ if (ch === '\\n') inComment = false;
3689
+ continue;
3690
+ }
3691
+ if (inString) {
3692
+ cur += ch;
3693
+ if (ch === "'") {
3694
+ if (sql[i + 1] === "'") { cur += "'"; i++; }
3695
+ else inString = false;
3696
+ }
3697
+ continue;
3698
+ }
3699
+ if (ch === "'") { inString = true; cur += ch; continue; }
3700
+ if (ch === '-' && sql[i + 1] === '-') { inComment = true; cur += ch; continue; }
3701
+ if (ch === ';') {
3702
+ out.push(cur);
3703
+ cur = '';
3704
+ continue;
3705
+ }
3706
+ cur += ch;
3707
+ }
3708
+ if (cur.trim()) out.push(cur);
3709
+ // Strip comment-only / empty fragments; keep executable text intact.
3710
+ return out
3711
+ .map(s => s.trim())
3712
+ .filter(s => s && s.split('\\n').some(line => {
3713
+ const l = line.trim();
3714
+ return l && !l.startsWith('--');
3715
+ }));
3716
+ }
3717
+
3718
+ // ---- Public functions ----------------------------------------------------------------
3719
+
3720
+ async function __schemaPlan() {
3721
+ const declared = __schemaCanonicalDeclared();
3722
+ if (!declared.tables.length) {
3723
+ throw new Error('schema.plan(): no :model schemas are registered — import your model files first');
3724
+ }
3725
+ const deployed = await __schemaIntrospect();
3726
+ return __schemaDiff(declared, deployed);
3727
+ }
3728
+
3729
+ async function __schemaStatus(opts = {}) {
3730
+ const dir = opts.dir || 'migrations';
3731
+ const steps = await __schemaPlan();
3732
+ const files = await __schemaMigrationFiles(dir);
3733
+ const applied = await __schemaAppliedMigrations();
3734
+ const appliedByVersion = new Map(applied.map(a => [a.version, a]));
3735
+ const pending = files.filter(f => !appliedByVersion.has(f.version));
3736
+ const mismatched = files.filter(f => {
3737
+ const a = appliedByVersion.get(f.version);
3738
+ return a && a.checksum !== f.checksum;
3739
+ }).map(f => f.version + '_' + f.name);
3740
+ return { steps, files, applied, pending, mismatched };
3741
+ }
3742
+
3743
+ async function __schemaMake(name, opts = {}) {
3744
+ if (!name || typeof name !== 'string') {
3745
+ throw new Error("schema.make(name): a migration name is required, e.g. schema.make('add_orders')");
3746
+ }
3747
+ const dir = opts.dir || 'migrations';
3748
+ const steps = await __schemaPlan();
3749
+ if (!steps.length) return null;
3750
+
3751
+ const blocked = steps.filter(s => s.class === 'blocked');
3752
+ if (blocked.length) {
3753
+ const list = blocked.map(s => ' [blocked] ' + s.kind + ' ' + s.table + '\\n ' + s.notes.join('\\n ')).join('\\n');
3754
+ throw new Error(
3755
+ 'schema.make: the plan contains steps DuckDB cannot execute while foreign keys reference the table:\\n' +
3756
+ list + '\\nThese need a manual rebuild of the referencing tables; no flag overrides this.');
3757
+ }
3758
+ const gated = [];
3759
+ for (const s of steps) {
3760
+ if (s.class === 'lossy' && !opts.allowLossy) gated.push(s);
3761
+ if (s.class === 'destructive' && !opts.allowDestructive) gated.push(s);
3762
+ }
3763
+ if (gated.length) {
3764
+ const list = gated.map(s => ' [' + s.class + '] ' + s.kind + ' ' + s.table).join('\\n');
3765
+ throw new Error(
3766
+ 'schema.make: the plan contains gated steps:\\n' + list +
3767
+ '\\nPass {allowLossy: true} / {allowDestructive: true} (CLI: --allow-lossy / --allow-destructive) to include them.');
3768
+ }
3769
+
3770
+ const fs = await import('node:fs');
3771
+ const path = await import('node:path');
3772
+ const files = await __schemaMigrationFiles(dir);
3773
+ const next = files.length ? Math.max(...files.map(f => parseInt(f.version, 10))) + 1 : 1;
3774
+ const version = String(next).padStart(4, '0');
3775
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'migration';
3776
+ const file = path.join(dir, version + '_' + slug + '.sql');
3777
+
3778
+ const body =
3779
+ '-- ' + version + '_' + slug + '.sql\\n' +
3780
+ '-- Generated by \`rip schema make\` — review (and edit) before applying.\\n' +
3781
+ '-- Apply with \`rip schema migrate\`.\\n\\n' +
3782
+ __schemaRenderPlan(steps);
3783
+
3784
+ fs.mkdirSync(dir, { recursive: true });
3785
+ fs.writeFileSync(file, body);
3786
+ return { file, version, steps };
3787
+ }
3788
+
3789
+ async function __schemaMigrate(opts = {}) {
3790
+ const dir = opts.dir || 'migrations';
3791
+ const files = await __schemaMigrationFiles(dir);
3792
+ await __schemaEnsureMigrationsTable();
3793
+ const applied = await __schemaAppliedMigrations();
3794
+ const appliedByVersion = new Map(applied.map(a => [a.version, a]));
3795
+
3796
+ // History integrity: an applied file whose content changed is an
3797
+ // edited-history error — abort unless {repair: true} re-records.
3798
+ for (const f of files) {
3799
+ const a = appliedByVersion.get(f.version);
3800
+ if (!a || a.checksum === f.checksum) continue;
3801
+ if (opts.repair) {
3802
+ await __schemaRunSQL(null,
3803
+ 'UPDATE ' + __SCHEMA_MIGRATIONS_TABLE + ' SET checksum = ? WHERE version = ?',
3804
+ [f.checksum, f.version]);
3805
+ } else {
3806
+ throw new Error(
3807
+ 'schema.migrate: checksum mismatch on applied migration ' + f.version + '_' + f.name +
3808
+ ' — the file changed after it was applied. Restore the original file, or re-record with {repair: true} (CLI: --repair).');
3809
+ }
3810
+ }
3811
+
3812
+ const pending = files.filter(f => !appliedByVersion.has(f.version));
3813
+ const ran = [];
3814
+ for (const f of pending) {
3815
+ const statements = __schemaSplitStatements(f.content);
3816
+ const apply = async () => {
3817
+ for (const stmt of statements) {
3818
+ await __schemaRunSQL(null, stmt, []);
3819
+ }
3820
+ await __schemaRunSQL(null,
3821
+ 'INSERT INTO ' + __SCHEMA_MIGRATIONS_TABLE + ' (version, name, checksum) VALUES (?, ?, ?)',
3822
+ [f.version, f.name, f.checksum]);
3823
+ };
3824
+ // Transactional apply when the adapter supports it — a failed
3825
+ // statement leaves neither earlier statements nor the history row.
3826
+ if (typeof __schemaAdapter.begin === 'function') {
3827
+ await __schemaTransaction(apply);
3828
+ } else {
3829
+ await apply();
3830
+ }
3831
+ ran.push(f.version + '_' + f.name);
3832
+ }
3833
+ return { ran, pending: [] };
3834
+ }
3835
+ `;
1587
3836
  export const SCHEMA_BROWSER_STUBS_RUNTIME = `// Browser stubs — throwing replacements for every ORM / DDL helper that
1588
3837
  // the validate fragment references but doesn't implement. Loaded ONLY
1589
3838
  // in browser mode.
@@ -1603,20 +3852,31 @@ const __schemaBrowserStub = (api) => function() {
1603
3852
  };
1604
3853
 
1605
3854
  // Static / class-level methods on __SchemaDef
1606
- __SchemaDef.prototype.find = __schemaBrowserStub('find');
1607
- __SchemaDef.prototype.where = __schemaBrowserStub('where');
1608
- __SchemaDef.prototype.all = __schemaBrowserStub('all');
1609
- __SchemaDef.prototype.first = __schemaBrowserStub('first');
1610
- __SchemaDef.prototype.count = __schemaBrowserStub('count');
1611
- __SchemaDef.prototype.create = __schemaBrowserStub('create');
1612
- __SchemaDef.prototype.toSQL = __schemaBrowserStub('toSQL');
3855
+ __SchemaDef.prototype.find = __schemaBrowserStub('find');
3856
+ __SchemaDef.prototype.findMany = __schemaBrowserStub('findMany');
3857
+ __SchemaDef.prototype.where = __schemaBrowserStub('where');
3858
+ __SchemaDef.prototype.includes = __schemaBrowserStub('includes');
3859
+ __SchemaDef.prototype.withDeleted = __schemaBrowserStub('withDeleted');
3860
+ __SchemaDef.prototype.onlyDeleted = __schemaBrowserStub('onlyDeleted');
3861
+ __SchemaDef.prototype.unscoped = __schemaBrowserStub('unscoped');
3862
+ __SchemaDef.prototype.all = __schemaBrowserStub('all');
3863
+ __SchemaDef.prototype.first = __schemaBrowserStub('first');
3864
+ __SchemaDef.prototype.count = __schemaBrowserStub('count');
3865
+ __SchemaDef.prototype.create = __schemaBrowserStub('create');
3866
+ __SchemaDef.prototype.upsert = __schemaBrowserStub('upsert');
3867
+ __SchemaDef.prototype.insertMany = __schemaBrowserStub('insertMany');
3868
+ __SchemaDef.prototype.toSQL = __schemaBrowserStub('toSQL');
1613
3869
 
1614
3870
  // Helpers referenced by the validate fragment that are otherwise
1615
3871
  // defined in db-naming / orm fragments. Kept inert (return safe
1616
3872
  // defaults or throw on use) so validate's _makeClass / _normalize
1617
3873
  // can run end-to-end in browser context.
1618
- function __schemaSave() { throw new Error("schema instance.save() is not available in the browser. Import @rip-lang/db on the server."); }
1619
- function __schemaDestroy() { throw new Error("schema instance.destroy() is not available in the browser. Import @rip-lang/db on the server."); }
3874
+ function __schemaSave() { throw new Error("schema instance.save() is not available in the browser. Import @rip-lang/db on the server."); }
3875
+ function __schemaDestroy() { throw new Error("schema instance.destroy() is not available in the browser. Import @rip-lang/db on the server."); }
3876
+ function __schemaRestore() { throw new Error("schema instance.restore() is not available in the browser. Import @rip-lang/db on the server."); }
3877
+ function __schemaResolveRelation() { throw new Error("schema relation accessors are not available in the browser. Import @rip-lang/db on the server."); }
3878
+ function __schemaTransaction() { throw new Error("schema.transaction() is not available in the browser. Import @rip-lang/db on the server."); }
3879
+ function __schemaInvokeScope() { throw new Error("schema query scopes are not available in the browser. Import @rip-lang/db on the server."); }
1620
3880
  function __schemaTableName(m) { return null; } // returned only for :model normalize; never used downstream in browser
1621
3881
  function __schemaPluralize(w) { return w; } // identity — relations work for type-resolution but never query
1622
3882
  function __schemaFkName(m) { return ''; } // ditto