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