@atscript/db 0.1.140 → 0.1.142

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 (49) hide show
  1. package/dist/agg.d.cts +1 -1
  2. package/dist/agg.d.mts +1 -1
  3. package/dist/{buckets-C-27xmtq.d.cts → buckets-Bv4pah66.d.cts} +225 -22
  4. package/dist/{buckets-BFG2RYRW.d.mts → buckets-CjL7F-hp.d.mts} +225 -22
  5. package/dist/{column-diff-CgxgFKzx.cjs → column-diff-CfPNcP6e.cjs} +663 -239
  6. package/dist/{column-diff-BwOA5101.mjs → column-diff-CmFNXV8C.mjs} +638 -220
  7. package/dist/column-diff-DiBbXyLA.d.cts +211 -0
  8. package/dist/column-diff-n-k5KY0u.d.mts +211 -0
  9. package/dist/derived-rules-0sKn4f5C.mjs +44 -0
  10. package/dist/derived-rules-YstgIxG-.cjs +67 -0
  11. package/dist/index.cjs +38 -4
  12. package/dist/index.d.cts +23 -6
  13. package/dist/index.d.mts +23 -6
  14. package/dist/index.mjs +34 -4
  15. package/dist/{nested-writer-FWD5oOYh.mjs → nested-writer-BO3vhbkP.mjs} +8 -4
  16. package/dist/{nested-writer-BZNCuqI6.cjs → nested-writer-DYsRxZ5f.cjs} +8 -4
  17. package/dist/object-DSN0h9lB.d.cts +30 -0
  18. package/dist/object-DSN0h9lB.d.mts +30 -0
  19. package/dist/plugin.cjs +392 -139
  20. package/dist/plugin.mjs +392 -139
  21. package/dist/rel.cjs +2 -2
  22. package/dist/rel.d.cts +2 -2
  23. package/dist/rel.d.mts +2 -2
  24. package/dist/rel.mjs +2 -2
  25. package/dist/{relation-helpers-D3Zu0Mta.d.mts → relation-helpers-B59to_dG.d.mts} +5 -4
  26. package/dist/{relation-helpers-DxrvS6ar.d.cts → relation-helpers-DQ_nRsV9.d.cts} +5 -4
  27. package/dist/{relation-loader-6ZB_5KFq.cjs → relation-loader-CgJ8bK6X.cjs} +1 -1
  28. package/dist/{relation-loader-CTFaZpVa.mjs → relation-loader-CuhEBzFU.mjs} +1 -1
  29. package/dist/shared.cjs +6 -1
  30. package/dist/shared.d.cts +48 -9
  31. package/dist/shared.d.mts +48 -9
  32. package/dist/shared.mjs +2 -2
  33. package/dist/sync.cjs +331 -105
  34. package/dist/sync.d.cts +62 -163
  35. package/dist/sync.d.mts +62 -163
  36. package/dist/sync.mjs +331 -105
  37. package/dist/{validation-utils-B4h-GW4d.mjs → validation-utils-CMR4fe2M.mjs} +99 -34
  38. package/dist/{validation-utils-Dg0hW6dn.cjs → validation-utils-DOsB4e6G.cjs} +128 -33
  39. package/dist/{validator-Drb2N-YL.d.cts → validator-Bw6ks9Hy.d.cts} +1 -11
  40. package/dist/{validator-Drb2N-YL.d.mts → validator-Bw6ks9Hy.d.mts} +1 -11
  41. package/dist/{validator-Ch7UIQl9.mjs → validator-D8bPsXPN.mjs} +54 -2
  42. package/dist/{validator-BtZbcLN2.cjs → validator-DASnXf1j.cjs} +77 -1
  43. package/dist/validator.cjs +1 -1
  44. package/dist/validator.d.cts +2 -1
  45. package/dist/validator.d.mts +2 -1
  46. package/dist/validator.mjs +1 -1
  47. package/package.json +6 -6
  48. package/dist/column-diff-BmqvgBWw.d.cts +0 -24
  49. package/dist/column-diff-DPkbZIVE.d.mts +0 -24
package/dist/plugin.mjs CHANGED
@@ -1,18 +1,11 @@
1
1
  import { i as SUPPORTED_AGGREGATE_FNS, r as NULL_WHEN_EMPTY_AGGREGATE_FNS } from "./aggregate-fns-CfsveE1w.mjs";
2
+ import { n as DERIVED_INCOMPATIBLE, r as JSON_LEAF_TYPES, t as DB_ENTITY_ANNOTATIONS } from "./derived-rules-0sKn4f5C.mjs";
2
3
  import "./consts-C_-5_pFq.mjs";
3
- import { _ as validateSiblingStringField, a as validateRefArgument, c as getAnnotationAlias, d as getParentStruct, f as getParentTypeName, g as validateFieldBaseType, h as validateExclusiveWith, i as validateQueryScope, l as getDbTableOwner, m as refActionAnnotation, n as hasAnyViewAnnotation, o as viewJoins, p as primitiveBaseType, r as joinTargets, s as viewScopeTypes, t as findFKFieldsPointingTo, u as getNavTargetTypeName } from "./validation-utils-B4h-GW4d.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";
4
5
  import path from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { AnnotationSpec, DEFAULT_FORMAT, isArray, isInterface, isPrimitive, isProp, isRef, isStructure } from "@atscript/core";
7
8
  //#region src/plugin/manifest.ts
