rip-lang 3.16.0 → 3.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +3 -4
  2. package/bin/rip +201 -18
  3. package/bin/rip-schema +175 -0
  4. package/docs/AGENTS.md +1 -1
  5. package/docs/RIP-APP.md +200 -19
  6. package/docs/RIP-DUCKDB.md +64 -1
  7. package/docs/RIP-INTRO.md +4 -4
  8. package/docs/RIP-LANG.md +36 -38
  9. package/docs/RIP-SCHEMA.md +1204 -364
  10. package/docs/RIP-TYPES.md +74 -103
  11. package/docs/demo/README.md +4 -3
  12. package/docs/dist/rip.js +4195 -966
  13. package/docs/dist/rip.min.js +1161 -284
  14. package/docs/dist/rip.min.js.br +0 -0
  15. package/docs/example/index.json +7 -7
  16. package/docs/example/index.json.br +0 -0
  17. package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
  18. package/docs/extensions/vscode/print/print-latest.vsix +0 -0
  19. package/docs/extensions/vscode/rip/index.html +2 -1
  20. package/docs/extensions/vscode/rip/rip-0.5.15.vsix +0 -0
  21. package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
  22. package/docs/extensions/vscode/rip/vscode-rip-0.6.0.vsix +0 -0
  23. package/docs/index.html +1 -1
  24. package/docs/ui/bundle.json +55 -55
  25. package/docs/ui/bundle.json.br +0 -0
  26. package/docs/ui/hljs-rip.js +1 -1
  27. package/docs/ui/index.html +1 -1
  28. package/package.json +15 -7
  29. package/rip-loader.js +59 -2
  30. package/src/AGENTS.md +43 -12
  31. package/src/browser.js +52 -11
  32. package/src/compiler.js +538 -80
  33. package/src/components.js +488 -48
  34. package/src/dts.js +80 -50
  35. package/src/grammar/README.md +29 -170
  36. package/src/grammar/grammar.rip +17 -12
  37. package/src/grammar/solar.rip +4 -17
  38. package/src/lexer.js +82 -32
  39. package/src/parser.js +229 -229
  40. package/src/schema/dts.js +328 -54
  41. package/src/schema/loader-server.js +2 -1
  42. package/src/schema/runtime-browser-stubs.js +20 -9
  43. package/src/schema/runtime-ddl.js +161 -44
  44. package/src/schema/runtime-migrate.js +681 -0
  45. package/src/schema/runtime-orm.js +698 -54
  46. package/src/schema/runtime-validate.js +808 -24
  47. package/src/schema/runtime.generated.js +2395 -135
  48. package/src/schema/schema.js +1054 -94
  49. package/src/typecheck.js +1610 -127
  50. package/src/types.js +90 -6
  51. package/src/grammar/lunar.rip +0 -2412
  52. /package/docs/demo/{components → routes}/_layout.rip +0 -0
  53. /package/docs/demo/{components → routes}/about.rip +0 -0
  54. /package/docs/demo/{components → routes}/card.rip +0 -0
  55. /package/docs/demo/{components → routes}/counter.rip +0 -0
  56. /package/docs/demo/{components → routes}/index.rip +0 -0
  57. /package/docs/demo/{components → routes}/todos.rip +0 -0
