@atscript/db 0.1.146 → 0.1.148

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 (59) hide show
  1. package/dist/{agg-CV7y8nC6.d.cts → agg-42CSpGDR.d.cts} +2 -2
  2. package/dist/{agg-D5DHsAby.d.mts → agg-Wo-smrYV.d.mts} +3 -3
  3. package/dist/agg.cjs +1 -1
  4. package/dist/agg.d.cts +2 -2
  5. package/dist/agg.d.mts +2 -2
  6. package/dist/agg.mjs +1 -1
  7. package/dist/{aggregate-fns-CGBv3E8S.cjs → aggregate-fns-C-UJRobm.cjs} +52 -5
  8. package/dist/{aggregate-fns-CfsveE1w.mjs → aggregate-fns-CyaZyb9I.mjs} +35 -6
  9. package/dist/aggregate-rules-D_bsCpUI.cjs +26 -0
  10. package/dist/aggregate-rules-jdPrxqWa.mjs +15 -0
  11. package/dist/{buckets-DRycmhOW.d.mts → buckets-CNdTOnei.d.mts} +832 -33
  12. package/dist/{buckets-DYFu0eZ8.d.cts → buckets-DJiYlMXc.d.cts} +832 -33
  13. package/dist/{column-diff-D_Kyuh0S.cjs → column-diff-CUU4GvYg.cjs} +1546 -193
  14. package/dist/{column-diff-e2oHc71_.mjs → column-diff-Cp6ZoyRE.mjs} +1529 -200
  15. package/dist/{db-error-D5uilS_A.mjs → db-error-Az85UhTX.mjs} +32 -1
  16. package/dist/{db-error-DTkkeu5b.cjs → db-error-DRQH4sLY.cjs} +55 -0
  17. package/dist/{column-diff-Q9UmWn5x.d.mts → fk-diff-BQ4krij8.d.cts} +48 -2
  18. package/dist/{column-diff-w-Mym_3w.d.cts → fk-diff-DsaIijVX.d.mts} +48 -2
  19. package/dist/index.cjs +95 -35
  20. package/dist/index.d.cts +92 -22
  21. package/dist/index.d.mts +92 -22
  22. package/dist/index.mjs +65 -35
  23. package/dist/{nested-writer-xfQwxplL.cjs → nested-writer-BUQvk6QT.cjs} +734 -2
  24. package/dist/{nested-writer-CnOOAehr.mjs → nested-writer-CUBoq1ZO.mjs} +598 -4
  25. package/dist/numeric-operand-B1jKH7x5.mjs +21 -0
  26. package/dist/numeric-operand-DKfiRLYp.cjs +26 -0
  27. package/dist/ops.cjs +1 -1
  28. package/dist/ops.mjs +1 -1
  29. package/dist/plugin.cjs +293 -5
  30. package/dist/plugin.mjs +294 -6
  31. package/dist/rel.cjs +2 -2
  32. package/dist/rel.d.cts +2 -2
  33. package/dist/rel.d.mts +2 -2
  34. package/dist/rel.mjs +2 -2
  35. package/dist/{relation-helpers-B-0NRKat.d.mts → relation-helpers-Ba0v49sn.d.mts} +1 -1
  36. package/dist/{relation-helpers-BOMm_HUI.d.cts → relation-helpers-kX7jjgME.d.cts} +1 -1
  37. package/dist/relation-loader-ByY1Byrl.mjs +370 -0
  38. package/dist/relation-loader-D8OrdH-r.cjs +369 -0
  39. package/dist/shared.cjs +4 -1
  40. package/dist/shared.d.cts +16 -4
  41. package/dist/shared.d.mts +16 -4
  42. package/dist/shared.mjs +3 -2
  43. package/dist/sync.cjs +11 -7
  44. package/dist/sync.d.cts +2 -25
  45. package/dist/sync.d.mts +2 -25
  46. package/dist/sync.mjs +11 -7
  47. package/dist/{validation-utils-DOsB4e6G.cjs → validation-utils-Da2GjobR.cjs} +35 -24
  48. package/dist/{validation-utils-CMR4fe2M.mjs → validation-utils-Dq0uZ7ef.mjs} +35 -24
  49. package/dist/{validator-Clu2q_7z.mjs → validator-BTiIOTKP.mjs} +9 -3
  50. package/dist/{validator-CVS-onRg.cjs → validator-UcuJxHNT.cjs} +8 -2
  51. package/dist/{validator-Bw6ks9Hy.d.mts → validator-tBNvM1qc.d.cts} +31 -7
  52. package/dist/{validator-Bw6ks9Hy.d.cts → validator-tBNvM1qc.d.mts} +31 -7
  53. package/dist/validator.cjs +2 -2
  54. package/dist/validator.d.cts +1 -1
  55. package/dist/validator.d.mts +1 -1
  56. package/dist/validator.mjs +2 -2
  57. package/package.json +8 -8
  58. package/dist/relation-loader-CBPY6kM7.cjs +0 -461
  59. package/dist/relation-loader-D9XuXaMv.mjs +0 -462
