@atscript/db 0.1.135 → 0.1.137

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-DKuf_v2L.d.cts → agg-CV7y8nC6.d.cts} +13 -3
  2. package/dist/{agg-BtlGeRfj.d.mts → agg-D5DHsAby.d.mts} +13 -3
  3. package/dist/agg.cjs +35 -3
  4. package/dist/agg.d.cts +2 -2
  5. package/dist/agg.d.mts +2 -2
  6. package/dist/agg.mjs +34 -1
  7. package/dist/aggregate-fns-CGBv3E8S.cjs +87 -0
  8. package/dist/aggregate-fns-CfsveE1w.mjs +58 -0
  9. package/dist/{buckets-GruoVxH5.d.mts → buckets-BFG2RYRW.d.mts} +662 -31
  10. package/dist/{buckets-D6PBXKRJ.d.cts → buckets-C-27xmtq.d.cts} +662 -31
  11. package/dist/column-diff-BmqvgBWw.d.cts +24 -0
  12. package/dist/{db-view-Doh8vPg3.mjs → column-diff-BwOA5101.mjs} +795 -125
  13. package/dist/{db-view-Dm7MpUGB.cjs → column-diff-CgxgFKzx.cjs} +873 -125
  14. package/dist/column-diff-DPkbZIVE.d.mts +24 -0
  15. package/dist/index.cjs +79 -55
  16. package/dist/index.d.cts +12 -10
  17. package/dist/index.d.mts +12 -10
  18. package/dist/index.mjs +30 -11
  19. package/dist/{nested-writer-wk1EFUNY.cjs → nested-writer-BZNCuqI6.cjs} +16 -2
  20. package/dist/{nested-writer-CqL24ojl.mjs → nested-writer-FWD5oOYh.mjs} +11 -3
  21. package/dist/plugin.cjs +217 -77
  22. package/dist/plugin.mjs +217 -78
  23. package/dist/rel.cjs +2 -2
  24. package/dist/rel.d.cts +2 -20
  25. package/dist/rel.d.mts +2 -20
  26. package/dist/rel.mjs +2 -2
  27. package/dist/relation-helpers-D3Zu0Mta.d.mts +30 -0
  28. package/dist/relation-helpers-DxrvS6ar.d.cts +30 -0
  29. package/dist/{relation-loader-BD4xANQJ.cjs → relation-loader-6ZB_5KFq.cjs} +1 -1
  30. package/dist/{relation-loader-B68R1LET.mjs → relation-loader-CTFaZpVa.mjs} +1 -1
  31. package/dist/shared.cjs +5 -1
  32. package/dist/shared.d.cts +16 -3
  33. package/dist/shared.d.mts +16 -3
  34. package/dist/shared.mjs +2 -2
  35. package/dist/sync.cjs +38 -321
  36. package/dist/sync.d.cts +29 -17
  37. package/dist/sync.d.mts +29 -17
  38. package/dist/sync.mjs +6 -289
  39. package/dist/{validation-utils-MWOP1Ts4.mjs → validation-utils-B4h-GW4d.mjs} +29 -7
  40. package/dist/{validation-utils-B7SXPkm7.cjs → validation-utils-Dg0hW6dn.cjs} +52 -6
  41. package/dist/{validator-CewfnGZj.d.mts → validator-Drb2N-YL.d.cts} +1 -1
  42. package/dist/{validator-CewfnGZj.d.cts → validator-Drb2N-YL.d.mts} +1 -1
  43. package/dist/validator.d.cts +1 -1
  44. package/dist/validator.d.mts +1 -1
  45. package/package.json +1 -1
  46. package/dist/agg-CvXDGnKi.mjs +0 -61
  47. package/dist/agg-EcIbAFnJ.cjs +0 -78
  48. package/dist/db-space-9CH5neN7.d.mts +0 -439
  49. package/dist/db-space-BdtBNTeH.d.cts +0 -439
package/dist/plugin.cjs CHANGED
@@ -32,8 +32,10 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
32
32
  value: mod,
33
33
  enumerable: true
34
34
  }) : target, mod));
