@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.mjs CHANGED
@@ -1,10 +1,11 @@
1
- import { i as SUPPORTED_AGGREGATE_FNS, r as NULL_WHEN_EMPTY_AGGREGATE_FNS } from "./aggregate-fns-CfsveE1w.mjs";
1
+ import { i as NULL_WHEN_EMPTY_AGGREGATE_FNS, o as SUPPORTED_AGGREGATE_FNS, t as AGG_ANNOTATIONS } from "./aggregate-fns-CyaZyb9I.mjs";
2
2
  import { n as DERIVED_INCOMPATIBLE, r as JSON_LEAF_TYPES, t as DB_ENTITY_ANNOTATIONS } from "./derived-rules-0sKn4f5C.mjs";
3
+ import { t as numericTypeProblem } from "./numeric-operand-B1jKH7x5.mjs";
3
4
  import "./consts-C_-5_pFq.mjs";
4
- import { S as validateSiblingStringField, _ as getParentTypeName, a as hasAnyViewAnnotation, b as validateExclusiveWith, d as viewJoins, f as viewScopeTypes, g as getParentStruct, h as getNavTargetTypeName, 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 primitiveBaseType, x as validateFieldBaseType, y as refActionAnnotation } from "./validation-utils-CMR4fe2M.mjs";
5
+ import { S as validateSiblingStringField, _ as getParentTypeName, a as hasAnyViewAnnotation, b as validateExclusiveWith, 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 primitiveBaseType, x as validateFieldBaseType, y as refActionAnnotation } from "./validation-utils-Dq0uZ7ef.mjs";
5
6
  import path from "node:path";
6
7
  import { fileURLToPath } from "node:url";
7
- import { AnnotationSpec, DEFAULT_FORMAT, isArray, isInterface, isPrimitive, isProp, isRef, isStructure } from "@atscript/core";
8
+ import { AnnotationSpec, DEFAULT_FORMAT, getFieldsForType, isArray, isInterface, isPrimitive, isProp, isQueryComparison, isQueryLogical, isRef, isStructure } from "@atscript/core";
8
9
  //#region src/plugin/manifest.ts
9
10
  /**
10
11
  * Renders the model-manifest module: an inventory of every exported
@@ -138,6 +139,27 @@ function viewHavingScope(view) {
138
139
  unqualifiedTarget: view.id
139
140
  } : void 0;
140
141
  }
142
+ /**
143
+ * The ordering (4th argument) of a first-row `@db.view.joins`: the join
144
+ * target only — an unqualified key is a field of the target.
145
+ * @since 0.1.147
146
+ */
147
+ function joinOrderScope(join) {
148
+ const target = join.args[0]?.text;
149
+ return target ? {
150
+ allowedTypes: [target],
151
+ unqualifiedTarget: target
152
+ } : void 0;
153
+ }
154
+ /**
155
+ * The expression of a `@db.compute` on a view field: the view's own fields,
156
+ * unqualified — the `@db.view.having` scope.
157
+ * @since 0.1.147
158
+ */
159
+ function computeScope(propToken) {
160
+ const view = getDbTableOwner(propToken);
161
+ return view ? viewHavingScope(view) : void 0;
162
+ }
141
163
  /** The condition of a `@db.agg.*` on a view field: the scope of the view's `@db.view.filter`. */
142
164
  function aggConditionScope(propToken) {
143
165
  const view = getDbTableOwner(propToken);
@@ -172,16 +194,25 @@ function relFilterScope(field) {
172
194
  unqualifiedTarget: target
173
195
  };
174
196
  }
197
+ /** The `@db.view.joins` annotation one of whose arguments is `arg`. */
198
+ function joinOfArg(joins, arg) {
199
+ return joins.find((a) => a.args.includes(arg));
200
+ }
175
201
  /** The `fieldScope` hooks (argument token → scope) the annotation specs declare. */