package/dist/plugin.cjs CHANGED
@@ -33,10 +33,11 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
33
33
  enumerable: true
34
34
  }) : target, mod));
35
35
  //#endregion
36
- const require_aggregate_fns = require("./aggregate-fns-CGBv3E8S.cjs");
36
+ const require_aggregate_fns = require("./aggregate-fns-C-UJRobm.cjs");
37
37
  const require_derived_rules = require("./derived-rules-YstgIxG-.cjs");
38
+ const require_numeric_operand = require("./numeric-operand-DKfiRLYp.cjs");
38
39
  require("./consts-BzRfCcH2.cjs");
39
- const require_validation_utils = require("./validation-utils-DOsB4e6G.cjs");
40
+ const require_validation_utils = require("./validation-utils-Da2GjobR.cjs");
40
41
  let node_path = require("node:path");
41
42
  node_path = __toESM(node_path, 1);
42
43
  let node_url = require("node:url");
@@ -174,6 +175,27 @@ function viewHavingScope(view) {
174
175
  unqualifiedTarget: view.id
175
176
  } : void 0;
176
177
  }
178
+ /**
179
+ * The ordering (4th argument) of a first-row `@db.view.joins`: the join
180
+ * target only — an unqualified key is a field of the target.
181
+ * @since 0.1.147
182
+ */
183
+ function joinOrderScope(join) {
184
+ const target = join.args[0]?.text;
185
+ return target ? {
186
+ allowedTypes: [target],
187
+ unqualifiedTarget: target
188
+ } : void 0;
189
+ }
190
+ /**
191
+ * The expression of a `@db.compute` on a view field: the view's own fields,
192
+ * unqualified — the `@db.view.having` scope.
193
+ * @since 0.1.147
194
+ */
195
+ function computeScope(propToken) {
196
+ const view = require_validation_utils.getDbTableOwner(propToken);
197
+ return view ? viewHavingScope(view) : void 0;
198
+ }
177
199
  /** The condition of a `@db.agg.*` on a view field: the scope of the view's `@db.view.filter`. */
178
200
  function aggConditionScope(propToken) {
179
201
  const view = require_validation_utils.getDbTableOwner(propToken);
@@ -208,16 +230,25 @@ function relFilterScope(field) {
208
230
  unqualifiedTarget: target
209
231
  };
210
232
  }
233
+ /** The `@db.view.joins` annotation one of whose arguments is `arg`. */
234
+ function joinOfArg(joins, arg) {
235
+ return joins.find((a) => a.args.includes(arg));
236
+ }
211
237
  /** The `fieldScope` hooks (argument token → scope) the annotation specs declare. */