8
- const DB_ENTITY_ANNOTATIONS = [
9
- "db.table",
10
- "db.view",
11
- "db.view.for"
12
- ];
13
- function isDbEntity(node) {
14
- return DB_ENTITY_ANNOTATIONS.some((name) => node.countAnnotations(name) > 0);
15
- }
16
9
  /**
17
10
  * Renders the model-manifest module: an inventory of every exported
18
11
  * `@db.table` / `@db.view` entity in the project, grouped by `@db.space`.
@@ -41,7 +34,7 @@ async function generateModelManifest(options, output, format, repo) {
41
34
  let specifier = path.relative(manifestDir, docPath).split(path.sep).join("/");
42
35
  if (!specifier.startsWith(".")) specifier = `./${specifier}`;
43
36
  for (const [exportName, node] of [...doc.exports.entries()].toSorted(([a], [b]) => a.localeCompare(b))) {
44
- if (!isDbEntity(node)) continue;
37
+ if (!isDbSourceDecl(node)) continue;
45
38
  let alias = exportName;
46
39
  for (let n = 1; usedAliases.has(alias); n++) alias = `${exportName}_${n}`;
47
40
  usedAliases.add(alias);
@@ -95,6 +88,113 @@ async function generateModelManifest(options, output, format, repo) {
95
88
  });
96
89
  }
97
90
  //#endregion
91
+ //#region src/plugin/lsp-scopes.ts
92
+ /**
93
+ * Editor scopes of the `@db.*` query / field-path arguments and the type
94
+ * filters of the ref arguments — one function per rule. The validators in
95
+ * `annotations/*` derive their in-scope types from these SAME functions, so a
96
+ * diagnostic and what the editor completes, hovers and jumps to never diverge.
97
+ * @since 0.1.141
98
+ */
99
+ /** The `@db.view.for` entry name of a view node. */
100
+ function viewEntry(view) {
101
+ return getAnnotationAlias(view, "db.view.for");
102
+ }
103
+ /**
104
+ * `@db.view.filter` — and a conditional `@db.agg.*` of the same view: the
105
+ * entry table and every `@db.view.joins` target (`@db.alias` names included),
106
+ * in declaration order; an unqualified field belongs to the entry.
107
+ */
108
+ function viewFilterScope(view) {
109
+ const entry = viewEntry(view);
110
+ return entry ? {
111
+ allowedTypes: viewScopeTypes(view),
112
+ unqualifiedTarget: entry
113
+ } : void 0;
114
+ }
115
+ /**
116
+ * `@db.view.joins` condition: the join target, the entry table and the joins
117
+ * declared before this one (chained joins — `earlier`, computed from the
118
+ * view's {@link viewJoins} when the caller has not); an unqualified field
119
+ * belongs to the entry.
120
+ */
121
+ function viewJoinScope(view, join, earlier = earlierJoinTargets(viewJoins(view), join)) {
122
+ const entry = viewEntry(view);
123
+ const target = join.args[0]?.text;
124
+ if (!entry || !target) return;
125
+ return {
126
+ allowedTypes: [
127
+ target,
128
+ entry,
129
+ ...earlier
130
+ ],
131
+ unqualifiedTarget: entry
132
+ };
133
+ }
134
+ /** `@db.view.having`: the view's own fields, unqualified — no source type is in scope. */
135
+ function viewHavingScope(view) {
136
+ return view.id ? {
137
+ allowedTypes: [],
138
+ unqualifiedTarget: view.id
139
+ } : void 0;
140
+ }
141
+ /** The condition of a `@db.agg.*` on a view field: the scope of the view's `@db.view.filter`. */
142
+ function aggConditionScope(propToken) {
143
+ const view = getDbTableOwner(propToken);
144
+ return view ? viewFilterScope(view) : void 0;
145
+ }
146
+ /**
147
+ * The field of a `@db.agg.*` (`'amount'`, `'settings.level'`): a field path
148
+ * of the prop's chain-ref type, else of the view's `@db.view.for` entry —
149
+ * where the runtime reads the aggregate's source column from.
150
+ */
151
+ function aggFieldScope(propToken) {
152
+ const def = propToken.parentNode?.getDefinition();
153
+ const refType = def && isRef(def) && def.hasChain ? def.id : void 0;
154
+ const view = refType ? void 0 : getDbTableOwner(propToken);
155
+ const target = refType ?? (view ? viewEntry(view) : void 0);
156
+ return target ? {
157
+ allowedTypes: [],
158
+ unqualifiedTarget: target
159
+ } : void 0;
160
+ }
161
+ /**
162
+ * `@db.rel.filter` on a navigational field: the related type (`Post` of
163
+ * `posts: Post[]`) and, for `@db.rel.via`, the junction table; an
164
+ * unqualified field belongs to the related type.
165
+ */
166
+ function relFilterScope(field) {
167
+ const target = getNavTargetTypeName(field);
168
+ if (!target) return;
169
+ const junction = getAnnotationAlias(field, "db.rel.via");
170
+ return {
171
+ allowedTypes: junction ? [target, junction] : [target],
172
+ unqualifiedTarget: target
173
+ };
174
+ }
175
+ /** The `fieldScope` hooks (argument token → scope) the annotation specs declare. */
176
+ const fieldScopes = {
177
+ viewFilter: (arg) => arg.parentNode ? viewFilterScope(arg.parentNode) : void 0,
178
+ viewJoin: (arg) => {
179
+ const view = arg.parentNode;
180
+ const joins = view ? viewJoins(view) : [];
181
+ const join = joins.find((a) => a.args.includes(arg));
182
+ return view && join ? viewJoinScope(view, join, earlierJoinTargets(joins, join)) : void 0;
183
+ },
184
+ viewHaving: (arg) => arg.parentNode ? viewHavingScope(arg.parentNode) : void 0,
185
+ aggCondition: aggConditionScope,
186
+ aggField: aggFieldScope,
187
+ relFilter: (arg) => arg.parentNode ? relFilterScope(arg.parentNode) : void 0
188
+ };
189
+ /** `refFilter` of the `@db.view.joins` target: a view source or a `@db.alias` of one. */
190
+ function isJoinTarget(decl) {
191
+ return isDbSourceDecl(decl) || isAliasDecl(decl);
192
+ }
193
+ /** `refFilter` of `@db.rel.via`: a `@db.table`. */
194
+ function isDbTable(decl) {
195
+ return decl.countAnnotations("db.table") > 0;
196
+ }
197
+ //#endregion
98
198
  //#region src/plugin/annotations/agg.ts
