@atscript/db 0.1.161 → 0.1.163

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 (65) hide show
  1. package/dist/agg.cjs +1 -1
  2. package/dist/agg.d.cts +1 -1
  3. package/dist/agg.d.mts +1 -1
  4. package/dist/agg.mjs +1 -1
  5. package/dist/{aggregate-fns-B_sgl4mB.mjs → aggregate-fns-DsS9UQVB.mjs} +1 -1
  6. package/dist/{aggregate-fns-DFtKAv7Q.cjs → aggregate-fns-DupYzFD7.cjs} +1 -1
  7. package/dist/{buckets-B4T_alUe.d.cts → buckets-B8UOod-v.d.cts} +488 -57
  8. package/dist/{buckets-8mG67Mtw.d.mts → buckets-CHyspjYP.d.mts} +488 -57
  9. package/dist/{column-diff-DpeUbhEI.cjs → column-diff-BIGX3sRH.cjs} +853 -232
  10. package/dist/{column-diff-DR1glykD.mjs → column-diff-BfNIlMwX.mjs} +836 -233
  11. package/dist/{validator-B3xlbrWf.d.cts → db-error-B_DRL1E9.d.cts} +18 -89
  12. package/dist/{validator-B3xlbrWf.d.mts → db-error-B_DRL1E9.d.mts} +18 -89
  13. package/dist/{db-error-JcfJqqpk.cjs → db-error-DmF1_xrz.cjs} +13 -0
  14. package/dist/{db-error-eSsQX7rt.mjs → db-error-hraC12B9.mjs} +8 -1
  15. package/dist/{db-validator-plugin-BWy60OvG.d.cts → db-validator-plugin-wRqMYNBI.d.cts} +6 -0
  16. package/dist/{db-validator-plugin-BWy60OvG.d.mts → db-validator-plugin-wRqMYNBI.d.mts} +6 -0
  17. package/dist/{fk-diff-5gX42hBt.d.mts → fk-diff-BNGawZi9.d.cts} +34 -10
  18. package/dist/{fk-diff-BWdQ7kRZ.d.cts → fk-diff-rj9N2QLw.d.mts} +34 -10
  19. package/dist/index.cjs +15 -11
  20. package/dist/index.d.cts +24 -21
  21. package/dist/index.d.mts +24 -21
  22. package/dist/index.mjs +10 -12
  23. package/dist/{nested-writer-BOj_c_lI.mjs → nested-writer-ClUX0DB0.mjs} +16 -22
  24. package/dist/{nested-writer-Bl1SZS7x.cjs → nested-writer-yCy6wGuN.cjs} +16 -22
  25. package/dist/{object-CtPYTIvy.d.cts → object-Czia_qD4.d.cts} +7 -1
  26. package/dist/{object-CtPYTIvy.d.mts → object-Czia_qD4.d.mts} +7 -1
  27. package/dist/{object-Djg28csK.cjs → object-GjsuPNqo.cjs} +19 -0
  28. package/dist/{object-TkiJQ-Dp.mjs → object-PQ1J8jri.mjs} +14 -1
  29. package/dist/ops.cjs +6 -21
  30. package/dist/ops.mjs +6 -21
  31. package/dist/plugin.cjs +263 -323
  32. package/dist/plugin.mjs +163 -223
  33. package/dist/rel.cjs +2 -2
  34. package/dist/rel.d.cts +3 -3
  35. package/dist/rel.d.mts +3 -3
  36. package/dist/rel.mjs +2 -2
  37. package/dist/{relation-helpers-17_2f5hX.d.mts → relation-helpers-DloMywNs.d.cts} +1 -1
  38. package/dist/{relation-helpers-BLBrLQYi.d.cts → relation-helpers-zwt19E7r.d.mts} +1 -1
  39. package/dist/{relation-loader-DeulLrAw.cjs → relation-loader-DujL_Mh9.cjs} +1 -1
  40. package/dist/{relation-loader-CNBMPfRr.mjs → relation-loader-Qv3LrQ42.mjs} +1 -1
  41. package/dist/{search-fields-CT_UZ9fg.cjs → search-fields-B49-RCWd.cjs} +65 -1
  42. package/dist/{search-fields-CE8cmj9X.mjs → search-fields-BPNsxrCk.mjs} +48 -2
  43. package/dist/search-fields-CHqqcC3W.d.cts +30 -0
  44. package/dist/search-fields-CHqqcC3W.d.mts +30 -0
  45. package/dist/server-stamped-B6XbUQ4p.cjs +33 -0
  46. package/dist/server-stamped-BoP56K9M.mjs +22 -0
  47. package/dist/shared.cjs +42 -26
  48. package/dist/shared.d.cts +98 -4
  49. package/dist/shared.d.mts +98 -4
  50. package/dist/shared.mjs +4 -2
  51. package/dist/sync.cjs +228 -65
  52. package/dist/sync.d.cts +41 -5
  53. package/dist/sync.d.mts +41 -5
  54. package/dist/sync.mjs +228 -66
  55. package/dist/{validator-B4YDLUkj.cjs → validator-DK9Ijpo7.cjs} +3 -2
  56. package/dist/validator-DX97RzC2.d.cts +108 -0
  57. package/dist/validator-DX97RzC2.d.mts +108 -0
  58. package/dist/{validator-B3fPJjS1.mjs → validator-DuDWsIdQ.mjs} +3 -2
  59. package/dist/validator.cjs +6 -3
  60. package/dist/validator.d.cts +5 -4
  61. package/dist/validator.d.mts +5 -4
  62. package/dist/validator.mjs +5 -4
  63. package/dist/{validation-utils-BGjqc2jU.mjs → view-validation-BcgDq7M9.mjs} +364 -20
  64. package/dist/{validation-utils-CtLNlBtB.cjs → view-validation-BiDLCYMw.cjs} +452 -18
  65. package/package.json +6 -6
package/dist/plugin.mjs CHANGED
@@ -1,10 +1,10 @@
1
- import { i as NULL_WHEN_EMPTY_AGGREGATE_FNS, o as SUPPORTED_AGGREGATE_FNS, t as AGG_ANNOTATIONS } from "./aggregate-fns-B_sgl4mB.mjs";
2
- import { a as DERIVED_INCOMPATIBLE, i as DB_ENTITY_ANNOTATIONS, o as JSON_LEAF_TYPES, r as numericTypeProblem } from "./search-fields-CE8cmj9X.mjs";
1
+ import { i as NULL_WHEN_EMPTY_AGGREGATE_FNS, t as AGG_ANNOTATIONS } from "./aggregate-fns-DsS9UQVB.mjs";
2
+ import { c as DERIVED_INCOMPATIBLE, l as JSON_LEAF_TYPES, o as numericTypeProblem, s as DB_ENTITY_ANNOTATIONS, t as ENCRYPTED_INCOMPATIBLE } from "./search-fields-BPNsxrCk.mjs";
3
3
  import "./consts-C_-5_pFq.mjs";
4
- import { C as validateExclusiveWith, S as searchFieldVerdict, T as validateSiblingStringField, _ as getParentTypeName, a as hasAnyViewAnnotation, b as primitiveBaseType, d as viewJoins, f as viewScopeTypes, g as getParentStruct, h as getNavTargetTypeName, i as forEachFieldRef, l as validateQueryScope, m as getDbTableOwner, n as findFKFieldsPointingTo, o as isAliasDecl, p as getAnnotationAlias, r as findViewCycle, s as isDbSourceDecl, t as earlierJoinTargets, u as validateRefArgument, v as integerMemberIndexProblem, w as validateFieldBaseType, x as refActionAnnotation, y as isNullableUnion } from "./validation-utils-BGjqc2jU.mjs";
4
+ import { A as getParentTypeName, B as validateSiblingStringField, C as viewJoins, D as getDbTableOwner, E as getAnnotationAlias, F as refActionAnnotation, I as searchFieldVerdict, L as validateExclusiveWith, M as isNullableUnion, O as getNavTargetTypeName, P as primitiveBaseType, R as validateFieldBaseType, S as validateRefArgument, T as effectiveAnnotationNames, _ as hasAnyViewAnnotation, c as refuseCiphertext, d as viewFieldSeals, f as viewProps, g as forEachFieldRef, h as findViewCycle, i as encryptedTypeSearchProblem, j as integerMemberIndexProblem, k as getParentStruct, l as sealedSearchProblem, m as findFKFieldsPointingTo, o as isNavProp, p as earlierJoinTargets, s as jsonChainInfo, u as validateViewInterface, v as isAliasDecl, w as viewScopeTypes, x as validateQueryScope, y as isDbSourceDecl, z as validateIncompatible } from "./view-validation-BcgDq7M9.mjs";
5
+ import { AnnotationSpec, DEFAULT_FORMAT, annotationExample, getFieldsForType, isArray, isInterface, isPrimitive, isProp, isQueryComparison, isQueryLogical, isRef, isStructure } from "@atscript/core";
5
6
  import path from "node:path";
6
7
  import { fileURLToPath } from "node:url";
7
- import { AnnotationSpec, DEFAULT_FORMAT, getFieldsForType, isArray, isInterface, isPrimitive, isProp, isQueryComparison, isQueryLogical, isRef, isStructure } from "@atscript/core";
8
8
  //#region src/plugin/manifest.ts
9
9
  /**
10
10
  * Renders the model-manifest module: an inventory of every exported
@@ -333,7 +333,7 @@ const VIEW_SOURCE_ARGUMENT = {
333
333
  * @since 0.1.141
334
334
  */