176
202
  const fieldScopes = {
177
203
  viewFilter: (arg) => arg.parentNode ? viewFilterScope(arg.parentNode) : void 0,
178
204
  viewJoin: (arg) => {
179
205
  const view = arg.parentNode;
180
206
  const joins = view ? viewJoins(view) : [];
181
- const join = joins.find((a) => a.args.includes(arg));
207
+ const join = joinOfArg(joins, arg);
182
208
  return view && join ? viewJoinScope(view, join, earlierJoinTargets(joins, join)) : void 0;
183
209
  },
210
+ joinOrder: (arg) => {
211
+ const join = arg.parentNode ? joinOfArg(viewJoins(arg.parentNode), arg) : void 0;
212
+ return join ? joinOrderScope(join) : void 0;
213
+ },
184
214
  viewHaving: (arg) => arg.parentNode ? viewHavingScope(arg.parentNode) : void 0,
215
+ compute: computeScope,
185
216
  aggCondition: aggConditionScope,
186
217
  aggField: aggFieldScope,
187
218
  relFilter: (arg) => arg.parentNode ? relFilterScope(arg.parentNode) : void 0
@@ -832,6 +863,123 @@ function columnCapability(capability, verb) {
832
863
  });
833
864
  }
834
865
  //#endregion
866
+ //#region src/plugin/annotations/compute.ts
867
+ /** What a `@db.compute` field cannot also carry (VC1). */
868
+ const EXCLUSIVE_WITH = [
869
+ ...AGG_ANNOTATIONS,
870
+ "db.json",
871
+ "db.ignore"
872
+ ].map((key) => ({ key }));
873
+ /** The `@db.compute` expression token of a prop, if any. */
874
+ function computeArg(prop) {
875
+ return prop?.annotations?.find((a) => a.name === "db.compute")?.args[0];
876
+ }
877
+ /**
878
+ * Whether a prop is typed exactly `number` (a plain primitive ref — no chain
879
+ * ref, no `number.*` extension).
880
+ */
881
+ function isPlainNumber(prop, doc) {
882
+ const def = prop.getDefinition();
883
+ if (!def || !isRef(def) || def.hasChain || def.id !== "number") return false;
884
+ return isPrimitive(doc.unwindType("number")?.def);
885
+ }
886
+ /**
887
+ * Why an operand prop cannot be computed with, or `undefined` when it can:
888
+ * its resolved type must be a `number` primitive — not `decimal`, not a
889
+ * timestamp — and it must have storage (no `@db.ignore`).
890
+ */
891
+ function operandProblem(prop, doc) {
892
+ if (prop.countAnnotations("db.ignore") > 0) return "is @db.ignore'd";
893
+ if (computeArg(prop)) return void 0;
894
+ const def = prop.getDefinition();
895
+ const leaf = def && isRef(def) ? doc.unwindType(def.id, def.chain)?.def : def;
896
+ const base = primitiveBaseType(leaf);
897
+ if (base === void 0) return void 0;
898
+ return numericTypeProblem({
899
+ base,
900
+ tags: isPrimitive(leaf) ? leaf.tags : void 0
901
+ });
902
+ }
903
+ /** VC6 — whether an expression may be NULL (`/` by zero, an optional operand). */
904
+ function exprNullable(node, props) {
905
+ if ("value" in node) return false;
906
+ if ("left" in node) return node.op === "/" || exprNullable(node.left, props) || exprNullable(node.right, props);
907
+ if ("operand" in node) return exprNullable(node.operand, props);
908
+ if ("args" in node) return node.args.every((a) => exprNullable(a, props));
909
+ return props.get(node.fieldRef.text)?.has("optional") ?? false;
910
+ }
911
+ /**
912
+ * VC4 — the first `@db.compute` cycle through `start`, as field names
913
+ * (`rank → priority → rank`), or `undefined`.
914
+ */
915
+ function findComputeCycle(start, props) {
916
+ const path = [];
917
+ const done = /* @__PURE__ */ new Set();
918
+ const visit = (name) => {
919
+ const at = path.indexOf(name);
920
+ if (at !== -1) return name === start ? [...path.slice(at), name] : void 0;
921
+ if (done.has(name)) return void 0;
922
+ const refs = computeArg(props.get(name))?.exprNode?.fieldRefs() ?? [];
923
+ path.push(name);
924
+ for (const ref of refs) {
925
+ if (ref.typeRef) continue;
926
+ const cycle = visit(ref.fieldRef.text);
927
+ if (cycle) return cycle;
928
+ }
929
+ path.pop();
930
+ done.add(name);
931
+ };
932
+ return visit(start);
933
+ }
934
+ function validateCompute(token, args, doc) {
935
+ const errors = [];
936
+ const prop = token.parentNode;
937
+ const field = prop.id ?? "field";
938
+ const view = getDbTableOwner(token);
939
+ const error = (message, range = token.range) => errors.push({
940
+ message,
941
+ severity: 1,
942
+ range
943
+ });
944
+ if (!view || view.countAnnotations("db.view.for") === 0) {
945
+ error("@db.compute is only valid on a field of a @db.view.for view");
946
+ return errors;
947
+ }
948
+ errors.push(...validateExclusiveWith(token, "@db.compute", EXCLUSIVE_WITH));
949
+ if (!isPlainNumber(prop, doc)) error(`Field "${field}" has a @db.compute and must be typed \`number\``);
950
+ const expr = args[0]?.exprNode;
951
+ if (!expr) return errors;
952
+ const refs = expr.fieldRefs();
953
+ const props = viewProps(view) ?? /* @__PURE__ */ new Map();
954
+ if (refs.length === 0) error("@db.compute needs at least one view field — a constant column is not supported", args[0].range);
955
+ const scope = viewHavingScope(view);
956
+ if (scope) errors.push(...validateQueryScope(args[0], scope, doc, "@db.compute references the view's own fields — declare it on the view first (`field: Type.field`)"));
957
+ for (const ref of refs) {
958
+ if (ref.typeRef) continue;
959
+ const operand = props.get(ref.fieldRef.text);
960
+ const why = operand && operandProblem(operand, doc);
961
+ if (why) error(`@db.compute operand '${ref.fieldRef.text}' ${why} — operands must be number fields`, ref.fieldRef.range);
962
+ }
963
+ if (prop.id) {
964
+ const cycle = findComputeCycle(prop.id, props);
965
+ if (cycle) error(`@db.compute of "${field}" depends on itself: ${cycle.join(" → ")}`, args[0].range);
966
+ }
967
+ 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`);
968
+ return errors;
969
+ }
970
+ const dbComputeAnnotations = { compute: new AnnotationSpec({
971
+ 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",
972
+ nodeType: ["prop"],
973
+ passedWhenReferred: false,
974
+ argument: {
975
+ name: "expression",
976
+ type: "expr",
977
+ description: "Arithmetic over the view's own fields (unqualified): `+ - * /`, unary `-`, parentheses, numbers, `coalesce(a, b, …)`.",
978
+ fieldScope: fieldScopes.compute
979
+ },
980
+ validate: validateCompute
981
+ }) };
982
+ //#endregion
835
983
  //#region src/plugin/annotations/index-ann.ts
836
984
  const dbIndexAnnotations = { index: {
837
985
  plain: new AnnotationSpec({
@@ -899,6 +1047,31 @@ const dbIndexAnnotations = { index: {
899
1047
  } };
900
1048
  //#endregion
901
1049
  //#region src/plugin/annotations/rel.ts
1050
+ /**
1051
+ * X2b: on a `@db.table` a foreign key is a real constraint — one column of a
1052
+ * composite primary key is not unique on its own. Columns that together cover
1053
+ * the whole key (same alias, same target, each naming a top-level key column)
1054
+ * are a valid composite foreign key. Returns whether `token`'s FK is covered
1055
+ * (or the rule does not apply). Cheap checks run before the sibling scan.
1056
+ */
1057
+ function checkCompositeFkCoverage(token, alias, refTypeName, targetStruct, targetProp) {
1058
+ if (targetProp.countAnnotations("db.index.unique") > 0) return true;
1059
+ const pk = [...targetStruct.props.entries()].filter(([, p]) => p.countAnnotations("meta.id") > 0).map(([name]) => name);
1060
+ if (pk.length <= 1) return true;
1061
+ const hostStruct = getParentStruct(token);
1062
+ const owner = getDbTableOwner(token);
1063
+ if (!hostStruct || owner === void 0 || !isDbTable(owner)) return true;
1064
+ const covered = /* @__PURE__ */ new Set();
1065
+ for (const [, sibling] of hostStruct.props) {
1066
+ if (sibling.countAnnotations("db.rel.FK") === 0) continue;
1067
+ if (getAnnotationAlias(sibling, "db.rel.FK") !== alias) continue;
1068
+ const siblingDef = sibling.getDefinition();
1069
+ if (siblingDef && isRef(siblingDef) && siblingDef.id === refTypeName) {
1070
+ if (siblingDef.chain.length === 1) covered.add(siblingDef.chain[0].text);
1071
+ }
1072
+ }
1073
+ return pk.every((name) => covered.has(name));
1074
+ }
902
1075
  const dbRelAnnotations = { rel: {
903
1076
  FK: new AnnotationSpec({
904
1077
  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",
@@ -944,6 +1117,11 @@ const dbRelAnnotations = { rel: {
944
1117
  severity: 1,
945
1118
  range: token.range
946
1119
  });
1120
+ if (!checkCompositeFkCoverage(token, alias, refTypeName, struct, targetProp)) errors.push({
1121
+ 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`,
1122
+ severity: 1,
1123
+ range: token.range
1124
+ });
947
1125
  const propDef = targetProp.getDefinition();