99
199
  /**
100
200
  * A conditional aggregate that is NULL when no row matches (so its field
@@ -109,7 +209,8 @@ const CONDITION_ARG = {
109
209
  name: "condition",
110
210
  type: "query",
111
211
  optional: true,
112
- description: "Row predicate of a conditional aggregate: only rows where it holds are aggregated (SQL `FN(CASE WHEN … THEN field END)`). May reference the entry table and every join; unqualified fields resolve to the entry table."
212
+ description: "Row predicate of a conditional aggregate: only rows where it holds are aggregated (SQL `FN(CASE WHEN … THEN field END)`). May reference the entry table and every join; unqualified fields resolve to the entry table.",
213
+ fieldScope: fieldScopes.aggCondition
113
214
  };
114
215
  /**
115
216
  * The rules every `@db.agg.*` shares: `'*'` is `count`'s only, and a
@@ -127,9 +228,8 @@ function validateAggArgs(name, token, args, doc) {
127
228
  });
128
229
  const condition = args[1];
129
230
  if (!condition?.queryNode) return errors;
130
- const owner = getDbTableOwner(token);
131
- const entryTypeName = owner ? getAnnotationAlias(owner, "db.view.for") : void 0;
132
- if (!owner || !entryTypeName) {
231
+ const scope = aggConditionScope(token);
232
+ if (!scope) {
133
233
  errors.push({
134
234
  message: `A conditional ${annotation} requires @db.view.for on the view`,
135
235
  severity: 1,
@@ -137,7 +237,7 @@ function validateAggArgs(name, token, args, doc) {
137
237
  });
138
238
  return errors;
139
239
  }
140
- errors.push(...validateQueryScope(condition, viewScopeTypes(owner), entryTypeName, doc));
240
+ errors.push(...validateQueryScope(condition, scope, doc));
141
241
  const prop = token.parentNode;
142
242
  if (conditionalIsNullable(name) && prop && !prop.has("optional")) {
143
243
  const field = prop.id ?? "field";
@@ -169,7 +269,8 @@ function aggSpec(name, description, field, types, extra = "") {
169
269
  name: "field",
170
270
  type: "string",
171
271
  optional: field.optional,
172
- description: field.description
272
+ description: field.description,
273
+ fieldScope: fieldScopes.aggField
173
274
  }, CONDITION_ARG],
174
275
  validate(token, args, doc) {
175
276
  const errors = validateAggArgs(name, token, args, doc);
@@ -190,6 +291,51 @@ const dbAggAnnotations = { agg: {
190
291
  max: aggSpec("max", "Declares a view field as MAX of a source column.", { description: "Source column name." })
191
292
  } };
192
293
  //#endregion
294
+ //#region src/plugin/annotations/alias.ts
295
+ /** The `accept` rule of a view-source ref argument (`@db.view.for`, `@db.alias`). */
296
+ const VIEW_SOURCE_ARGUMENT = {
297
+ accept: isDbSourceDecl,
298
+ expected: "must be a @db.table or a @db.view."
299
+ };
300
+ /**
301
+ * `@db.alias <Target>` — a named join scope over a table or view, so a view
302
+ * can join the same table twice or join its own entry table (self-join).
303
+ * @since 0.1.141
304
+ */
305
+ const dbAliasAnnotations = { alias: new AnnotationSpec({
306
+ 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",
307
+ nodeType: ["type"],
308
+ passedWhenReferred: false,
309
+ argument: {
310
+ name: "target",
311
+ type: "ref",
312
+ description: "The aliased table or view type (must have @db.table or @db.view).",
313
+ refFilter: isDbSourceDecl
314
+ },
315
+ validate(token, args, doc) {
316
+ const errors = [];
317
+ const owner = token.parentNode;
318
+ const target = args[0]?.text;
319
+ for (const name of DB_ENTITY_ANNOTATIONS) if (owner.countAnnotations(name) > 0) {
320
+ errors.push({
321
+ message: `A @db.alias type cannot carry @${name} — it names a join scope over "${target ?? "…"}", not a table or view`,
322
+ severity: 1,
323
+ range: token.range
324
+ });
325
+ break;
326
+ }
327
+ if (!target) return errors;
328
+ const def = owner.getDefinition();
329
+ if ((def && isRef(def) && !def.hasChain ? def.id : void 0) !== target) errors.push({
330
+ message: `@db.alias ${target} must be declared on 'export type ${owner.id ?? "X"} = ${target}' — the type must be a plain reference to the aliased ${target}`,
331
+ severity: 1,
332
+ range: token.range
333
+ });
334
+ errors.push(...validateRefArgument(args[0], doc, VIEW_SOURCE_ARGUMENT));
335
+ return errors;
336
+ }
337
+ }) };
338
+ //#endregion
193
339
  //#region src/plugin/annotations/amount.ts
194
340
  const CURRENCY_CODE_PATTERN = /^[A-Z0-9]{2,10}$/;
