rip-lang 3.16.1 → 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 +2 -3
- package/bin/rip +39 -8
- package/bin/rip-schema +175 -0
- package/docs/RIP-APP.md +91 -2
- package/docs/RIP-DUCKDB.md +64 -1
- package/docs/RIP-INTRO.md +4 -4
- package/docs/RIP-LANG.md +32 -33
- package/docs/RIP-SCHEMA.md +1204 -364
- package/docs/dist/rip.js +3245 -611
- package/docs/dist/rip.min.js +1161 -289
- package/docs/dist/rip.min.js.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-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/hljs-rip.js +1 -1
- package/package.json +7 -4
- package/src/AGENTS.md +39 -8
- package/src/compiler.js +220 -36
- package/src/components.js +315 -14
- package/src/dts.js +18 -3
- 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 +24 -17
- 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 +1049 -89
- package/src/typecheck.js +283 -55
- package/src/types.js +5 -1
- package/src/grammar/lunar.rip +0 -2412
package/src/schema/schema.js
CHANGED
|
@@ -56,15 +56,47 @@ export function setSchemaRuntimeProvider(fn) { _schemaRuntimeProvider = fn; }
|
|
|
56
56
|
// Anything else at schema top level is a schema-mode-aware compile error
|
|
57
57
|
// with a helpful message.
|
|
58
58
|
|
|
59
|
-
const VALID_KINDS = new Set(['input', 'shape', 'model', 'mixin', 'enum']);
|
|
59
|
+
const VALID_KINDS = new Set(['input', 'shape', 'model', 'mixin', 'enum', 'union']);
|
|
60
60
|
const KIND_DEFAULT = 'input';
|
|
61
61
|
|
|
62
|
+
// Wire-friendly built-ins the `~type` coercion marker accepts. Each has
|
|
63
|
+
// a strict coercion table in the runtime (__SCHEMA_COERCERS); anything
|
|
64
|
+
// else needs an explicit transform.
|
|
65
|
+
const SCHEMA_COERCIBLE_TYPES = new Set(['integer', 'number', 'boolean', 'date', 'datetime']);
|
|
66
|
+
|
|
67
|
+
// `~:name` — NAMED coercers from the runtime registry
|
|
68
|
+
// (__schemaRegisterCoercer). @rip-lang/server registers its read()
|
|
69
|
+
// validator vocabulary (id, money, ssn, phone, name, date, …) at load;
|
|
70
|
+
// apps add their own. The coercer's OUTPUT type matters at compile time
|
|
71
|
+
// (shadow TS + DDL), so the shipped names carry a static output-type
|
|
72
|
+
// table here; unknown (custom) names type as `any` — annotate with an
|
|
73
|
+
// explicit transform when you need strictness.
|
|
74
|
+
const SCHEMA_NAMED_COERCER_TYPES = {
|
|
75
|
+
id: 'integer', int: 'integer', whole: 'integer',
|
|
76
|
+
float: 'number', money: 'number', money_even: 'number',
|
|
77
|
+
cents: 'integer', cents_even: 'integer',
|
|
78
|
+
bool: 'boolean', truthy: 'boolean', falsy: 'boolean',
|
|
79
|
+
json: 'json', hash: 'json', array: 'json',
|
|
80
|
+
ids: { type: 'integer', array: true },
|
|
81
|
+
// everything else in the shipped set normalizes to a string:
|
|
82
|
+
string: 'string', text: 'string', name: 'string', address: 'string',
|
|
83
|
+
date: 'string', time: 'string', time12: 'string',
|
|
84
|
+
email: 'email', state: 'string', zip: 'zip', zipplus4: 'string',
|
|
85
|
+
ssn: 'string', sex: 'string', phone: 'string', username: 'string',
|
|
86
|
+
ip: 'string', mac: 'string', url: 'url', color: 'string',
|
|
87
|
+
uuid: 'uuid', semver: 'string', slug: 'string',
|
|
88
|
+
};
|
|
89
|
+
|
|
62
90
|
const HOOK_NAMES = new Set([
|
|
63
91
|
'beforeValidation', 'afterValidation',
|
|
64
92
|
'beforeSave', 'afterSave',
|
|
65
93
|
'beforeCreate', 'afterCreate',
|
|
66
94
|
'beforeUpdate', 'afterUpdate',
|
|
67
95
|
'beforeDestroy', 'afterDestroy',
|
|
96
|
+
// Transaction-aware hooks: fire after the outermost transaction
|
|
97
|
+
// commits / rolls back, or immediately after save/destroy when no
|
|
98
|
+
// transaction is open. See __schemaTransaction in runtime-orm.js.
|
|
99
|
+
'afterCommit', 'afterRollback',
|
|
68
100
|
]);
|
|
69
101
|
|
|
70
102
|
// Positions where `schema` can legitimately start an expression.
|
|
@@ -92,7 +124,11 @@ const EXPR_START_PREV = new Set([
|
|
|
92
124
|
export function hasSchemas(source) {
|
|
93
125
|
if (typeof source !== 'string') return false;
|
|
94
126
|
if (!/\bschema\b/.test(source)) return false;
|
|
95
|
-
|
|
127
|
+
// Matches a schema declaration (`schema :kind` / `schema` + indented
|
|
128
|
+
// body) or a runtime-namespace call (`schema.transaction`). The probe
|
|
129
|
+
// is conservative: a false positive just means typecheck pays a bit
|
|
130
|
+
// more work, never wrong behavior.
|
|
131
|
+
return /(?:^|[\s=,(\[{:])schema(?:\s*:[A-Za-z_$][\w$]*|\s*\n[ \t]+\S|\.transaction\b)/m.test(source);
|
|
96
132
|
}
|
|
97
133
|
|
|
98
134
|
// ============================================================================
|
|
@@ -232,8 +268,20 @@ function isSchemaStart(tokens, i) {
|
|
|
232
268
|
// SYMBOL? then `TERMINATOR ;` — inline body (one-liner), with field
|
|
233
269
|
// entries separated by more `;`
|
|
234
270
|
// terminators up to the newline.
|
|
271
|
+
// SYMBOL `, on: expr` then INDENT — declaration options (per-schema
|
|
272
|
+
// adapter) before the block body.
|
|
235
273
|
let j = i + 1;
|
|
236
|
-
if (tokens[j]?.[0] === 'SYMBOL')
|
|
274
|
+
if (tokens[j]?.[0] === 'SYMBOL') {
|
|
275
|
+
j++;
|
|
276
|
+
// `, on: expr` option tail — scan past it to the line end.
|
|
277
|
+
if (tokens[j]?.[0] === ',' &&
|
|
278
|
+
(tokens[j + 1]?.[0] === 'PROPERTY' || tokens[j + 1]?.[0] === 'IDENTIFIER') &&
|
|
279
|
+
tokens[j + 1]?.[1] === 'on') {
|
|
280
|
+
j += 2;
|
|
281
|
+
while (j < tokens.length && tokens[j][0] !== 'TERMINATOR' &&
|
|
282
|
+
tokens[j][0] !== 'INDENT') j++;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
237
285
|
if (tokens[j]?.[0] === 'TERMINATOR') {
|
|
238
286
|
if (tokens[j][1] === ';') return true;
|
|
239
287
|
j++;
|
|
@@ -257,12 +305,36 @@ function collapseSchemaAt(lexer, tokens, i, config) {
|
|
|
257
305
|
let k = kindToken[1];
|
|
258
306
|
if (!VALID_KINDS.has(k)) {
|
|
259
307
|
throw schemaError(kindToken,
|
|
260
|
-
`Unknown schema kind :${k}. Expected one of :input, :shape, :model, :mixin, :enum.`);
|
|
308
|
+
`Unknown schema kind :${k}. Expected one of :input, :shape, :model, :mixin, :enum, :union.`);
|
|
261
309
|
}
|
|
262
310
|
kind = k;
|
|
263
311
|
j++;
|
|
264
312
|
}
|
|
265
313
|
|
|
314
|
+
// Declaration options: `schema :model, on: analytics` pins the model
|
|
315
|
+
// to a specific adapter (an expression evaluated at declaration time).
|
|
316
|
+
// The expression tokens are captured raw and re-emitted at codegen.
|
|
317
|
+
let adapterTokens = null;
|
|
318
|
+
if (tokens[j]?.[0] === ',' &&
|
|
319
|
+
(tokens[j + 1]?.[0] === 'PROPERTY' || tokens[j + 1]?.[0] === 'IDENTIFIER') &&
|
|
320
|
+
tokens[j + 1]?.[1] === 'on') {
|
|
321
|
+
if (kind !== 'model') {
|
|
322
|
+
throw schemaError(tokens[j + 1],
|
|
323
|
+
`'on:' (per-schema adapter) applies to :model only — :${kind} never queries a database.`);
|
|
324
|
+
}
|
|
325
|
+
let k2 = j + 2;
|
|
326
|
+
if (tokens[k2]?.[0] === ':') k2++;
|
|
327
|
+
let exprStart = k2;
|
|
328
|
+
while (k2 < tokens.length && tokens[k2][0] !== 'TERMINATOR' &&
|
|
329
|
+
tokens[k2][0] !== 'INDENT') k2++;
|
|
330
|
+
adapterTokens = tokens.slice(exprStart, k2);
|
|
331
|
+
if (!adapterTokens.length) {
|
|
332
|
+
throw schemaError(tokens[j + 1],
|
|
333
|
+
`'on:' requires an adapter expression — 'schema :model, on: analytics'.`);
|
|
334
|
+
}
|
|
335
|
+
j = k2;
|
|
336
|
+
}
|
|
337
|
+
|
|
266
338
|
let bodyTokens;
|
|
267
339
|
let endIdx;
|
|
268
340
|
if (tokens[j]?.[0] === 'TERMINATOR' && tokens[j][1] === ';') {
|
|
@@ -289,7 +361,14 @@ function collapseSchemaAt(lexer, tokens, i, config) {
|
|
|
289
361
|
if (tag === '(' || tag === '[' || tag === '{' ||
|
|
290
362
|
tag === 'CALL_START' || tag === 'INDEX_START' || tag === 'PARAM_START') depth++;
|
|
291
363
|
else if (tag === ')' || tag === ']' || tag === '}' ||
|
|
292
|
-
tag === 'CALL_END' || tag === 'INDEX_END' || tag === 'PARAM_END')
|
|
364
|
+
tag === 'CALL_END' || tag === 'INDEX_END' || tag === 'PARAM_END') {
|
|
365
|
+
depth--;
|
|
366
|
+
// A closer the body never opened belongs to the ENCLOSING
|
|
367
|
+
// expression — `Admin = User.extend(schema :shape; perms!)`
|
|
368
|
+
// ends the inline body at the call's own `)`. Leave it in the
|
|
369
|
+
// stream for the outer parser.
|
|
370
|
+
if (depth < 0) break;
|
|
371
|
+
}
|
|
293
372
|
// Inline body ends at the first depth-0 newline OR at any
|
|
294
373
|
// INDENT/OUTDENT — INDENT would mean the user opened a block
|
|
295
374
|
// (incompatible with inline), and OUTDENT means we're exiting
|
|
@@ -355,6 +434,7 @@ function collapseSchemaAt(lexer, tokens, i, config) {
|
|
|
355
434
|
// don't retroactively change already-parsed schemas.
|
|
356
435
|
defaultMaxString: config?.defaultMaxString ?? null,
|
|
357
436
|
});
|
|
437
|
+
if (adapterTokens) descriptor.adapterTokens = adapterTokens;
|
|
358
438
|
|
|
359
439
|
// Replace range `[i, endIdx-1]` with `SCHEMA SCHEMA_BODY`.
|
|
360
440
|
let schemaNewTok = mkToken('SCHEMA', 'schema', schemaTok);
|
|
@@ -385,6 +465,22 @@ function parseSchemaBody(kind, bodyTokens, ctx) {
|
|
|
385
465
|
for (let line of lines) {
|
|
386
466
|
parseEnumLine(line, entries);
|
|
387
467
|
}
|
|
468
|
+
} else if (kind === 'union') {
|
|
469
|
+
for (let line of lines) {
|
|
470
|
+
parseUnionLine(line, entries);
|
|
471
|
+
}
|
|
472
|
+
let onCount = entries.filter(e => e.tag === 'directive' && e.name === 'on').length;
|
|
473
|
+
let members = entries.filter(e => e.tag === 'union-member');
|
|
474
|
+
if (onCount !== 1) {
|
|
475
|
+
throw schemaError({ loc: ctx.schemaLoc },
|
|
476
|
+
onCount === 0
|
|
477
|
+
? `:union requires an '@on :field' discriminator — untagged unions are not supported.`
|
|
478
|
+
: `:union takes exactly one '@on :field' discriminator (got ${onCount}).`);
|
|
479
|
+
}
|
|
480
|
+
if (members.length < 2) {
|
|
481
|
+
throw schemaError({ loc: ctx.schemaLoc },
|
|
482
|
+
`:union needs at least two constituent schemas (got ${members.length}).`);
|
|
483
|
+
}
|
|
388
484
|
} else {
|
|
389
485
|
for (let line of lines) {
|
|
390
486
|
parseFieldedLine(kind, line, entries, ctx);
|
|
@@ -403,6 +499,10 @@ function parseSchemaBody(kind, bodyTokens, ctx) {
|
|
|
403
499
|
throw schemaError({ loc: e.headerLoc || e.loc },
|
|
404
500
|
`:mixin schemas don't accept @ensure refinements. Move the invariant to a :shape or :model that composes this mixin.`);
|
|
405
501
|
}
|
|
502
|
+
if (e.tag === 'scope' || e.tag === 'defaultScope') {
|
|
503
|
+
throw schemaError({ loc: e.headerLoc || e.loc },
|
|
504
|
+
`:mixin schemas don't accept query scopes. '@${e.tag === 'scope' ? 'scope' : 'defaultScope'}' is :model-only.`);
|
|
505
|
+
}
|
|
406
506
|
if (e.tag === 'directive' && e.name !== 'mixin') {
|
|
407
507
|
throw schemaError({ loc: e.loc },
|
|
408
508
|
`:mixin schemas only accept '@mixin Name' directives. '@${e.name}' is not allowed.`);
|
|
@@ -418,6 +518,10 @@ function parseSchemaBody(kind, bodyTokens, ctx) {
|
|
|
418
518
|
throw schemaError({ loc: e.headerLoc || e.loc },
|
|
419
519
|
`:input schemas are fields-only. '${e.name}' is a ${e.tag}; use :shape or :model if you need behavior.`);
|
|
420
520
|
}
|
|
521
|
+
if (e.tag === 'scope' || e.tag === 'defaultScope') {
|
|
522
|
+
throw schemaError({ loc: e.headerLoc || e.loc },
|
|
523
|
+
`:input schemas don't accept query scopes. '@${e.tag === 'scope' ? 'scope' : 'defaultScope'}' is :model-only.`);
|
|
524
|
+
}
|
|
421
525
|
if (e.tag === 'directive' && e.name !== 'mixin') {
|
|
422
526
|
throw schemaError({ loc: e.loc },
|
|
423
527
|
`:input schemas only accept '@mixin Name' and '@ensure'. '@${e.name}' is not allowed.`);
|
|
@@ -432,6 +536,10 @@ function parseSchemaBody(kind, bodyTokens, ctx) {
|
|
|
432
536
|
throw schemaError({ loc: e.headerLoc || e.loc },
|
|
433
537
|
`:shape schemas don't have lifecycle hooks. '${e.name}' runs only on :model; move it or remove it.`);
|
|
434
538
|
}
|
|
539
|
+
if (e.tag === 'scope' || e.tag === 'defaultScope') {
|
|
540
|
+
throw schemaError({ loc: e.headerLoc || e.loc },
|
|
541
|
+
`:shape schemas don't accept query scopes. '@${e.tag === 'scope' ? 'scope' : 'defaultScope'}' is :model-only.`);
|
|
542
|
+
}
|
|
435
543
|
if (e.tag === 'directive' && e.name !== 'mixin') {
|
|
436
544
|
throw schemaError({ loc: e.loc },
|
|
437
545
|
`:shape schemas only accept '@mixin Name'. '@${e.name}' is :model-only.`);
|
|
@@ -513,17 +621,24 @@ function parseFieldedLine(kind, line, entries, ctx) {
|
|
|
513
621
|
let dname = nameTok[1];
|
|
514
622
|
|
|
515
623
|
// `@ensure` is a refinement directive with its own grammar — it takes
|
|
516
|
-
// either an inline `"msg", (args) -> body` or a bracketed
|
|
517
|
-
// those
|
|
518
|
-
//
|
|
519
|
-
//
|
|
624
|
+
// either an inline `"msg"[, :field], (args) -> body` or a bracketed
|
|
625
|
+
// array of those tuples. The optional `:field` symbol attributes the
|
|
626
|
+
// failure to a specific field so form libraries can attach the error
|
|
627
|
+
// to the right input; without it the issue stays schema-wide
|
|
628
|
+
// (`field: ''`). `@ensure!` (dammit) marks ASYNC refinements — the
|
|
629
|
+
// schema becomes async-validating (parseAsync!/safeAsync!/okAsync!).
|
|
630
|
+
// Emits one `tag: "ensure"` entry per refinement; the per-entry
|
|
631
|
+
// shape mirrors methods so compileCallableFn-style codegen can fire.
|
|
520
632
|
if (dname === 'ensure') {
|
|
633
|
+
let isAsync = nameTok.data?.bang === true;
|
|
521
634
|
let pairs = parseEnsurePairs(argTokens, first);
|
|
522
635
|
for (let p of pairs) {
|
|
523
636
|
entries.push({
|
|
524
637
|
tag: 'ensure',
|
|
525
638
|
name: 'ensure',
|
|
526
639
|
message: p.message,
|
|
640
|
+
field: p.field || null,
|
|
641
|
+
async: isAsync,
|
|
527
642
|
paramTokens: p.paramTokens,
|
|
528
643
|
bodyTokens: p.bodyTokens,
|
|
529
644
|
loc: p.loc,
|
|
@@ -533,6 +648,49 @@ function parseFieldedLine(kind, line, entries, ctx) {
|
|
|
533
648
|
return;
|
|
534
649
|
}
|
|
535
650
|
|
|
651
|
+
// `@scope` declares a named, composable query scope — :model-only.
|
|
652
|
+
// Two forms:
|
|
653
|
+
//
|
|
654
|
+
// @scope :active, -> @where(active: true) (zero-arg)
|
|
655
|
+
// @scope :since, (d) -> @where("created_at > ?", d) (parameterized)
|
|
656
|
+
//
|
|
657
|
+
// `this` inside the body is the query builder. Emits a
|
|
658
|
+
// `tag: "scope"` entry carrying the compiled fn, parallel to
|
|
659
|
+
// `@ensure`'s shape.
|
|
660
|
+
if (dname === 'scope') {
|
|
661
|
+
let parsed = parseScopeDirective(argTokens, first);
|
|
662
|
+
entries.push({
|
|
663
|
+
tag: 'scope',
|
|
664
|
+
name: parsed.name,
|
|
665
|
+
paramTokens: parsed.paramTokens,
|
|
666
|
+
bodyTokens: parsed.bodyTokens,
|
|
667
|
+
loc: first.loc,
|
|
668
|
+
headerLoc: first.loc,
|
|
669
|
+
});
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// `@defaultScope -> body` applies to every query builder unless
|
|
674
|
+
// `.unscoped()` is called. Exactly one per model (enforced at
|
|
675
|
+
// normalize time, where duplicates across mixin expansion would
|
|
676
|
+
// also surface).
|
|
677
|
+
if (dname === 'defaultScope') {
|
|
678
|
+
let parsed = parseScopeFn(stripCallWrapper(argTokens), first, '@defaultScope');
|
|
679
|
+
if (parsed.paramTokens.length) {
|
|
680
|
+
throw schemaError(first,
|
|
681
|
+
"@defaultScope takes no parameters — write '@defaultScope -> @where(...)'.");
|
|
682
|
+
}
|
|
683
|
+
entries.push({
|
|
684
|
+
tag: 'defaultScope',
|
|
685
|
+
name: 'defaultScope',
|
|
686
|
+
paramTokens: [],
|
|
687
|
+
bodyTokens: parsed.bodyTokens,
|
|
688
|
+
loc: first.loc,
|
|
689
|
+
headerLoc: first.loc,
|
|
690
|
+
});
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
|
|
536
694
|
// Pre-parse structured args so shadow-TS and runtime-codegen share
|
|
537
695
|
// the same descriptor shape. Relation and mixin directives get a
|
|
538
696
|
// `[{target, optional?}]` array; other directives leave `args` unset.
|
|
@@ -582,17 +740,27 @@ function parseFieldedLine(kind, line, entries, ctx) {
|
|
|
582
740
|
let modifiers = collectModifiers(first);
|
|
583
741
|
let pos = 1;
|
|
584
742
|
|
|
585
|
-
// Adjacent `!`,
|
|
586
|
-
//
|
|
587
|
-
// token
|
|
588
|
-
// adjacent to an identifier. A modifier must be unspaced from the
|
|
589
|
-
// token it follows, so we check the preceding token's `.spaced` flag
|
|
743
|
+
// Adjacent `!`, `?` modifier tokens. They are absorbed into the
|
|
744
|
+
// IDENTIFIER's data by the main lexer. A modifier must be unspaced from
|
|
745
|
+
// the token it follows, so we check the preceding token's `.spaced` flag
|
|
590
746
|
// (which the whitespace pass sets to true when whitespace follows).
|
|
747
|
+
//
|
|
748
|
+
// `#` used to be a third (unique) modifier; it arrives as a standalone
|
|
749
|
+
// token because the schema commentToken exception kicks in when `#` is
|
|
750
|
+
// adjacent to an identifier. Uniqueness is now a storage concern spelled
|
|
751
|
+
// `@unique` — so a leftover `#` here is a removed-syntax migration error,
|
|
752
|
+
// not a silent no-op.
|
|
591
753
|
while (pos < line.length) {
|
|
592
754
|
let tk = line[pos];
|
|
593
755
|
let adjacent = line[pos - 1] && !line[pos - 1].spaced;
|
|
594
756
|
if (!adjacent) break;
|
|
595
|
-
if (tk[0] === '#'
|
|
757
|
+
if (tk[0] === '#') {
|
|
758
|
+
throw schemaError(tk,
|
|
759
|
+
`The '#' unique modifier was removed. Mark uniqueness with '@unique': ` +
|
|
760
|
+
`inline as '${name}! <type> @unique', or as a directive '@unique :${name}' ` +
|
|
761
|
+
`(composite: '@unique [:a, :b]').`);
|
|
762
|
+
}
|
|
763
|
+
if (tk[0] === '?' || tk[0] === '!') {
|
|
596
764
|
modifiers.push(tk[0]);
|
|
597
765
|
pos++;
|
|
598
766
|
continue;
|
|
@@ -609,13 +777,54 @@ function parseFieldedLine(kind, line, entries, ctx) {
|
|
|
609
777
|
}
|
|
610
778
|
|
|
611
779
|
// Type: IDENTIFIER (optionally followed by `[]` for array) OR a
|
|
612
|
-
// string-literal union like `"M" | "F" | "U"
|
|
780
|
+
// string-literal union like `"M" | "F" | "U"`, optionally prefixed
|
|
781
|
+
// with `~` for coercion ("coerce, then validate"). The type slot is
|
|
613
782
|
// OPTIONAL — if the next token isn't a type-starting token, the
|
|
614
783
|
// field defaults to `string` and we fall through to constraint
|
|
615
784
|
// parsing.
|
|
616
785
|
let typeName = 'string';
|
|
617
786
|
let literals = null;
|
|
618
|
-
|
|
787
|
+
let coerce = false;
|
|
788
|
+
let coercer = null;
|
|
789
|
+
let coercerArray = false;
|
|
790
|
+
// `~type` / `~:name` — coercion markers. Lexically unambiguous in the
|
|
791
|
+
// type slot: `~>` (computed) lexes as EFFECT and lives after a `:`, a
|
|
792
|
+
// different slot entirely. `~type` coerces via the strict built-in
|
|
793
|
+
// tables; `~:name` coerces via the named-coercer registry (the
|
|
794
|
+
// rip-server read() vocabulary, plus app registrations).
|
|
795
|
+
if (typeFirst?.[0] === 'UNARY_MATH' && typeFirst[1] === '~') {
|
|
796
|
+
let typeTok = line[pos + 1];
|
|
797
|
+
if (typeTok?.[0] === 'SYMBOL') {
|
|
798
|
+
coerce = true;
|
|
799
|
+
coercer = typeTok[1];
|
|
800
|
+
let out = SCHEMA_NAMED_COERCER_TYPES[coercer];
|
|
801
|
+
if (out && typeof out === 'object') {
|
|
802
|
+
// e.g. ~:ids → integer[] — the coercer's output is a list.
|
|
803
|
+
typeName = out.type;
|
|
804
|
+
coercerArray = true;
|
|
805
|
+
} else {
|
|
806
|
+
typeName = out || 'any';
|
|
807
|
+
}
|
|
808
|
+
pos += 2;
|
|
809
|
+
typeFirst = typeTok;
|
|
810
|
+
} else if (typeTok?.[0] === 'IDENTIFIER') {
|
|
811
|
+
if (!SCHEMA_COERCIBLE_TYPES.has(typeTok[1])) {
|
|
812
|
+
throw schemaError(typeTok,
|
|
813
|
+
`'~${typeTok[1]}' is not coercible. Built-in coercion is defined for: ${[...SCHEMA_COERCIBLE_TYPES].join(', ')}. ` +
|
|
814
|
+
`Named coercers use a symbol — '~:${typeTok[1]}' — and resolve through the registry ` +
|
|
815
|
+
`(@rip-lang/validate's vocabulary, or schema.registerCoercer). ` +
|
|
816
|
+
`For anything else, write an explicit transform: '${name}, -> …'.`);
|
|
817
|
+
}
|
|
818
|
+
coerce = true;
|
|
819
|
+
typeName = typeTok[1];
|
|
820
|
+
pos += 2;
|
|
821
|
+
typeFirst = typeTok; // for the typeConsumed / comma-rule checks below
|
|
822
|
+
} else {
|
|
823
|
+
throw schemaError(typeFirst,
|
|
824
|
+
`'~' in the type slot marks coercion and needs a type name ('~integer', '~date', …) ` +
|
|
825
|
+
`or a registered coercer symbol ('~:ssn', '~:money', …).`);
|
|
826
|
+
}
|
|
827
|
+
} else if (typeFirst?.[0] === 'IDENTIFIER') {
|
|
619
828
|
typeName = typeFirst[1];
|
|
620
829
|
pos++;
|
|
621
830
|
} else if (typeFirst?.[0] === 'STRING') {
|
|
@@ -634,10 +843,9 @@ function parseFieldedLine(kind, line, entries, ctx) {
|
|
|
634
843
|
throw schemaError(next || line[pos],
|
|
635
844
|
`Literal unions contain string literals only. '${tag}' is not allowed as a union member. Use the '?' modifier for nullability.`);
|
|
636
845
|
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
}
|
|
846
|
+
// A single literal is a constant field — `kind! "click"` — the
|
|
847
|
+
// building block of discriminated unions (each :union constituent
|
|
848
|
+
// declares its tag this way). Multi-member unions need 2+ as before.
|
|
641
849
|
typeName = 'literal-union';
|
|
642
850
|
}
|
|
643
851
|
let array = false;
|
|
@@ -651,6 +859,11 @@ function parseFieldedLine(kind, line, entries, ctx) {
|
|
|
651
859
|
array = true;
|
|
652
860
|
pos += 2;
|
|
653
861
|
}
|
|
862
|
+
if (coerce && array) {
|
|
863
|
+
throw schemaError(typeFirst,
|
|
864
|
+
`Coercion ('~${typeName}') does not apply to array types. Coerce per-element with a transform instead.`);
|
|
865
|
+
}
|
|
866
|
+
if (coercerArray) array = true;
|
|
654
867
|
|
|
655
868
|
// Remaining tokens on the line are a mix of `[…]` constraints (default,
|
|
656
869
|
// regex), `{…}` attrs, and `n..n` range constraints. Each form is
|
|
@@ -661,7 +874,7 @@ function parseFieldedLine(kind, line, entries, ctx) {
|
|
|
661
874
|
// Comma-required rule: if a type was consumed and the next token is
|
|
662
875
|
// `->` (no comma separator), reject with a clear diagnostic. The
|
|
663
876
|
// comma is a structural boundary between the field declaration and
|
|
664
|
-
// the transform; skipping it makes `email
|
|
877
|
+
// the transform; skipping it makes `email! email -> fn` read as
|
|
665
878
|
// if 'email' were an argument to the arrow, which it isn't.
|
|
666
879
|
let typeConsumed = typeFirst?.[0] === 'IDENTIFIER' || typeFirst?.[0] === 'STRING';
|
|
667
880
|
if (typeConsumed && rest[0]?.[0] === '->') {
|
|
@@ -673,6 +886,7 @@ function parseFieldedLine(kind, line, entries, ctx) {
|
|
|
673
886
|
let rangeTokens = null;
|
|
674
887
|
let regexToken = null;
|
|
675
888
|
let transformTokens = null;
|
|
889
|
+
let uniqueAttr = false;
|
|
676
890
|
|
|
677
891
|
if (rest.length > 0) {
|
|
678
892
|
// The leading comma is only required when a type was consumed. If
|
|
@@ -681,6 +895,23 @@ function parseFieldedLine(kind, line, entries, ctx) {
|
|
|
681
895
|
if (rest[0]?.[0] === ',') {
|
|
682
896
|
rest = rest.slice(1);
|
|
683
897
|
}
|
|
898
|
+
// Multi-line trailers (`password! string,\n 8..100,\n /[A-Z]/`)
|
|
899
|
+
// arrive wrapped in one INDENT … OUTDENT pair. Strip it when it
|
|
900
|
+
// spans the whole remainder, so the inner commas are top-level for
|
|
901
|
+
// the split below — otherwise the whole continuation reads as ONE
|
|
902
|
+
// nested part and per-constraint classification never happens.
|
|
903
|
+
if (rest.length >= 2 && rest[0][0] === 'INDENT') {
|
|
904
|
+
let d = 0;
|
|
905
|
+
let lastIdx = -1;
|
|
906
|
+
for (let k = 0; k < rest.length; k++) {
|
|
907
|
+
if (rest[k][0] === 'INDENT') d++;
|
|
908
|
+
else if (rest[k][0] === 'OUTDENT') {
|
|
909
|
+
d--;
|
|
910
|
+
if (d === 0) { lastIdx = k; break; }
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
if (lastIdx === rest.length - 1) rest = rest.slice(1, -1);
|
|
914
|
+
}
|
|
684
915
|
// Split top-level by commas. Multi-line trailers (`name! type,\n
|
|
685
916
|
// [8, 100]`) introduce surrounding INDENT/OUTDENT tokens that
|
|
686
917
|
// don't affect semantics — strip them from each part so the head
|
|
@@ -750,9 +981,26 @@ function parseFieldedLine(kind, line, entries, ctx) {
|
|
|
750
981
|
`Transform '-> …' must be the last element on the field line for '${name}'.`);
|
|
751
982
|
}
|
|
752
983
|
transformTokens = part.slice(1);
|
|
984
|
+
} else if (head[0] === '@') {
|
|
985
|
+
// Trailing `@unique` — single-column uniqueness co-located on the
|
|
986
|
+
// field (mirrors Prisma's field-level `@unique`). Uniqueness is a
|
|
987
|
+
// storage property, so it rides as a field attribute, not a `!`/`?`
|
|
988
|
+
// shape modifier. Composite uniqueness uses the `@unique [...]`
|
|
989
|
+
// directive on its own line.
|
|
990
|
+
let attrTok = part[1];
|
|
991
|
+
let attrName = attrTok && (attrTok[0] === 'IDENTIFIER' || attrTok[0] === 'PROPERTY') ? attrTok[1] : null;
|
|
992
|
+
if (part.length === 2 && attrName === 'unique') {
|
|
993
|
+
if (uniqueAttr) {
|
|
994
|
+
throw schemaError(head, `Field '${name}' has more than one '@unique'.`);
|
|
995
|
+
}
|
|
996
|
+
uniqueAttr = true;
|
|
997
|
+
} else {
|
|
998
|
+
throw schemaError(head,
|
|
999
|
+
`Unknown inline attribute '@${attrName ?? ''}' on field '${name}'. The only inline attribute is '@unique'.`);
|
|
1000
|
+
}
|
|
753
1001
|
} else {
|
|
754
1002
|
throw schemaError(head,
|
|
755
|
-
`Unexpected trailer for field '${name}'. Expected '[…]' default, '{…}' attrs, '/regex/', 'min..max' range,
|
|
1003
|
+
`Unexpected trailer for field '${name}'. Expected '[…]' default, '{…}' attrs, '/regex/', 'min..max' range, '-> transform', or '@unique'.`);
|
|
756
1004
|
}
|
|
757
1005
|
}
|
|
758
1006
|
}
|
|
@@ -776,13 +1024,24 @@ function parseFieldedLine(kind, line, entries, ctx) {
|
|
|
776
1024
|
defaultMax = ctx.defaultMaxString;
|
|
777
1025
|
}
|
|
778
1026
|
|
|
1027
|
+
// Coercion IS the wire-value derivation; a transform is full manual
|
|
1028
|
+
// control of the same step. Both on one field is a contradiction.
|
|
1029
|
+
if (coerce && transformTokens) {
|
|
1030
|
+
throw schemaError(first,
|
|
1031
|
+
`Field '${name}' has both '~${typeName}' coercion and a '->' transform. ` +
|
|
1032
|
+
`A transform replaces coercion — coerce inside it instead.`);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
779
1035
|
entries.push({
|
|
780
1036
|
tag: 'field',
|
|
781
1037
|
name,
|
|
782
1038
|
modifiers,
|
|
1039
|
+
unique: uniqueAttr,
|
|
783
1040
|
typeName,
|
|
784
1041
|
array,
|
|
785
1042
|
literals,
|
|
1043
|
+
coerce,
|
|
1044
|
+
coercer,
|
|
786
1045
|
constraintTokens,
|
|
787
1046
|
attrsTokens,
|
|
788
1047
|
rangeTokens,
|
|
@@ -841,27 +1100,56 @@ function parseCallableLine(kind, headerTok, line, entries) {
|
|
|
841
1100
|
throw schemaError(headerTok,
|
|
842
1101
|
`Expected ':' after '${name}' before arrow.`);
|
|
843
1102
|
}
|
|
844
|
-
//
|
|
845
|
-
// name: -> body
|
|
846
|
-
// name:
|
|
847
|
-
//
|
|
848
|
-
|
|
849
|
-
|
|
1103
|
+
// Arrow forms:
|
|
1104
|
+
// name: -> body — method / hook
|
|
1105
|
+
// name: (params) -> body — parameterized method (methods only —
|
|
1106
|
+
// hooks/computed/derived are accessor-
|
|
1107
|
+
// shaped and take no arguments)
|
|
1108
|
+
// name: ~> body — lazy computed getter (EFFECT token)
|
|
1109
|
+
// name: !> body — eager derived field (UNARY_MATH '!' + COMPARE '>')
|
|
1110
|
+
//
|
|
1111
|
+
// Method params may carry Rip type annotations — `(other:: Money)` —
|
|
1112
|
+
// which the type rewriter (an earlier lexer pass) has already stripped
|
|
1113
|
+
// and stamped onto the identifier tokens as `.data.type`; we keep the
|
|
1114
|
+
// raw tokens so codegen can rebuild typed params.
|
|
1115
|
+
let pos = 2;
|
|
1116
|
+
let paramTokens = [];
|
|
1117
|
+
let hadParams = false;
|
|
1118
|
+
if (line[pos] && (line[pos][0] === 'PARAM_START' || line[pos][0] === '(')) {
|
|
1119
|
+
hadParams = true;
|
|
1120
|
+
let depth = 1;
|
|
1121
|
+
pos++;
|
|
1122
|
+
while (pos < line.length && depth > 0) {
|
|
1123
|
+
let tag = line[pos][0];
|
|
1124
|
+
if (tag === '(' || tag === 'PARAM_START') depth++;
|
|
1125
|
+
if (tag === ')' || tag === 'PARAM_END') {
|
|
1126
|
+
depth--;
|
|
1127
|
+
if (depth === 0) { pos++; break; }
|
|
1128
|
+
}
|
|
1129
|
+
paramTokens.push(line[pos]);
|
|
1130
|
+
pos++;
|
|
1131
|
+
}
|
|
1132
|
+
if (depth !== 0) {
|
|
1133
|
+
throw schemaError(line[2], `'${name}': unclosed '(' in parameters.`);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
let arrowTok = line[pos];
|
|
1137
|
+
let nextTok = line[pos + 1];
|
|
850
1138
|
let arrow, arrowLoc, bodyStart;
|
|
851
1139
|
if (arrowTok && arrowTok[0] === '->') {
|
|
852
1140
|
arrow = '->';
|
|
853
1141
|
arrowLoc = arrowTok.loc;
|
|
854
|
-
bodyStart =
|
|
1142
|
+
bodyStart = pos + 1;
|
|
855
1143
|
} else if (arrowTok && arrowTok[0] === 'EFFECT') {
|
|
856
1144
|
arrow = '~>';
|
|
857
1145
|
arrowLoc = arrowTok.loc;
|
|
858
|
-
bodyStart =
|
|
1146
|
+
bodyStart = pos + 1;
|
|
859
1147
|
} else if (arrowTok && arrowTok[0] === 'UNARY_MATH' && arrowTok[1] === '!' &&
|
|
860
1148
|
nextTok && nextTok[0] === 'COMPARE' && nextTok[1] === '>' &&
|
|
861
1149
|
!arrowTok.spaced) {
|
|
862
1150
|
arrow = '!>';
|
|
863
1151
|
arrowLoc = arrowTok.loc;
|
|
864
|
-
bodyStart =
|
|
1152
|
+
bodyStart = pos + 2;
|
|
865
1153
|
} else {
|
|
866
1154
|
throw schemaError(colonTok,
|
|
867
1155
|
`Schema top-level '${name}:' must be followed by '->' (method/hook), '~>' (computed getter), or '!>' (eager derived).`);
|
|
@@ -878,17 +1166,100 @@ function parseCallableLine(kind, headerTok, line, entries) {
|
|
|
878
1166
|
} else {
|
|
879
1167
|
entryTag = 'method';
|
|
880
1168
|
}
|
|
1169
|
+
if (hadParams && entryTag !== 'method') {
|
|
1170
|
+
let what = entryTag === 'hook' ? 'lifecycle hooks' :
|
|
1171
|
+
entryTag === 'computed' ? 'computed getters (~>)' : 'eager-derived fields (!>)';
|
|
1172
|
+
throw schemaError(colonTok,
|
|
1173
|
+
`'${name}': ${what} take no parameters — only methods do.`);
|
|
1174
|
+
}
|
|
881
1175
|
entries.push({
|
|
882
1176
|
tag: entryTag,
|
|
883
1177
|
name,
|
|
884
1178
|
arrow,
|
|
885
|
-
paramTokens
|
|
1179
|
+
paramTokens,
|
|
886
1180
|
bodyTokens,
|
|
887
1181
|
headerLoc: headerTok.loc,
|
|
888
1182
|
arrowLoc,
|
|
889
1183
|
});
|
|
890
1184
|
}
|
|
891
1185
|
|
|
1186
|
+
// Strip the implicit CALL_START/CALL_END wrapper that Rip puts around
|
|
1187
|
+
// directive arguments (`@scope args...` is a call to the lexer).
|
|
1188
|
+
function stripCallWrapper(tokens) {
|
|
1189
|
+
if (tokens[0]?.[0] === 'CALL_START' &&
|
|
1190
|
+
tokens[tokens.length - 1]?.[0] === 'CALL_END') {
|
|
1191
|
+
return tokens.slice(1, -1);
|
|
1192
|
+
}
|
|
1193
|
+
return tokens;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// Parse `@scope :name, fn` arguments. The name must be a :symbol; the fn
|
|
1197
|
+
// is either `-> body` (zero-arg, builder-thisbound) or `(args) -> body`.
|
|
1198
|
+
function parseScopeDirective(argTokens, directiveTok) {
|
|
1199
|
+
let tokens = stripCallWrapper(argTokens);
|
|
1200
|
+
if (!tokens.length) {
|
|
1201
|
+
throw schemaError(directiveTok,
|
|
1202
|
+
"@scope requires ':name, -> body' (or ':name, (args) -> body').");
|
|
1203
|
+
}
|
|
1204
|
+
let nameTok = tokens[0];
|
|
1205
|
+
if (nameTok[0] !== 'SYMBOL') {
|
|
1206
|
+
throw schemaError(nameTok,
|
|
1207
|
+
"@scope name must be a :symbol — '@scope :active, -> @where(active: true)'.");
|
|
1208
|
+
}
|
|
1209
|
+
let name = nameTok[1];
|
|
1210
|
+
if (!/^[a-z][a-zA-Z0-9]*$/.test(name)) {
|
|
1211
|
+
throw schemaError(nameTok,
|
|
1212
|
+
`@scope name ':${name}' must be a lowercase-first alphanumeric identifier.`);
|
|
1213
|
+
}
|
|
1214
|
+
let rest = tokens.slice(1);
|
|
1215
|
+
if (rest[0]?.[0] === ',') rest = rest.slice(1);
|
|
1216
|
+
if (!rest.length) {
|
|
1217
|
+
throw schemaError(nameTok,
|
|
1218
|
+
`@scope :${name} is missing its body — '@scope :${name}, -> @where(...)'.`);
|
|
1219
|
+
}
|
|
1220
|
+
let fn = parseScopeFn(rest, nameTok, `@scope :${name}`);
|
|
1221
|
+
return { name, paramTokens: fn.paramTokens, bodyTokens: fn.bodyTokens };
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// Parse a scope function token slice: `-> body` or `(params) -> body`.
|
|
1225
|
+
// Unlike @ensure predicates, the parameter list is optional — scopes
|
|
1226
|
+
// read the builder through `this`, so most take no arguments.
|
|
1227
|
+
function parseScopeFn(tokens, anchorTok, label) {
|
|
1228
|
+
if (!tokens.length) {
|
|
1229
|
+
throw schemaError(anchorTok, `${label}: expected '-> body' or '(args) -> body'.`);
|
|
1230
|
+
}
|
|
1231
|
+
let paramTokens = [];
|
|
1232
|
+
let pos = 0;
|
|
1233
|
+
let t0 = tokens[0];
|
|
1234
|
+
if (t0[0] === '(' || t0[0] === 'PARAM_START') {
|
|
1235
|
+
let depth = 1;
|
|
1236
|
+
pos = 1;
|
|
1237
|
+
while (pos < tokens.length && depth > 0) {
|
|
1238
|
+
let tag = tokens[pos][0];
|
|
1239
|
+
if (tag === '(' || tag === 'PARAM_START') depth++;
|
|
1240
|
+
if (tag === ')' || tag === 'PARAM_END') {
|
|
1241
|
+
depth--;
|
|
1242
|
+
if (depth === 0) { pos++; break; }
|
|
1243
|
+
}
|
|
1244
|
+
paramTokens.push(tokens[pos]);
|
|
1245
|
+
pos++;
|
|
1246
|
+
}
|
|
1247
|
+
if (depth !== 0) {
|
|
1248
|
+
throw schemaError(t0, `${label}: unclosed '(' in parameters.`);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
let arrowTok = tokens[pos];
|
|
1252
|
+
if (!arrowTok || arrowTok[0] !== '->') {
|
|
1253
|
+
throw schemaError(arrowTok || anchorTok,
|
|
1254
|
+
`${label}: expected '->' ${paramTokens.length ? 'after parameters' : 'to start the body'}.`);
|
|
1255
|
+
}
|
|
1256
|
+
let bodyTokens = tokens.slice(pos + 1);
|
|
1257
|
+
if (!bodyTokens.length) {
|
|
1258
|
+
throw schemaError(arrowTok, `${label}: function body is empty.`);
|
|
1259
|
+
}
|
|
1260
|
+
return { paramTokens, bodyTokens };
|
|
1261
|
+
}
|
|
1262
|
+
|
|
892
1263
|
// Parse `@ensure` arguments into one or more refinement pairs. Accepts two
|
|
893
1264
|
// forms:
|
|
894
1265
|
//
|
|
@@ -928,28 +1299,47 @@ function parseEnsurePairs(argTokens, directiveTok) {
|
|
|
928
1299
|
if (parts.length === 0) {
|
|
929
1300
|
throw schemaError(first, "@ensure [...] must contain at least one 'message, fn' pair.");
|
|
930
1301
|
}
|
|
931
|
-
|
|
932
|
-
throw schemaError(first,
|
|
933
|
-
`@ensure [...] must have pairs of 'message, fn' (got ${parts.length} elements; odd count).`);
|
|
934
|
-
}
|
|
935
|
-
let pairs = [];
|
|
936
|
-
for (let i = 0; i < parts.length; i += 2) {
|
|
937
|
-
pairs.push(extractEnsurePair(parts[i], parts[i + 1], first));
|
|
938
|
-
}
|
|
939
|
-
return pairs;
|
|
1302
|
+
return consumeEnsureTuples(parts, first);
|
|
940
1303
|
}
|
|
941
1304
|
|
|
942
|
-
// Inline form: STRING, (args) -> body
|
|
1305
|
+
// Inline form: STRING [, :field], (args) -> body
|
|
943
1306
|
let parts = splitTopLevelByComma(tokens);
|
|
944
1307
|
if (parts.length < 2) {
|
|
945
1308
|
throw schemaError(first,
|
|
946
1309
|
"@ensure inline form must be 'message, (x) -> body'. Did you forget the comma?");
|
|
947
1310
|
}
|
|
948
|
-
if (parts.length >
|
|
1311
|
+
if (parts.length > 3 || (parts.length === 3 && !isEnsureFieldPart(parts[1]))) {
|
|
949
1312
|
throw schemaError(first,
|
|
950
|
-
`@ensure inline form takes
|
|
1313
|
+
`@ensure inline form takes 'message[, :field], fn' (got ${parts.length} comma-separated parts). Use '@ensure [...]' for multiple refinements.`);
|
|
951
1314
|
}
|
|
952
|
-
return
|
|
1315
|
+
return consumeEnsureTuples(parts, first);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// One element that is exactly a `:field` symbol — the optional
|
|
1319
|
+
// attribution slot between message and predicate.
|
|
1320
|
+
function isEnsureFieldPart(part) {
|
|
1321
|
+
return part && part.length === 1 && part[0][0] === 'SYMBOL';
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// Walk a flat element stream consuming `message[, :field], fn` tuples.
|
|
1325
|
+
// Both the inline form and the array form normalize through here, so
|
|
1326
|
+
// the two source shapes stay indistinguishable downstream.
|
|
1327
|
+
function consumeEnsureTuples(parts, anchorTok) {
|
|
1328
|
+
let pairs = [];
|
|
1329
|
+
let i = 0;
|
|
1330
|
+
while (i < parts.length) {
|
|
1331
|
+
let msgPart = parts[i++];
|
|
1332
|
+
let fieldPart = null;
|
|
1333
|
+
if (i < parts.length && isEnsureFieldPart(parts[i])) {
|
|
1334
|
+
fieldPart = parts[i++];
|
|
1335
|
+
}
|
|
1336
|
+
if (i >= parts.length) {
|
|
1337
|
+
throw schemaError(msgPart?.[0] || anchorTok,
|
|
1338
|
+
"@ensure: missing function after message" + (fieldPart ? " and :field" : "") + ".");
|
|
1339
|
+
}
|
|
1340
|
+
pairs.push(extractEnsurePair(msgPart, fieldPart, parts[i++], anchorTok));
|
|
1341
|
+
}
|
|
1342
|
+
return pairs;
|
|
953
1343
|
}
|
|
954
1344
|
|
|
955
1345
|
// Walk `[ ... ]` tokens and return the inner slice. Rejects trailing
|
|
@@ -1027,11 +1417,12 @@ function splitEnsureElements(tokens) {
|
|
|
1027
1417
|
return parts;
|
|
1028
1418
|
}
|
|
1029
1419
|
|
|
1030
|
-
// Extract one refinement
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1033
|
-
// "expected
|
|
1034
|
-
function
|
|
1420
|
+
// Extract one refinement tuple from `messagePart`, optional `fieldPart`
|
|
1421
|
+
// (a single `:field` symbol), and `fnPart` (token slices already split
|
|
1422
|
+
// by splitTopLevelByComma). Validates shape at parse time so typos
|
|
1423
|
+
// surface with targeted diagnostics instead of runtime "expected
|
|
1424
|
+
// function" noise.
|
|
1425
|
+
function extractEnsurePair(messagePart, fieldPart, fnPart, refTok) {
|
|
1035
1426
|
if (!messagePart || !messagePart.length) {
|
|
1036
1427
|
throw schemaError(refTok, "@ensure: missing message (expected a string literal).");
|
|
1037
1428
|
}
|
|
@@ -1041,6 +1432,7 @@ function extractEnsurePair(messagePart, fnPart, refTok) {
|
|
|
1041
1432
|
}
|
|
1042
1433
|
let msgTok = messagePart[0];
|
|
1043
1434
|
let message = JSON.parse(msgTok[1]);
|
|
1435
|
+
let field = fieldPart ? fieldPart[0][1] : null;
|
|
1044
1436
|
|
|
1045
1437
|
if (!fnPart || !fnPart.length) {
|
|
1046
1438
|
throw schemaError(msgTok, "@ensure: missing function after message.");
|
|
@@ -1080,7 +1472,7 @@ function extractEnsurePair(messagePart, fnPart, refTok) {
|
|
|
1080
1472
|
if (!bodyTokens.length) {
|
|
1081
1473
|
throw schemaError(arrowTok, "@ensure: predicate function body is empty.");
|
|
1082
1474
|
}
|
|
1083
|
-
return { message, paramTokens, bodyTokens, loc: msgTok.loc };
|
|
1475
|
+
return { message, field, paramTokens, bodyTokens, loc: msgTok.loc };
|
|
1084
1476
|
}
|
|
1085
1477
|
|
|
1086
1478
|
// Extract param names from `(u)` or `(u, opts)` token slice. Accepts
|
|
@@ -1099,6 +1491,46 @@ function ensureParamNames(paramTokens, refTok) {
|
|
|
1099
1491
|
});
|
|
1100
1492
|
}
|
|
1101
1493
|
|
|
1494
|
+
// :union body lines: `@on :field` (the discriminator, exactly once) and
|
|
1495
|
+
// bare constituent schema names. Anything else is a targeted error.
|
|
1496
|
+
function parseUnionLine(line, entries) {
|
|
1497
|
+
let first = line[0];
|
|
1498
|
+
if (!first) return;
|
|
1499
|
+
|
|
1500
|
+
if (first[0] === '@') {
|
|
1501
|
+
let nameTok = line[1];
|
|
1502
|
+
if (!nameTok || nameTok[1] !== 'on') {
|
|
1503
|
+
throw schemaError(nameTok || first,
|
|
1504
|
+
`:union bodies accept only '@on :field' and constituent schema names. '@${nameTok?.[1] ?? ''}' is not allowed.`);
|
|
1505
|
+
}
|
|
1506
|
+
let args = stripCallWrapper(line.slice(2));
|
|
1507
|
+
let symTok = args[0];
|
|
1508
|
+
if (!symTok || symTok[0] !== 'SYMBOL') {
|
|
1509
|
+
throw schemaError(symTok || nameTok,
|
|
1510
|
+
`@on requires the discriminator field as a :symbol — '@on :kind'.`);
|
|
1511
|
+
}
|
|
1512
|
+
if (args.length > 1) {
|
|
1513
|
+
throw schemaError(args[1], `@on takes exactly one :field symbol.`);
|
|
1514
|
+
}
|
|
1515
|
+
entries.push({
|
|
1516
|
+
tag: 'directive', name: 'on',
|
|
1517
|
+
args: [{ field: symTok[1] }],
|
|
1518
|
+
argTokens: line.slice(2),
|
|
1519
|
+
loc: first.loc,
|
|
1520
|
+
});
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
if (first[0] === 'IDENTIFIER' && line.length === 1) {
|
|
1525
|
+
entries.push({ tag: 'union-member', name: first[1], loc: first.loc });
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
throw schemaError(first,
|
|
1530
|
+
`:union bodies accept only '@on :field' and bare constituent schema names (one per line). ` +
|
|
1531
|
+
`Got ${first[0]}${line.length > 1 ? ' followed by ' + line[1][0] : ''}.`);
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1102
1534
|
function parseEnumLine(line, entries) {
|
|
1103
1535
|
let first = line[0];
|
|
1104
1536
|
if (!first) return;
|
|
@@ -1166,7 +1598,31 @@ function emitSchemaNode(emitter, head, rest, context) {
|
|
|
1166
1598
|
|
|
1167
1599
|
let parts = [`kind: ${JSON.stringify(descriptor.kind)}`];
|
|
1168
1600
|
if (schemaName) parts.push(`name: ${JSON.stringify(schemaName)}`);
|
|
1601
|
+
else if (emitter.options.inlineTypes && (descriptor.kind === 'shape' || descriptor.kind === 'input')) {
|
|
1602
|
+
// Anonymous (expression-position) schema — e.g. `Base.extend(schema :shape …)`.
|
|
1603
|
+
// Synthesize a shadow-only identity so the dts pass can key a typed
|
|
1604
|
+
// `__schema` overload on it; without one the call falls to the
|
|
1605
|
+
// `(d: any) => any` overload and the schema's type evaporates (an
|
|
1606
|
+
// `.extend()` argument then contributes nothing to the derived type).
|
|
1607
|
+
// Keyed as `__anon`, not `name`: only the shadow compile (`inlineTypes`)
|
|
1608
|
+
// emits it, and the runtime never registers anonymous schemas, so
|
|
1609
|
+
// cross-file name collisions in `__SchemaRegistry` are impossible.
|
|
1610
|
+
// Field-bearing kinds only — :enum/:union have no field list to emit a
|
|
1611
|
+
// type from, so they keep the untyped fallback.
|
|
1612
|
+
let anonList = (emitter._schemaAnon ??= []);
|
|
1613
|
+
let anonName = `__AnonSchema${anonList.length + 1}`;
|
|
1614
|
+
parts.push(`__anon: ${JSON.stringify(anonName)}`);
|
|
1615
|
+
anonList.push({ name: anonName, descriptor });
|
|
1616
|
+
}
|
|
1169
1617
|
parts.push(`entries: [${descriptor.entries.map(e => entryLiteral(emitter, e)).join(', ')}]`);
|
|
1618
|
+
// `schema :model, on: <expr>` — the adapter expression evaluates at
|
|
1619
|
+
// declaration time in the user's scope.
|
|
1620
|
+
if (descriptor.adapterTokens) {
|
|
1621
|
+
let adapterSexpr = parseBodyTokens(descriptor.adapterTokens);
|
|
1622
|
+
if (adapterSexpr) {
|
|
1623
|
+
parts.push(`adapter: ${emitter.emit(unwrapSingleStatement(adapterSexpr), 'value')}`);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1170
1626
|
return `__schema({${parts.join(', ')}})`;
|
|
1171
1627
|
}
|
|
1172
1628
|
|
|
@@ -1178,6 +1634,301 @@ function readDescriptor(node) {
|
|
|
1178
1634
|
return null;
|
|
1179
1635
|
}
|
|
1180
1636
|
|
|
1637
|
+
// Descriptor for a `schema …` s-expr node (`['schema', SCHEMA_BODY]`) — the
|
|
1638
|
+
// grammar's metadata bridge carries the descriptor on the body value. The
|
|
1639
|
+
// single decoder for that shape: used by the projection fold below and by
|
|
1640
|
+
// the dts pass (src/schema/dts.js, which may import from this module; the
|
|
1641
|
+
// reverse import is barred by the browser-bundle graph).
|
|
1642
|
+
export function schemaNodeDescriptor(node) {
|
|
1643
|
+
if (!Array.isArray(node)) return null;
|
|
1644
|
+
const head = node[0]?.valueOf?.() ?? node[0];
|
|
1645
|
+
if (head !== 'schema') return null;
|
|
1646
|
+
return readDescriptor(node[1]);
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
// ============================================================================
|
|
1650
|
+
// Compile-time projection folding (opt-in: options.foldProjections)
|
|
1651
|
+
// ============================================================================
|
|
1652
|
+
//
|
|
1653
|
+
// A derived schema like `UserView = User.pick("id", "firstName")` normally
|
|
1654
|
+
// compiles to a runtime method call, which (a) needs the source `User` loaded
|
|
1655
|
+
// and (b) only yields a descriptor at load time. Folding statically evaluates
|
|
1656
|
+
// the algebra against the source's descriptor and rewrites the assignment to a
|
|
1657
|
+
// fresh, self-contained `__schema({kind:"shape", entries:[…]})` node — no
|
|
1658
|
+
// reference to `User`. That's what lets a projection cross the client bundle
|
|
1659
|
+
// boundary (the bundler extracts the folded literal) without dragging the
|
|
1660
|
+
// model's ORM/DDL along.
|
|
1661
|
+
//
|
|
1662
|
+
// Folding is intentionally conservative: it only fires for a same-file source
|
|
1663
|
+
// schema and static string-literal keys, and it BAILS (leaving the runtime
|
|
1664
|
+
// call untouched) on anything it can't prove — an unknown base, dynamic args,
|
|
1665
|
+
// a field the source doesn't expose, an extend collision. The runtime path is
|
|
1666
|
+
// always a correct fallback, so a bail is never a regression. Folding is OFF
|
|
1667
|
+
// by default (server/CLI/check keep the runtime algebra, including its
|
|
1668
|
+
// `_sourceModel` back-pointer); the browser-bundle extractor turns it on.
|
|
1669
|
+
|
|
1670
|
+
const FOLD_ALGEBRA = new Set(['pick', 'omit', 'partial', 'required', 'extend']);
|
|
1671
|
+
|
|
1672
|
+
function foldStr(n) { return n && n.valueOf ? n.valueOf() : n; }
|
|
1673
|
+
|
|
1674
|
+
// True when a descriptor pulls fields in via `@mixin`. The fold can't expand
|
|
1675
|
+
// mixins (resolution is a runtime `_normalize` concern), so any projection over
|
|
1676
|
+
// such a base must bail to the runtime call rather than silently emit a shape
|
|
1677
|
+
// missing the mixin's fields.
|
|
1678
|
+
function foldHasMixin(descriptor) {
|
|
1679
|
+
return descriptor.entries.some(e => e.tag === 'directive' && e.name === 'mixin');
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// The `@belongs_to <Target>` FK column name, computed exactly as the runtime
|
|
1683
|
+
// does (`__schemaCamel(__schemaSnake(target) + '_id')` in runtime-validate.js).
|
|
1684
|
+
// The naive `target[0].toLowerCase() + …` diverges for acronym targets
|
|
1685
|
+
// (`ABCWidget` → runtime `abcwidgetId`, naive `aBCWidgetId`).
|
|
1686
|
+
function foldFkName(target) {
|
|
1687
|
+
const snake = target.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
|
|
1688
|
+
return (snake + '_id').replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// The projectable columns of a descriptor as an ordered Map(name → field
|
|
1692
|
+
// entry): declared fields, then a :model's implicit id / @timestamps /
|
|
1693
|
+
// @softDelete / @belongs_to FK columns — matching __schemaProjectableFields
|
|
1694
|
+
// in runtime-validate.js so a fold yields the same field set the runtime would.
|
|
1695
|
+
// Returns null (bail) when the base uses `@mixin`.
|
|
1696
|
+
function foldProjectableMap(descriptor) {
|
|
1697
|
+
if (foldHasMixin(descriptor)) return null;
|
|
1698
|
+
const map = new Map();
|
|
1699
|
+
for (const e of descriptor.entries) {
|
|
1700
|
+
if (e.tag === 'field') map.set(e.name, e);
|
|
1701
|
+
}
|
|
1702
|
+
if (descriptor.kind !== 'model') return map;
|
|
1703
|
+
const synth = (name, typeName, required) => {
|
|
1704
|
+
if (!map.has(name)) map.set(name, { tag: 'field', name, modifiers: required ? ['!'] : ['?'], typeName, array: false });
|
|
1705
|
+
};
|
|
1706
|
+
let timestamps = false, softDelete = false;
|
|
1707
|
+
const fks = [];
|
|
1708
|
+
for (const e of descriptor.entries) {
|
|
1709
|
+
if (e.tag !== 'directive') continue;
|
|
1710
|
+
if (e.name === 'timestamps') timestamps = true;
|
|
1711
|
+
else if (e.name === 'softDelete') softDelete = true;
|
|
1712
|
+
else if (e.name === 'belongs_to') {
|
|
1713
|
+
const t = e.args && e.args[0] && e.args[0].target;
|
|
1714
|
+
if (t) fks.push({ fk: foldFkName(t), required: e.args[0].optional !== true });
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
// Insertion order mirrors the runtime: id, timestamps, softDelete, then FKs.
|
|
1718
|
+
synth('id', 'integer', true);
|
|
1719
|
+
if (timestamps) { synth('createdAt', 'datetime', true); synth('updatedAt', 'datetime', true); }
|
|
1720
|
+
if (softDelete) synth('deletedAt', 'datetime', false);
|
|
1721
|
+
for (const { fk, required } of fks) synth(fk, 'integer', required);
|
|
1722
|
+
return map;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// Declared fields only (no implicit :model columns) — what runtime `.extend()`
|
|
1726
|
+
// merges from its argument (`other._normalize().fields`). Returns null (bail)
|
|
1727
|
+
// when the argument uses `@mixin`.
|
|
1728
|
+
function foldDeclaredMap(descriptor) {
|
|
1729
|
+
if (foldHasMixin(descriptor)) return null;
|
|
1730
|
+
const map = new Map();
|
|
1731
|
+
for (const e of descriptor.entries) {
|
|
1732
|
+
if (e.tag === 'field') map.set(e.name, e);
|
|
1733
|
+
}
|
|
1734
|
+
return map;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// Rewrite a field entry's modifiers, preserving any constraint/transform token
|
|
1738
|
+
// fields so emission stays faithful. `mode` is 'partial' (drop !, ensure ?) or
|
|
1739
|
+
// 'required' (drop ?, ensure !). Unique (#) and other modifiers are kept.
|
|
1740
|
+
function foldRemark(entry, mode) {
|
|
1741
|
+
let mods = entry.modifiers.filter(m => m !== (mode === 'partial' ? '!' : '?'));
|
|
1742
|
+
const want = mode === 'partial' ? '?' : '!';
|
|
1743
|
+
if (!mods.includes(want)) mods = [...mods, want];
|
|
1744
|
+
return { ...entry, modifiers: mods };
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// Apply one algebra op to an ordered field Map, returning a new Map or null
|
|
1748
|
+
// (bail). `op` is { method, keys?, otherName? }. `byName` resolves a same-file
|
|
1749
|
+
// schema descriptor for extend's argument.
|
|
1750
|
+
function foldApplyOp(map, op, byName) {
|
|
1751
|
+
switch (op.method) {
|
|
1752
|
+
case 'pick': {
|
|
1753
|
+
const out = new Map();
|
|
1754
|
+
for (const k of op.keys) {
|
|
1755
|
+
if (!map.has(k)) return null;
|
|
1756
|
+
out.set(k, map.get(k));
|
|
1757
|
+
}
|
|
1758
|
+
return out;
|
|
1759
|
+
}
|
|
1760
|
+
case 'omit': {
|
|
1761
|
+
const drop = new Set(op.keys);
|
|
1762
|
+
const out = new Map();
|
|
1763
|
+
for (const [k, v] of map) if (!drop.has(k)) out.set(k, v);
|
|
1764
|
+
return out;
|
|
1765
|
+
}
|
|
1766
|
+
case 'partial': {
|
|
1767
|
+
const out = new Map();
|
|
1768
|
+
for (const [k, v] of map) out.set(k, foldRemark(v, 'partial'));
|
|
1769
|
+
return out;
|
|
1770
|
+
}
|
|
1771
|
+
case 'required': {
|
|
1772
|
+
const req = new Set(op.keys);
|
|
1773
|
+
const out = new Map();
|
|
1774
|
+
for (const [k, v] of map) out.set(k, req.has(k) ? foldRemark(v, 'required') : v);
|
|
1775
|
+
return out;
|
|
1776
|
+
}
|
|
1777
|
+
case 'extend': {
|
|
1778
|
+
const other = op.otherDescriptor || byName.get(op.otherName);
|
|
1779
|
+
if (!other) return null;
|
|
1780
|
+
// Runtime `.extend()` merges the argument's DECLARED fields only — not a
|
|
1781
|
+
// :model's implicit id/timestamp/FK columns — so use the declared map.
|
|
1782
|
+
const otherMap = foldDeclaredMap(other);
|
|
1783
|
+
if (!otherMap) return null; // @mixin in the argument — bail
|
|
1784
|
+
const out = new Map(map);
|
|
1785
|
+
for (const [k, v] of otherMap) {
|
|
1786
|
+
if (out.has(k)) return null; // collision — let the runtime throw
|
|
1787
|
+
out.set(k, v);
|
|
1788
|
+
}
|
|
1789
|
+
return out;
|
|
1790
|
+
}
|
|
1791
|
+
default:
|
|
1792
|
+
return null;
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
// Evaluate an algebra chain against a base descriptor. Returns a folded
|
|
1797
|
+
// `{kind:"shape", entries}` descriptor, or null to bail to the runtime call.
|
|
1798
|
+
function foldProjectionDescriptor(baseDescriptor, ops, byName) {
|
|
1799
|
+
let map = foldProjectableMap(baseDescriptor);
|
|
1800
|
+
if (!map) return null; // base uses @mixin — bail to the runtime call
|
|
1801
|
+
for (const op of ops) {
|
|
1802
|
+
map = foldApplyOp(map, op, byName);
|
|
1803
|
+
if (!map) return null;
|
|
1804
|
+
}
|
|
1805
|
+
return { kind: 'shape', entries: [...map.values()] };
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
// Parse an assignment RHS s-expr into an algebra chain { base, ops } when it's
|
|
1809
|
+
// `Base.pick(...)`/`.omit(...)`/… (including chains like `.pick(...).omit(...)`),
|
|
1810
|
+
// else null. Keys must be static string literals (or arrays of them); extend's
|
|
1811
|
+
// argument must be a bare identifier or an inline schema literal. Anything
|
|
1812
|
+
// dynamic returns null → no fold.
|
|
1813
|
+
function foldParseChain(rhs) {
|
|
1814
|
+
const ops = [];
|
|
1815
|
+
let node = rhs;
|
|
1816
|
+
while (true) {
|
|
1817
|
+
if (!Array.isArray(node)) return null;
|
|
1818
|
+
const callee = node[0];
|
|
1819
|
+
if (!Array.isArray(callee)) return null;
|
|
1820
|
+
if (foldStr(callee[0]) !== '.') return null;
|
|
1821
|
+
const method = foldStr(callee[2]);
|
|
1822
|
+
if (!FOLD_ALGEBRA.has(method)) return null;
|
|
1823
|
+
const argNodes = node.slice(1);
|
|
1824
|
+
let op;
|
|
1825
|
+
if (method === 'partial') {
|
|
1826
|
+
if (argNodes.length) return null;
|
|
1827
|
+
op = { method };
|
|
1828
|
+
} else if (method === 'extend') {
|
|
1829
|
+
if (argNodes.length !== 1) return null;
|
|
1830
|
+
const a = argNodes[0];
|
|
1831
|
+
if (Array.isArray(a)) {
|
|
1832
|
+
// Inline anonymous schema as the argument (`Base.extend(schema
|
|
1833
|
+
// :shape …)`) — its descriptor rides on the node itself, so it
|
|
1834
|
+
// folds with no name resolution at all. Field-less kinds
|
|
1835
|
+
// (:enum/:union) bail like any other unfoldable argument.
|
|
1836
|
+
const inline = schemaNodeDescriptor(a);
|
|
1837
|
+
if (!inline || (inline.kind !== 'shape' && inline.kind !== 'input')) return null;
|
|
1838
|
+
op = { method, otherDescriptor: inline };
|
|
1839
|
+
} else {
|
|
1840
|
+
const name = foldStr(a);
|
|
1841
|
+
if (typeof name !== 'string' || !/^[A-Za-z_$][\w$]*$/.test(name)) return null;
|
|
1842
|
+
op = { method, otherName: name };
|
|
1843
|
+
}
|
|
1844
|
+
} else {
|
|
1845
|
+
const keys = foldLiteralKeys(argNodes);
|
|
1846
|
+
if (!keys || !keys.length) return null;
|
|
1847
|
+
op = { method, keys };
|
|
1848
|
+
}
|
|
1849
|
+
ops.unshift(op);
|
|
1850
|
+
const obj = callee[1];
|
|
1851
|
+
if (Array.isArray(obj)) { node = obj; continue; } // inner call in the chain
|
|
1852
|
+
const base = foldStr(obj);
|
|
1853
|
+
if (typeof base !== 'string') return null;
|
|
1854
|
+
return { base, ops };
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
// Collect static string-literal keys from call args (each a string literal, or
|
|
1859
|
+
// an array literal of string literals). Returns null on any non-literal arg.
|
|
1860
|
+
function foldLiteralKeys(argNodes) {
|
|
1861
|
+
const keys = [];
|
|
1862
|
+
for (const a of argNodes) {
|
|
1863
|
+
if (Array.isArray(a)) {
|
|
1864
|
+
if (foldStr(a[0]) !== 'array') return null;
|
|
1865
|
+
for (let i = 1; i < a.length; i++) {
|
|
1866
|
+
const k = foldParseStrLit(a[i]);
|
|
1867
|
+
if (k == null) return null;
|
|
1868
|
+
keys.push(k);
|
|
1869
|
+
}
|
|
1870
|
+
} else {
|
|
1871
|
+
const k = foldParseStrLit(a);
|
|
1872
|
+
if (k == null) return null;
|
|
1873
|
+
keys.push(k);
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
return keys;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
// A string-literal node's value carries its surrounding quotes (e.g. `"id"`).
|
|
1880
|
+
// Strip them; return null for anything that isn't a plain string literal.
|
|
1881
|
+
function foldParseStrLit(node) {
|
|
1882
|
+
const v = foldStr(node);
|
|
1883
|
+
if (typeof v !== 'string' || v.length < 2) return null;
|
|
1884
|
+
const q = v[0];
|
|
1885
|
+
if ((q !== '"' && q !== "'") || v[v.length - 1] !== q) return null;
|
|
1886
|
+
const inner = v.slice(1, -1);
|
|
1887
|
+
// Keys are identifiers in practice; reject anything with escapes/interpolation.
|
|
1888
|
+
if (/[\\#]/.test(inner)) return null;
|
|
1889
|
+
return inner;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
// Walk the program's top-level statements, fold every foldable derived-schema
|
|
1893
|
+
// assignment in place, and thread folded results into the same-file schema map
|
|
1894
|
+
// so a later `.extend`/chain can reference an already-folded projection. Mutates
|
|
1895
|
+
// `sexpr`. Called by the emitter only when options.foldProjections is set.
|
|
1896
|
+
export function foldDerivedSchemas(sexpr) {
|
|
1897
|
+
if (!Array.isArray(sexpr)) return;
|
|
1898
|
+
const head = foldStr(sexpr[0]);
|
|
1899
|
+
const stmts = (head === 'program' || head === 'block') ? sexpr.slice(1) : [sexpr];
|
|
1900
|
+
const byName = new Map();
|
|
1901
|
+
for (const stmt of stmts) {
|
|
1902
|
+
if (!Array.isArray(stmt)) continue;
|
|
1903
|
+
// Unwrap `export <assign>` to reach the assignment node.
|
|
1904
|
+
let assign = stmt;
|
|
1905
|
+
if (foldStr(stmt[0]) === 'export' && Array.isArray(stmt[1])) assign = stmt[1];
|
|
1906
|
+
if (foldStr(assign[0]) !== '=' || !Array.isArray(assign[2])) continue;
|
|
1907
|
+
const name = foldStr(assign[1]);
|
|
1908
|
+
if (typeof name !== 'string') continue;
|
|
1909
|
+
|
|
1910
|
+
// Already a schema literal — register its descriptor and move on.
|
|
1911
|
+
if (foldStr(assign[2][0]) === 'schema') {
|
|
1912
|
+
const existing = readDescriptor(assign[2][1]);
|
|
1913
|
+
if (existing) byName.set(name, existing);
|
|
1914
|
+
continue;
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
const chain = foldParseChain(assign[2]);
|
|
1918
|
+
if (!chain) continue;
|
|
1919
|
+
const baseDesc = byName.get(chain.base);
|
|
1920
|
+
if (!baseDesc) continue; // base not a same-file schema — leave the runtime call
|
|
1921
|
+
const folded = foldProjectionDescriptor(baseDesc, chain.ops, byName);
|
|
1922
|
+
if (!folded) continue; // not statically foldable — leave the runtime call
|
|
1923
|
+
|
|
1924
|
+
const bridge = new String('shape');
|
|
1925
|
+
bridge.descriptor = folded;
|
|
1926
|
+
bridge.data = { descriptor: folded };
|
|
1927
|
+
assign[2] = ['schema', bridge];
|
|
1928
|
+
byName.set(name, folded);
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1181
1932
|
function entryLiteral(emitter, e) {
|
|
1182
1933
|
switch (e.tag) {
|
|
1183
1934
|
case 'field': {
|
|
@@ -1188,14 +1939,23 @@ function entryLiteral(emitter, e) {
|
|
|
1188
1939
|
`typeName: ${JSON.stringify(e.typeName)}`,
|
|
1189
1940
|
`array: ${e.array ? 'true' : 'false'}`,
|
|
1190
1941
|
];
|
|
1942
|
+
if (e.unique) obj.push('unique: true');
|
|
1191
1943
|
if (e.literals) {
|
|
1192
1944
|
obj.push(`literals: ${JSON.stringify(e.literals)}`);
|
|
1193
1945
|
}
|
|
1946
|
+
if (e.coerce) {
|
|
1947
|
+
obj.push('coerce: true');
|
|
1948
|
+
if (e.coercer) obj.push(`coercer: ${JSON.stringify(e.coercer)}`);
|
|
1949
|
+
}
|
|
1194
1950
|
let range = e.rangeTokens ? compileRangeTokens(e.rangeTokens, e) : null;
|
|
1195
1951
|
let bracket = e.constraintTokens ? compileConstraintsLiteral(e.constraintTokens, e) : null;
|
|
1196
1952
|
let regex = e.regexToken ? regexLiteralOf(e.regexToken) : null;
|
|
1197
1953
|
let merged = mergeFieldConstraints(range, bracket, regex, e);
|
|
1198
1954
|
if (merged) obj.push(`constraints: ${merged}`);
|
|
1955
|
+
if (e.attrsTokens) {
|
|
1956
|
+
let attrs = compileAttrsLiteral(e.attrsTokens, e);
|
|
1957
|
+
if (attrs) obj.push(`attrs: ${attrs}`);
|
|
1958
|
+
}
|
|
1199
1959
|
if (e.transformTokens) {
|
|
1200
1960
|
obj.push(`transform: ${compileTransformFn(emitter, e.transformTokens)}`);
|
|
1201
1961
|
}
|
|
@@ -1214,6 +1974,21 @@ function entryLiteral(emitter, e) {
|
|
|
1214
1974
|
`message: ${JSON.stringify(e.message)}`,
|
|
1215
1975
|
`fn: ${fnCode}`,
|
|
1216
1976
|
];
|
|
1977
|
+
if (e.field) obj.push(`field: ${JSON.stringify(e.field)}`);
|
|
1978
|
+
if (e.async) obj.push('async: true');
|
|
1979
|
+
return `{${obj.join(', ')}}`;
|
|
1980
|
+
}
|
|
1981
|
+
case 'scope':
|
|
1982
|
+
case 'defaultScope': {
|
|
1983
|
+
// Scope bodies compile like @ensure predicates (explicit params,
|
|
1984
|
+
// thin-arrow `function` so `this` binds to the query builder),
|
|
1985
|
+
// except the parameter list is optional.
|
|
1986
|
+
let fnCode = compileScopeFn(emitter, e);
|
|
1987
|
+
let obj = [
|
|
1988
|
+
`tag: ${JSON.stringify(e.tag)}`,
|
|
1989
|
+
`name: ${JSON.stringify(e.name)}`,
|
|
1990
|
+
`fn: ${fnCode}`,
|
|
1991
|
+
];
|
|
1217
1992
|
return `{${obj.join(', ')}}`;
|
|
1218
1993
|
}
|
|
1219
1994
|
case 'computed':
|
|
@@ -1233,6 +2008,9 @@ function entryLiteral(emitter, e) {
|
|
|
1233
2008
|
if (e.value !== undefined) obj.push(`value: ${JSON.stringify(e.value)}`);
|
|
1234
2009
|
return `{${obj.join(', ')}}`;
|
|
1235
2010
|
}
|
|
2011
|
+
case 'union-member': {
|
|
2012
|
+
return `{tag: "union-member", name: ${JSON.stringify(e.name)}}`;
|
|
2013
|
+
}
|
|
1236
2014
|
default:
|
|
1237
2015
|
return `{tag: "unknown"}`;
|
|
1238
2016
|
}
|
|
@@ -1243,27 +2021,110 @@ function entryLiteral(emitter, e) {
|
|
|
1243
2021
|
// emitted using the Rip thin-arrow codegen, which naturally produces a
|
|
1244
2022
|
// `function() { ... }` (Rip `->` is NOT a JS arrow). This gives us the
|
|
1245
2023
|
// right `this` semantics for instance-attached methods and proto getters.
|
|
2024
|
+
//
|
|
2025
|
+
// In shadow-TS (`inlineTypes`) mode the body's `@field` reads (`this.field`)
|
|
2026
|
+
// would otherwise trip `noImplicitAny` (TS2683), since the bare `function()`
|
|
2027
|
+
// has an untyped `this`. So in that mode only we prepend a TypeScript `this`
|
|
2028
|
+
// parameter typed to the schema's instance type (its bare name — what
|
|
2029
|
+
// methods/computed/derived bodies actually see, including other behavior).
|
|
2030
|
+
// The `this` parameter is erased at runtime and is illegal as a real JS
|
|
2031
|
+
// param, so it is emitted ONLY under inlineTypes; normal codegen is
|
|
2032
|
+
// unchanged (`function() { ... }`).
|
|
2033
|
+
// Build the real parameter list for a method from its captured tokens.
|
|
2034
|
+
// Plain identifiers only (defaults/destructuring can come later); type
|
|
2035
|
+
// annotations were stripped by the type rewriter and live on the
|
|
2036
|
+
// identifier tokens as `.data.type` — re-attach them ONLY in shadow-TS
|
|
2037
|
+
// mode so normal codegen emits plain `function(other) { … }`.
|
|
2038
|
+
function callableParams(emitter, entry) {
|
|
2039
|
+
if (!entry.paramTokens || !entry.paramTokens.length) return [];
|
|
2040
|
+
let parts = splitTopLevelByComma(entry.paramTokens);
|
|
2041
|
+
return parts.map(part => {
|
|
2042
|
+
let toks = part.filter(t =>
|
|
2043
|
+
t[0] !== 'TERMINATOR' && t[0] !== 'INDENT' && t[0] !== 'OUTDENT');
|
|
2044
|
+
if (toks.length !== 1 || toks[0][0] !== 'IDENTIFIER') {
|
|
2045
|
+
throw schemaError(toks[0] || { loc: entry.headerLoc },
|
|
2046
|
+
`'${entry.name}': method parameters must be plain identifiers (with optional ':: Type' annotations).`);
|
|
2047
|
+
}
|
|
2048
|
+
let p = new String(toks[0][1]);
|
|
2049
|
+
if (emitter.options.inlineTypes) {
|
|
2050
|
+
// Unannotated params get explicit `any` so the shadow file never
|
|
2051
|
+
// trips noImplicitAny (TS7006) — same policy as transform `it`.
|
|
2052
|
+
p.type = toks[0].data?.type || 'any';
|
|
2053
|
+
}
|
|
2054
|
+
return p;
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
|
|
1246
2058
|
function compileCallableFn(emitter, entry) {
|
|
1247
2059
|
let bodySexpr = parseBodyTokens(entry.bodyTokens);
|
|
2060
|
+
let methodParams = callableParams(emitter, entry);
|
|
1248
2061
|
if (!bodySexpr) {
|
|
1249
2062
|
// Empty body — emit a no-op.
|
|
1250
2063
|
return `(function() {})`;
|
|
1251
2064
|
}
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
2065
|
+
let params = [];
|
|
2066
|
+
if (emitter.options.inlineTypes && emitter._schemaName) {
|
|
2067
|
+
let thisParam = new String('this');
|
|
2068
|
+
thisParam.type = emitter._schemaName;
|
|
2069
|
+
params.push(thisParam);
|
|
2070
|
+
}
|
|
2071
|
+
params.push(...methodParams);
|
|
2072
|
+
// Wrap as a thin-arrow. `emit` in value context produces a parenthesized
|
|
2073
|
+
// function expression.
|
|
2074
|
+
let arrowSexpr = ['->', params, bodySexpr];
|
|
2075
|
+
let fnCode = emitter.emit(arrowSexpr, 'value');
|
|
2076
|
+
// Shadow-TS only: stash bodies so the type emitter can infer member
|
|
2077
|
+
// types via `typeof __<Name>__behavior.field` instead of falling back
|
|
2078
|
+
// to `unknown` (the body is already a `function(this: <Name>) { … }`).
|
|
2079
|
+
// Computed (`~>`), eager-derived (`!>`), and methods all qualify —
|
|
2080
|
+
// method params carry their declared annotations (or explicit `any`),
|
|
2081
|
+
// so `typeof` yields the full signature: typed params, `this`, and
|
|
2082
|
+
// the inferred return. The buffer rides on the emitter instance
|
|
2083
|
+
// (per-compile, no globals) and is read by emitSchemaTypes after
|
|
2084
|
+
// codegen finishes.
|
|
2085
|
+
let bufferable =
|
|
2086
|
+
entry.tag === 'computed' || entry.tag === 'derived' || entry.tag === 'method';
|
|
2087
|
+
if (emitter.options.inlineTypes && emitter._schemaName && bufferable) {
|
|
2088
|
+
if (!emitter._schemaBehavior) emitter._schemaBehavior = new Map();
|
|
2089
|
+
let list = emitter._schemaBehavior.get(emitter._schemaName);
|
|
2090
|
+
if (!list) { list = []; emitter._schemaBehavior.set(emitter._schemaName, list); }
|
|
2091
|
+
list.push({ field: entry.name, tag: entry.tag, fnExpr: fnCode });
|
|
2092
|
+
}
|
|
2093
|
+
return fnCode;
|
|
1256
2094
|
}
|
|
1257
2095
|
|
|
1258
2096
|
// Compile an inline field transform body (`-> body`). The body receives
|
|
1259
|
-
// the raw input object via Rip's
|
|
1260
|
-
//
|
|
2097
|
+
// the raw input object via Rip's `it` parameter. Transform runs on
|
|
2098
|
+
// .parse() only, not on hydrate.
|
|
2099
|
+
//
|
|
2100
|
+
// `it` is emitted as an explicit `any`-typed param (rather than relying on
|
|
2101
|
+
// the implicit-`it` injection) so the shadow-TS pass doesn't trip
|
|
2102
|
+
// `noImplicitAny` (TS7006) — runtime output is identical (`function(it)`).
|
|
2103
|
+
// `any` is the correct type, not a cop-out: a transform sees the RAW,
|
|
2104
|
+
// pre-validation input, which legitimately carries keys that aren't
|
|
2105
|
+
// declared fields (e.g. `it.Id` remapped to `id`), so typing `it` to the
|
|
2106
|
+
// schema's own shape would wrongly reject those reads.
|
|
1261
2107
|
function compileTransformFn(emitter, bodyTokens) {
|
|
1262
2108
|
let bodySexpr = parseBodyTokens(bodyTokens);
|
|
1263
2109
|
if (!bodySexpr) {
|
|
1264
2110
|
return `(function() { return undefined; })`;
|
|
1265
2111
|
}
|
|
1266
|
-
let
|
|
2112
|
+
let itParam = new String('it');
|
|
2113
|
+
itParam.type = 'any';
|
|
2114
|
+
let arrowSexpr = ['->', [itParam], bodySexpr];
|
|
2115
|
+
return emitter.emit(arrowSexpr, 'value');
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
// Compile a `@scope` / `@defaultScope` body into a thin-arrow function
|
|
2119
|
+
// expression. Params are optional ident-only (reuses the @ensure param
|
|
2120
|
+
// extractor); `this` is the query builder at call time.
|
|
2121
|
+
function compileScopeFn(emitter, entry) {
|
|
2122
|
+
let bodySexpr = parseBodyTokens(entry.bodyTokens);
|
|
2123
|
+
if (!bodySexpr) {
|
|
2124
|
+
return `(function() { return this; })`;
|
|
2125
|
+
}
|
|
2126
|
+
let params = entry.paramTokens.length ? ensureParamNames(entry.paramTokens, entry) : [];
|
|
2127
|
+
let arrowSexpr = ['->', params, bodySexpr];
|
|
1267
2128
|
return emitter.emit(arrowSexpr, 'value');
|
|
1268
2129
|
}
|
|
1269
2130
|
|
|
@@ -1428,6 +2289,45 @@ function mergeFieldConstraints(range, bracketLiteral, regex, fieldEntry) {
|
|
|
1428
2289
|
return constraintLiteral(c);
|
|
1429
2290
|
}
|
|
1430
2291
|
|
|
2292
|
+
// Compile a field's `{key: value}` attrs bracket into a JS object
|
|
2293
|
+
// literal. Known keys only — a typo'd attr silently baked into every
|
|
2294
|
+
// downstream schema would be worse than a hard error. Current keys:
|
|
2295
|
+
//
|
|
2296
|
+
// was: "old_column_name" migration-rename annotation, consumed by
|
|
2297
|
+
// the schema differ (`rip schema make`) to
|
|
2298
|
+
// emit RENAME COLUMN instead of DROP + ADD.
|
|
2299
|
+
// Removable once the migration has landed.
|
|
2300
|
+
const SCHEMA_FIELD_ATTRS = new Set(['was']);
|
|
2301
|
+
|
|
2302
|
+
function compileAttrsLiteral(tokens, fieldEntry) {
|
|
2303
|
+
// tokens: `{` … `}` — strip the braces, split entries on top-level commas.
|
|
2304
|
+
let inner = tokens.slice(1, -1).filter(t => t[0] !== 'TERMINATOR' && t[0] !== 'INDENT' && t[0] !== 'OUTDENT');
|
|
2305
|
+
let items = splitTopLevelByComma(inner);
|
|
2306
|
+
let parts = [];
|
|
2307
|
+
for (let item of items) {
|
|
2308
|
+
if (!item.length) continue;
|
|
2309
|
+
let keyTok = item[0];
|
|
2310
|
+
if (keyTok[0] !== 'PROPERTY' && keyTok[0] !== 'IDENTIFIER') {
|
|
2311
|
+
throw schemaError(keyTok,
|
|
2312
|
+
`Field attrs must be '{key: value}' pairs. Got ${keyTok[0]}.`);
|
|
2313
|
+
}
|
|
2314
|
+
let key = keyTok[1];
|
|
2315
|
+
if (!SCHEMA_FIELD_ATTRS.has(key)) {
|
|
2316
|
+
throw schemaError(keyTok,
|
|
2317
|
+
`Unknown field attr '${key}'. Known attrs: ${[...SCHEMA_FIELD_ATTRS].join(', ')}.`);
|
|
2318
|
+
}
|
|
2319
|
+
let valueStart = 1;
|
|
2320
|
+
if (item[valueStart]?.[0] === ':') valueStart++;
|
|
2321
|
+
let value = evalLiteralTokens(item.slice(valueStart), fieldEntry);
|
|
2322
|
+
if (key === 'was' && typeof value !== 'string') {
|
|
2323
|
+
throw schemaError(keyTok,
|
|
2324
|
+
`Field attr 'was' requires a string column name, e.g. {was: "first_name"}.`);
|
|
2325
|
+
}
|
|
2326
|
+
parts.push(`${key}: ${serializeLiteral(value)}`);
|
|
2327
|
+
}
|
|
2328
|
+
return parts.length ? `{${parts.join(', ')}}` : null;
|
|
2329
|
+
}
|
|
2330
|
+
|
|
1431
2331
|
function constraintLiteral(c) {
|
|
1432
2332
|
let parts = [];
|
|
1433
2333
|
if (c.min !== undefined) parts.push(`min: ${serializeLiteral(c.min)}`);
|
|
@@ -1446,6 +2346,37 @@ function serializeLiteral(v) {
|
|
|
1446
2346
|
return JSON.stringify(v);
|
|
1447
2347
|
}
|
|
1448
2348
|
|
|
2349
|
+
// Extract a column-name list from a directive's args — a single name
|
|
2350
|
+
// (`field` / `:field`) or a bracketed list (`[:a, :b]`). Accepts bare
|
|
2351
|
+
// identifiers and `:symbol` form interchangeably; both denote field names.
|
|
2352
|
+
// Shared by `@unique` and `@index`.
|
|
2353
|
+
function collectDirectiveFieldNames(tokens) {
|
|
2354
|
+
let isName = (t) => t && (t[0] === 'IDENTIFIER' || t[0] === 'PROPERTY' || t[0] === 'SYMBOL');
|
|
2355
|
+
let fields = [];
|
|
2356
|
+
let pos = 0;
|
|
2357
|
+
if (isName(tokens[pos])) {
|
|
2358
|
+
fields.push(tokens[pos][1]);
|
|
2359
|
+
} else if (tokens[pos]?.[0] === '[' || tokens[pos]?.[0] === 'INDEX_START') {
|
|
2360
|
+
let inner = [];
|
|
2361
|
+
let depth = 1;
|
|
2362
|
+
pos++;
|
|
2363
|
+
while (pos < tokens.length && depth > 0) {
|
|
2364
|
+
let t = tokens[pos];
|
|
2365
|
+
if (t[0] === '[' || t[0] === 'INDEX_START') depth++;
|
|
2366
|
+
if (t[0] === ']' || t[0] === 'INDEX_END') {
|
|
2367
|
+
depth--;
|
|
2368
|
+
if (depth === 0) { pos++; break; }
|
|
2369
|
+
}
|
|
2370
|
+
inner.push(t);
|
|
2371
|
+
pos++;
|
|
2372
|
+
}
|
|
2373
|
+
for (let part of splitTopLevelByComma(inner)) {
|
|
2374
|
+
if (isName(part[0])) fields.push(part[0][1]);
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
return fields;
|
|
2378
|
+
}
|
|
2379
|
+
|
|
1449
2380
|
// Compile directive args to a JS literal list or null. Each directive has
|
|
1450
2381
|
// its own arg shape — we centralize the parsing here so Layer 2 can rely
|
|
1451
2382
|
// on normalized structures.
|
|
@@ -1478,38 +2409,56 @@ function compileDirectiveArgsLiteral(name, tokens) {
|
|
|
1478
2409
|
return `[{${parts.join(', ')}}]`;
|
|
1479
2410
|
}
|
|
1480
2411
|
|
|
1481
|
-
// `@
|
|
2412
|
+
// `@unique :field`, `@unique field`, or `@unique [:a, :b]` — a unique
|
|
2413
|
+
// constraint (model-only). Single-field uniqueness is more commonly
|
|
2414
|
+
// written inline as `field! type @unique`; this directive form covers
|
|
2415
|
+
// composite keys and the single-field case when you prefer it separate.
|
|
2416
|
+
if (name === 'unique') {
|
|
2417
|
+
let fields = collectDirectiveFieldNames(tokens);
|
|
2418
|
+
if (!fields.length) {
|
|
2419
|
+
throw schemaError(tokens[0] || tokens[tokens.length - 1],
|
|
2420
|
+
`@unique requires a field name or list — '@unique :email' or '@unique [:a, :b]'.`);
|
|
2421
|
+
}
|
|
2422
|
+
return `[{fields: ${JSON.stringify(fields)}}]`;
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
// `@index field` or `@index [a, b]` — a non-unique index. Uniqueness is
|
|
2426
|
+
// spelled `@unique`, never `@index … #` (the `#` unique flag was removed).
|
|
1482
2427
|
if (name === 'index') {
|
|
1483
|
-
let fields =
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
let t = tokens[pos];
|
|
1495
|
-
if (t[0] === '[' || t[0] === 'INDEX_START') depth++;
|
|
1496
|
-
if (t[0] === ']' || t[0] === 'INDEX_END') {
|
|
1497
|
-
depth--;
|
|
1498
|
-
if (depth === 0) { pos++; break; }
|
|
1499
|
-
}
|
|
1500
|
-
inner.push(t);
|
|
1501
|
-
pos++;
|
|
1502
|
-
}
|
|
1503
|
-
for (let part of splitTopLevelByComma(inner)) {
|
|
1504
|
-
if (part[0] && (part[0][0] === 'IDENTIFIER' || part[0][0] === 'PROPERTY')) {
|
|
1505
|
-
fields.push(part[0][1]);
|
|
1506
|
-
}
|
|
1507
|
-
}
|
|
2428
|
+
let fields = collectDirectiveFieldNames(tokens);
|
|
2429
|
+
return `[{fields: ${JSON.stringify(fields)}}]`;
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
// `@on :field` — the :union discriminator. Pre-validated by
|
|
2433
|
+
// parseUnionLine; recompiled here from the raw tokens for the
|
|
2434
|
+
// descriptor literal.
|
|
2435
|
+
if (name === 'on') {
|
|
2436
|
+
let t = stripCallWrapper(tokens)[0];
|
|
2437
|
+
if (t && t[0] === 'SYMBOL') {
|
|
2438
|
+
return `[{field: ${JSON.stringify(t[1])}}]`;
|
|
1508
2439
|
}
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
2440
|
+
throw schemaError(t || tokens[tokens.length - 1],
|
|
2441
|
+
`@on requires the discriminator field as a :symbol — '@on :kind'.`);
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
// `@tableWas old_name` — migration-rename annotation for the whole
|
|
2445
|
+
// table. Consumed by the schema differ to emit `ALTER TABLE … RENAME
|
|
2446
|
+
// TO …` instead of DROP + CREATE. Accepts an identifier or string.
|
|
2447
|
+
if (name === 'tableWas') {
|
|
2448
|
+
// A string arg gets the implicit-call wrapper (like @ensure); a bare
|
|
2449
|
+
// identifier doesn't. Strip defensively.
|
|
2450
|
+
if (tokens[0]?.[0] === 'CALL_START' && tokens[tokens.length - 1]?.[0] === 'CALL_END') {
|
|
2451
|
+
tokens = tokens.slice(1, -1);
|
|
2452
|
+
}
|
|
2453
|
+
let t0 = tokens[0];
|
|
2454
|
+
if (t0 && t0[0] === 'STRING') {
|
|
2455
|
+
return `[{name: ${t0[1]}}]`;
|
|
2456
|
+
}
|
|
2457
|
+
if (t0 && (t0[0] === 'IDENTIFIER' || t0[0] === 'PROPERTY')) {
|
|
2458
|
+
return `[{name: ${JSON.stringify(t0[1])}}]`;
|
|
2459
|
+
}
|
|
2460
|
+
throw schemaError(t0 || tokens[tokens.length - 1],
|
|
2461
|
+
`@tableWas requires the previous table name, e.g. @tableWas legacy_users.`);
|
|
1513
2462
|
}
|
|
1514
2463
|
|
|
1515
2464
|
// @idStart N sets the seed value for the table's auto-id sequence.
|
|
@@ -1576,6 +2525,17 @@ function parseRegexLiteral(val) {
|
|
|
1576
2525
|
// the result through parser.parse() via a temporary lex adapter. The
|
|
1577
2526
|
// returned s-expression is the parsed body — either a single statement or
|
|
1578
2527
|
// a block of statements — ready to wrap in `['->', [], body]`.
|
|
2528
|
+
// Unwrap a single-statement `['block', stmt]` to the statement itself —
|
|
2529
|
+
// used when a captured token slice is a value expression (the `on:`
|
|
2530
|
+
// adapter), not a function body.
|
|
2531
|
+
function unwrapSingleStatement(sexpr) {
|
|
2532
|
+
if (Array.isArray(sexpr)) {
|
|
2533
|
+
const head = sexpr[0]?.valueOf?.() ?? sexpr[0];
|
|
2534
|
+
if (head === 'block' && sexpr.length === 2) return sexpr[1];
|
|
2535
|
+
}
|
|
2536
|
+
return sexpr;
|
|
2537
|
+
}
|
|
2538
|
+
|
|
1579
2539
|
function parseBodyTokens(bodyTokens) {
|
|
1580
2540
|
if (!bodyTokens || !bodyTokens.length) return null;
|
|
1581
2541
|
|