35
+ //#endregion
36
+ const require_aggregate_fns = require("./aggregate-fns-CGBv3E8S.cjs");
35
37
  require("./consts-BzRfCcH2.cjs");
36
- const require_validation_utils = require("./validation-utils-B7SXPkm7.cjs");
38
+ const require_validation_utils = require("./validation-utils-Dg0hW6dn.cjs");
37
39
  let node_path = require("node:path");
38
40
  node_path = __toESM(node_path, 1);
39
41
  let node_url = require("node:url");
@@ -130,67 +132,98 @@ async function generateModelManifest(options, output, format, repo) {
130
132
  }
131
133
  //#endregion
132
134
  //#region src/plugin/annotations/agg.ts
133
- const dbAggAnnotations = { agg: {
134
- sum: new _atscript_core.AnnotationSpec({
135
- description: "Declares a view field as SUM of a source column.",
136
- nodeType: ["prop"],
137
- passedWhenReferred: false,
138
- argument: {
139
- name: "field",
140
- type: "string",
141
- description: "Source column name to sum."
142
- },
143
- validate(token, _args, doc) {
144
- return require_validation_utils.validateFieldBaseType(token, doc, "@db.agg.sum", ["number", "decimal"]);
145
- }
146
- }),
147
- avg: new _atscript_core.AnnotationSpec({
148
- description: "Declares a view field as AVG of a source column.",
149
- nodeType: ["prop"],
150
- passedWhenReferred: false,
151
- argument: {
152
- name: "field",
153
- type: "string",
154
- description: "Source column name to average."
155
- },
156
- validate(token, _args, doc) {
157
- return require_validation_utils.validateFieldBaseType(token, doc, "@db.agg.avg", ["number", "decimal"]);
158
- }
159
- }),
160
- count: new _atscript_core.AnnotationSpec({
161
- description: "Declares a view field as COUNT. Without argument: COUNT(*). With field name argument: COUNT(field) (non-null count).",
162
- nodeType: ["prop"],
163
- passedWhenReferred: false,
164
- argument: {
165
- name: "field",
166
- type: "string",
167
- optional: true,
168
- description: "Source column name to count non-null values. Omit for COUNT(*)."
169
- },
170
- validate(token, _args, doc) {
171
- return require_validation_utils.validateFieldBaseType(token, doc, "@db.agg.count", ["number"]);
172
- }
173
- }),
174
- min: new _atscript_core.AnnotationSpec({
175
- description: "Declares a view field as MIN of a source column.",
176
- nodeType: ["prop"],
177
- passedWhenReferred: false,
178
- argument: {
179
- name: "field",
180
- type: "string",
181
- description: "Source column name."
182
- }
183
- }),
184
- max: new _atscript_core.AnnotationSpec({
185
- description: "Declares a view field as MAX of a source column.",
135
+ /**
136
+ * A conditional aggregate that is NULL when no row matches (so its field
137
+ * must be optional): every NULL-when-empty function but `sum`, whose
138
+ * conditional form is `COALESCE(…, 0)`.
139
+ */
140
+ function conditionalIsNullable(name) {
141
+ return name !== "sum" && require_aggregate_fns.NULL_WHEN_EMPTY_AGGREGATE_FNS.has(name);
142
+ }
143
+ /** The optional 2nd argument every `@db.agg.*` takes. */
144
+ const CONDITION_ARG = {
145
+ name: "condition",
146
+ type: "query",
147
+ optional: true,
148
+ 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."
149
+ };
150
+ /**
151
+ * The rules every `@db.agg.*` shares: `'*'` is `count`'s only, and a
152
+ * condition (2nd argument) must stay within the view's tables (entry + every
153
+ * join, like `@db.view.filter`) and — for the aggregates that are NULL when
154
+ * no row matches (avg / min / max) — sit on an optional field.
155
+ */
156
+ function validateAggArgs(name, token, args, doc) {
157
+ const errors = [];
158
+ const annotation = `@db.agg.${name}`;
159
+ if (args[0]?.text === "*" && name !== "count") errors.push({
160
+ message: `${annotation} needs a field — only @db.agg.count accepts '*'`,
161
+ severity: 1,
162
+ range: args[0].range
163
+ });
164
+ const condition = args[1];
165
+ if (!condition?.queryNode) return errors;
166
+ const owner = require_validation_utils.getDbTableOwner(token);
167
+ const entryTypeName = owner ? require_validation_utils.getAnnotationAlias(owner, "db.view.for") : void 0;
168
+ if (!owner || !entryTypeName) {
169
+ errors.push({
170
+ message: `A conditional ${annotation} requires @db.view.for on the view`,
171
+ severity: 1,
172
+ range: condition.range
173
+ });
174
+ return errors;
175
+ }
176
+ errors.push(...require_validation_utils.validateQueryScope(condition, require_validation_utils.viewScopeTypes(owner), entryTypeName, doc));
177
+ const prop = token.parentNode;
178
+ if (conditionalIsNullable(name) && prop && !prop.has("optional")) {
179
+ const field = prop.id ?? "field";
180
+ errors.push({
181
+ message: `Field "${field}" has a conditional ${annotation} and must be optional (${field}?: …) — it is NULL when no row matches`,
182
+ severity: 1,
183
+ range: token.range
184
+ });
185
+ }
186
+ return errors;
187
+ }
188
+ const CONDITIONAL = " An optional 2nd argument (a query) makes it conditional: only rows where it holds are aggregated.";
189
+ /** What a conditional `name` yields when no row matches (from `NULL_WHEN_EMPTY_AGGREGATE_FNS`). */
190
+ function conditionalNote(name) {
191
+ if (!require_aggregate_fns.NULL_WHEN_EMPTY_AGGREGATE_FNS.has(name)) return "";
192
+ const fn = name.toUpperCase();
193
+ return conditionalIsNullable(name) ? ` A conditional ${fn} is NULL when no row matches, so its field must be optional.` : ` A conditional ${fn} is 0 (not NULL) when no row matches.`;
194
+ }
195
+ /**
196
+ * One `@db.agg.<name>` spec: `[field, condition?]` (field optional for count
197
+ * only). The description gets the conditional-form sentences appended.
198
+ */
199
+ function aggSpec(name, description, field, types, extra = "") {
200
+ return new _atscript_core.AnnotationSpec({
201
+ description: description + CONDITIONAL + conditionalNote(name) + extra,
186
202
  nodeType: ["prop"],
187
203
  passedWhenReferred: false,
188
- argument: {
204
+ argument: [{
189
205
  name: "field",
190
206
  type: "string",
191
- description: "Source column name."
207
+ optional: field.optional,
208
+ description: field.description
209
+ }, CONDITION_ARG],
210
+ validate(token, args, doc) {
211
+ const errors = validateAggArgs(name, token, args, doc);
212
+ if (types) errors.push(...require_validation_utils.validateFieldBaseType(token, doc, `@db.agg.${name}`, types));
213
+ return errors;
192
214
  }
193
- })
215
+ });
216
+ }
217
+ const dbAggAnnotations = { agg: {
218
+ sum: aggSpec("sum", "Declares a view field as SUM of a source column.", { description: "Source column name to sum." }, ["number", "decimal"]),
219
+ avg: aggSpec("avg", "Declares a view field as AVG of a source column.", { description: "Source column name to average." }, ["number", "decimal"]),
220
+ count: aggSpec("count", "Declares a view field as COUNT. Without argument (or with `'*'`): COUNT(*). With field name argument: COUNT(field) (non-null count).", {
221
+ optional: true,
222
+ description: "Source column name to count non-null values. Omit (or `'*'`) for COUNT(*)."
223
+ }, ["number"], " A conditional COUNT(*) is spelled `@db.agg.count '*', <query>`."),
224
+ countDistinct: aggSpec("countDistinct", "Declares a view field as COUNT(DISTINCT field): the number of distinct non-null values of a source column.", { description: "Source column name whose distinct non-null values are counted." }, ["number"]),
225
+ min: aggSpec("min", "Declares a view field as MIN of a source column.", { description: "Source column name." }),
226
+ max: aggSpec("max", "Declares a view field as MAX of a source column.", { description: "Source column name." })
194
227
  } };
195
228
  //#endregion
196
229
  //#region src/plugin/annotations/amount.ts
@@ -280,7 +313,7 @@ const dbColumnAnnotations = {
280
313
  }) },
281
314
  column: {
282
315
  $self: new _atscript_core.AnnotationSpec({
283
- description: "Overrides the physical column name in the database. For nested (flattened) fields, the parent prefix is still prepended automatically.\n\n**Example:**\n```atscript\n@db.column \"first_name\"\nfirstName: string\n// → physical column: first_name\n\n// Nested:\naddress: {\n @db.column \"zip_code\"\n zip: string\n}\n// → physical column: address__zip_code\n```\n",
316
+ description: "Overrides the physical column name in the database. For nested (flattened) fields, the parent prefix is still prepended automatically. Document storage (MongoDB) renames top-level fields only — a nested field keeps its name there.\n\n**Example:**\n```atscript\n@db.column \"first_name\"\nfirstName: string\n// → physical column: first_name\n\n// Nested:\naddress: {\n @db.column \"zip_code\"\n zip: string\n}\n// → physical column: address__zip_code\n```\n",
284
317
  nodeType: ["prop"],
285
318
  passedWhenReferred: false,
286
319
  argument: {
@@ -290,7 +323,7 @@ const dbColumnAnnotations = {
290
323
  }
291
324
  }),
292
325
  renamed: new _atscript_core.AnnotationSpec({
293
- description: "Specifies the previous local field name for column rename migration. The sync engine generates ALTER TABLE RENAME COLUMN instead of drop+add.\n\n**Example:**\n```atscript\n@db.column.renamed \"zip\"\npostalCode: string\n// Renames address__zip → address__postalCode\n```\n",
326
+ description: "Specifies the previous local field name for column rename migration. The sync engine generates ALTER TABLE RENAME COLUMN instead of drop+add (on document storage, top-level fields only).\n\n**Example:**\n```atscript\n@db.column.renamed \"zip\"\npostalCode: string\n// Renames address__zip → address__postalCode\n```\n",
294
327
  nodeType: ["prop"],
295
328
  passedWhenReferred: false,
296
329
  argument: {
@@ -1296,6 +1329,86 @@ const dbUnitAnnotations = { unit: {
1296
1329
  })
1297
1330
  } };
1298
1331
  //#endregion
1332
+ //#region src/shared/view-validation.ts
1333
+ /** `@db.agg.*` annotations that are never NULL (the counts: 0 over no value). */
1334
+ const NULL_SAFE_AGG_ANNOTATIONS = require_aggregate_fns.SUPPORTED_AGGREGATE_FNS.filter((fn) => !require_aggregate_fns.NULL_WHEN_EMPTY_AGGREGATE_FNS.has(fn)).map((fn) => `db.agg.${fn}`);
1335
+ /** Primitive leaf types a JSON-stored path may end at. */
1336
+ const JSON_LEAF_TYPES = new Set([
1337
+ "string",
1338
+ "number",
1339
+ "boolean"
1340
+ ]);
1341
+ /** Props of a view interface (its own structure; views don't use `extends`). */
1342
+ function viewProps(owner) {
1343
+ if ((0, _atscript_core.isInterface)(owner)) return owner.props;
1344
+ const def = owner.getDefinition();
1345
+ return def && (0, _atscript_core.isStructure)(def) ? def.props : void 0;
1346
+ }
1347
+ /**
1348
+ * VW8: a chain ref that passes through a `@db.json` or array node reads a
1349
+ * JSON leaf, which views can only extract as a string / number / boolean.
1350
+ * Intermediate nodes that `unwindType` can't reach are skipped (sync-time
1351
+ * resolution reports them).
1352
+ */
1353
+ function validateJsonChain(fieldName, ref, doc, range) {
1354
+ const typeName = ref.id;
1355
+ const chain = ref.chain.map((t) => t.text);
1356
+ if (!typeName || chain.length < 2) return [];
1357
+ let throughJson = false;
1358
+ for (let i = 1; i < chain.length; i++) {
1359
+ const step = doc.unwindType(typeName, chain.slice(0, i));
1360
+ if (!step) return [];
1361
+ const stepNode = step.node;
1362
+ if (stepNode && (0, _atscript_core.isProp)(stepNode) && stepNode.countAnnotations("db.json") > 0 || (0, _atscript_core.isArray)(step.def)) {
1363
+ throughJson = true;
1364
+ break;
1365
+ }
1366
+ }
1367
+ if (!throughJson) return [];
1368
+ const leaf = doc.unwindType(typeName, chain)?.def;
1369
+ const leafType = require_validation_utils.primitiveBaseType(leaf);
1370
+ if (leafType !== void 0 && JSON_LEAF_TYPES.has(leafType)) return [];
1371
+ return [{
1372
+ message: `Field "${fieldName}" reads "${typeName}.${chain.join(".")}" inside a JSON-stored field — it must end at a string, number or boolean leaf`,
1373
+ severity: 1,
1374
+ range
1375
+ }];
1376
+ }
1377
+ /**
1378
+ * Whole-view checks, run once per `@db.view.for` interface:
1379
+ *
1380
+ * - VW7 — a field reading from a left-joined table must be optional (the
1381
+ * join yields NULL for unmatched rows); `@db.agg.count` / `countDistinct`
1382
+ * fields are exempt (they count 0, never NULL).
1383
+ * - VW8 — a chain ref through a `@db.json` or array node must end at a
1384
+ * primitive string / number / boolean leaf.
1385
+ * @since 0.1.136
1386
+ */
1387
+ function validateViewInterface(owner, doc) {
1388
+ const errors = [];
1389
+ const props = viewProps(owner);
1390
+ if (!props) return errors;
1391
+ const leftJoined = /* @__PURE__ */ new Set();
1392
+ for (const join of require_validation_utils.viewJoins(owner)) {
1393
+ const target = join.args[0]?.text;
1394
+ if (target && join.args[2]?.text === "left") leftJoined.add(target);
1395
+ }
1396
+ for (const [fieldName, prop] of props) {
1397
+ const def = prop.getDefinition();
1398
+ if (!def || !(0, _atscript_core.isRef)(def)) continue;
1399
+ const ref = def;
1400
+ const range = (prop.token("identifier") ?? ref.token("identifier"))?.range;
1401
+ if (!range) continue;
1402
+ if (ref.id && leftJoined.has(ref.id) && !prop.has("optional") && !NULL_SAFE_AGG_ANNOTATIONS.some((name) => prop.countAnnotations(name) > 0)) errors.push({
1403
+ message: `Field "${fieldName}" reads from left-joined "${ref.id}" and must be optional (${fieldName}?: …)`,
1404
+ severity: 1,
1405
+ range
1406
+ });
1407
+ errors.push(...validateJsonChain(fieldName, ref, doc, range));
1408
+ }
1409
+ return errors;
1410
+ }
1411
+ //#endregion
1299
1412
  //#region src/plugin/annotations/view.ts
1300
1413
  const dbViewAnnotations = { view: {
1301
1414
  $self: new _atscript_core.AnnotationSpec({
@@ -1327,29 +1440,41 @@ const dbViewAnnotations = { view: {
1327
1440
  },
1328
1441
  validate(token, args, doc) {
1329
1442
  const errors = [];
1330
- if (token.parentNode.countAnnotations("db.table") > 0) errors.push({
1443
+ const owner = token.parentNode;
1444
+ if (owner.countAnnotations("db.table") > 0) errors.push({
1331
1445
  message: "An interface cannot be both a @db.table and a @db.view",
1332
1446
  severity: 1,
1333
1447
  range: token.range
1334
1448
  });
1335
1449
  if (args[0]) errors.push(...require_validation_utils.validateRefArgument(args[0], doc, { requireDbTable: true }));
1450
+ errors.push(...validateViewInterface(owner, doc));
1336
1451
  return errors;
1337
1452
  }
1338
1453
  }),
1339
1454
  joins: new _atscript_core.AnnotationSpec({
1340
- description: "Declares an explicit join for a view. Use when no `@db.rel.*` path exists between the entry table and the target.\n\n**Example:**\n```atscript\n@db.view.for Order\n@db.view.joins Warehouse, `Warehouse.regionId = Order.regionId`\nexport interface OrderWarehouse { ... }\n```\n",
1455
+ 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",
1341
1456
  nodeType: ["interface"],
1342
1457
  multiple: true,
1343
1458
  mergeStrategy: "append",
1344
- argument: [{
1345
- name: "target",
1346
- type: "ref",
1347
- description: "The table type to join (must have @db.table)."
1348
- }, {
1349
- name: "condition",
1350
- type: "query",
1351
- description: "Join condition expression."
1352
- }],
1459
+ argument: [
1460
+ {
1461
+ name: "target",
1462
+ type: "ref",
1463
+ description: "The table type to join (must have @db.table)."
1464
+ },
1465
+ {
1466
+ name: "condition",
1467
+ type: "query",
1468
+ description: "Join condition expression."
1469
+ },
1470
+ {
1471
+ name: "kind",
1472
+ type: "string",
1473
+ optional: true,
1474
+ description: "`\"inner\"` (default) drops entry rows without a match; `\"left\"` keeps them with NULLs.",
1475
+ values: ["inner", "left"]
1476
+ }
1477
+ ],
1353
1478
  validate(token, args, doc) {
1354
1479
  const errors = [];
1355
1480
  const owner = token.parentNode;
@@ -1371,9 +1496,29 @@ const dbViewAnnotations = { view: {
1371
1496
  });
1372
1497
  return errors;
1373
1498
  }
1499
+ const allJoins = require_validation_utils.viewJoins(owner);
1500
+ const position = allJoins.findIndex((a) => a.token === token);
1501
+ const earlier = require_validation_utils.joinTargets(position === -1 ? [] : allJoins.slice(0, position));
1502
+ if (args[0]) {
1503
+ const target = args[0].text;
1504
+ if (target === entryTypeName) errors.push({
1505
+ message: `@db.view.joins cannot join the entry table '${target}' — no join aliases / self-joins yet`,
1506
+ severity: 1,
1507
+ range: args[0].range
1508
+ });
1509
+ else if (earlier.includes(target)) errors.push({
1510
+ message: `'${target}' is joined more than once — no join aliases / self-joins yet`,
1511
+ severity: 1,
1512
+ range: args[0].range
1513
+ });
1514
+ }
1374
1515
  if (args[1]?.queryNode && args[0]) {
1375
1516
  const joinTargetName = args[0].text;
1376
- errors.push(...require_validation_utils.validateQueryScope(args[1], [joinTargetName, entryTypeName], entryTypeName, doc));
1517
+ errors.push(...require_validation_utils.validateQueryScope(args[1], [
1518
+ joinTargetName,
1519
+ entryTypeName,
1520
+ ...earlier
1521
+ ], entryTypeName, doc, "a join may reference the entry table and joins declared before it"));
1377
1522
  }
1378
1523
  return errors;
1379
1524
  }
@@ -1407,12 +1552,7 @@ const dbViewAnnotations = { view: {
1407
1552
  });
1408
1553
  return errors;
1409
1554
  }
1410
- const allowedTypes = [entryTypeName];
1411
- const joinsAnnotations = owner.annotations?.filter((a) => a.name === "db.view.joins");
1412
- if (joinsAnnotations) {
1413
- for (const join of joinsAnnotations) if (join.args[0]) allowedTypes.push(join.args[0].text);
1414
- }
1415
- errors.push(...require_validation_utils.validateQueryScope(args[0], allowedTypes, entryTypeName, doc));
1555
+ errors.push(...require_validation_utils.validateQueryScope(args[0], require_validation_utils.viewScopeTypes(owner), entryTypeName, doc));
1416
1556
  return errors;
1417
1557
  }
1418
1558
  }),