195
341
  const dbAmountAnnotations = { amount: { currency: {
@@ -244,6 +390,100 @@ const dbAmountAnnotations = { amount: { currency: {
244
390
  })
245
391
  } } };
246
392
  //#endregion
393
+ //#region src/shared/view-validation.ts
394
+ /** `@db.agg.*` annotations that are never NULL (the counts: 0 over no value). */
395
+ const NULL_SAFE_AGG_ANNOTATIONS = SUPPORTED_AGGREGATE_FNS.filter((fn) => !NULL_WHEN_EMPTY_AGGREGATE_FNS.has(fn)).map((fn) => `db.agg.${fn}`);
396
+ /**
397
+ * Walks a chain ref step by step (`Order.payload`, `Order.payload.customer`, …)
398
+ * and reports the JSON root, arrays, encryption, optionality and the leaf type.
399
+ * A chain shorter than 2 is not a field path (`resolved: false`).
400
+ * @since 0.1.141
401
+ */
402
+ function jsonChainInfo(ref, doc) {
403
+ const typeName = ref.id ?? "";
404
+ const chain = ref.chain.map((t) => t.text);
405
+ const info = {
406
+ typeName,
407
+ chain,
408
+ resolved: false,
409
+ viaArray: false,
410
+ viaEncrypted: false,
411
+ optional: false
412
+ };
413
+ if (!typeName || chain.length < 2) return info;
414
+ for (let i = 1; i <= chain.length; i++) {
415
+ const step = doc.unwindType(typeName, chain.slice(0, i));
416
+ if (!step) return info;
417
+ const node = step.node;
418
+ const prop = node && isProp(node) ? node : void 0;
419
+ if (prop?.has("optional")) info.optional = true;
420
+ if (prop && prop.countAnnotations("db.encrypted") > 0) info.viaEncrypted = true;
421
+ const isJsonProp = prop !== void 0 && prop.countAnnotations("db.json") > 0;
422
+ const isArr = isArray(step.def);
423
+ if (isArr) info.viaArray = true;
424
+ if (info.jsonRoot === void 0 && (isJsonProp || isArr)) info.jsonRoot = i;
425
+ if (i === chain.length) info.leafType = primitiveBaseType(step.def);
426
+ }
427
+ info.resolved = true;
428
+ return info;
429
+ }
430
+ /** Props of a view interface (its own structure; views don't use `extends`). */
431
+ function viewProps(owner) {
432
+ if (isInterface(owner)) return owner.props;
433
+ const def = owner.getDefinition();
434
+ return def && isStructure(def) ? def.props : void 0;
435
+ }
436
+ /**
437
+ * VW8: a chain ref that passes through a `@db.json` or array node reads a
438
+ * JSON leaf, which views can only extract as a string / number / boolean.
439
+ * Intermediate nodes that `unwindType` can't reach are skipped (sync-time
440
+ * resolution reports them).
441
+ */
442
+ function validateJsonChain(fieldName, ref, doc, range) {
443
+ const info = jsonChainInfo(ref, doc);
444
+ if (!info.resolved || info.jsonRoot === void 0 || info.jsonRoot >= info.chain.length) return [];
445
+ if (info.leafType !== void 0 && JSON_LEAF_TYPES.has(info.leafType)) return [];
446
+ return [{
447
+ message: `Field "${fieldName}" reads "${info.typeName}.${info.chain.join(".")}" inside a JSON-stored field — it must end at a string, number or boolean leaf`,
448
+ severity: 1,
449
+ range
450
+ }];
451
+ }
452
+ /**
453
+ * Whole-view checks, run once per `@db.view.for` interface:
454
+ *
455
+ * - VW7 — a field reading from a left-joined table must be optional (the
456
+ * join yields NULL for unmatched rows); `@db.agg.count` / `countDistinct`
457
+ * fields are exempt (they count 0, never NULL).
458
+ * - VW8 — a chain ref through a `@db.json` or array node must end at a
459
+ * primitive string / number / boolean leaf.
460
+ * @since 0.1.136
461
+ */
462
+ function validateViewInterface(owner, doc) {
463
+ const errors = [];
464
+ const props = viewProps(owner);
465
+ if (!props) return errors;
466
+ const leftJoined = /* @__PURE__ */ new Set();
467
+ for (const join of viewJoins(owner)) {
468
+ const target = join.args[0]?.text;
469
+ if (target && join.args[2]?.text === "left") leftJoined.add(target);
470
+ }
471
+ for (const [fieldName, prop] of props) {
472
+ const def = prop.getDefinition();
473
+ if (!def || !isRef(def)) continue;
474
+ const ref = def;
475
+ const range = (prop.token("identifier") ?? ref.token("identifier"))?.range;
476
+ if (!range) continue;
477
+ if (ref.id && leftJoined.has(ref.id) && !prop.has("optional") && !NULL_SAFE_AGG_ANNOTATIONS.some((name) => prop.countAnnotations(name) > 0)) errors.push({
478
+ message: `Field "${fieldName}" reads from left-joined "${ref.id}" and must be optional (${fieldName}?: …)`,
479
+ severity: 1,
480
+ range
481
+ });
482
+ errors.push(...validateJsonChain(fieldName, ref, doc, range));
483
+ }
484
+ return errors;
485
+ }
486
+ //#endregion
247
487
  //#region src/plugin/annotations/column.ts
248
488
  const dbColumnAnnotations = {
249
489
  patch: { strategy: new AnnotationSpec({
@@ -331,6 +571,66 @@ const dbColumnAnnotations = {
331
571
  return validateFieldBaseType(token, doc, "@db.column.precision", ["number", "decimal"]);
332
572
  }
333
573
  }),
574
+ derived: new AnnotationSpec({
575
+ 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",
576
+ nodeType: ["prop"],
577
+ passedWhenReferred: false,
578
+ multiple: false,
579
+ validate(token, _args, doc) {
580
+ const errors = [];
581
+ const field = token.parentNode;
582
+ const fail = (message, severity = 1) => {
583
+ errors.push({
584
+ message,
585
+ severity,
586
+ range: token.range
587
+ });
588
+ };
589
+ const owner = getDbTableOwner(token);
590
+ if (!owner || !isInterface(owner) || owner.countAnnotations("db.table") === 0) {
591
+ fail("@db.column.derived is only valid on a top-level field of a @db.table interface");
592
+ return errors;
593
+ }
594
+ for (const [name, why] of DERIVED_INCOMPATIBLE) if (field.countAnnotations(name) > 0) fail(`@db.column.derived cannot coexist with @${name} — ${why}`);
595
+ const definition = field.getDefinition();
596
+ if (!definition || !isRef(definition) || !definition.hasChain) {
597
+ fail("@db.column.derived requires a chain reference into a @db.json field of the same table (e.g. `customerId: Order.payload.customer.id`)");
598
+ return errors;
599
+ }
600
+ const ref = definition;
601
+ const tableName = getParentTypeName(token);
602
+ if (ref.id !== tableName) {
603
+ fail(`@db.column.derived must reference the enclosing table '${tableName ?? ""}', not '${ref.id ?? ""}' — a derived column reads its own row`);
604
+ return errors;
605
+ }
606
+ const info = jsonChainInfo(ref, doc);
607
+ const path = `${info.typeName}.${info.chain.join(".")}`;
608
+ const notInsideJson = `@db.column.derived path '${path}' does not read inside a @db.json field — a flattened or scalar column needs no derived column`;
609
+ if (info.chain.length < 2) {
610
+ fail(notInsideJson);
611
+ return errors;
612
+ }
613
+ if (!info.resolved) return errors;
614
+ if (info.viaArray) {
615
+ fail(`@db.column.derived path '${path}' crosses an array — a derived column reads one scalar leaf`);
616
+ return errors;
617
+ }
618
+ if (info.jsonRoot === void 0 || info.jsonRoot >= info.chain.length) {
619
+ fail(notInsideJson);
620
+ return errors;
621
+ }
622
+ if (info.viaEncrypted) {
623
+ fail(`@db.column.derived path '${path}' reads inside a @db.encrypted field — ciphertext cannot be extracted`);
624
+ return errors;
625
+ }
626
+ if (info.leafType === void 0 || !JSON_LEAF_TYPES.has(info.leafType)) {
627
+ fail(`@db.column.derived path '${path}' must end at a string, number or boolean leaf` + (info.leafType ? ` (got '${info.leafType}')` : ""));
628
+ return errors;
629
+ }
630
+ if (info.optional && !field.has("optional")) fail(`@db.column.derived path '${path}' may be absent — declare the field optional (\`${field.id ?? ""}?:\`) so a missing leaf reads as null`, 2);
631
+ return errors;
632
+ }
633
+ }),
334
634
  dimension: new AnnotationSpec({
335
635
  description: "Marks a field as a dimension — groupable in aggregate queries ($groupBy). Dimension fields automatically receive a database index during schema sync.",
336
636
  nodeType: ["prop"],
@@ -865,7 +1165,8 @@ const dbRelAnnotations = { rel: {
865
1165
  argument: {
866
1166
  name: "junction",
867
1167
  type: "ref",
868
- description: "The junction table type (must have @db.table and @db.rel.FK fields pointing to both sides)."
1168
+ description: "The junction table type (must have @db.table and @db.rel.FK fields pointing to both sides).",
1169
+ refFilter: isDbTable
869
1170
  },
870
1171
  validate(token, args, doc) {
871
1172
  const errors = [];
@@ -882,7 +1183,10 @@ const dbRelAnnotations = { rel: {
882
1183
  });
883
1184
  if (!args[0]) return errors;
884
1185
  const junctionName = args[0].text;
885
- errors.push(...validateRefArgument(args[0], doc, { requireDbTable: true }));
1186
+ errors.push(...validateRefArgument(args[0], doc, {
1187
+ accept: isDbTable,
1188
+ expected: "must have @db.table annotation."
1189
+ }));
886
1190
  if (errors.length > 0) return errors;
887
1191
  const junctionUnwound = doc.unwindType(junctionName);
888
1192
  if (!junctionUnwound) return errors;
@@ -925,7 +1229,8 @@ const dbRelAnnotations = { rel: {
925
1229
  argument: {
926
1230
  name: "condition",
927
1231
  type: "query",
928
- description: "Filter expression restricting which related records are loaded."
1232
+ description: "Filter expression restricting which related records are loaded — references the related type (and the @db.rel.via junction); unqualified fields belong to the related type.",
1233
+ fieldScope: fieldScopes.relFilter
929
1234
  },
930
1235
  validate(token, args, doc) {
931
1236
  const errors = [];
@@ -942,14 +1247,8 @@ const dbRelAnnotations = { rel: {
942
1247
  return errors;
943
1248
  }
944
1249
  if (!args[0]?.queryNode) return errors;
945
- const targetTypeName = getNavTargetTypeName(field);
946
- if (!targetTypeName) return errors;
947
- const allowedTypes = [targetTypeName];
948
- if (hasVia) {
949
- const junctionType = getAnnotationAlias(field, "db.rel.via");
950
- if (junctionType) allowedTypes.push(junctionType);
951
- }
952
- errors.push(...validateQueryScope(args[0], allowedTypes, targetTypeName, doc));
1250
+ const scope = relFilterScope(field);
1251
+ if (scope) errors.push(...validateQueryScope(args[0], scope, doc));
953
1252
  return errors;
954
1253
  }
955
1254
  })
@@ -1038,6 +1337,7 @@ const dbTableAnnotations = {
1038
1337
  $self: new AnnotationSpec({
1039
1338
  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",
1040
1339
  nodeType: ["interface"],
1340
+ passedWhenReferred: false,
1041
1341
  argument: {
1042
1342
  optional: true,
1043
1343
  name: "name",
@@ -1058,6 +1358,7 @@ const dbTableAnnotations = {
1058
1358
  renamed: new AnnotationSpec({
1059
1359
  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",
1060
1360
  nodeType: ["interface"],
1361
+ passedWhenReferred: false,
1061
1362
  argument: {
1062
1363
  name: "oldName",
1063
1364
  type: "string",
@@ -1076,6 +1377,7 @@ const dbTableAnnotations = {
1076
1377
  preferredId: { uniqueIndex: new AnnotationSpec({
1077
1378
  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",
1078
1379
  nodeType: ["interface"],
1380
+ passedWhenReferred: false,
1079
1381
  multiple: false,
1080
1382
  argument: {
1081
1383
  optional: true,
@@ -1123,6 +1425,7 @@ const dbTableAnnotations = {
1123
1425
  schema: new AnnotationSpec({
1124
1426
  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",
1125
1427
  nodeType: ["interface"],
1428
+ passedWhenReferred: false,
1126
1429
  argument: {
1127
1430
  name: "name",
1128
1431
  type: "string",
@@ -1132,6 +1435,7 @@ const dbTableAnnotations = {
1132
1435
  space: new AnnotationSpec({
1133
1436
  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",
1134
1437
  nodeType: ["interface"],
1438
+ passedWhenReferred: false,
1135
1439
  argument: {
1136
1440
  name: "name",
1137
1441
  type: "string",
@@ -1141,6 +1445,7 @@ const dbTableAnnotations = {
1141
1445
  http: { path: new AnnotationSpec({
1142
1446
  description: "HTTP endpoint path where this table is served. Used by the UI for value-help on FK fields. Gets overwritten by the final controller prefix at runtime.\n\n**Example:**\n```atscript\n@db.table \"authors\"\n@db.http.path \"/authors\"\nexport interface Author { ... }\n```\n",
1143
1447
  nodeType: ["interface"],
1448
+ passedWhenReferred: false,
1144
1449
  argument: {
1145
1450
  name: "path",
1146
1451
  type: "string",
@@ -1150,6 +1455,7 @@ const dbTableAnnotations = {
1150
1455
  sync: { method: new AnnotationSpec({
1151
1456
  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",
1152
1457
  nodeType: ["interface"],
1458
+ passedWhenReferred: false,
1153
1459
  argument: {
1154
1460
  name: "method",
1155
1461
  type: "string",
@@ -1160,6 +1466,7 @@ const dbTableAnnotations = {
1160
1466
  depth: { limit: new AnnotationSpec({
1161
1467
  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",
1162
1468
  nodeType: ["interface"],
1469
+ passedWhenReferred: false,
1163
1470
  multiple: false,
1164
1471
  argument: {
1165
1472
  name: "depth",
@@ -1225,6 +1532,7 @@ function tableCapability(capability) {
1225
1532
  @db.table.${capability} "manual"\nexport interface User {
1226
1533
  ` + example + "}\n```\n",
1227
1534
  nodeType: ["interface"],
1535
+ passedWhenReferred: false,
1228
1536
  multiple: false,
1229
1537
  argument: {
1230
1538
  optional: true,
@@ -1293,91 +1601,12 @@ const dbUnitAnnotations = { unit: {
1293
1601
  })
1294
1602
  } };
1295
1603
  //#endregion
1296
- //#region src/shared/view-validation.ts
1297
- /** `@db.agg.*` annotations that are never NULL (the counts: 0 over no value). */
1298
- const NULL_SAFE_AGG_ANNOTATIONS = SUPPORTED_AGGREGATE_FNS.filter((fn) => !NULL_WHEN_EMPTY_AGGREGATE_FNS.has(fn)).map((fn) => `db.agg.${fn}`);
1299
- /** Primitive leaf types a JSON-stored path may end at. */
1300
- const JSON_LEAF_TYPES = new Set([
1301
- "string",
1302
- "number",
1303
- "boolean"
1304
- ]);
1305
- /** Props of a view interface (its own structure; views don't use `extends`). */
1306
- function viewProps(owner) {
1307
- if (isInterface(owner)) return owner.props;
1308
- const def = owner.getDefinition();
1309
- return def && isStructure(def) ? def.props : void 0;
1310
- }
1311
- /**
1312
- * VW8: a chain ref that passes through a `@db.json` or array node reads a
1313
- * JSON leaf, which views can only extract as a string / number / boolean.
1314
- * Intermediate nodes that `unwindType` can't reach are skipped (sync-time
1315
- * resolution reports them).
1316
- */
1317
- function validateJsonChain(fieldName, ref, doc, range) {
1318
- const typeName = ref.id;
1319
- const chain = ref.chain.map((t) => t.text);
1320
- if (!typeName || chain.length < 2) return [];
1321
- let throughJson = false;
1322
- for (let i = 1; i < chain.length; i++) {
1323
- const step = doc.unwindType(typeName, chain.slice(0, i));
1324
- if (!step) return [];
1325
- const stepNode = step.node;
1326
- if (stepNode && isProp(stepNode) && stepNode.countAnnotations("db.json") > 0 || isArray(step.def)) {
1327
- throughJson = true;
1328
- break;
1329
- }
1330
- }
1331
- if (!throughJson) return [];
1332
- const leaf = doc.unwindType(typeName, chain)?.def;
1333
- const leafType = primitiveBaseType(leaf);
1334
- if (leafType !== void 0 && JSON_LEAF_TYPES.has(leafType)) return [];
1335
- return [{
1336
- message: `Field "${fieldName}" reads "${typeName}.${chain.join(".")}" inside a JSON-stored field — it must end at a string, number or boolean leaf`,
1337
- severity: 1,
1338
- range
1339
- }];
1340
- }
1341
- /**
1342
- * Whole-view checks, run once per `@db.view.for` interface:
1343
- *
1344
- * - VW7 — a field reading from a left-joined table must be optional (the
1345
- * join yields NULL for unmatched rows); `@db.agg.count` / `countDistinct`
1346
- * fields are exempt (they count 0, never NULL).
1347
- * - VW8 — a chain ref through a `@db.json` or array node must end at a
1348
- * primitive string / number / boolean leaf.
1349
- * @since 0.1.136
1350
- */
1351
- function validateViewInterface(owner, doc) {
1352
- const errors = [];
1353
- const props = viewProps(owner);
1354
- if (!props) return errors;
1355
- const leftJoined = /* @__PURE__ */ new Set();
1356
- for (const join of viewJoins(owner)) {
1357
- const target = join.args[0]?.text;
1358
- if (target && join.args[2]?.text === "left") leftJoined.add(target);
1359
- }
1360
- for (const [fieldName, prop] of props) {
1361
- const def = prop.getDefinition();
1362
- if (!def || !isRef(def)) continue;
1363
- const ref = def;
1364
- const range = (prop.token("identifier") ?? ref.token("identifier"))?.range;
1365
- if (!range) continue;
1366
- if (ref.id && leftJoined.has(ref.id) && !prop.has("optional") && !NULL_SAFE_AGG_ANNOTATIONS.some((name) => prop.countAnnotations(name) > 0)) errors.push({
1367
- message: `Field "${fieldName}" reads from left-joined "${ref.id}" and must be optional (${fieldName}?: …)`,
1368
- severity: 1,
1369
- range
1370
- });
1371
- errors.push(...validateJsonChain(fieldName, ref, doc, range));
1372
- }
1373
- return errors;
1374
- }
1375
- //#endregion
1376
1604
  //#region src/plugin/annotations/view.ts
1377
1605
  const dbViewAnnotations = { view: {
1378
1606
  $self: new AnnotationSpec({
1379
1607
  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",
1380
1608
  nodeType: ["interface"],
1609
+ passedWhenReferred: false,
1381
1610
  argument: {
1382
1611
  optional: true,
1383
1612
  name: "name",
@@ -1395,12 +1624,14 @@ const dbViewAnnotations = { view: {
1395
1624
  }
1396
1625
  }),
1397
1626
  for: new AnnotationSpec({
1398
- description: "Specifies the entry/primary table for a computed view. Required for views that map fields via chain refs.\n\n**Example:**\n```atscript\n@db.view.for Order\n@db.view.filter `Order.status = 'active'`\nexport interface ActiveOrderDetails { ... }\n```\n",
1627
+ 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",
1399
1628
  nodeType: ["interface"],
1629
+ passedWhenReferred: false,
1400
1630
  argument: {
1401
1631
  name: "entry",
1402
1632
  type: "ref",
1403
- description: "The primary/entry table type (must have @db.table)."
1633
+ description: "The primary/entry table or view type (must have @db.table or @db.view).",
1634
+ refFilter: isDbSourceDecl
1404
1635
  },
1405
1636
  validate(token, args, doc) {
1406
1637
  const errors = [];
@@ -1410,26 +1641,35 @@ const dbViewAnnotations = { view: {
1410
1641
  severity: 1,
1411
1642
  range: token.range
1412
1643
  });
1413
- if (args[0]) errors.push(...validateRefArgument(args[0], doc, { requireDbTable: true }));
1644
+ if (args[0]) errors.push(...validateRefArgument(args[0], doc, VIEW_SOURCE_ARGUMENT));
1645
+ const cycle = findViewCycle(owner, doc);
1646
+ if (cycle) errors.push({
1647
+ message: `View '${cycle[0]}' depends on itself: ${cycle.join(" → ")}`,
1648
+ severity: 1,
1649
+ range: args[0]?.range ?? token.range
1650
+ });
1414
1651
  errors.push(...validateViewInterface(owner, doc));
1415
1652
  return errors;
1416
1653
  }
1417
1654
  }),
1418
1655
  joins: new AnnotationSpec({
1419
- 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); a table can be joined once (no aliases / self-joins).\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",
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",
1420
1657
  nodeType: ["interface"],
1658
+ passedWhenReferred: false,
1421
1659
  multiple: true,
1422
1660
  mergeStrategy: "append",
1423
1661
  argument: [
1424
1662
  {
1425
1663
  name: "target",
1426
1664
  type: "ref",
1427
- description: "The table type to join (must have @db.table)."
1665
+ description: "The type to join: a @db.table, a @db.view, or a @db.alias of one (for a second join of the same table / a self-join).",
1666
+ refFilter: isJoinTarget
1428
1667
  },
1429
1668
  {
1430
1669
  name: "condition",
1431
1670
  type: "query",
1432
- description: "Join condition expression."
1671
+ description: "Join condition expression — may reference the join target, the entry table and the joins declared before this one.",
1672
+ fieldScope: fieldScopes.viewJoin
1433
1673
  },
1434
1674
  {
1435
1675
  name: "kind",
@@ -1450,7 +1690,10 @@ const dbViewAnnotations = { view: {
1450
1690
  });
1451
1691
  return errors;
1452
1692
  }
1453
- if (args[0]) errors.push(...validateRefArgument(args[0], doc, { requireDbTable: true }));
1693
+ if (args[0]) errors.push(...validateRefArgument(args[0], doc, {
1694
+ accept: isJoinTarget,
1695
+ expected: "must be a @db.table or a @db.view."
1696
+ }));
1454
1697
  const entryTypeName = getAnnotationAlias(owner, "db.view.for");
1455
1698
  if (!entryTypeName) {
1456
1699
  errors.push({
@@ -1460,40 +1703,40 @@ const dbViewAnnotations = { view: {
1460
1703
  });
1461
1704
  return errors;
1462
1705
  }
1463
- const allJoins = viewJoins(owner);
1464
- const position = allJoins.findIndex((a) => a.token === token);
1465
- const earlier = joinTargets(position === -1 ? [] : allJoins.slice(0, position));
1706
+ const joins = viewJoins(owner);
1707
+ const join = joins.find((a) => a.token === token);
1708
+ const earlier = join ? earlierJoinTargets(joins, join) : [];
1466
1709
  if (args[0]) {
1467
1710
  const target = args[0].text;
1711
+ const aliasHint = (name) => {
1712
+ const decl = doc.getDeclarationOwnerNode(target)?.node;
1713
+ return decl && isAliasDecl(decl) ? "" : ` — declare a @db.alias type (\`@db.alias ${name}\` + \`export type Other = ${name}\`) to join it under another name`;
1714
+ };
1468
1715
  if (target === entryTypeName) errors.push({
1469
- message: `@db.view.joins cannot join the entry table '${target}' — no join aliases / self-joins yet`,
1716
+ message: `@db.view.joins cannot join the entry table '${target}' directly${aliasHint(target)}`,
1470
1717
  severity: 1,
1471
1718
  range: args[0].range
1472
1719
  });
1473
1720
  else if (earlier.includes(target)) errors.push({
1474
- message: `'${target}' is joined more than once — no join aliases / self-joins yet`,
1721
+ message: `'${target}' is joined more than once${aliasHint(target)}`,
1475
1722
  severity: 1,
1476
1723
  range: args[0].range
1477
1724
  });
1478
1725
  }
1479
- if (args[1]?.queryNode && args[0]) {
1480
- const joinTargetName = args[0].text;
1481
- errors.push(...validateQueryScope(args[1], [
1482
- joinTargetName,
1483
- entryTypeName,
1484
- ...earlier
1485
- ], entryTypeName, doc, "a join may reference the entry table and joins declared before it"));
1486
- }
1726
+ const scope = join && viewJoinScope(owner, join, earlier);
1727
+ if (args[1]?.queryNode && scope) errors.push(...validateQueryScope(args[1], scope, doc, "a join may reference the entry table and joins declared before it"));
1487
1728
  return errors;
1488
1729
  }
1489
1730
  }),
1490
1731
  filter: new AnnotationSpec({
1491
1732
  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",
1492
1733
  nodeType: ["interface"],
1734
+ passedWhenReferred: false,
1493
1735
  argument: {
1494
1736
  name: "condition",
1495
1737
  type: "query",
1496
- description: "Filter expression for the view WHERE clause."
1738
+ description: "Filter expression for the view WHERE clause — may reference the entry table and every join; unqualified fields belong to the entry.",
1739
+ fieldScope: fieldScopes.viewFilter
1497
1740
  },
1498
1741
  validate(token, args, doc) {
1499
1742
  const errors = [];
@@ -1507,8 +1750,8 @@ const dbViewAnnotations = { view: {
1507
1750
  return errors;
1508
1751
  }
1509
1752
  if (!args[0]?.queryNode) return errors;
1510
- const entryTypeName = getAnnotationAlias(owner, "db.view.for");
1511
- if (!entryTypeName) {
1753
+ const scope = viewFilterScope(owner);
1754
+ if (!scope) {
1512
1755
  errors.push({
1513
1756
  message: "@db.view.filter requires @db.view.for to identify the entry table",
1514
1757
  severity: 1,
@@ -1516,13 +1759,14 @@ const dbViewAnnotations = { view: {
1516
1759
  });
1517
1760
  return errors;
1518
1761
  }
1519
- errors.push(...validateQueryScope(args[0], viewScopeTypes(owner), entryTypeName, doc));
1762
+ errors.push(...validateQueryScope(args[0], scope, doc));
1520
1763
  return errors;
1521
1764
  }
1522
1765
  }),
1523
1766
  materialized: new AnnotationSpec({
1524
1767
  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",
1525
1768
  nodeType: ["interface"],
1769
+ passedWhenReferred: false,
1526
1770
  validate(token, _args, _doc) {
1527
1771
  const errors = [];
1528
1772
  const owner = token.parentNode;
@@ -1537,6 +1781,7 @@ const dbViewAnnotations = { view: {
1537
1781
  renamed: new AnnotationSpec({
1538
1782
  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",
1539
1783
  nodeType: ["interface"],
1784
+ passedWhenReferred: false,
1540
1785
  argument: {
1541
1786
  name: "oldName",
1542
1787
  type: "string",
@@ -1554,21 +1799,28 @@ const dbViewAnnotations = { view: {
1554
1799
  }
1555
1800
  }),
1556
1801
  having: new AnnotationSpec({
1557
- description: "Post-aggregation filter (HAVING clause) for analytical views. References view field aliases with applied aggregate functions.\n\n**Example:**\n```atscript\n@db.view\n@db.view.for Order\n@db.view.having `totalRevenue > 100`\nexport interface TopCategories { ... }\n```\n",
1802
+ 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",
1558
1803
  nodeType: ["interface"],
1804
+ passedWhenReferred: false,
1559
1805
  argument: {
1560
1806
  name: "condition",
1561
1807
  type: "query",
1562
- description: "HAVING condition referencing view aliases."
1808
+ description: "HAVING condition — the view's own fields, unqualified.",
1809
+ fieldScope: fieldScopes.viewHaving
1563
1810
  },
1564
- validate(token, _args, _doc) {
1811
+ validate(token, args, doc) {
1565
1812
  const errors = [];
1566
1813
  const owner = token.parentNode;
1567
- if (!hasAnyViewAnnotation(owner)) errors.push({
1568
- message: "@db.view.having is only valid on @db.view interfaces",
1569
- severity: 1,
1570
- range: token.range
1571
- });
1814
+ if (!hasAnyViewAnnotation(owner)) {
1815
+ errors.push({
1816
+ message: "@db.view.having is only valid on @db.view interfaces",
1817
+ severity: 1,
1818
+ range: token.range
1819
+ });
1820
+ return errors;
1821
+ }
1822
+ const scope = viewHavingScope(owner);
1823
+ if (args[0]?.queryNode && scope) errors.push(...validateQueryScope(args[0], scope, doc, "@db.view.having references the view's own fields unqualified"));
1572
1824
  return errors;
1573
1825
  }
1574
1826
  })
@@ -1600,6 +1852,7 @@ const dbPlugin = (options) => ({
1600
1852
  depth: dbTableAnnotations.depth,
1601
1853
  rel: dbRelAnnotations.rel,
1602
1854
  view: dbViewAnnotations.view,
1855
+ alias: dbAliasAnnotations.alias,
1603
1856
  agg: dbAggAnnotations.agg,
1604
1857
  search: dbSearchAnnotations.search,
1605
1858
  amount: dbAmountAnnotations.amount,