212
238
  const fieldScopes = {
213
239
  viewFilter: (arg) => arg.parentNode ? viewFilterScope(arg.parentNode) : void 0,
214
240
  viewJoin: (arg) => {
215
241
  const view = arg.parentNode;
216
242
  const joins = view ? require_validation_utils.viewJoins(view) : [];
217
- const join = joins.find((a) => a.args.includes(arg));
243
+ const join = joinOfArg(joins, arg);
218
244
  return view && join ? viewJoinScope(view, join, require_validation_utils.earlierJoinTargets(joins, join)) : void 0;
219
245
  },
246
+ joinOrder: (arg) => {
247
+ const join = arg.parentNode ? joinOfArg(require_validation_utils.viewJoins(arg.parentNode), arg) : void 0;
248
+ return join ? joinOrderScope(join) : void 0;
249
+ },
220
250
  viewHaving: (arg) => arg.parentNode ? viewHavingScope(arg.parentNode) : void 0,
251
+ compute: computeScope,
221
252
  aggCondition: aggConditionScope,
222
253
  aggField: aggFieldScope,
223
254
  relFilter: (arg) => arg.parentNode ? relFilterScope(arg.parentNode) : void 0
@@ -868,6 +899,123 @@ function columnCapability(capability, verb) {
868
899
  });
869
900
  }
870
901
  //#endregion