948
1126
  if (propDef && isRef(propDef)) {
949
1127
  const propUnwound = targetUnwound.doc.unwindType(propDef.id, propDef.chain);
@@ -1222,8 +1400,23 @@ const dbRelAnnotations = { rel: {
1222
1400
  return errors;
1223
1401
  }
1224
1402
  }),
1403
+ filterable: new AnnotationSpec({
1404
+ 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",
1405
+ nodeType: ["prop"],
1406
+ passedWhenReferred: false,
1407
+ validate(token) {
1408
+ const errors = [];
1409
+ const field = token.parentNode;
1410
+ if (!(field.countAnnotations("db.rel.to") > 0 || field.countAnnotations("db.rel.from") > 0 || field.countAnnotations("db.rel.via") > 0)) errors.push({
1411
+ message: "@db.rel.filterable is only valid on navigational fields (@db.rel.to, @db.rel.from, or @db.rel.via)",
1412
+ severity: 1,
1413
+ range: token.range
1414
+ });
1415
+ return errors;
1416
+ }
1417
+ }),
1225
1418
  filter: new AnnotationSpec({
1226
- 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",
1419
+ 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",
1227
1420
  nodeType: ["prop"],
1228
1421
  passedWhenReferred: false,
1229
1422
  argument: {
@@ -1249,10 +1442,40 @@ const dbRelAnnotations = { rel: {
1249
1442
  if (!args[0]?.queryNode) return errors;
1250
1443
  const scope = relFilterScope(field);
1251
1444
  if (scope) errors.push(...validateQueryScope(args[0], scope, doc));
1445
+ const expression = args[0].queryNode.expression;
1446
+ forEachComparison(expression, (cmp) => {
1447
+ if (cmp.right && "fieldRef" in cmp.right) errors.push({
1448
+ message: "@db.rel.filter compares two fields — only field-to-value conditions are supported",
1449
+ severity: 1,
1450
+ range: cmp.right.fieldRef.range
1451
+ });
1452
+ });
1453
+ const junction = hasVia ? getAnnotationAlias(field, "db.rel.via") : void 0;
1454
+ if (junction) {
1455
+ const conjuncts = isQueryLogical(expression) && expression.operator === "and" ? expression.operands : [expression];
1456
+ for (const conjunct of conjuncts) {
1457
+ let junctionRef;
1458
+ let targetRef = false;
1459
+ forEachFieldRef(conjunct, (ref) => {
1460
+ if (ref.typeRef?.text === junction) junctionRef ??= ref.typeRef.range;
1461
+ else targetRef = true;
1462
+ });
1463
+ if (junctionRef && targetRef) errors.push({
1464
+ message: `@db.rel.filter has a condition reading both the junction '${junction}' and the related type — split it into separate top-level "and" conditions`,
1465
+ severity: 1,
1466
+ range: junctionRef
1467
+ });
1468
+ }
1469
+ }
1252
1470
  return errors;
1253
1471
  }
1254
1472
  })
1255
1473
  } };