335
335
  const dbAliasAnnotations = { alias: new AnnotationSpec({
336
- description: "Declares a **join alias**: a type alias of a `@db.table` or `@db.view` that a view can join under its own name — to join the same table twice or to self-join the entry table. Only valid on `export type X = Target`, where `Target` is the argument. The alias is a scope name inside view definitions, not a table: it is never synced or registered on a `DbSpace`, and it cannot be the `@db.view.for` entry.\n\n**Example:**\n```atscript\n@db.alias Employee\nexport type Manager = Employee\n\n@db.view.for Employee\n@db.view.joins Manager, `Manager.id = Employee.managerId`, 'left'\nexport interface Staff {\n id: Employee.id\n managerName?: Manager.name\n}\n```\n",
336
+ description: annotationExample("Declares a **join alias**: a type alias of a `@db.table` or `@db.view` that a view can join under its own name — to join the same table twice or to self-join the entry table. Only valid on `export type X = Target`, where `Target` is the argument. The alias is a scope name inside view definitions, not a table: it is never synced or registered on a `DbSpace`, and it cannot be the `@db.view.for` entry.", "@db.alias Employee", "export type Manager = Employee", "", "@db.view.for Employee", "@db.view.joins Manager, `Manager.id = Employee.managerId`, 'left'", "export interface Staff {", " id: Employee.id", " managerName?: Manager.name", "}"),
337
337
  nodeType: ["type"],
338
338
  passedWhenReferred: false,
339
339
  argument: {
@@ -370,7 +370,7 @@ const dbAliasAnnotations = { alias: new AnnotationSpec({
370
370
  const CURRENCY_CODE_PATTERN = /^[A-Z0-9]{2,10}$/;
371
371
  const dbAmountAnnotations = { amount: { currency: {
372
372
  $self: new AnnotationSpec({
373
- description: "Hard-coded **currency code** for this amount field. Use when a table stores amounts in a single, fixed currency.\n\nFor per-row currency, use `@db.amount.currency.ref 'fieldName'` instead.\n\nOnly valid on `decimal` fields — money in floating-point is wrong.\n\n**Example:**\n```atscript\n@db.amount.currency 'EUR'\namount: decimal\n```\n",
373
+ description: annotationExample("Hard-coded **currency code** for this amount field. Use when a table stores amounts in a single, fixed currency.\n\nFor per-row currency, use `@db.amount.currency.ref 'fieldName'` instead.\n\nOnly valid on `decimal` fields — money in floating-point is wrong.", "@db.amount.currency 'EUR'", "amount: decimal"),
374
374
  nodeType: ["prop"],
375
375
  multiple: false,
376
376
  argument: {
@@ -384,7 +384,7 @@ const dbAmountAnnotations = { amount: { currency: {
384
384
  errors.push(...validateExclusiveWith(token, "@db.amount.currency", [{
385
385
  key: "db.amount.currency.ref",
386
386
  displayName: "@db.amount.currency.ref"
387
- }]));
387
+ }], doc));
388
388
  const code = args[0]?.text;
389
389
  if (code && !CURRENCY_CODE_PATTERN.test(code)) errors.push({
390
390
  message: `@db.amount.currency '${code}': invalid currency code (expected 2–10 uppercase letters or digits)`,
@@ -395,7 +395,7 @@ const dbAmountAnnotations = { amount: { currency: {
395
395
  }
396
396
  }),
397
397
  ref: new AnnotationSpec({
398
- description: "Binds this amount to a **sibling field** that holds its currency code. Use when each row may have a different currency.\n\nThe referenced field should be of type `db.currencyCode` (or at least a `string`).\n\nOnly valid on `decimal` fields.\n\n**Example:**\n```atscript\ncurrency: db.currencyCode\n@db.amount.currency.ref 'currency'\namount: decimal\n```\n",
398
+ description: annotationExample("Binds this amount to a **sibling field** that holds its currency code. Use when each row may have a different currency.\n\nThe referenced field should be of type `db.currencyCode` (or at least a `string`).\n\nOnly valid on `decimal` fields.", "currency: db.currencyCode", "@db.amount.currency.ref 'currency'", "amount: decimal"),
399
399
  nodeType: ["prop"],
400
400
  passedWhenReferred: false,
401
401
  multiple: false,
@@ -413,114 +413,14 @@ const dbAmountAnnotations = { amount: { currency: {
413
413
  }, {
414
414
  key: "db.unit.ref",
415
415
  displayName: "@db.unit.ref"
416
- }]));
416
+ }], doc));
417
417
  errors.push(...validateSiblingStringField(token, args, doc, "@db.amount.currency.ref"));
418
418
  return errors;
419
419
  }
420
420
  })
421
421
  } } };
422
422
  //#endregion
423
- //#region src/shared/view-validation.ts
424
- /** `@db.agg.*` annotations that are never NULL (the counts: 0 over no value). */
425
- const NULL_SAFE_AGG_ANNOTATIONS = SUPPORTED_AGGREGATE_FNS.filter((fn) => !NULL_WHEN_EMPTY_AGGREGATE_FNS.has(fn)).map((fn) => `db.agg.${fn}`);
426
- /**
427
- * Walks a chain ref step by step (`Order.payload`, `Order.payload.customer`, …)
428
- * and reports the JSON root, arrays, encryption, optionality and the leaf type.
429
- * A chain shorter than 2 is not a field path (`resolved: false`).
430
- * @since 0.1.141
431
- */
432
- function jsonChainInfo(ref, doc) {
433
- const typeName = ref.id ?? "";
434
- const chain = ref.chain.map((t) => t.text);
435
- const info = {
436
- typeName,
437
- chain,
438
- resolved: false,
439
- viaArray: false,
440
- viaEncrypted: false,
441
- optional: false
442
- };
443
- if (!typeName || chain.length < 2) return info;
444
- for (let i = 1; i <= chain.length; i++) {
445
- const step = doc.unwindType(typeName, chain.slice(0, i));
446
- if (!step) return info;
447
- const node = step.node;
448
- const prop = node && isProp(node) ? node : void 0;
449
- if (prop?.has("optional")) info.optional = true;
450
- if (prop && prop.countAnnotations("db.encrypted") > 0) info.viaEncrypted = true;
451
- const isJsonProp = prop !== void 0 && prop.countAnnotations("db.json") > 0;
452
- const isArr = isArray(step.def);
453
- if (isArr) info.viaArray = true;
454
- if (info.jsonRoot === void 0 && (isJsonProp || isArr)) info.jsonRoot = i;
455
- if (i === chain.length) info.leafType = primitiveBaseType(step.def);
456
- }
457
- info.resolved = true;
458
- return info;
459
- }
460
- /** Props of a view interface (its own structure; views don't use `extends`). */
461
- function viewProps(owner) {
462
- if (isInterface(owner)) return owner.props;
463
- const def = owner.getDefinition();
464
- return def && isStructure(def) ? def.props : void 0;
465
- }
466
- /**
467
- * VW8: a chain ref that passes through a `@db.json` or array node reads a
468
- * JSON leaf, which views can only extract as a string / number / boolean.
469
- * Intermediate nodes that `unwindType` can't reach are skipped (sync-time
470
- * resolution reports them).
471
- */
472
- function validateJsonChain(fieldName, ref, doc, range) {
473
- const info = jsonChainInfo(ref, doc);
474
- if (!info.resolved || info.jsonRoot === void 0 || info.jsonRoot >= info.chain.length) return [];
475
- if (info.leafType !== void 0 && JSON_LEAF_TYPES.has(info.leafType)) return [];
476
- return [{
477
- message: `Field "${fieldName}" reads "${info.typeName}.${info.chain.join(".")}" inside a JSON-stored field — it must end at a string, number or boolean leaf`,
478
- severity: 1,
479
- range
480
- }];
481
- }
482
- /**
483
- * Whole-view checks, run once per `@db.view.for` interface:
484
- *
485
- * - VW7 — a field reading from a left-joined table must be optional (the
486
- * join yields NULL for unmatched rows); `@db.agg.count` / `countDistinct`
487
- * fields are exempt (they count 0, never NULL).
488
- * - VW8 — a chain ref through a `@db.json` or array node must end at a
489
- * primitive string / number / boolean leaf.
490
- * @since 0.1.136
491
- */
492
- function validateViewInterface(owner, doc) {
493
- const errors = [];
494
- const props = viewProps(owner);
495
- if (!props) return errors;
496
- const leftJoined = /* @__PURE__ */ new Set();
497
- for (const join of viewJoins(owner)) {
498
- const target = join.args[0]?.text;
499
- if (target && join.args[2]?.text === "left") leftJoined.add(target);
500
- }
501
- for (const [fieldName, prop] of props) {
502
- const def = prop.getDefinition();
503
- if (!def || !isRef(def)) continue;
504
- const ref = def;
505
- const range = (prop.token("identifier") ?? ref.token("identifier"))?.range;
506
- if (!range) continue;
507
- if (ref.id && leftJoined.has(ref.id) && !prop.has("optional") && !NULL_SAFE_AGG_ANNOTATIONS.some((name) => prop.countAnnotations(name) > 0)) errors.push({
508
- message: `Field "${fieldName}" reads from left-joined "${ref.id}" and must be optional (${fieldName}?: …)`,
509
- severity: 1,
510
- range
511
- });
512
- errors.push(...validateJsonChain(fieldName, ref, doc, range));
513
- }
514
- return errors;
515
- }
516
- //#endregion
517
423
  //#region src/plugin/annotations/column.ts
518
- /** Nav-field annotations: a navigation field has no column on this table. */
519
- const NAV_ANNOTATIONS = [
520
- "db.rel.to",
521
- "db.rel.from",
522
- "db.rel.via"
523
- ];
524
424
  /**
525
425
  * Compile-time placement rules of `@db.column.version.exempt` (E1–E5, W1, W2).
526
426
  * E6 (derived) lives in `DERIVED_INCOMPATIBLE`. The runtime mirror is
@@ -540,7 +440,7 @@ function validateVersionExempt(token) {
540
440
  const tag = "@db.column.version.exempt";
541
441
  if (field.countAnnotations("db.column.version") > 0) fail(`${tag} cannot mark the version column itself`);
542
442
  if (field.countAnnotations("meta.id") > 0) fail(`${tag} cannot mark a primary key — it identifies the row and is never patched`);
543
- if (NAV_ANNOTATIONS.some((n) => field.countAnnotations(n) > 0)) fail(`${tag} cannot mark a navigation field — related rows follow their own table's versioning`);
443
+ if (isNavProp(field)) fail(`${tag} cannot mark a navigation field — related rows follow their own table's versioning`);
544
444
  if (field.countAnnotations("db.ignore") > 0) fail(`${tag} has no effect on an ignored field`, 2);
545
445
  let node = field;
546
446
  let tableOwner;
@@ -577,9 +477,51 @@ function validateVersionExempt(token) {
577
477
  }
578
478
  return errors;
579
479
  }
480
+ /** Annotations a `@db.onUpdate.inc` field cannot carry, with the reason. */
481
+ const ON_UPDATE_INC_INCOMPATIBLE = [
482
+ ["meta.id", "the primary key identifies the row and is never updated"],
483
+ ["db.column.version", "the version column is already bumped on every update"],
484
+ ["db.onUpdate.now", "a field is either a time stamp or a counter"],
485
+ ["db.default", "the counter starts at 0"],
486
+ ["db.default.increment", "the counter starts at 0"],
487
+ ["db.default.uuid", "the counter starts at 0"],
488
+ ["db.default.now", "the counter starts at 0"],
489
+ ["db.json", "a JSON column is written as one value"]
490
+ ];
491
+ /**
492
+ * Compile-time rules of `@db.onUpdate.inc` (since 0.1.162). The runtime
493
+ * mirror (top level, encryption) is in `TableMetadata`.
494
+ */
495
+ function validateOnUpdateInc(token, doc) {
496
+ const tag = "@db.onUpdate.inc";
497
+ const errors = validateFieldBaseType(token, doc, tag, "number");
498
+ const field = token.parentNode;
499
+ const fail = (message) => {
500
+ errors.push({
501
+ message,
502
+ severity: 1,
503
+ range: token.range
504
+ });
505
+ };
506
+ if (errors.length === 0 && !isIntegerField(field, doc)) fail(`${tag} requires an integer field — use number.int (or @expect.int)`);
507
+ if (field.has("optional")) fail(`${tag} requires a non-optional field — the counter is always populated (default 0)`);
508
+ const struct = getParentStruct(token);
509
+ if (!struct?.ownerNode || !isInterface(struct.ownerNode)) fail(`${tag} must sit on a top-level field of the table, not inside a nested object`);
510
+ errors.push(...validateIncompatible(token, tag, ON_UPDATE_INC_INCOMPATIBLE, doc));
511
+ return errors;
512
+ }
513
+ /** Whether a numeric field is an integer: `number.int`, an `integer` base or `@expect.int`. */
514
+ function isIntegerField(field, doc) {
515
+ const def = field.getDefinition();
516
+ if (!def || !isRef(def)) return true;
517
+ const leaf = doc.unwindType(def.id, def.chain)?.def;
518
+ if (primitiveBaseType(leaf) !== "number") return true;
519
+ if (leaf.tags?.has("int")) return true;
520
+ return effectiveAnnotationNames(field, doc).has("expect.int");
521
+ }
580
522
  const dbColumnAnnotations = {
581
523
  patch: { strategy: new AnnotationSpec({
582
- description: "Defines the **patching strategy** for updating nested objects.\n\n- **\"replace\"** → The field or object will be **fully replaced**.\n- **\"merge\"** → The field or object will be **merged recursively** (applies only to objects, not arrays).\n\n**Example:**\n```atscript\n@db.patch.strategy \"merge\"\nsettings: {\n notifications: boolean\n preferences: {\n theme: string\n }\n}\n```\n",
524
+ description: annotationExample("Defines the **patching strategy** for updating nested objects.\n\n- **\"replace\"** → The field or object will be **fully replaced**.\n- **\"merge\"** → The field or object will be **merged recursively** (applies only to objects, not arrays).", "@db.patch.strategy \"merge\"", "settings: {", " notifications: boolean", " preferences: {", " theme: string", " }", "}"),
583
525
  nodeType: ["prop"],
584
526
  passedWhenReferred: false,
585
527
  multiple: false,
@@ -609,7 +551,7 @@ const dbColumnAnnotations = {
609
551
  }) },
610
552
  column: {
611
553
  $self: new AnnotationSpec({
612
- description: "Overrides the physical column name in the database. For nested (flattened) fields, the parent prefix is still prepended automatically. Document storage (MongoDB) renames top-level fields only — a nested field keeps its name there.\n\n**Example:**\n```atscript\n@db.column \"first_name\"\nfirstName: string\n// → physical column: first_name\n\n// Nested:\naddress: {\n @db.column \"zip_code\"\n zip: string\n}\n// → physical column: address__zip_code\n```\n",
554
+ description: annotationExample("Overrides the physical column name in the database. For nested (flattened) fields, the parent prefix is still prepended automatically. Document storage (MongoDB) renames top-level fields only — a nested field keeps its name there.", "@db.column \"first_name\"", "firstName: string", "// → physical column: first_name", "", "// Nested:", "address: {", " @db.column \"zip_code\"", " zip: string", "}", "// → physical column: address__zip_code"),
613
555
  nodeType: ["prop"],
614
556
  passedWhenReferred: false,
615
557
  argument: {
@@ -619,7 +561,7 @@ const dbColumnAnnotations = {
619
561
  }
620
562
  }),
621
563
  renamed: new AnnotationSpec({
622
- description: "Specifies the previous local field name for column rename migration. The sync engine generates ALTER TABLE RENAME COLUMN instead of drop+add (on document storage, top-level fields only).\n\n**Example:**\n```atscript\n@db.column.renamed \"zip\"\npostalCode: string\n// Renames address__zip → address__postalCode\n```\n",
564
+ description: annotationExample("Specifies the previous local field name for column rename migration. The sync engine generates ALTER TABLE RENAME COLUMN instead of drop+add (on document storage, top-level fields only).", "@db.column.renamed \"zip\"", "postalCode: string", "// Renames address__zip → address__postalCode"),
623
565
  nodeType: ["prop"],
624
566
  passedWhenReferred: false,
625
567
  argument: {
@@ -629,7 +571,7 @@ const dbColumnAnnotations = {
629
571
  }
630
572
  }),
631
573
  collate: new AnnotationSpec({
632
- description: "Portable collation for string comparison and sorting. Adapters map the generic value to their native collation.\n\n- **\"binary\"** — exact byte comparison (case-sensitive)\n- **\"nocase\"** — case-insensitive comparison\n- **\"unicode\"** — full Unicode-aware sorting\n\nFor adapter-specific collations, use `@db.<engine>.collate` instead.\n\n**Example:**\n```atscript\n@db.column.collate \"nocase\"\nusername: string\n```\n",
574
+ description: annotationExample("Portable collation for string comparison and sorting. Adapters map the generic value to their native collation.\n\n- **\"binary\"** — exact byte comparison (case-sensitive)\n- **\"nocase\"** — case-insensitive comparison\n- **\"unicode\"** — full Unicode-aware sorting\n\nFor adapter-specific collations, use `@db.<engine>.collate` instead.", "@db.column.collate \"nocase\"", "username: string"),
633
575
  nodeType: ["prop"],
634
576
  passedWhenReferred: false,
635
577
  argument: {
@@ -647,7 +589,7 @@ const dbColumnAnnotations = {
647
589
  }
648
590
  }),
649
591
  precision: new AnnotationSpec({
650
- description: "Sets decimal precision and scale for database storage. Adapters map this to their native decimal type (e.g., `DECIMAL(10,2)` in SQL, ignored in MongoDB).\n\nFor `decimal` fields the runtime value is a string; for `number` fields this is a DB storage hint only.\n\n**Example:**\n```atscript\n@db.column.precision 10, 2\nprice: decimal\n```\n",
592
+ description: annotationExample("Sets decimal precision and scale for database storage. Adapters map this to their native decimal type (e.g., `DECIMAL(10,2)` in SQL, ignored in MongoDB).\n\nFor `decimal` fields the runtime value is a string; for `number` fields this is a DB storage hint only.", "@db.column.precision 10, 2", "price: decimal"),
651
593
  nodeType: ["prop"],
652
594
  passedWhenReferred: false,
653
595
  argument: [{
@@ -664,7 +606,7 @@ const dbColumnAnnotations = {
664
606
  }
665
607
  }),
666
608
  derived: new AnnotationSpec({
667
- description: "Declares a **derived column**: a read-only column computed from one `string`, `number` or `boolean` leaf inside a `@db.json` field of the **same table**, so the leaf can be filtered, sorted, grouped and indexed like a real column. The field's type is a chain reference into that field (`customerId: Order.payload.customer.id`); the path may not cross an array or a `@db.encrypted` field. Declare the field optional when the path can be absent (a missing leaf reads as `null`). A value written to it is dropped; `$inc` / `$dec` / `$mul` on it are rejected.\n\nStorage per adapter, schema sync and the compatible annotations: https://atscript.dev/db/api/storage#derived-columns\n\n**Example:**\n```atscript\n@db.table 'orders'\nexport interface Order {\n @meta.id\n id: number\n\n @db.json\n payload: {\n customer: { id: string, vip: boolean }\n total: number\n }\n\n @db.column.derived\n @db.index.plain\n customerId: Order.payload.customer.id\n\n @db.column.derived\n vip?: Order.payload.customer.vip\n}\n```\n",
609
+ description: annotationExample("Declares a **derived column**: a read-only column computed from one `string`, `number` or `boolean` leaf inside a `@db.json` field of the **same table**, so the leaf can be filtered, sorted, grouped and indexed like a real column. The field's type is a chain reference into that field (`customerId: Order.payload.customer.id`); the path may not cross an array or a `@db.encrypted` field. Declare the field optional when the path can be absent (a missing leaf reads as `null`). A value written to it is dropped; `$inc` / `$dec` / `$mul` on it are rejected.\n\nStorage per adapter, schema sync and the compatible annotations: https://atscript.dev/db/api/storage#derived-columns", "@db.table 'orders'", "export interface Order {", " @meta.id", " id: number", "", " @db.json", " payload: {", " customer: { id: string, vip: boolean }", " total: number", " }", "", " @db.column.derived", " @db.index.plain", " customerId: Order.payload.customer.id", "", " @db.column.derived", " vip?: Order.payload.customer.vip", "}"),
668
610
  nodeType: ["prop"],
669
611
  passedWhenReferred: false,
670
612
  multiple: false,
@@ -739,11 +681,13 @@ const dbColumnAnnotations = {
739
681
  filterable: columnCapability("filterable", "filtering"),
740
682
  sortable: columnCapability("sortable", "sorting"),
741
683
  searchable: new AnnotationSpec({
742
- description: "Includes this column in the generic `$search` fallback: when the adapter reports no native search capability (no FTS / Atlas index configured), the readable controller matches the `$search` term as a case-insensitive substring across all `@db.column.searchable` fields (`$or`). Where adapter-native search IS available it wins and this annotation is not consulted. The term is escaped literally — no user-supplied regex. String and integer columns only: an integer column (`number.int` and its sizes, `@expect.int`, `@db.default.increment`) matches when the term is a substring of the number's decimal text (`2946` finds `29461277`). Floats, decimals and timestamps are refused.\n\n**Example:**\n```atscript\n@db.table \"jobs\"\nexport interface Job {\n @db.column.searchable\n jobName: string\n @db.column.searchable\n description: string\n @db.column.searchable\n refNo: number.int\n}\n```\n",
684
+ description: annotationExample("Includes this column in the generic `$search` fallback: when the adapter reports no native search capability (no FTS / Atlas index configured), the readable controller matches the `$search` term as a case-insensitive substring across all `@db.column.searchable` fields (`$or`). Where adapter-native search IS available it wins and this annotation is not consulted. The term is escaped literally — no user-supplied regex. String and integer columns only: an integer column (`number.int` and its sizes, `@expect.int`, `@db.default.increment`) matches when the term is a substring of the number's decimal text (`2946` finds `29461277`). Floats, decimals and timestamps are refused.", "@db.table \"jobs\"", "export interface Job {", " @db.column.searchable", " jobName: string", " @db.column.searchable", " description: string", " @db.column.searchable", " refNo: number.int", "}"),
743
685
  nodeType: ["prop"],
744
686
  passedWhenReferred: false,
745
687
  multiple: false,
746
688
  validate(token, _args, doc) {
689
+ const sealed = sealedSearchProblem(token, doc, "@db.column.searchable");
690
+ if (sealed) return [sealed];
747
691
  const verdict = searchFieldVerdict(token.parentNode, doc);
748
692
  if (!("problem" in verdict)) return [];
749
693
  return [{
@@ -755,7 +699,7 @@ const dbColumnAnnotations = {
755
699
  }),
756
700
  version: {
757
701
  $self: new AnnotationSpec({
758
- description: "Marks a numeric column as the row's version for optimistic concurrency control (OCC). The adapter auto-increments this column on every UPDATE, and callers may pass `$cas: { <col>: N }` in a write payload to make the update conditional on the current version. Direct writes to the version column (as plain SET, `$inc`, or `$mul`) are rejected. Fields marked `@db.column.version.exempt` do not bump it.\n\n**Constraints:**\n- At most one version column per table.\n- Must resolve to an integer type (`int`, `int32`, `int64`, etc.).\n- Default value on insert is `0`.\n\n**Example:**\n```atscript\n@db.column.version\nversion: int\n```\n",
702
+ description: annotationExample("Marks a numeric column as the row's version for optimistic concurrency control (OCC). The adapter auto-increments this column on every UPDATE, and callers may pass `$cas: { <col>: N }` in a write payload to make the update conditional on the current version. Direct writes to the version column (as plain SET, `$inc`, or `$mul`) are rejected. Fields marked `@db.column.version.exempt` do not bump it.\n\n**Constraints:**\n- At most one version column per table.\n- Must resolve to an integer type (`int`, `int32`, `int64`, etc.).\n- Default value on insert is `0`.", "@db.column.version", "version: int"),
759
703
  nodeType: ["prop"],
760
704
  passedWhenReferred: false,
761
705
  validate(token, _args, doc) {
@@ -779,7 +723,7 @@ const dbColumnAnnotations = {
779
723
  }
780
724
  }),
781
725
  exempt: new AnnotationSpec({
782
- description: "Marks a field as **version-exempt**: a patch that writes ONLY exempt fields (`updateOne` / `bulkUpdate` without `$cas`, `updateMany`) leaves `@db.column.version` unchanged and adds no version check. A patch touching any other field bumps as usual; `$cas`, replace and `touchMany` always bump. Use it for derived/reporting columns refreshed in the background (scores, counters, caches) so they do not invalidate versions held by editors. On an object field it covers every nested field.\n\n**Example:**\n```atscript\n@db.column.version\nversion: number.int\n@db.column.version.exempt\nscore: number\n```\n",
726
+ description: annotationExample("Marks a field as **version-exempt**: a patch that writes ONLY exempt fields (`updateOne` / `bulkUpdate` without `$cas`, `updateMany`) leaves `@db.column.version` unchanged and adds no version check. A patch touching any other field bumps as usual; `$cas`, replace and `touchMany` always bump. Use it for derived/reporting columns refreshed in the background (scores, counters, caches) so they do not invalidate versions held by editors. On an object field it covers every nested field.", "@db.column.version", "version: number.int", "@db.column.version.exempt", "score: number"),
783
727
  nodeType: ["prop"],
784
728
  passedWhenReferred: false,
785
729
  multiple: false,
@@ -791,7 +735,7 @@ const dbColumnAnnotations = {
791
735
  },
792
736
  default: {
793
737
  $self: new AnnotationSpec({
794
- description: "Sets a static DB-level default value (used in DDL DEFAULT clause). For string fields the value is used as-is; for other types it is parsed as JSON.\n\n**Example:**\n```atscript\n@db.default \"active\"\nstatus: string\n```\n",
738
+ description: annotationExample("Sets a static DB-level default value (used in DDL DEFAULT clause). For string fields the value is used as-is; for other types it is parsed as JSON.", "@db.default \"active\"", "status: string"),
795
739
  nodeType: ["prop"],
796
740
  passedWhenReferred: false,
797
741
  argument: {
@@ -801,7 +745,7 @@ const dbColumnAnnotations = {
801
745
  }
802
746
  }),
803
747
  increment: new AnnotationSpec({
804
- description: "Auto-incrementing integer default. Each adapter maps this to its native mechanism (e.g., `AUTO_INCREMENT` in MySQL, `INTEGER PRIMARY KEY` in SQLite, counter collection in MongoDB).\n\n**Example:**\n```atscript\n@db.default.increment\nid: number.int\n\n// With optional start value:\n@db.default.increment 1000\nid: number.int\n```\n",
748
+ description: annotationExample("Auto-incrementing integer default. Each adapter maps this to its native mechanism (e.g., `AUTO_INCREMENT` in MySQL, `INTEGER PRIMARY KEY` in SQLite, counter collection in MongoDB).", "@db.default.increment", "id: number.int", "", "// With optional start value:", "@db.default.increment 1000", "id: number.int"),
805
749
  nodeType: ["prop"],
806
750
  passedWhenReferred: false,
807
751
  argument: {
@@ -821,7 +765,7 @@ const dbColumnAnnotations = {
821
765
  }
822
766
  }),
823
767
  uuid: new AnnotationSpec({
824
- description: "UUID generation default. Each adapter maps this to its native mechanism (e.g., `DEFAULT (UUID())` in MySQL, `gen_random_uuid()` in PostgreSQL, app-level in SQLite).\n\n**Example:**\n```atscript\n@db.default.uuid\nid: string.uuid\n```\n",
768
+ description: annotationExample("UUID generation default. Each adapter maps this to its native mechanism (e.g., `DEFAULT (UUID())` in MySQL, `gen_random_uuid()` in PostgreSQL, app-level in SQLite).", "@db.default.uuid", "id: string.uuid"),
825
769
  nodeType: ["prop"],
826
770
  passedWhenReferred: false,
827
771
  validate(token, args, doc) {
@@ -829,7 +773,7 @@ const dbColumnAnnotations = {
829
773
  }
830
774
  }),
831
775
  now: new AnnotationSpec({
832
- description: "Current timestamp default. Each adapter maps this to its native mechanism (e.g., `DEFAULT CURRENT_TIMESTAMP` in MySQL, `DEFAULT now()` in PostgreSQL).\n\n**Example:**\n```atscript\n@db.default.now\ncreatedAt: number.timestamp\n```\n",
776
+ description: annotationExample("Current timestamp default. Each adapter maps this to its native mechanism (e.g., `DEFAULT CURRENT_TIMESTAMP` in MySQL, `DEFAULT now()` in PostgreSQL).", "@db.default.now", "createdAt: number.timestamp"),
833
777
  nodeType: ["prop"],
834
778
  passedWhenReferred: false,
835
779
  validate(token, args, doc) {
@@ -837,23 +781,34 @@ const dbColumnAnnotations = {
837
781
  }
838
782
  })
839
783
  },
840
- onUpdate: { now: new AnnotationSpec({
841
- description: "Sets the field to the current time (epoch ms) on every update: patches (`updateOne`, `bulkUpdate`, `updateMany`, HTTP `PATCH`) and replaces (`replaceOne`, `bulkReplace`, `replaceMany`, HTTP `PUT`). The value is written by the SDK in the same statement on every adapter and overrides a value the payload carries. Inserts are not affected — combine with `@db.default.now` (`number.timestamp.updated` applies both). Writes outside the SDK (raw SQL) do not set it.\n\n**Example:**\n```atscript\n@db.default.now\n@db.onUpdate.now\nupdatedAt: number.timestamp\n```\n",
842
- nodeType: ["prop"],
843
- passedWhenReferred: false,
844
- validate(token, args, doc) {
845
- const errors = validateFieldBaseType(token, doc, "@db.onUpdate.now", "number");
846
- const field = token.parentNode;
847
- for (const [name, why] of [["meta.id", "the primary key identifies the row and is never updated"], ["db.column.version", "the version column is adapter-managed"]]) if (field.countAnnotations(name) > 0) errors.push({
848
- message: `@db.onUpdate.now cannot coexist with @${name} — ${why}`,
849
- severity: 1,
850
- range: token.range
851
- });
852
- return errors;
853
- }
854
- }) },
784
+ onUpdate: {
785
+ now: new AnnotationSpec({
786
+ description: annotationExample("Sets the field to the current time (epoch ms) on every update: patches (`updateOne`, `bulkUpdate`, `updateMany`, HTTP `PATCH`) and replaces (`replaceOne`, `bulkReplace`, `replaceMany`, HTTP `PUT`). The value is written by the SDK in the same statement on every adapter and overrides a value the payload carries. Inserts are not affected — combine with `@db.default.now` (`number.timestamp.updated` applies both). Writes outside the SDK (raw SQL) do not set it.", "@db.default.now", "@db.onUpdate.now", "updatedAt: number.timestamp"),
787
+ nodeType: ["prop"],
788
+ passedWhenReferred: false,
789
+ validate(token, args, doc) {
790
+ const errors = validateFieldBaseType(token, doc, "@db.onUpdate.now", "number");
791
+ const field = token.parentNode;
792
+ for (const [name, why] of [["meta.id", "the primary key identifies the row and is never updated"], ["db.column.version", "the version column is adapter-managed"]]) if (field.countAnnotations(name) > 0) errors.push({
793
+ message: `@db.onUpdate.now cannot coexist with @${name} — ${why}`,
794
+ severity: 1,
795
+ range: token.range
796
+ });
797
+ return errors;
798
+ }
799
+ }),
800
+ inc: new AnnotationSpec({
801
+ description: annotationExample("A per-row **change counter**: `0` on insert (unless the insert supplies a value), then `+1` in the same statement on every SDK update of the row — patches (`updateOne`, `bulkUpdate`, `updateMany`, HTTP `PATCH`, including patches that write only `@db.column.version.exempt` fields), replaces (`replaceOne`, `bulkReplace`, `replaceMany`, HTTP `PUT`) and `touchMany`. A value the payload carries is ignored, so clients cannot set it. An empty patch writes nothing and leaves it unchanged. It has no compare-and-set and does not affect `@db.column.version`. Writes outside the SDK (raw SQL, native `ON DELETE SET NULL` actions) do not move it.\n\n**Constraints:** a non-optional integer field at the top level of the table; not the primary key, the version column, a `@db.onUpdate.now`, `@db.default*`, `@db.json` or `@db.encrypted` field.", "@db.onUpdate.inc", "revision: number.int"),
802
+ nodeType: ["prop"],
803
+ passedWhenReferred: false,
804
+ multiple: false,
805
+ validate(token, _args, doc) {
806
+ return validateOnUpdateInc(token, doc);
807
+ }
808
+ })
809
+ },
855
810
  json: new AnnotationSpec({
856
- description: "Forces a field to be stored as a single JSON column instead of being flattened into separate columns. Use on nested object fields that should remain as JSON in the database.\n\n**Example:**\n```atscript\n@db.json\nmetadata: { key: string, value: string }\n```\n",
811
+ description: annotationExample("Forces a field to be stored as a single JSON column instead of being flattened into separate columns. Use on nested object fields that should remain as JSON in the database. On a primitive field (string / number / boolean) it stores the value as JSON too: SQL adapters filter the column only with `$exists` and never sort it — drop `@db.json` to get a typed, sortable scalar column.", "@db.json", "metadata: { key: string, value: string }"),
857
812
  nodeType: ["prop"],
858
813
  passedWhenReferred: false,
859
814
  validate(token, _args, doc) {
@@ -862,7 +817,7 @@ const dbColumnAnnotations = {
862
817
  if (definition && isRef(definition)) {
863
818
  const unwound = doc.unwindType(definition.id, definition.chain);
864
819
  if (unwound && isPrimitive(unwound.def)) errors.push({
865
- message: "@db.json on a primitive field has no effect — primitive fields are already stored as scalar columns",
820
+ message: "@db.json on a primitive field stores it as one JSON column — filterable only with $exists and never sortable; drop @db.json for a typed scalar column",
866
821
  severity: 2,
867
822
  range: token.range
868
823
  });
@@ -871,7 +826,7 @@ const dbColumnAnnotations = {
871
826
  }
872
827
  }),
873
828
  ignore: new AnnotationSpec({
874
- description: "Excludes a field from the database schema. The field exists in the Atscript type but has no column in the DB.\n\n**Example:**\n```atscript\n@db.ignore\ndisplayName: string\n```\n",
829
+ description: annotationExample("Excludes a field from the database schema. The field exists in the Atscript type but has no column in the DB.", "@db.ignore", "displayName: string"),
875
830
  nodeType: ["prop"],
876
831
  passedWhenReferred: false,
877
832
  validate(token, _args, _doc) {
@@ -885,38 +840,18 @@ const dbColumnAnnotations = {
885
840
  }
886
841
  }),
887
842
  encrypted: new AnnotationSpec({
888
- description: "Encrypts this field at rest. Values are AES-256-GCM encrypted by the core layer before reaching the database and decrypted on read — transparent to application code.\n\nConstraints: the field cannot be filtered, sorted, indexed, used as PK/FK, patched with arithmetic ops, or referenced by search/vector/geo features.\n\n**Example:**\n```atscript\n@db.encrypted\napiToken?: string\n```\n",
843
+ description: annotationExample("Encrypts this field at rest. Values are AES-256-GCM encrypted by the core layer before reaching the database and decrypted on read — transparent to application code.\n\nConstraints: the field cannot be filtered, sorted, indexed, used as PK/FK, patched with arithmetic ops, or referenced by search/vector/geo features.", "@db.encrypted", "apiToken?: string"),
889
844
  nodeType: ["prop"],
890
845
  passedWhenReferred: false,
891
846
  multiple: false,
892
- validate(token, _args, _doc) {
893
- const errors = [];
894
- const field = token.parentNode;
895
- for (const [name, why] of [
896
- ["meta.id", "the primary key must be addressable"],
897
- ["db.rel.FK", "joins are impossible over ciphertext"],
898
- ["db.index.plain", "indexes over ciphertext are meaningless"],
899
- ["db.index.unique", "indexes over ciphertext are meaningless"],
900
- ["db.index.fulltext", "indexes over ciphertext are meaningless"],
901
- ["db.index.geo", "geo indexes over ciphertext are meaningless"],
902
- ["db.search.vector", "vector search over ciphertext is impossible"],
903
- ["db.search.filter", "search filters need cleartext equality"],
904
- ["db.column.version", "the OCC filter needs cleartext equality"],
905
- ["db.default.increment", "engine-side defaults bypass the encryption transform"],
906
- ["db.default.now", "engine-side defaults bypass the encryption transform"],
907
- ["db.onUpdate.now", "the update time is set after the encryption transform"],
908
- ["db.mongo.search.text", "Atlas Search over ciphertext is impossible"],
909
- ["db.mongo.search.autocomplete", "Atlas Search over ciphertext is impossible"]
910
- ]) if (field.countAnnotations(name) > 0) errors.push({
911
- message: `@db.encrypted cannot coexist with @${name} — ${why}`,
912
- severity: 1,
913
- range: token.range
914
- });
915
- return errors;
847
+ validate(token, _args, doc) {
848
+ const typeProblem = encryptedTypeSearchProblem(token, doc);
849
+ const errors = validateIncompatible(token, "@db.encrypted", ENCRYPTED_INCOMPATIBLE, doc);
850
+ return typeProblem ? [typeProblem, ...errors] : errors;
916
851
  }
917
852
  }),
918
853
  writeOnly: new AnnotationSpec({
919
- description: "Marks a field as write-only over HTTP: it may be set through insert/update/replace payloads but NEVER appears in read responses — the readable controller excludes it from every projection, rejects filtering/sorting/grouping on it, and `/meta` serves its TYPE (flagged `writeOnly`) so client preflight validation and generated forms still know its shape. The classic case is a sealed secret (pair with `@db.encrypted`): writable via generic forms, unreadable by anyone.\n\nServer-side code reading through `AtscriptDbTable` still sees the value — the seal is an HTTP-layer contract, not a storage one. It cannot be combined with `@db.index.fulltext` or `@db.column.searchable`: a search hit would reveal the sealed value.\n\n**Example:**\n```atscript\n@db.writeOnly\n@db.encrypted\ncredentials?: string\n```\n",
854
+ description: annotationExample("Marks a field as write-only over HTTP: it may be set through insert/update/replace payloads but NEVER appears in read responses — the readable controller excludes it from every projection, rejects filtering/sorting/grouping on it, and `/meta` serves its TYPE (flagged `writeOnly`) so client preflight validation and generated forms still know its shape. The classic case is a sealed secret (pair with `@db.encrypted`): writable via generic forms, unreadable by anyone.\n\nServer-side code reading through `AtscriptDbTable` still sees the value — the seal is an HTTP-layer contract, not a storage one. It cannot be combined with `@db.index.fulltext` or `@db.column.searchable`: a search hit would reveal the sealed value.", "@db.writeOnly", "@db.encrypted", "credentials?: string"),
920
855
  nodeType: ["prop"],
921
856
  passedWhenReferred: false,
922
857
  multiple: false,
@@ -938,15 +873,9 @@ const dbColumnAnnotations = {
938
873
  })
939
874
  };
940
875
  function columnCapability(capability, verb) {
941
- const example = capability === "filterable" ? " @db.column.filterable\n email: string\n" : " @db.column.sortable\n createdAt: number.timestamp\n";
876
+ const example = capability === "filterable" ? [" @db.column.filterable", " email: string"] : [" @db.column.sortable", " createdAt: number.timestamp"];
942
877
  return new AnnotationSpec({
943
- description: `Marks a column as ${capability} in the readable controller's query/pages endpoints. Relevant only when the host \`@db.table\` interface opts into strict mode with \`@db.table.${capability} 'manual'\`; otherwise ${verb} is open on every column the adapter can handle (default-open, back-compat) and the annotation is a no-op. Adapter capability always wins: \`@db.json\` / array columns stay non-sortable (and non-filterable on SQL adapters) even when annotated. \`/meta.fields\` advertises exactly what the gate accepts; index-backed columns additionally carry the advisory \`indexed\` flag.
944
-
945
- **Example:**
946
- \`\`\`atscript
947
- @db.table "users"
948
- @db.table.${capability} "manual"\nexport interface User {
949
- ` + example + "}\n```\n",
878
+ description: annotationExample(`Marks a column as ${capability} in the readable controller's query/pages endpoints. Relevant only when the host \`@db.table\` interface opts into strict mode with \`@db.table.${capability} 'manual'\`; otherwise ${verb} is open on every column the adapter can handle (default-open, back-compat) and the annotation is a no-op. Adapter capability always wins: \`@db.json\` / array columns stay non-sortable (and non-filterable on SQL adapters) even when annotated. \`/meta.fields\` advertises exactly what the gate accepts; index-backed columns additionally carry the advisory \`indexed\` flag.`, "@db.table \"users\"", `@db.table.${capability} "manual"`, "export interface User {", ...example, "}"),
950
879
  nodeType: ["prop"],
951
880
  passedWhenReferred: false,
952
881
  multiple: false,
@@ -1045,7 +974,7 @@ function validateCompute(token, args, doc) {
1045
974
  error("@db.compute is only valid on a field of a @db.view.for view");
1046
975
  return errors;
1047
976
  }
1048
- errors.push(...validateExclusiveWith(token, "@db.compute", EXCLUSIVE_WITH));
977
+ errors.push(...validateExclusiveWith(token, "@db.compute", EXCLUSIVE_WITH, doc));
1049
978
  if (!isPlainNumber(prop, doc)) error(`Field "${field}" has a @db.compute and must be typed \`number\``);
1050
979
  const expr = args[0]?.exprNode;
1051
980
  if (!expr) return errors;
@@ -1068,7 +997,7 @@ function validateCompute(token, args, doc) {
1068
997
  return errors;
1069
998
  }
1070
999
  const dbComputeAnnotations = { compute: new AnnotationSpec({
1071
- description: "Declares a **computed view column** (since 0.1.147): closed arithmetic over the view's own fields — dimensions, aggregates, first-row-join fields and other computed fields — evaluated by the database, so the column sorts, filters and pages like any other.\n\nGrammar: `+ - * /`, unary `-`, parentheses, numeric literals, `coalesce(a, b, …)`. Every value is a double (`7 / 2 = 3.5`); a NULL operand yields NULL; division by zero yields NULL — so an expression with `/` or an optional operand needs an optional field.\n\n**Example:**\n```atscript\n@db.agg.count 'id', `Issue.status = 'open'`\nopenCount: Issue.id\n\n@db.compute `openCount * 10 + coalesce(oldestSeverity, 0)`\nrank: number\n```\n",
1000
+ description: annotationExample("Declares a **computed view column** (since 0.1.147): closed arithmetic over the view's own fields — dimensions, aggregates, first-row-join fields and other computed fields — evaluated by the database, so the column sorts, filters and pages like any other.\n\nGrammar: `+ - * /`, unary `-`, parentheses, numeric literals, `coalesce(a, b, …)`. Every value is a double (`7 / 2 = 3.5`); a NULL operand yields NULL; division by zero yields NULL — so an expression with `/` or an optional operand needs an optional field.", "@db.agg.count 'id', `Issue.status = 'open'`", "openCount: Issue.id", "", "@db.compute `openCount * 10 + coalesce(oldestSeverity, 0)`", "rank: number"),
1072
1001
  nodeType: ["prop"],
1073
1002
  passedWhenReferred: false,
1074
1003
  argument: {
@@ -1083,7 +1012,7 @@ const dbComputeAnnotations = { compute: new AnnotationSpec({
1083
1012
  //#region src/plugin/annotations/index-ann.ts
1084
1013
  const dbIndexAnnotations = { index: {
1085
1014
  plain: new AnnotationSpec({
1086
- description: "Standard (non-unique) index for query performance. Fields sharing the same index name form a composite index.\n\n**Example:**\n```atscript\n@db.index.plain \"idx_timeline\", \"desc\"\ncreatedAt: number.timestamp\n```\n",
1015
+ description: annotationExample("Standard (non-unique) index for query performance. Fields sharing the same index name form a composite index.", "@db.index.plain \"idx_timeline\", \"desc\"", "createdAt: number.timestamp"),
1087
1016
  nodeType: ["prop"],
1088
1017
  passedWhenReferred: false,
1089
1018
  multiple: true,
@@ -1102,7 +1031,7 @@ const dbIndexAnnotations = { index: {
1102
1031
  }]
1103
1032
  }),
1104
1033
  unique: new AnnotationSpec({
1105
- description: "Unique index — ensures no two rows/documents have the same value(s). Fields sharing the same index name form a composite unique constraint.\n\n**Example:**\n```atscript\n@db.index.unique \"tenant_email\"\nemail: string.email\n```\n",
1034
+ description: annotationExample("Unique index — ensures no two rows/documents have the same value(s). Fields sharing the same index name form a composite unique constraint.", "@db.index.unique \"tenant_email\"", "email: string.email"),
1106
1035
  nodeType: ["prop"],
1107
1036
  passedWhenReferred: false,
1108
1037
  multiple: true,
@@ -1115,9 +1044,11 @@ const dbIndexAnnotations = { index: {
1115
1044
  }
1116
1045
  }),
1117
1046
  fulltext: new AnnotationSpec({
1118
- description: "Full-text search index. Fields sharing the same index name form a composite full-text index. A string member is part of the engine's text index. An integer member (`number.int` and its sizes, `@expect.int`, `@db.default.increment`) is never part of the physical text index: when the whole search term is a whole number (`2946`, `-12`; no leading zeros), rows whose member equals it exactly also match (OR'd with the text match). An integer member must be index-backed — the primary key (first `@meta.id`) or the first field of a `@db.index.plain` / `@db.index.unique`. Floats, decimals and timestamps are refused.\n\n**Example:**\n```atscript\n@db.index.fulltext \"ft_content\"\ntitle: string\n\n@db.index.fulltext \"ft_content\", 5\nbio: string\n\n@db.index.fulltext \"ft_content\"\n@db.index.unique\nrefNo: number.int\n```\n",
1047
+ description: annotationExample("Full-text search index. Fields sharing the same index name form a composite full-text index. A string member is part of the engine's text index. An integer member (`number.int` and its sizes, `@expect.int`, `@db.default.increment`) is never part of the physical text index: when the whole search term is a whole number (`2946`, `-12`; no leading zeros), rows whose member equals it exactly also match (OR'd with the text match). An integer member must be index-backed — the primary key (first `@meta.id`) or the first field of a `@db.index.plain` / `@db.index.unique`. Floats, decimals and timestamps are refused.", "@db.index.fulltext \"ft_content\"", "title: string", "", "@db.index.fulltext \"ft_content\", 5", "bio: string", "", "@db.index.fulltext \"ft_content\"", "@db.index.unique", "refNo: number.int"),
1119
1048
  validate(token, args, doc) {
1120
1049
  const field = token.parentNode;
1050
+ const sealed = sealedSearchProblem(token, doc, "@db.index.fulltext");
1051
+ if (sealed) return [sealed];
1121
1052
  const verdict = searchFieldVerdict(field, doc);
1122
1053
  if ("problem" in verdict) return [{
1123
1054
  message: `@db.index.fulltext needs a string or an integer field — "${field.id}" ${verdict.problem}`,
@@ -1156,7 +1087,7 @@ const dbIndexAnnotations = { index: {
1156
1087
  }]
1157
1088
  }),
1158
1089
  geo: new AnnotationSpec({
1159
- description: "Geospatial index on a `db.geoPoint` field. Enables `geoSearch()` (distance-ranked) and accelerates `$geoWithin` filters.\n\n**Example:**\n```atscript\n@db.index.geo\ngeo: db.geoPoint\n```\n",
1090
+ description: annotationExample("Geospatial index on a `db.geoPoint` field. Enables `geoSearch()` (distance-ranked) and accelerates `$geoWithin` filters.", "@db.index.geo", "geo: db.geoPoint"),
1160
1091
  nodeType: ["prop"],
1161
1092
  passedWhenReferred: false,
1162
1093
  multiple: false,
@@ -1165,7 +1096,8 @@ const dbIndexAnnotations = { index: {
1165
1096
  name: "name",
1166
1097
  type: "string",
1167
1098
  description: "Index name. Defaults to the field name."
1168
- }
1099
+ },
1100
+ validate: refuseCiphertext("@db.index.geo")
1169
1101
  })
1170
1102
  } };
1171
1103
  //#endregion
@@ -1197,7 +1129,7 @@ function checkCompositeFkCoverage(token, alias, refTypeName, targetStruct, targe
1197
1129
  }
1198
1130
  const dbRelAnnotations = { rel: {
1199
1131
  FK: new AnnotationSpec({
1200
- description: "Declares a foreign key reference on this field. The field must use a chain reference type (e.g., `User.id`) whose target is a primary key (`@meta.id`) or unique (`@db.index.unique`) field.\n\n**Dual role:**\n- On a `@db.table` interface, `@db.rel.FK` additionally drives DB-relation semantics — relation loading with `@db.rel.to` / `@db.rel.from`, junction pairing with `@db.rel.via`, etc.\n- On any other interface (value-help sources, WF forms, plain interfaces), `@db.rel.FK` acts purely as the value-help indicator: the client-side picker resolver uses it to decide which fields render a value-help picker. The target's `@db.http.path` (published by its canonical readable controller) supplies the picker URL.\n- In `/meta` (and `/meta/form/:name`) the marker is inherited through reference chains: a field declared as `code: Issue.code` where `Issue.code: Dict.code` carries `@db.rel.FK` is served with `ref` pointing at the terminal field (`Dict.code`) and `db.rel.FK: true`, so view fields get the dictionary picker. Runtime metadata is untouched.\n\n**Example:**\n```atscript\n@db.rel.FK\nauthorId: User.id\n\n// With alias (required when multiple FKs point to the same type)\n@db.rel.FK \"author\"\nauthorId: User.id\n```\n",
1132
+ description: annotationExample("Declares a foreign key reference on this field. The field must use a chain reference type (e.g., `User.id`) whose target is a primary key (`@meta.id`) or unique (`@db.index.unique`) field.\n\n**Dual role:**\n- On a `@db.table` interface, `@db.rel.FK` additionally drives DB-relation semantics — relation loading with `@db.rel.to` / `@db.rel.from`, junction pairing with `@db.rel.via`, etc.\n- On any other interface (value-help sources, WF forms, plain interfaces), `@db.rel.FK` acts purely as the value-help indicator: the client-side picker resolver uses it to decide which fields render a value-help picker. The target's `@db.http.path` (published by its canonical readable controller) supplies the picker URL.\n- In `/meta` (and `/meta/form/:name`) the marker is inherited through reference chains: a field declared as `code: Issue.code` where `Issue.code: Dict.code` carries `@db.rel.FK` is served with `ref` pointing at the terminal field (`Dict.code`) and `db.rel.FK: true`, so view fields get the dictionary picker. Runtime metadata is untouched.", "@db.rel.FK", "authorId: User.id", "", "// With alias (required when multiple FKs point to the same type)", "@db.rel.FK \"author\"", "authorId: User.id"),
1201
1133
  nodeType: ["prop"],
1202
1134
  passedWhenReferred: false,
1203
1135
  argument: {
@@ -1287,7 +1219,7 @@ const dbRelAnnotations = { rel: {
1287
1219
  }
1288
1220
  }),
1289
1221
  to: new AnnotationSpec({
1290
- description: "Forward navigational property — the FK is on **this** interface. The compiler resolves the matching @db.rel.FK by target type or alias.\n\n**Example:**\n```atscript\n@db.rel.to\nauthor?: User\n\n// With alias\n@db.rel.to \"author\"\nauthor?: User\n```\n",
1222
+ description: annotationExample("Forward navigational property — the FK is on **this** interface. The compiler resolves the matching @db.rel.FK by target type or alias.", "@db.rel.to", "author?: User", "", "// With alias", "@db.rel.to \"author\"", "author?: User"),
1291
1223
  nodeType: ["prop"],
1292
1224
  passedWhenReferred: false,
1293
1225
  argument: {
@@ -1371,7 +1303,7 @@ const dbRelAnnotations = { rel: {
1371
1303
  }
1372
1304
  }),
1373
1305
  from: new AnnotationSpec({
1374
- description: "Inverse navigational property — the FK is on the **target** interface, pointing back to this one.\n\n**Example:**\n```atscript\n@db.rel.from\nposts: Post[]\n\n// With alias\n@db.rel.from \"original\"\ncomments: Comment[]\n```\n",
1306
+ description: annotationExample("Inverse navigational property — the FK is on the **target** interface, pointing back to this one.", "@db.rel.from", "posts: Post[]", "", "// With alias", "@db.rel.from \"original\"", "comments: Comment[]"),
1375
1307
  nodeType: ["prop"],
1376
1308
  passedWhenReferred: false,
1377
1309
  argument: {
@@ -1460,7 +1392,7 @@ const dbRelAnnotations = { rel: {
1460
1392
  onDelete: refActionAnnotation("onDelete"),
1461
1393
  onUpdate: refActionAnnotation("onUpdate"),
1462
1394
  via: new AnnotationSpec({
1463
- description: "Declares a many-to-many navigational property through an explicit junction table. `@db.rel.via` is self-sufficient — no `@db.rel.from` pairing is needed.\n\n**Example:**\n```atscript\n@db.rel.via PostTag\ntags: Tag[]\n```\n",
1395
+ description: annotationExample("Declares a many-to-many navigational property through an explicit junction table. `@db.rel.via` is self-sufficient — no `@db.rel.from` pairing is needed.", "@db.rel.via PostTag", "tags: Tag[]"),
1464
1396
  nodeType: ["prop"],
1465
1397
  passedWhenReferred: false,
1466
1398
  argument: {
@@ -1524,7 +1456,7 @@ const dbRelAnnotations = { rel: {
1524
1456
  }
1525
1457
  }),
1526
1458
  filterable: new AnnotationSpec({
1527
- description: "Lets HTTP clients filter the parent rows by this relation's related rows — `ticket=$some(status=open)` / `ticket=$none(...)` in a moost-db query (`{ ticket: { $some: { status: 'open' } } }`). Off by default: a client predicate filters the PARENT set and runs a correlated subquery. Server-side code (`transformFilter`, `actionRowScope`, direct table calls) never needs it. The related table's own field rules (visibility, encryption, write-only) apply inside the predicate.\n\n**Example:**\n```atscript\n@db.rel.to\n@db.rel.filterable\nticket?: Ticket\n```\n",
1459
+ description: annotationExample("Lets HTTP clients filter the parent rows by this relation's related rows — `ticket=$some(status=open)` / `ticket=$none(...)` in a moost-db query (`{ ticket: { $some: { status: 'open' } } }`). Off by default: a client predicate filters the PARENT set and runs a correlated subquery. Server-side code (`transformFilter`, `actionRowScope`, direct table calls) never needs it. The related table's own field rules (visibility, encryption, write-only) apply inside the predicate.", "@db.rel.to", "@db.rel.filterable", "ticket?: Ticket"),
1528
1460
  nodeType: ["prop"],
1529
1461
  passedWhenReferred: false,
1530
1462
  validate(token) {
@@ -1539,7 +1471,7 @@ const dbRelAnnotations = { rel: {
1539
1471
  }
1540
1472
  }),
1541
1473
  filter: new AnnotationSpec({
1542
- description: "Applies a filter to a navigational property, restricting which related records are loaded (`$with`) and which count in relational predicates (`$some` / `$none`).\n\n**Example:**\n```atscript\n@db.rel.from\n@db.rel.filter `Post.published = true`\npublishedPosts: Post[]\n```\n",
1474
+ description: annotationExample("Applies a filter to a navigational property, restricting which related records are loaded (`$with`) and which count in relational predicates (`$some` / `$none`).", "@db.rel.from", "@db.rel.filter `Post.published = true`", "publishedPosts: Post[]"),
1543
1475
  nodeType: ["prop"],
1544
1476
  passedWhenReferred: false,
1545
1477
  argument: {
@@ -1604,8 +1536,9 @@ function forEachComparison(expr, fn) {
1604
1536
  const dbSearchAnnotations = { search: {
1605
1537
  vector: {
1606
1538
  $self: new AnnotationSpec({
1607
- description: "Marks a field as a **vector embedding** for **similarity search**.\n\n- Each adapter maps this to its native vector type and index:\n - **MongoDB** → Atlas `$vectorSearch` index\n - **MySQL 9+** → `VECTOR(N)` column + `VEC_DISTANCE_*` functions\n - **PostgreSQL** → pgvector `vector(N)` column + distance operators\n - **SQLite** → `BLOB` column + `vec0` virtual index (requires the `sqlite-vec` extension; falls back to JSON `TEXT` when unavailable)\n\n**Example:**\n```atscript\n@db.search.vector 1536, \"cosine\"\nembedding: db.vector\n```\n",
1539
+ description: annotationExample("Marks a field as a **vector embedding** for **similarity search**.\n\n- Each adapter maps this to its native vector type and index:\n - **MongoDB** → Atlas `$vectorSearch` index\n - **MySQL 9+** → `VECTOR(N)` column + `VEC_DISTANCE_*` functions\n - **PostgreSQL** → pgvector `vector(N)` column + distance operators\n - **SQLite** → `BLOB` column + `vec0` virtual index (requires the `sqlite-vec` extension; falls back to JSON `TEXT` when unavailable)", "@db.search.vector 1536, \"cosine\"", "embedding: db.vector"),
1608
1540
  nodeType: ["prop"],
1541
+ validate: refuseCiphertext("@db.search.vector"),
1609
1542
  passedWhenReferred: false,
1610
1543
  multiple: false,
1611
1544
  argument: [
@@ -1649,7 +1582,7 @@ const dbSearchAnnotations = { search: {
1649
1582
  ]
1650
1583
  }),
1651
1584
  threshold: new AnnotationSpec({
1652
- description: "Sets a **default minimum similarity threshold** for vector search queries on this field.\n\n- Results with a similarity score below this threshold are excluded.\n- Query-time `$threshold` control overrides this default.\n- Value range: `0` to `1` (where `1` means exact match).\n\n**Example:**\n```atscript\n@db.search.vector 1536, \"cosine\"\n@db.search.vector.threshold 0.7\nembedding: db.vector\n```\n",
1585
+ description: annotationExample("Sets a **default minimum similarity threshold** for vector search queries on this field.\n\n- Results with a similarity score below this threshold are excluded.\n- Query-time `$threshold` control overrides this default.\n- Value range: `0` to `1` (where `1` means exact match).", "@db.search.vector 1536, \"cosine\"", "@db.search.vector.threshold 0.7", "embedding: db.vector"),
1653
1586
  nodeType: ["prop"],
1654
1587
  passedWhenReferred: false,
1655
1588
  multiple: false,
@@ -1662,8 +1595,9 @@ const dbSearchAnnotations = { search: {
1662
1595
  })
1663
1596
  },
1664
1597
  filter: new AnnotationSpec({
1665
- description: "Assigns a field as a **pre-filter** for a **vector search index**.\n\n- Filters allow vector search queries to return results only within a specific subset.\n- The referenced index must be defined using `@db.search.vector`.\n\n**Example:**\n```atscript\n@db.search.vector 1536, \"cosine\"\nembedding: db.vector\n\n@db.search.filter \"embedding\"\ncategory: string\n```\n",
1598
+ description: annotationExample("Assigns a field as a **pre-filter** for a **vector search index**.\n\n- Filters allow vector search queries to return results only within a specific subset.\n- The referenced index must be defined using `@db.search.vector`.", "@db.search.vector 1536, \"cosine\"", "embedding: db.vector", "", "@db.search.filter \"embedding\"", "category: string"),
1666
1599
  nodeType: ["prop"],
1600
+ validate: refuseCiphertext("@db.search.filter"),
1667
1601
  passedWhenReferred: false,
1668
1602
  multiple: true,
1669
1603
  argument: {
@@ -1681,7 +1615,7 @@ const dbSearchAnnotations = { search: {
1681
1615
  * read sorts by it without a `$nulls` entry for it.
1682
1616
  */
1683
1617
  const dbSortAnnotations = { sort: { nulls: new AnnotationSpec({
1684
- description: "Default **NULL placement** when a read sorts by this field and the request names none (`$nulls` control, URL suffix `$sort=-closedAt:last`).\n\n- **`'first'`** — rows where the field is NULL (or missing) come before every value, in both sort directions\n- **`'last'`** — they come after every value, in both directions\n\nWithout it (and without `$nulls`) NULL placement is the database's own: first in ascending order on SQLite, MySQL, MongoDB and the in-memory adapter, last on PostgreSQL. Applies to `$sort` and to the `$rowOrder` of `first()` / `last()`. Only optional and `| null` fields can be NULL — on another required table field it has no effect. On PostgreSQL, MySQL and MongoDB a placement the engine does not produce natively costs the index its ORDER BY (a sort step instead).\n\n**Example:**\n```atscript\n@db.sort.nulls 'last'\nclosedAt?: number.timestamp\n```\n",
1618
+ description: annotationExample("Default **NULL placement** when a read sorts by this field and the request names none (`$nulls` control, URL suffix `$sort=-closedAt:last`).\n\n- **`'first'`** — rows where the field is NULL (or missing) come before every value, in both sort directions\n- **`'last'`** — they come after every value, in both directions\n\nWithout it (and without `$nulls`) NULL placement is the database's own: first in ascending order on SQLite, MySQL, MongoDB and the in-memory adapter, last on PostgreSQL. Applies to `$sort` and to the `$rowOrder` of `first()` / `last()`. Only optional and `| null` fields can be NULL — on another required table field it has no effect. On PostgreSQL, MySQL and MongoDB a placement the engine does not produce natively costs the index its ORDER BY (a sort step instead).", "@db.sort.nulls 'last'", "closedAt?: number.timestamp"),
1685
1619
  nodeType: ["prop"],
1686
1620
  passedWhenReferred: false,
1687
1621
  multiple: false,
@@ -1707,6 +1641,10 @@ const dbSortAnnotations = { sort: { nulls: new AnnotationSpec({
1707
1641
  fail("@db.sort.nulls on a @db.encrypted field — encrypted fields are not sortable");
1708
1642
  return errors;
1709
1643
  }
1644
+ if (viewFieldSeals(field, doc).encrypted) {
1645
+ fail("@db.sort.nulls on a view field over a @db.encrypted source field — encrypted fields are not sortable");
1646
+ return errors;
1647
+ }
1710
1648
  const def = field.getDefinition();
1711
1649
  let leaf = def;
1712
1650
  if (def && isRef(def)) leaf = doc.unwindType(def.id, def.chain)?.def;
@@ -1727,7 +1665,7 @@ const dbTableAnnotations = {
1727
1665
  filterable: tableCapability("filterable"),
1728
1666
  sortable: tableCapability("sortable"),
1729
1667
  $self: new AnnotationSpec({
1730
- description: "Marks an interface as a database-persisted entity (table in SQL, collection in MongoDB). If the name argument is omitted, the adapter derives the table name from the interface name.\n\n**Example:**\n```atscript\n@db.table \"users\"\nexport interface User { ... }\n```\n",
1668
+ description: annotationExample("Marks an interface as a database-persisted entity (table in SQL, collection in MongoDB). If the name argument is omitted, the adapter derives the table name from the interface name.", "@db.table \"users\"", "export interface User { ... }"),
1731
1669
  nodeType: ["interface"],
1732
1670
  passedWhenReferred: false,
1733
1671
  argument: {
@@ -1748,7 +1686,7 @@ const dbTableAnnotations = {
1748
1686
  }
1749
1687
  }),
1750
1688
  renamed: new AnnotationSpec({
1751
- description: "Specifies the previous table name for table rename migration. The sync engine generates ALTER TABLE RENAME instead of drop+create.\n\n**Example:**\n```atscript\n@db.table \"app_users\"\n@db.table.renamed \"users\"\nexport interface User { ... }\n```\n",
1689
+ description: annotationExample("Specifies the previous table name for table rename migration. The sync engine generates ALTER TABLE RENAME instead of drop+create.", "@db.table \"app_users\"", "@db.table.renamed \"users\"", "export interface User { ... }"),
1752
1690
  nodeType: ["interface"],
1753
1691
  passedWhenReferred: false,
1754
1692
  argument: {
@@ -1767,7 +1705,7 @@ const dbTableAnnotations = {
1767
1705
  }
1768
1706
  }),
1769
1707
  preferredId: { uniqueIndex: new AnnotationSpec({
1770
- description: "Selects a unique index as the table's preferred row identifier. When the unique-index name is omitted, the first declared unique-index group wins.\n\n**Example:**\n```atscript\n@db.table\n@db.table.preferredId.uniqueIndex \"by_slug\"\nexport interface Post {\n @db.index.unique \"by_slug\"\n slug: string\n}\n```\n",
1708
+ description: annotationExample("Selects a unique index as the table's preferred row identifier. When the unique-index name is omitted, the first declared unique-index group wins.", "@db.table", "@db.table.preferredId.uniqueIndex \"by_slug\"", "export interface Post {", " @db.index.unique \"by_slug\"", " slug: string", "}"),
1771
1709
  nodeType: ["interface"],
1772
1710
  passedWhenReferred: false,
1773
1711
  multiple: false,
@@ -1815,7 +1753,7 @@ const dbTableAnnotations = {
1815
1753
  }) }
1816
1754
  },
1817
1755
  schema: new AnnotationSpec({
1818
- description: "Assigns the entity to a database schema/namespace.\n\n**Example:**\n```atscript\n@db.table \"users\"\n@db.schema \"auth\"\nexport interface User { ... }\n```\n",
1756
+ description: annotationExample("Assigns the entity to a database schema/namespace.", "@db.table \"users\"", "@db.schema \"auth\"", "export interface User { ... }"),
1819
1757
  nodeType: ["interface"],
1820
1758
  passedWhenReferred: false,
1821
1759
  argument: {
@@ -1825,7 +1763,7 @@ const dbTableAnnotations = {
1825
1763
  }
1826
1764
  }),
1827
1765
  space: new AnnotationSpec({
1828
- description: "Binds the entity to a named database space (a `DbSpace` — one per physical database) for apps running more than one database at once (e.g. MongoDB + PostgreSQL). Absent means the default space.\n\nConsumed by the generated model manifest (grouping in `modelsBySpace`) and by `@TableController(Model)` token binding, which resolves the space registered under this name via `provideDbSpace(space, name)`.\n\n**Example:**\n```atscript\n@db.table \"feed_runs\"\n@db.space \"analytics\"\nexport interface FeedRun { ... }\n```\n",
1766
+ description: annotationExample("Binds the entity to a named database space (a `DbSpace` — one per physical database) for apps running more than one database at once (e.g. MongoDB + PostgreSQL). Absent means the default space.\n\nConsumed by the generated model manifest (grouping in `modelsBySpace`) and by `@TableController(Model)` token binding, which resolves the space registered under this name via `provideDbSpace(space, name)`.", "@db.table \"feed_runs\"", "@db.space \"analytics\"", "export interface FeedRun { ... }"),
1829
1767
  nodeType: ["interface"],
1830
1768
  passedWhenReferred: false,
1831
1769
  argument: {
@@ -1835,7 +1773,7 @@ const dbTableAnnotations = {
1835
1773
  }
1836
1774
  }),
1837
1775
  http: { path: new AnnotationSpec({
1838
- description: "HTTP endpoint path where this table is served. Used by the UI for value-help on FK fields. After app.init() the app publishes the canonical controller route here at runtime; a model mounted on several routes needs `canonical: true` on one of its controllers (or declare this annotation and mount that controller without an explicit prefix).\n\n**Example:**\n```atscript\n@db.table \"authors\"\n@db.http.path \"/authors\"\nexport interface Author { ... }\n```\n",
1776
+ description: annotationExample("HTTP endpoint path where this table is served. Used by the UI for value-help on FK fields. After app.init() the app publishes the canonical controller route here at runtime; a model mounted on several routes needs `canonical: true` on one of its controllers (or declare this annotation and mount that controller without an explicit prefix).", "@db.table \"authors\"", "@db.http.path \"/authors\"", "export interface Author { ... }"),
1839
1777
  nodeType: ["interface"],
1840
1778
  passedWhenReferred: false,
1841
1779
  argument: {
@@ -1845,7 +1783,7 @@ const dbTableAnnotations = {
1845
1783
  }
1846
1784
  }) },
1847
1785
  sync: { method: new AnnotationSpec({
1848
- description: "Controls how the sync engine handles structural changes that cannot be applied via ALTER TABLE.\n\n- `\"recreate\"` — lossless: create temp table, copy data, drop old, rename.\n- `\"drop\"` — lossy: drop table entirely and create from scratch.\n\nWithout this annotation, structural changes fail with an error requiring manual intervention.\n\n**Example:**\n```atscript\n@db.sync.method \"drop\"\ninterface Logs { ... }\n```\n",
1786
+ description: annotationExample("Controls how the sync engine handles structural changes that cannot be applied via ALTER TABLE.\n\n- `\"recreate\"` — lossless: create temp table, copy data, drop old, rename.\n- `\"drop\"` — lossy: drop table entirely and create from scratch.\n\nWithout this annotation, structural changes fail with an error requiring manual intervention.", "@db.sync.method \"drop\"", "interface Logs { ... }"),
1849
1787
  nodeType: ["interface"],
1850
1788
  passedWhenReferred: false,
1851
1789
  argument: {
@@ -1856,7 +1794,7 @@ const dbTableAnnotations = {
1856
1794
  }
1857
1795
  }) },
1858
1796
  depth: { limit: new AnnotationSpec({
1859
- description: "Security guard on nested-write payloads. `N` is a non-negative integer declaring the maximum depth a client may nest `@db.rel.from` children in insert, replace, or patch payloads. Writes deeper than `N` are rejected at the server boundary with HTTP 400 before any DB access.\n\n**Default when absent:** `0` — any nested-write payload is rejected. Authors opt in explicitly to `N >= 1` when they want the server to accept deep writes. This is a security / blast-radius control, not a performance knob.\n\n**Scope:** affects only write acceptance. Has no effect on `/meta` serialization, read/query paths, or wire shape — the meta endpoint always ships FK refs as the shallow `{ id, metadata }` shape regardless of this annotation.\n\n**Example:**\n```atscript\n@db.table \"authors\"\n@db.depth.limit 2\nexport interface Author { ... }\n```\n",
1797
+ description: annotationExample("Security guard on nested-write payloads. `N` is a non-negative integer declaring the maximum depth a client may nest `@db.rel.from` children in insert, replace, or patch payloads. Writes deeper than `N` are rejected at the server boundary with HTTP 400 before any DB access.\n\n**Default when absent:** `0` — any nested-write payload is rejected. Authors opt in explicitly to `N >= 1` when they want the server to accept deep writes. This is a security / blast-radius control, not a performance knob.\n\n**Scope:** affects only write acceptance. Has no effect on `/meta` serialization, read/query paths, or wire shape — the meta endpoint always ships FK refs as the shallow `{ id, metadata }` shape regardless of this annotation.", "@db.table \"authors\"", "@db.depth.limit 2", "export interface Author { ... }"),
1860
1798
  nodeType: ["interface"],
1861
1799
  passedWhenReferred: false,
1862
1800
  multiple: false,
@@ -1910,19 +1848,21 @@ function collectUniqueIndexGroups(owner) {
1910
1848
  return groups;
1911
1849
  }
1912
1850
  function tableCapability(capability) {
1913
- const example = capability === "filterable" ? " @db.column.filterable\n email: string\n // other fields not filterable via the controller\n" : " @db.column.sortable\n createdAt: number.timestamp\n // other fields not sortable via the controller\n";
1851
+ const example = capability === "filterable" ? [
1852
+ " @db.column.filterable",
1853
+ " email: string",
1854
+ " // other fields not filterable via the controller"
1855
+ ] : [
1856
+ " @db.column.sortable",
1857
+ " createdAt: number.timestamp",
1858
+ " // other fields not sortable via the controller"
1859
+ ];
1914
1860
  const verb = capability === "filterable" ? "filter" : "sort";
1915
1861
  return new AnnotationSpec({
1916
- description: `Controls ${verb}-gating on the readable controller's \`/query\` and \`/pages\` endpoints.\n\n- **\`'auto'\`** (default when the annotation is absent) — every column the adapter can ${verb} is ${capability} (JSON / array columns are never sortable, and not filterable on SQL adapters).
1862
+ description: annotationExample(`Controls ${verb}-gating on the readable controller's \`/query\` and \`/pages\` endpoints.\n\n- **\`'auto'\`** (default when the annotation is absent) — every column the adapter can ${verb} is ${capability} (JSON / array columns are never sortable, and not filterable on SQL adapters).
1917
1863
  - **\`'manual'\`** — only fields annotated \`@db.column.${capability}\` are ${capability}; all others are rejected with HTTP 400.
1918
1864
 
1919
- \`/meta.fields[*].` + capability + "` advertises exactly what the gate accepts in either mode. The policy applies to " + (capability === "filterable" ? "filters only — `$groupBy`, `$having` keys and aggregate fields use the adapter's physical capability.\n\n" : "`$sort` only.\n\n") + `Writing the annotation explicitly as \`@db.table.${capability} 'auto'\` has the same runtime effect as omitting it; use it to document intent.
1920
-
1921
- **Example:**
1922
- \`\`\`atscript
1923
- @db.table "users"
1924
- @db.table.${capability} "manual"\nexport interface User {
1925
- ` + example + "}\n```\n",
1865
+ \`/meta.fields[*].` + capability + "` advertises exactly what the gate accepts in either mode. The policy applies to " + (capability === "filterable" ? "filters only — `$groupBy`, `$having` keys and aggregate fields use the adapter's physical capability.\n\n" : "`$sort` only.\n\n") + `Writing the annotation explicitly as \`@db.table.${capability} 'auto'\` has the same runtime effect as omitting it; use it to document intent.`, "@db.table \"users\"", `@db.table.${capability} "manual"`, "export interface User {", ...example, "}"),
1926
1866
  nodeType: ["interface"],
1927
1867
  passedWhenReferred: false,
1928
1868
  multiple: false,
@@ -1949,7 +1889,7 @@ function tableCapability(capability) {
1949
1889
  const UNIT_HOST_TYPES = ["decimal", "number"];
1950
1890
  const dbUnitAnnotations = { unit: {
1951
1891
  $self: new AnnotationSpec({
1952
- description: "Hard-coded **unit of measure** for this quantity field. Use when a table stores all values for this field in a single, fixed unit.\n\nFor per-row units, use `@db.unit.ref 'fieldName'` instead.\n\nValid on `decimal` and `number` fields. Unlike `@db.amount.currency`, the unit code is free-form (`'kg'`, `'rpm'`, `'qps'`, …) — no ISO-style validation.\n\nAggregations on a unit-tagged field do not need grouping when the unit is a fixed literal — the constraint is satisfied schema-wide.\n\n**Example:**\n```atscript\n@db.unit 'kg'\nweight: decimal\n```\n",
1892
+ description: annotationExample("Hard-coded **unit of measure** for this quantity field. Use when a table stores all values for this field in a single, fixed unit.\n\nFor per-row units, use `@db.unit.ref 'fieldName'` instead.\n\nValid on `decimal` and `number` fields. Unlike `@db.amount.currency`, the unit code is free-form (`'kg'`, `'rpm'`, `'qps'`, …) — no ISO-style validation.\n\nAggregations on a unit-tagged field do not need grouping when the unit is a fixed literal — the constraint is satisfied schema-wide.", "@db.unit 'kg'", "weight: decimal"),
1953
1893
  nodeType: ["prop"],
1954
1894
  multiple: false,
1955
1895
  argument: {
@@ -1963,12 +1903,12 @@ const dbUnitAnnotations = { unit: {
1963
1903
  errors.push(...validateExclusiveWith(token, "@db.unit", [{
1964
1904
  key: "db.unit.ref",
1965
1905
  displayName: "@db.unit.ref"
1966
- }]));
1906
+ }], doc));
1967
1907
  return errors;
1968
1908
  }
1969
1909
  }),
1970
1910
  ref: new AnnotationSpec({
1971
- description: "Binds this quantity to a **sibling field** that holds its unit. Use when different rows may carry different units (e.g. mixed kg/lb measurements).\n\nThe referenced field must be a `string`.\n\nValid on `decimal` and `number` fields. Aggregating this field forces the referenced unit field into `$groupBy` at runtime — summing rows with different units is rejected.\n\n**Example:**\n```atscript\nunit: string\n@db.unit.ref 'unit'\nweight: decimal\n```\n",
1911
+ description: annotationExample("Binds this quantity to a **sibling field** that holds its unit. Use when different rows may carry different units (e.g. mixed kg/lb measurements).\n\nThe referenced field must be a `string`.\n\nValid on `decimal` and `number` fields. Aggregating this field forces the referenced unit field into `$groupBy` at runtime — summing rows with different units is rejected.", "unit: string", "@db.unit.ref 'unit'", "weight: decimal"),
1972
1912
  nodeType: ["prop"],
1973
1913
  passedWhenReferred: false,
1974
1914
  multiple: false,
@@ -1986,7 +1926,7 @@ const dbUnitAnnotations = { unit: {
1986
1926
  }, {
1987
1927
  key: "db.amount.currency.ref",
1988
1928
  displayName: "@db.amount.currency.ref"
1989
- }]));
1929
+ }], doc));
1990
1930
  errors.push(...validateSiblingStringField(token, args, doc, "@db.unit.ref"));
1991
1931
  return errors;
1992
1932
  }
@@ -2051,7 +1991,7 @@ function orderKeyProblem(doc, target, path) {
2051
1991
  }
2052
1992
  const dbViewAnnotations = { view: {
2053
1993
  $self: new AnnotationSpec({
2054
- description: "Marks an interface as a **database view**. Optionally takes a view name argument.\n\n**Example:**\n```atscript\n@db.view \"active_premium_users\"\n@db.view.for User\nexport interface ActivePremiumUser { ... }\n```\n",
1994
+ description: annotationExample("Marks an interface as a **database view**. Optionally takes a view name argument.", "@db.view \"active_premium_users\"", "@db.view.for User", "export interface ActivePremiumUser { ... }"),
2055
1995
  nodeType: ["interface"],
2056
1996
  passedWhenReferred: false,
2057
1997
  argument: {
@@ -2071,7 +2011,7 @@ const dbViewAnnotations = { view: {
2071
2011
  }
2072
2012
  }),
2073
2013
  for: new AnnotationSpec({
2074
- description: "Specifies the entry/primary source for a computed view — a `@db.table`, or another `@db.view` (views over views, since 0.1.141). Required for views that map fields via chain refs. A `@db.alias` type cannot be the entry.\n\n**Example:**\n```atscript\n@db.view.for Order\n@db.view.filter `Order.status = 'active'`\nexport interface ActiveOrderDetails { ... }\n```\n",
2014
+ description: annotationExample("Specifies the entry/primary source for a computed view — a `@db.table`, or another `@db.view` (views over views, since 0.1.141). Required for views that map fields via chain refs. A `@db.alias` type cannot be the entry.", "@db.view.for Order", "@db.view.filter `Order.status = 'active'`", "export interface ActiveOrderDetails { ... }"),
2075
2015
  nodeType: ["interface"],
2076
2016
  passedWhenReferred: false,
2077
2017
  argument: {
@@ -2100,7 +2040,7 @@ const dbViewAnnotations = { view: {
2100
2040
  }
2101
2041
  }),
2102
2042
  joins: new AnnotationSpec({
2103
- description: "Declares an explicit join for a view. Joins are INNER by default — pass `'left'` as the third argument to keep entry rows without a match (fields read from a left-joined table must be optional). A join condition may reference the entry table and joins declared before it (chained joins). The target is a `@db.table`, a `@db.view` (since 0.1.141), or a `@db.alias` type — the way to join one table twice or to self-join the entry table: every scope name (entry + joins) must be unique.\n\n**Example:**\n```atscript\n@db.view.for Order\n@db.view.joins Customer, `Customer.id = Order.customerId`\n@db.view.joins Region, `Region.id = Customer.regionId`, 'left'\nexport interface OrderRegion { ... }\n```\n\n**First-row join** (since 0.1.147): a 4th argument — an ordering of the target's fields (`` `raisedAt, id` ``, `` `severity desc` ``) — joins only the FIRST matching target row by that ordering (the target's primary key is appended as the final tie-break), so every field read through the join comes from that one row. NULL sorts first in `asc`.\n```atscript\n@db.view.joins OldestOpenIssue, `OldestOpenIssue.ticketId = Ticket.id and OldestOpenIssue.status = 'open'`, 'left', `raisedAt`\n```\n",
2043
+ description: annotationExample("Declares an explicit join for a view. Joins are INNER by default — pass `'left'` as the third argument to keep entry rows without a match (fields read from a left-joined table must be optional). A join condition may reference the entry table and joins declared before it (chained joins). The target is a `@db.table`, a `@db.view` (since 0.1.141), or a `@db.alias` type — the way to join one table twice or to self-join the entry table: every scope name (entry + joins) must be unique.", "@db.view.for Order", "@db.view.joins Customer, `Customer.id = Order.customerId`", "@db.view.joins Region, `Region.id = Customer.regionId`, 'left'", "export interface OrderRegion { ... }", "```", "", "**First-row join** (since 0.1.147): a 4th argument — an ordering of the target's fields (`` `raisedAt, id` ``, `` `severity desc` ``) — joins only the FIRST matching target row by that ordering (the target's primary key is appended as the final tie-break), so every field read through the join comes from that one row. NULL sorts first in `asc`.", "```atscript", "@db.view.joins OldestOpenIssue, `OldestOpenIssue.ticketId = Ticket.id and OldestOpenIssue.status = 'open'`, 'left', `raisedAt`"),
2104
2044
  nodeType: ["interface"],
2105
2045
  passedWhenReferred: false,
2106
2046
  multiple: true,
@@ -2185,7 +2125,7 @@ const dbViewAnnotations = { view: {
2185
2125
  }
2186
2126
  }),
2187
2127
  filter: new AnnotationSpec({
2188
- description: "WHERE clause for a view, filtering which rows are included.\n\n**Example:**\n```atscript\n@db.view.for User\n@db.view.filter `User.status = 'active' and User.age >= 18`\nexport interface ActiveUser { ... }\n```\n",
2128
+ description: annotationExample("WHERE clause for a view, filtering which rows are included.", "@db.view.for User", "@db.view.filter `User.status = 'active' and User.age >= 18`", "export interface ActiveUser { ... }"),
2189
2129
  nodeType: ["interface"],
2190
2130
  passedWhenReferred: false,
2191
2131
  argument: {
@@ -2220,7 +2160,7 @@ const dbViewAnnotations = { view: {
2220
2160
  }
2221
2161
  }),
2222
2162
  materialized: new AnnotationSpec({
2223
- description: "Marks a view as materialized (precomputed, stored on disk). Supported by PostgreSQL, CockroachDB, Oracle, SQL Server (indexed views), Snowflake. MongoDB supports on-demand materialized views via $merge/$out. Not applicable to MySQL or SQLite.\n\n**Example:**\n```atscript\n@db.view.materialized\n@db.view.for User\n@db.view.filter `User.status = 'active'`\nexport interface ActiveUsers { ... }\n```\n",
2163
+ description: annotationExample("Marks a view as materialized (precomputed, stored on disk). Supported by PostgreSQL, CockroachDB, Oracle, SQL Server (indexed views), Snowflake. MongoDB supports on-demand materialized views via $merge/$out. Not applicable to MySQL or SQLite.", "@db.view.materialized", "@db.view.for User", "@db.view.filter `User.status = 'active'`", "export interface ActiveUsers { ... }"),
2224
2164
  nodeType: ["interface"],
2225
2165
  passedWhenReferred: false,
2226
2166
  validate(token, _args, _doc) {
@@ -2235,7 +2175,7 @@ const dbViewAnnotations = { view: {
2235
2175
  }
2236
2176
  }),
2237
2177
  renamed: new AnnotationSpec({
2238
- description: "Specifies the previous view name for view rename migration. The sync engine drops the old view and creates the new one.\n\n**Example:**\n```atscript\n@db.view \"active_premium_users\"\n@db.view.renamed \"active_users\"\n@db.view.for User\nexport interface ActivePremiumUser { ... }\n```\n",
2178
+ description: annotationExample("Specifies the previous view name for view rename migration. The sync engine drops the old view and creates the new one.", "@db.view \"active_premium_users\"", "@db.view.renamed \"active_users\"", "@db.view.for User", "export interface ActivePremiumUser { ... }"),
2239
2179
  nodeType: ["interface"],
2240
2180
  passedWhenReferred: false,
2241
2181
  argument: {
@@ -2255,7 +2195,7 @@ const dbViewAnnotations = { view: {
2255
2195
  }
2256
2196
  }),
2257
2197
  having: new AnnotationSpec({
2258
- description: "Post-aggregation filter (HAVING clause) for analytical views. References the view's own fields, unqualified (`totalRevenue > 100`) — aggregate fields by their alias, plain fields as GROUP BY dimensions; source-table refs are not in scope.\n\n**Example:**\n```atscript\n@db.view\n@db.view.for Order\n@db.view.having `totalRevenue > 100`\nexport interface TopCategories { ... }\n```\n",
2198
+ description: annotationExample("Post-aggregation filter (HAVING clause) for analytical views. References the view's own fields, unqualified (`totalRevenue > 100`) — aggregate fields by their alias, plain fields as GROUP BY dimensions; source-table refs are not in scope.", "@db.view", "@db.view.for Order", "@db.view.having `totalRevenue > 100`", "export interface TopCategories { ... }"),
2259
2199
  nodeType: ["interface"],
2260
2200
  passedWhenReferred: false,
2261
2201
  argument: {
@@ -2323,18 +2263,18 @@ const dbPlugin = (options) => ({
2323
2263
  kind: "array",
2324
2264
  of: "number"
2325
2265
  },
2326
- documentation: "Represents a **vector embedding** (array of numbers) for **similarity search**.\n\n- Equivalent to `number[]` but explicitly marks the field as a vector embedding.\n- Each adapter maps this to its native vector type:\n - **MongoDB** → BSON array\n - **MySQL 9+** → `VECTOR(N)`\n - **PostgreSQL** → pgvector `vector(N)`\n - **SQLite** → JSON\n\n**Example:**\n```atscript\n@db.search.vector 1536, \"cosine\"\nembedding: db.vector\n```\n"
2266
+ documentation: annotationExample("Represents a **vector embedding** (array of numbers) for **similarity search**.\n\n- Equivalent to `number[]` but explicitly marks the field as a vector embedding.\n- Each adapter maps this to its native vector type:\n - **MongoDB** → BSON array\n - **MySQL 9+** → `VECTOR(N)`\n - **PostgreSQL** → pgvector `vector(N)`\n - **SQLite** → JSON", "@db.search.vector 1536, \"cosine\"", "embedding: db.vector")
2327
2267
  },
2328
2268
  geoPoint: {
2329
2269
  type: {
2330
2270
  kind: "array",
2331
2271
  of: "number"
2332
2272
  },
2333
- documentation: "Represents a **geographic point** as a `[longitude, latitude]` tuple (GeoJSON coordinate order).\n\n- Equivalent to `number[]` of length 2, but explicitly marks the field as a geo point.\n- **Coordinate order is GeoJSON order: longitude first.**\n- Each adapter maps this to its native storage:\n - **MongoDB** → GeoJSON `{ type: 'Point', coordinates: [lng, lat] }`\n - **PostgreSQL** → PostGIS `geography(Point,4326)` (JSONB without PostGIS)\n - **MySQL** → `POINT SRID 4326`\n - **SQLite** → JSON `TEXT` (haversine-based search)\n\n**Example:**\n```atscript\n@db.index.geo\ngeo: db.geoPoint\n```\n"
2273
+ documentation: annotationExample("Represents a **geographic point** as a `[longitude, latitude]` tuple (GeoJSON coordinate order).\n\n- Equivalent to `number[]` of length 2, but explicitly marks the field as a geo point.\n- **Coordinate order is GeoJSON order: longitude first.**\n- Each adapter maps this to its native storage:\n - **MongoDB** → GeoJSON `{ type: 'Point', coordinates: [lng, lat] }`\n - **PostgreSQL** → PostGIS `geography(Point,4326)` (JSONB without PostGIS)\n - **MySQL** → `POINT SRID 4326`\n - **SQLite** → JSON `TEXT` (haversine-based search)", "@db.index.geo", "geo: db.geoPoint")
2334
2274
  },
2335
2275
  currencyCode: {
2336
2276
  type: "string",
2337
- documentation: "Represents a **currency code** — typically ISO 4217 (`'USD'`, `'EUR'`, `'JPY'`) but accepts any uppercase alphanumeric code 2–10 chars long, so crypto and custom codes (`'BTC'`, `'USDC'`, `'POINTS'`) fit too.\n\nPair with `@db.amount.currency.ref 'fieldName'` on a `decimal` field to bind the amount to its row-level currency. The validator checks that the ref target resolves to this type (or a plain `string`).\n\n**Example:**\n```atscript\ncurrency: db.currencyCode\n@db.amount.currency.ref 'currency'\namount: decimal\n```\n",
2277
+ documentation: annotationExample("Represents a **currency code** — typically ISO 4217 (`'USD'`, `'EUR'`, `'JPY'`) but accepts any uppercase alphanumeric code 2–10 chars long, so crypto and custom codes (`'BTC'`, `'USDC'`, `'POINTS'`) fit too.\n\nPair with `@db.amount.currency.ref 'fieldName'` on a `decimal` field to bind the amount to its row-level currency. The validator checks that the ref target resolves to this type (or a plain `string`).", "currency: db.currencyCode", "@db.amount.currency.ref 'currency'", "amount: decimal"),
2338
2278
  annotations: { "expect.pattern": [{
2339
2279
  pattern: "^[A-Z0-9]{2,10}$",
2340
2280
  message: "Invalid currency code (expected 2–10 uppercase letters or digits)"