902
+ //#region src/plugin/annotations/compute.ts
903
+ /** What a `@db.compute` field cannot also carry (VC1). */
904
+ const EXCLUSIVE_WITH = [
905
+ ...require_aggregate_fns.AGG_ANNOTATIONS,
906
+ "db.json",
907
+ "db.ignore"
908
+ ].map((key) => ({ key }));
909
+ /** The `@db.compute` expression token of a prop, if any. */
910
+ function computeArg(prop) {
911
+ return prop?.annotations?.find((a) => a.name === "db.compute")?.args[0];
912
+ }
913
+ /**
914
+ * Whether a prop is typed exactly `number` (a plain primitive ref — no chain
915
+ * ref, no `number.*` extension).
916
+ */
917
+ function isPlainNumber(prop, doc) {
918
+ const def = prop.getDefinition();
919
+ if (!def || !(0, _atscript_core.isRef)(def) || def.hasChain || def.id !== "number") return false;
920
+ return (0, _atscript_core.isPrimitive)(doc.unwindType("number")?.def);
921
+ }
922
+ /**
923
+ * Why an operand prop cannot be computed with, or `undefined` when it can:
924
+ * its resolved type must be a `number` primitive — not `decimal`, not a
925
+ * timestamp — and it must have storage (no `@db.ignore`).
926
+ */
927
+ function operandProblem(prop, doc) {
928
+ if (prop.countAnnotations("db.ignore") > 0) return "is @db.ignore'd";
929
+ if (computeArg(prop)) return void 0;
930
+ const def = prop.getDefinition();
931
+ const leaf = def && (0, _atscript_core.isRef)(def) ? doc.unwindType(def.id, def.chain)?.def : def;
932
+ const base = require_validation_utils.primitiveBaseType(leaf);
933
+ if (base === void 0) return void 0;
934
+ return require_numeric_operand.numericTypeProblem({
935
+ base,
936
+ tags: (0, _atscript_core.isPrimitive)(leaf) ? leaf.tags : void 0
937
+ });
938
+ }
939
+ /** VC6 — whether an expression may be NULL (`/` by zero, an optional operand). */
940
+ function exprNullable(node, props) {
941
+ if ("value" in node) return false;
942
+ if ("left" in node) return node.op === "/" || exprNullable(node.left, props) || exprNullable(node.right, props);
943
+ if ("operand" in node) return exprNullable(node.operand, props);
944
+ if ("args" in node) return node.args.every((a) => exprNullable(a, props));
945
+ return props.get(node.fieldRef.text)?.has("optional") ?? false;
946
+ }
947
+ /**
948
+ * VC4 — the first `@db.compute` cycle through `start`, as field names
949
+ * (`rank → priority → rank`), or `undefined`.
950
+ */
951
+ function findComputeCycle(start, props) {
952
+ const path = [];
953
+ const done = /* @__PURE__ */ new Set();
954
+ const visit = (name) => {
955
+ const at = path.indexOf(name);
956
+ if (at !== -1) return name === start ? [...path.slice(at), name] : void 0;
957
+ if (done.has(name)) return void 0;
958
+ const refs = computeArg(props.get(name))?.exprNode?.fieldRefs() ?? [];
959
+ path.push(name);
960
+ for (const ref of refs) {
961
+ if (ref.typeRef) continue;
962
+ const cycle = visit(ref.fieldRef.text);
963
+ if (cycle) return cycle;
964
+ }
965
+ path.pop();
966
+ done.add(name);
967
+ };
968
+ return visit(start);
969
+ }
970
+ function validateCompute(token, args, doc) {
971
+ const errors = [];
972
+ const prop = token.parentNode;
973
+ const field = prop.id ?? "field";
974
+ const view = require_validation_utils.getDbTableOwner(token);
975
+ const error = (message, range = token.range) => errors.push({
976
+ message,
977
+ severity: 1,
978
+ range
979
+ });
980
+ if (!view || view.countAnnotations("db.view.for") === 0) {
981
+ error("@db.compute is only valid on a field of a @db.view.for view");
982
+ return errors;
983
+ }
984
+ errors.push(...require_validation_utils.validateExclusiveWith(token, "@db.compute", EXCLUSIVE_WITH));
985
+ if (!isPlainNumber(prop, doc)) error(`Field "${field}" has a @db.compute and must be typed \`number\``);
986
+ const expr = args[0]?.exprNode;
987
+ if (!expr) return errors;
988
+ const refs = expr.fieldRefs();
989
+ const props = viewProps(view) ?? /* @__PURE__ */ new Map();
990
+ if (refs.length === 0) error("@db.compute needs at least one view field — a constant column is not supported", args[0].range);
991
+ const scope = viewHavingScope(view);
992
+ if (scope) errors.push(...require_validation_utils.validateQueryScope(args[0], scope, doc, "@db.compute references the view's own fields — declare it on the view first (`field: Type.field`)"));
993
+ for (const ref of refs) {
994
+ if (ref.typeRef) continue;
995
+ const operand = props.get(ref.fieldRef.text);
996
+ const why = operand && operandProblem(operand, doc);
997
+ if (why) error(`@db.compute operand '${ref.fieldRef.text}' ${why} — operands must be number fields`, ref.fieldRef.range);
998
+ }
999
+ if (prop.id) {
1000
+ const cycle = findComputeCycle(prop.id, props);
1001
+ if (cycle) error(`@db.compute of "${field}" depends on itself: ${cycle.join(" → ")}`, args[0].range);
1002
+ }
1003
+ if (!prop.has("optional") && exprNullable(expr.expression, props)) error(`Field "${field}" has a @db.compute that may be NULL and must be optional (${field}?: …) — it is NULL when an operand is NULL or a divisor is 0`);
1004
+ return errors;
1005
+ }
1006
+ const dbComputeAnnotations = { compute: new _atscript_core.AnnotationSpec({
1007
+ 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",
1008
+ nodeType: ["prop"],
1009
+ passedWhenReferred: false,
1010
+ argument: {
1011
+ name: "expression",
1012
+ type: "expr",
1013
+ description: "Arithmetic over the view's own fields (unqualified): `+ - * /`, unary `-`, parentheses, numbers, `coalesce(a, b, …)`.",
1014
+ fieldScope: fieldScopes.compute
1015
+ },
1016
+ validate: validateCompute
1017
+ }) };
1018
+ //#endregion
871
1019
  //#region src/plugin/annotations/index-ann.ts
