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.
- package/README.md +3 -4
- package/bin/rip +201 -18
- package/bin/rip-schema +175 -0
- package/docs/AGENTS.md +1 -1
- package/docs/RIP-APP.md +200 -19
- package/docs/RIP-DUCKDB.md +64 -1
- package/docs/RIP-INTRO.md +4 -4
- package/docs/RIP-LANG.md +36 -38
- package/docs/RIP-SCHEMA.md +1204 -364
- package/docs/RIP-TYPES.md +74 -103
- package/docs/demo/README.md +4 -3
- package/docs/dist/rip.js +4195 -966
- package/docs/dist/rip.min.js +1161 -284
- package/docs/dist/rip.min.js.br +0 -0
- package/docs/example/index.json +7 -7
- package/docs/example/index.json.br +0 -0
- package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
- package/docs/extensions/vscode/print/print-latest.vsix +0 -0
- package/docs/extensions/vscode/rip/index.html +2 -1
- package/docs/extensions/vscode/rip/rip-0.5.15.vsix +0 -0
- package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
- package/docs/extensions/vscode/rip/vscode-rip-0.6.0.vsix +0 -0
- package/docs/index.html +1 -1
- package/docs/ui/bundle.json +55 -55
- package/docs/ui/bundle.json.br +0 -0
- package/docs/ui/hljs-rip.js +1 -1
- package/docs/ui/index.html +1 -1
- package/package.json +15 -7
- package/rip-loader.js +59 -2
- package/src/AGENTS.md +43 -12
- package/src/browser.js +52 -11
- package/src/compiler.js +538 -80
- package/src/components.js +488 -48
- package/src/dts.js +80 -50
- package/src/grammar/README.md +29 -170
- package/src/grammar/grammar.rip +17 -12
- package/src/grammar/solar.rip +4 -17
- package/src/lexer.js +82 -32
- package/src/parser.js +229 -229
- package/src/schema/dts.js +328 -54
- package/src/schema/loader-server.js +2 -1
- package/src/schema/runtime-browser-stubs.js +20 -9
- package/src/schema/runtime-ddl.js +161 -44
- package/src/schema/runtime-migrate.js +681 -0
- package/src/schema/runtime-orm.js +698 -54
- package/src/schema/runtime-validate.js +808 -24
- package/src/schema/runtime.generated.js +2395 -135
- package/src/schema/schema.js +1054 -94
- package/src/typecheck.js +1610 -127
- package/src/types.js +90 -6
- package/src/grammar/lunar.rip +0 -2412
- /package/docs/demo/{components → routes}/_layout.rip +0 -0
- /package/docs/demo/{components → routes}/about.rip +0 -0
- /package/docs/demo/{components → routes}/card.rip +0 -0
- /package/docs/demo/{components → routes}/counter.rip +0 -0
- /package/docs/demo/{components → routes}/index.rip +0 -0
- /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
|
|
51
|
-
//
|
|
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')
|
|
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','
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
502
|
-
// a promise to a target-model instance (or array
|
|
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() {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
1034
|
-
|
|
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
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
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.
|
|
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
|
|
1088
|
-
const
|
|
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
|
|
1100
|
-
|
|
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 =
|
|
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
|
|
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
|
|
2481
|
+
const res = await __schemaRunSQL(def, sql, values);
|
|
1203
2482
|
if (res.data?.[0] && res.columns) {
|
|
1204
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
return this
|
|
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
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
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.
|
|
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
|
-
|
|
1515
|
-
|
|
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(
|
|
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
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
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('
|
|
1533
|
-
columns.push('
|
|
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('
|
|
3052
|
+
columns.push({ name: 'deleted_at', type: 'TIMESTAMP', notNull: false, unique: false, default: null, was: null });
|
|
1537
3053
|
}
|
|
1538
3054
|
|
|
1539
|
-
//
|
|
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
|
|
1544
|
-
if (!
|
|
1545
|
-
|
|
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
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
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
|
-
|
|
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
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
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
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
if (
|
|
1566
|
-
|
|
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
|
-
|
|
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
|
|
1607
|
-
__SchemaDef.prototype.
|
|
1608
|
-
__SchemaDef.prototype.
|
|
1609
|
-
__SchemaDef.prototype.
|
|
1610
|
-
__SchemaDef.prototype.
|
|
1611
|
-
__SchemaDef.prototype.
|
|
1612
|
-
__SchemaDef.prototype.
|
|
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()
|
|
1619
|
-
function __schemaDestroy()
|
|
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
|