1474
+ /** Every comparison of a query expression (through and / or / not). */
1475
+ function forEachComparison(expr, fn) {
1476
+ if (isQueryLogical(expr)) for (const operand of expr.operands) forEachComparison(operand, fn);
1477
+ else if (isQueryComparison(expr)) fn(expr);
1478
+ }
1256
1479
  //#endregion
1257
1480
  //#region src/plugin/annotations/search.ts
1258
1481
  const dbSearchAnnotations = { search: {
@@ -1602,6 +1825,61 @@ const dbUnitAnnotations = { unit: {
1602
1825
  } };
1603
1826
  //#endregion
1604
1827
  //#region src/plugin/annotations/view.ts
1828
+ /**
1829
+ * VJ6–VJ8 — the ordering of a first-row join: every key is a scalar field of
1830
+ * the join target (no object, array, `@db.json`, `@db.encrypted`, `@db.writeOnly` or
1831
+ * navigation field), no key repeats, and the target (through `@db.alias`)
1832
+ * declares exactly one `@meta.id` field — the anchor of the join's
1833
+ * correlated subquery.
1834
+ */
1835
+ function validateJoinOrder(orderToken, scope, doc) {
1836
+ const target = scope.unqualifiedTarget;
1837
+ const errors = validateQueryScope(orderToken, scope, doc, `a first-row join orders by fields of its target '${target}'`);
1838
+ const seen = /* @__PURE__ */ new Set();
1839
+ for (const item of orderToken.orderNode.items) {
1840
+ const { ref } = item;
1841
+ if (ref.typeRef && ref.typeRef.text !== target) continue;
1842
+ const path = ref.fieldRef.text;
1843
+ if (seen.has(path)) {
1844
+ errors.push({
1845
+ message: `Order key '${path}' appears more than once`,
1846
+ severity: 1,
1847
+ range: ref.fieldRef.range
1848
+ });
1849
+ continue;
1850
+ }
1851
+ seen.add(path);
1852
+ const why = orderKeyProblem(doc, target, path.split("."));
1853
+ if (why) errors.push({
1854
+ message: `Order key '${path}' ${why} — order by a scalar field of '${target}'`,
1855
+ severity: 1,
1856
+ range: ref.fieldRef.range
1857
+ });
1858
+ }
1859
+ const ids = getFieldsForType(doc, target).filter((p) => p.countAnnotations("meta.id") > 0);
1860
+ if (ids.length !== 1) errors.push({
1861
+ 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)`}`,
1862
+ severity: 1,
1863
+ range: orderToken.range
1864
+ });
1865
+ return errors;
1866
+ }
1867
+ /** Why `path` of `target` cannot order a first-row join, or `undefined` when it can. */
1868
+ function orderKeyProblem(doc, target, path) {
1869
+ for (let i = 1; i <= path.length; i++) {
1870
+ const step = doc.unwindType(target, path.slice(0, i));
1871
+ if (!step) return void 0;
1872
+ const node = step.node;
1873
+ if (node && isProp(node)) {
1874
+ if (node.countAnnotations("db.json") > 0) return "reads a @db.json field";
1875
+ if (node.countAnnotations("db.encrypted") > 0) return "is @db.encrypted";
1876
+ if (node.countAnnotations("db.writeOnly") > 0) return "is @db.writeOnly";
1877
+ if (node.countAnnotations("db.ignore") > 0) return "is @db.ignore'd";
1878
+ }
1879
+ if (isArray(step.def)) return "is an array";
1880
+ if (i === path.length && !isPrimitive(step.def)) return "is not a scalar";
1881
+ }
1882
+ }
1605
1883
  const dbViewAnnotations = { view: {
1606
1884
  $self: new AnnotationSpec({
1607
1885
  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",
@@ -1653,7 +1931,7 @@ const dbViewAnnotations = { view: {
1653
1931
  }
1654
1932
  }),
1655
1933
  joins: new AnnotationSpec({
1656
- 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",
1934
+ 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",
1657
1935
  nodeType: ["interface"],
1658
1936
  passedWhenReferred: false,
1659
1937
  multiple: true,
@@ -1677,6 +1955,13 @@ const dbViewAnnotations = { view: {
1677
1955
  optional: true,
1678
1956
  description: "`\"inner\"` (default) drops entry rows without a match; `\"left\"` keeps them with NULLs.",
1679
1957
  values: ["inner", "left"]
1958
+ },
1959
+ {
1960
+ name: "order",
1961
+ type: "order",
1962
+ optional: true,
1963
+ 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`).",
1964
+ fieldScope: fieldScopes.joinOrder
1680
1965
  }
1681
1966
  ],
1682
1967
  validate(token, args, doc) {
@@ -1725,6 +2010,8 @@ const dbViewAnnotations = { view: {
1725
2010
  }
1726
2011
  const scope = join && viewJoinScope(owner, join, earlier);
1727
2012
  if (args[1]?.queryNode && scope) errors.push(...validateQueryScope(args[1], scope, doc, "a join may reference the entry table and joins declared before it"));
2013
+ const order = join && args[3]?.orderNode ? joinOrderScope(join) : void 0;
2014
+ if (order) errors.push(...validateJoinOrder(args[3], order, doc));
1728
2015
  return errors;
1729
2016
  }
1730
2017
  }),
@@ -1854,6 +2141,7 @@ const dbPlugin = (options) => ({
1854
2141
  view: dbViewAnnotations.view,
1855
2142
  alias: dbAliasAnnotations.alias,
1856
2143
  agg: dbAggAnnotations.agg,
2144
+ compute: dbComputeAnnotations.compute,
1857
2145
  search: dbSearchAnnotations.search,
1858
2146
  amount: dbAmountAnnotations.amount,
1859
2147
  unit: dbUnitAnnotations.unit
package/dist/rel.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_nested_writer = require("./nested-writer-xfQwxplL.cjs");
3
- const require_relation_loader = require("./relation-loader-CBPY6kM7.cjs");
2
+ const require_nested_writer = require("./nested-writer-BUQvk6QT.cjs");
3
+ const require_relation_loader = require("./relation-loader-D8OrdH-r.cjs");
4
4
  exports.applyPatchNestedTo = require_nested_writer.applyPatchNestedTo;
5
5
  exports.batchInsertNestedFrom = require_nested_writer.batchInsertNestedFrom;
6
6
  exports.batchInsertNestedTo = require_nested_writer.batchInsertNestedTo;
package/dist/rel.d.cts CHANGED
@@ -1,6 +1,6 @@
1
- import { Dt as TTableResolver, Lt as TableMetadata, Mt as TWriteTableResolver, V as TDbForeignKey, Vt as BaseDbAdapter, Y as TDbRelation, gn as TGenericLogger, m as AtscriptDbWritable, p as AtscriptDbTableLike } from "./buckets-DYFu0eZ8.cjs";
1
+ import { Jt as BaseDbAdapter, Nt as TTableResolver, Rt as TWriteTableResolver, U as TDbForeignKey, Ut as TableMetadata, Vn as TGenericLogger, et as TDbRelation, h as AtscriptDbWritable, m as AtscriptDbTableLike } from "./buckets-DJiYlMXc.cjs";
2
2
  import { t as DbValidationContext } from "./db-validator-plugin-BWy60OvG.cjs";
3
- import { n as findRemoteFK, r as resolveRelationTargetTable, t as findFKForRelation } from "./relation-helpers-BOMm_HUI.cjs";
3
+ import { n as findRemoteFK, r as resolveRelationTargetTable, t as findFKForRelation } from "./relation-helpers-kX7jjgME.cjs";
4
4
  import { FilterExpr, WithRelation } from "@uniqu/core";
5
5
  import { Validator } from "@atscript/typescript/utils";
6
6
 
package/dist/rel.d.mts CHANGED
@@ -1,6 +1,6 @@
1
- import { Dt as TTableResolver, Lt as TableMetadata, Mt as TWriteTableResolver, V as TDbForeignKey, Vt as BaseDbAdapter, Y as TDbRelation, gn as TGenericLogger, m as AtscriptDbWritable, p as AtscriptDbTableLike } from "./buckets-DRycmhOW.mjs";
1
+ import { Jt as BaseDbAdapter, Nt as TTableResolver, Rt as TWriteTableResolver, U as TDbForeignKey, Ut as TableMetadata, Vn as TGenericLogger, et as TDbRelation, h as AtscriptDbWritable, m as AtscriptDbTableLike } from "./buckets-CNdTOnei.mjs";
2
2
  import { t as DbValidationContext } from "./db-validator-plugin-BWy60OvG.mjs";
3
- import { n as findRemoteFK, r as resolveRelationTargetTable, t as findFKForRelation } from "./relation-helpers-B-0NRKat.mjs";
3
+ import { n as findRemoteFK, r as resolveRelationTargetTable, t as findFKForRelation } from "./relation-helpers-Ba0v49sn.mjs";
4
4
  import { Validator } from "@atscript/typescript/utils";
5
5
  import { FilterExpr, WithRelation } from "@uniqu/core";
6
6
 
package/dist/rel.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { t as loadRelationsImpl } from "./relation-loader-D9XuXaMv.mjs";
2
- import { C as findRemoteFK, S as findFKForRelation, a as batchPatchNestedFrom, c as batchReplaceNestedFrom, d as checkDepthOverflow, f as planNestedFromVia, h as validateBatch, i as batchInsertNestedVia, l as batchReplaceNestedTo, m as preValidateNestedFrom, n as batchInsertNestedFrom, o as batchPatchNestedTo, p as planPatchNestedTo, r as batchInsertNestedTo, s as batchPatchNestedVia, t as applyPatchNestedTo, u as batchReplaceNestedVia, w as resolveRelationTargetTable } from "./nested-writer-CnOOAehr.mjs";
1
+ import { t as loadRelationsImpl } from "./relation-loader-ByY1Byrl.mjs";
2
+ import { L as findFKForRelation, R as findRemoteFK, a as batchPatchNestedFrom, c as batchReplaceNestedFrom, d as checkDepthOverflow, f as planNestedFromVia, h as validateBatch, i as batchInsertNestedVia, l as batchReplaceNestedTo, m as preValidateNestedFrom, n as batchInsertNestedFrom, o as batchPatchNestedTo, p as planPatchNestedTo, r as batchInsertNestedTo, s as batchPatchNestedVia, t as applyPatchNestedTo, u as batchReplaceNestedVia, z as resolveRelationTargetTable } from "./nested-writer-CUBoq1ZO.mjs";
3
3
  export { applyPatchNestedTo, batchInsertNestedFrom, batchInsertNestedTo, batchInsertNestedVia, batchPatchNestedFrom, batchPatchNestedTo, batchPatchNestedVia, batchReplaceNestedFrom, batchReplaceNestedTo, batchReplaceNestedVia, checkDepthOverflow, findFKForRelation, findRemoteFK, loadRelationsImpl, planNestedFromVia, planPatchNestedTo, preValidateNestedFrom, resolveRelationTargetTable, validateBatch };
@@ -1,4 +1,4 @@
1
- import { V as TDbForeignKey, Y as TDbRelation } from "./buckets-DRycmhOW.mjs";
1
+ import { U as TDbForeignKey, et as TDbRelation } from "./buckets-CNdTOnei.mjs";
2
2
  import { TAtscriptAnnotatedType } from "@atscript/typescript/utils";
3
3
 
4
4
  //#region src/rel/relation-helpers.d.ts
@@ -1,4 +1,4 @@
1
- import { V as TDbForeignKey, Y as TDbRelation } from "./buckets-DYFu0eZ8.cjs";
1
+ import { U as TDbForeignKey, et as TDbRelation } from "./buckets-DJiYlMXc.cjs";
2
2
  import { TAtscriptAnnotatedType } from "@atscript/typescript/utils";
3
3
 
4
4
  //#region src/rel/relation-helpers.d.ts