872
1020
  const dbIndexAnnotations = { index: {
873
1021
  plain: new _atscript_core.AnnotationSpec({
@@ -935,6 +1083,31 @@ const dbIndexAnnotations = { index: {
935
1083
  } };
936
1084
  //#endregion
937
1085
  //#region src/plugin/annotations/rel.ts
1086
+ /**
1087
+ * X2b: on a `@db.table` a foreign key is a real constraint — one column of a
1088
+ * composite primary key is not unique on its own. Columns that together cover
1089
+ * the whole key (same alias, same target, each naming a top-level key column)
1090
+ * are a valid composite foreign key. Returns whether `token`'s FK is covered
1091
+ * (or the rule does not apply). Cheap checks run before the sibling scan.
1092
+ */
1093
+ function checkCompositeFkCoverage(token, alias, refTypeName, targetStruct, targetProp) {
1094
+ if (targetProp.countAnnotations("db.index.unique") > 0) return true;
1095
+ const pk = [...targetStruct.props.entries()].filter(([, p]) => p.countAnnotations("meta.id") > 0).map(([name]) => name);
1096
+ if (pk.length <= 1) return true;
1097
+ const hostStruct = require_validation_utils.getParentStruct(token);
1098
+ const owner = require_validation_utils.getDbTableOwner(token);
1099
+ if (!hostStruct || owner === void 0 || !isDbTable(owner)) return true;
1100
+ const covered = /* @__PURE__ */ new Set();
1101
+ for (const [, sibling] of hostStruct.props) {
1102
+ if (sibling.countAnnotations("db.rel.FK") === 0) continue;
1103
+ if (require_validation_utils.getAnnotationAlias(sibling, "db.rel.FK") !== alias) continue;
1104
+ const siblingDef = sibling.getDefinition();
1105
+ if (siblingDef && (0, _atscript_core.isRef)(siblingDef) && siblingDef.id === refTypeName) {
1106
+ if (siblingDef.chain.length === 1) covered.add(siblingDef.chain[0].text);
1107
+ }
1108
+ }
1109
+ return pk.every((name) => covered.has(name));
1110
+ }
938
1111
  const dbRelAnnotations = { rel: {
939
1112
  FK: new _atscript_core.AnnotationSpec({
940
1113
  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` (stamped by its 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",
@@ -980,6 +1153,11 @@ const dbRelAnnotations = { rel: {
980
1153
  severity: 1,
981
1154
  range: token.range
982
1155
  });
1156
+ if (!checkCompositeFkCoverage(token, alias, refTypeName, struct, targetProp)) errors.push({
1157
+ message: `@db.rel.FK target '${refTypeName}.${chainFields.join(".")}' is one column of a composite primary key — a foreign key must reference a unique column; for value help without a constraint use @ui.valueHelp`,
1158
+ severity: 1,
1159
+ range: token.range
1160
+ });
983
1161
  const propDef = targetProp.getDefinition();
984
1162
  if (propDef && (0, _atscript_core.isRef)(propDef)) {
985
1163
  const propUnwound = targetUnwound.doc.unwindType(propDef.id, propDef.chain);
@@ -1258,8 +1436,23 @@ const dbRelAnnotations = { rel: {
1258
1436
  return errors;
1259
1437
  }
1260
1438
  }),
1439
+ filterable: new _atscript_core.AnnotationSpec({
1440
+ 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",
1441
+ nodeType: ["prop"],
1442
+ passedWhenReferred: false,
1443
+ validate(token) {
1444
+ const errors = [];
1445
+ const field = token.parentNode;
1446
+ if (!(field.countAnnotations("db.rel.to") > 0 || field.countAnnotations("db.rel.from") > 0 || field.countAnnotations("db.rel.via") > 0)) errors.push({
1447
+ message: "@db.rel.filterable is only valid on navigational fields (@db.rel.to, @db.rel.from, or @db.rel.via)",
1448
+ severity: 1,
1449
+ range: token.range
1450
+ });
1451
+ return errors;
1452
+ }
1453
+ }),
1261
1454
  filter: new _atscript_core.AnnotationSpec({
1262
- description: "Applies a filter to a navigational property, restricting which related records are loaded.\n\n**Example:**\n```atscript\n@db.rel.from\n@db.rel.filter `Post.published = true`\npublishedPosts: Post[]\n```\n",
1455
+ 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",
1263
1456
  nodeType: ["prop"],
1264
1457
  passedWhenReferred: false,
1265
1458
  argument: {
@@ -1285,10 +1478,40 @@ const dbRelAnnotations = { rel: {
1285
1478
  if (!args[0]?.queryNode) return errors;
1286
1479
  const scope = relFilterScope(field);
1287
1480
  if (scope) errors.push(...require_validation_utils.validateQueryScope(args[0], scope, doc));
1481
+ const expression = args[0].queryNode.expression;
1482
+ forEachComparison(expression, (cmp) => {
1483
+ if (cmp.right && "fieldRef" in cmp.right) errors.push({
1484
+ message: "@db.rel.filter compares two fields — only field-to-value conditions are supported",
1485
+ severity: 1,
1486
+ range: cmp.right.fieldRef.range
1487
+ });
1488
+ });
1489
+ const junction = hasVia ? require_validation_utils.getAnnotationAlias(field, "db.rel.via") : void 0;
1490
+ if (junction) {
1491
+ const conjuncts = (0, _atscript_core.isQueryLogical)(expression) && expression.operator === "and" ? expression.operands : [expression];
1492
+ for (const conjunct of conjuncts) {
1493
+ let junctionRef;
1494
+ let targetRef = false;
1495
+ require_validation_utils.forEachFieldRef(conjunct, (ref) => {
1496
+ if (ref.typeRef?.text === junction) junctionRef ??= ref.typeRef.range;
1497
+ else targetRef = true;
1498
+ });
1499
+ if (junctionRef && targetRef) errors.push({
1500
+ message: `@db.rel.filter has a condition reading both the junction '${junction}' and the related type — split it into separate top-level "and" conditions`,
1501
+ severity: 1,
1502
+ range: junctionRef
1503
+ });
1504
+ }
1505
+ }
1288
1506
  return errors;
1289
1507
  }
1290
1508
  })
1291
1509
  } };
1510
+ /** Every comparison of a query expression (through and / or / not). */
1511
+ function forEachComparison(expr, fn) {
1512
+ if ((0, _atscript_core.isQueryLogical)(expr)) for (const operand of expr.operands) forEachComparison(operand, fn);
1513
+ else if ((0, _atscript_core.isQueryComparison)(expr)) fn(expr);
1514
+ }
1292
1515
  //#endregion
1293
1516
  //#region src/plugin/annotations/search.ts
1294
1517
  const dbSearchAnnotations = { search: {
@@ -1638,6 +1861,61 @@ const dbUnitAnnotations = { unit: {
1638
1861
  } };
1639
1862
  //#endregion
1640
1863
  //#region src/plugin/annotations/view.ts
1864
+ /**
1865
+ * VJ6–VJ8 — the ordering of a first-row join: every key is a scalar field of
1866
+ * the join target (no object, array, `@db.json`, `@db.encrypted`, `@db.writeOnly` or
1867
+ * navigation field), no key repeats, and the target (through `@db.alias`)
1868
+ * declares exactly one `@meta.id` field — the anchor of the join's
1869
+ * correlated subquery.
1870
+ */
1871
+ function validateJoinOrder(orderToken, scope, doc) {
1872
+ const target = scope.unqualifiedTarget;
1873
+ const errors = require_validation_utils.validateQueryScope(orderToken, scope, doc, `a first-row join orders by fields of its target '${target}'`);
1874
+ const seen = /* @__PURE__ */ new Set();
1875
+ for (const item of orderToken.orderNode.items) {
1876
+ const { ref } = item;
1877
+ if (ref.typeRef && ref.typeRef.text !== target) continue;
1878
+ const path = ref.fieldRef.text;
1879
+ if (seen.has(path)) {
1880
+ errors.push({
1881
+ message: `Order key '${path}' appears more than once`,
1882
+ severity: 1,
1883
+ range: ref.fieldRef.range
1884
+ });
1885
+ continue;
1886
+ }
1887
+ seen.add(path);
1888
+ const why = orderKeyProblem(doc, target, path.split("."));
1889
+ if (why) errors.push({
1890
+ message: `Order key '${path}' ${why} — order by a scalar field of '${target}'`,
1891
+ severity: 1,
1892
+ range: ref.fieldRef.range
1893
+ });
1894
+ }
1895
+ const ids = (0, _atscript_core.getFieldsForType)(doc, target).filter((p) => p.countAnnotations("meta.id") > 0);
1896
+ if (ids.length !== 1) errors.push({
1897
+ message: `A first-row join needs a target with exactly one @meta.id field — '${target}' has ${ids.length === 0 ? "none" : `${ids.length} (a composite key)`}`,
1898
+ severity: 1,
1899
+ range: orderToken.range
1900
+ });
1901
+ return errors;
1902
+ }
1903
+ /** Why `path` of `target` cannot order a first-row join, or `undefined` when it can. */
1904
+ function orderKeyProblem(doc, target, path) {
1905
+ for (let i = 1; i <= path.length; i++) {
1906
+ const step = doc.unwindType(target, path.slice(0, i));
1907
+ if (!step) return void 0;
1908
+ const node = step.node;
1909
+ if (node && (0, _atscript_core.isProp)(node)) {
1910
+ if (node.countAnnotations("db.json") > 0) return "reads a @db.json field";
1911
+ if (node.countAnnotations("db.encrypted") > 0) return "is @db.encrypted";
1912
+ if (node.countAnnotations("db.writeOnly") > 0) return "is @db.writeOnly";
1913
+ if (node.countAnnotations("db.ignore") > 0) return "is @db.ignore'd";
1914
+ }
1915
+ if ((0, _atscript_core.isArray)(step.def)) return "is an array";
1916
+ if (i === path.length && !(0, _atscript_core.isPrimitive)(step.def)) return "is not a scalar";
1917
+ }
1918
+ }
1641
1919
  const dbViewAnnotations = { view: {
1642
1920
  $self: new _atscript_core.AnnotationSpec({
1643
1921
  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",
@@ -1689,7 +1967,7 @@ const dbViewAnnotations = { view: {
1689
1967
  }
1690
1968
  }),
1691
1969
  joins: new _atscript_core.AnnotationSpec({
1692
- 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",
1970
+ 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",
1693
1971
  nodeType: ["interface"],
1694
1972
  passedWhenReferred: false,
1695
1973
  multiple: true,
@@ -1713,6 +1991,13 @@ const dbViewAnnotations = { view: {
1713
1991
  optional: true,
1714
1992
  description: "`\"inner\"` (default) drops entry rows without a match; `\"left\"` keeps them with NULLs.",
1715
1993
  values: ["inner", "left"]
1994
+ },
1995
+ {
1996
+ name: "order",
1997
+ type: "order",
1998
+ optional: true,
1999
+ description: "Makes this a first-row join: of the target rows matching the condition only the first by this ordering joins (`` `raisedAt, id` ``, `` `severity desc` ``). Keys are scalar fields of the join target; its primary key is appended as the final tie-break. NULL is the smallest value (first in `asc`, last in `desc`).",
2000
+ fieldScope: fieldScopes.joinOrder
1716
2001
  }
1717
2002
  ],
1718
2003
  validate(token, args, doc) {
@@ -1761,6 +2046,8 @@ const dbViewAnnotations = { view: {
1761
2046
  }
1762
2047
  const scope = join && viewJoinScope(owner, join, earlier);
1763
2048
  if (args[1]?.queryNode && scope) errors.push(...require_validation_utils.validateQueryScope(args[1], scope, doc, "a join may reference the entry table and joins declared before it"));
2049
+ const order = join && args[3]?.orderNode ? joinOrderScope(join) : void 0;
2050
+ if (order) errors.push(...validateJoinOrder(args[3], order, doc));
1764
2051
  return errors;
1765
2052
  }
1766
2053
  }),
@@ -1890,6 +2177,7 @@ const dbPlugin = (options) => ({
1890
2177
  view: dbViewAnnotations.view,
1891
2178
  alias: dbAliasAnnotations.alias,
1892
2179
  agg: dbAggAnnotations.agg,
2180
+ compute: dbComputeAnnotations.compute,
1893
2181
  search: dbSearchAnnotations.search,
1894
2182
  amount: dbAmountAnnotations.amount,
1895
2183
  unit: dbUnitAnnotations.unit