@@ -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
- return /(?:^|[\s=,(\[{:])schema(?:\s*:[A-Za-z_$][\w$]*|\s*\n[ \t]+\S)/m.test(source);
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') j++;
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') depth--;
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 array of
517
- // those pairs. Emits one `tag: "ensure"` entry per refinement; the
518
- // per-entry shape mirrors methods so compileCallableFn-style codegen
519
- // can fire.
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.
@@ -541,7 +699,7 @@ function parseFieldedLine(kind, line, entries, ctx) {
541
699
  dname === 'one' || dname === 'many' || dname === 'mixin') {
542
700
  let t0 = argTokens[0];
543
701
  if (t0 && (t0[0] === 'IDENTIFIER' || t0[0] === 'PROPERTY')) {
544
- let optional = t0.data?.predicate === true;
702
+ let optional = t0.data?.optional === true;
545
703
  if (!optional && argTokens[1]?.[0] === '?') optional = true;
546
704
  args = [{ target: t0[1], optional }];
547
705
  }
@@ -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 `!`, `#`, `?` modifier tokens. `!` and `?` are absorbed into
586
- // the IDENTIFIER's data by the main lexer. `#` arrives as a standalone
587
- // token because the schema commentToken exception kicks in when `#` is
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] === '#' || tk[0] === '?' || 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"`. The type slot is
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
- if (typeFirst?.[0] === 'IDENTIFIER') {
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
- if (literals.length < 2) {
638
- throw schemaError(typeFirst,
639
- `Literal union needs at least two string literals. Use '${JSON.stringify(literals[0])}' as a default with '[${JSON.stringify(literals[0])}]' instead.`);
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!# email -> fn` read as
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, or '-> transform'.`);
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
- // Three arrow forms:
845
- // name: -> body — method / hook
846
- // name: ~> body — lazy computed getter (EFFECT token)
847
- // name: !> body — eager derived field (UNARY_MATH '!' + COMPARE '>')
848
- let arrowTok = line[2];
849
- let nextTok = line[3];
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 = 3;
1142
+ bodyStart = pos + 1;
855
1143
  } else if (arrowTok && arrowTok[0] === 'EFFECT') {
856
1144
  arrow = '~>';
857
1145
  arrowLoc = arrowTok.loc;
858
- bodyStart = 3;
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 = 4;
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
- if (parts.length % 2 !== 0) {
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 > 2) {
1311
+ if (parts.length > 3 || (parts.length === 3 && !isEnsureFieldPart(parts[1]))) {
949
1312
  throw schemaError(first,
950
- `@ensure inline form takes exactly 'message, fn' (got ${parts.length} comma-separated parts). Use '@ensure [...]' for multiple refinements.`);
1313
+ `@ensure inline form takes 'message[, :field], fn' (got ${parts.length} comma-separated parts). Use '@ensure [...]' for multiple refinements.`);
951
1314
  }
952
- return [extractEnsurePair(parts[0], parts[1], first)];
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 pair from `messagePart` and `fnPart` (two token
1031
- // slices already split by splitTopLevelByComma). Validates shape at parse
1032
- // time so typos surface with targeted diagnostics instead of runtime
1033
- // "expected function" noise.
1034
- function extractEnsurePair(messagePart, fnPart, refTok) {
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
- // Wrap as a thin-arrow with no params. `emit` in value context produces
1253
- // a parenthesized function expression.
1254
- let arrowSexpr = ['->', [], bodySexpr];
1255
- return emitter.emit(arrowSexpr, 'value');
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 implicit `it` parameter; no explicit
1260
- // params are emitted. Transform runs on .parse() only, not on hydrate.
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 arrowSexpr = ['->', [], bodySexpr];
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.
@@ -1468,9 +2399,9 @@ function compileDirectiveArgsLiteral(name, tokens) {
1468
2399
  }
1469
2400
  let target = t0[1];
1470
2401
  // `@belongs_to User?` tokenizes as IDENTIFIER "User" with
1471
- // data.predicate=true. A trailing `?` in a later token position is
2402
+ // data.optional=true. A trailing `?` in a later token position is
1472
2403
  // also accepted for robustness.
1473
- let optional = t0.data?.predicate === true;
2404
+ let optional = t0.data?.optional === true;
1474
2405
  let pos = 1;
1475
2406
  if (!optional && tokens[pos]?.[0] === '?') { optional = true; pos++; }
1476
2407
  let parts = [`target: ${JSON.stringify(target)}`];
@@ -1478,38 +2409,56 @@ function compileDirectiveArgsLiteral(name, tokens) {
1478
2409
  return `[{${parts.join(', ')}}]`;
1479
2410
  }
1480
2411
 
1481
- // `@index field` or `@index [a, b]` or `@index [a, b] #` for unique.
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
- let unique = false;
1485
- let pos = 0;
1486
- if (tokens[pos]?.[0] === 'IDENTIFIER' || tokens[pos]?.[0] === 'PROPERTY') {
1487
- fields.push(tokens[pos][1]);
1488
- pos++;
1489
- } else if (tokens[pos]?.[0] === '[' || tokens[pos]?.[0] === 'INDEX_START') {
1490
- let inner = [];
1491
- let depth = 1;
1492
- pos++;
1493
- while (pos < tokens.length && depth > 0) {
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
- if (tokens[pos]?.[0] === '#') unique = true;
1510
- let parts = [`fields: ${JSON.stringify(fields)}`];
1511
- if (unique) parts.push('unique: true');
1512
- return `[{${parts.join(', ')}}]`;
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
 
@@ -1673,8 +2633,8 @@ function parseBodyTokens(bodyTokens) {
1673
2633
  function collectModifiers(identToken) {
1674
2634
  let mods = [];
1675
2635
  let d = identToken.data;
1676
- if (d?.await === true) mods.push('!');
1677
- if (d?.predicate === true) mods.push('?');
2636
+ if (d?.bang === true) mods.push('!');
2637
+ if (d?.optional === true) mods.push('?');
1678
2638
  return mods;
1679
2639
  }
1680
2640