@sequenceholdings/orm 2.0.0 → 2.1.0

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 (50) hide show
  1. package/README.md +57 -2
  2. package/dist/authoring.cjs +122 -15
  3. package/dist/authoring.cjs.map +1 -1
  4. package/dist/authoring.d.cts +20 -3
  5. package/dist/authoring.d.ts +20 -3
  6. package/dist/authoring.js +122 -15
  7. package/dist/authoring.js.map +1 -1
  8. package/dist/canonical.cjs +83 -9
  9. package/dist/canonical.cjs.map +1 -1
  10. package/dist/canonical.d.cts +47 -1
  11. package/dist/canonical.d.ts +47 -1
  12. package/dist/canonical.js +83 -10
  13. package/dist/canonical.js.map +1 -1
  14. package/dist/cli.cjs +721 -132
  15. package/dist/cli.cjs.map +1 -1
  16. package/dist/cli.js +723 -134
  17. package/dist/cli.js.map +1 -1
  18. package/dist/client.cjs.map +1 -1
  19. package/dist/client.js.map +1 -1
  20. package/dist/compiler.cjs +482 -96
  21. package/dist/compiler.cjs.map +1 -1
  22. package/dist/compiler.d.cts +34 -2
  23. package/dist/compiler.d.ts +34 -2
  24. package/dist/compiler.js +483 -97
  25. package/dist/compiler.js.map +1 -1
  26. package/dist/index.cjs +414 -70
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.d.cts +266 -9
  29. package/dist/index.d.ts +266 -9
  30. package/dist/index.js +404 -72
  31. package/dist/index.js.map +1 -1
  32. package/dist/ir.cjs +146 -12
  33. package/dist/ir.cjs.map +1 -1
  34. package/dist/ir.d.cts +161 -5
  35. package/dist/ir.d.ts +161 -5
  36. package/dist/ir.js +144 -13
  37. package/dist/ir.js.map +1 -1
  38. package/dist/{naming-CTvWSRTf.d.ts → naming-DCn94LJ4.d.ts} +15 -1
  39. package/dist/{naming-DRyinU4u.d.cts → naming-DPglwzkB.d.cts} +15 -1
  40. package/dist/predicate-v2.cjs +130 -11
  41. package/dist/predicate-v2.cjs.map +1 -1
  42. package/dist/predicate-v2.js +130 -11
  43. package/dist/predicate-v2.js.map +1 -1
  44. package/dist/sdl.cjs +460 -13
  45. package/dist/sdl.cjs.map +1 -1
  46. package/dist/sdl.d.cts +1 -1
  47. package/dist/sdl.d.ts +1 -1
  48. package/dist/sdl.js +459 -14
  49. package/dist/sdl.js.map +1 -1
  50. package/package.json +3 -3
package/dist/compiler.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { createHash } from 'crypto';
2
2
  import { z } from 'zod';
3
- import { Kind, print, buildSchema, parse, validate, valueFromASTUntyped } from 'graphql';
3
+ import { Kind, print, visit, buildSchema, parse, validate, valueFromASTUntyped } from 'graphql';
4
4
 
5
5
  // src/compiler-gql/artifact.ts
6
6
 
7
7
  // src/constants.ts
8
+ var COLUMN_IDENTIFIER_PATTERN = /^_?[a-z][a-z0-9_]*$/;
8
9
  var CAPABILITY_KEY_PATTERN = /^(?:org\/)?[a-z0-9-]{1,64}$/;
9
10
  var ORM_SCHEMA_PREFIX = "orm_";
10
11
  var ormSchemaName = (namespace) => `${ORM_SCHEMA_PREFIX}${namespace}`;
@@ -19,6 +20,7 @@ function stableJson(value) {
19
20
  var CANONICAL_FORMAT = 2;
20
21
  var IDENTIFIER_RE = /^[a-z][a-z0-9_]*$/;
21
22
  var identifier = z.string().min(1).max(63).regex(IDENTIFIER_RE);
23
+ var columnIdentifier = z.string().min(1).max(63).regex(COLUMN_IDENTIFIER_PATTERN);
22
24
  var COLUMN_TYPES = [
23
25
  "uuid",
24
26
  "text",
@@ -39,18 +41,33 @@ var canonicalEnumSchema = z.object({
39
41
  var canonicalColumnSchema = z.object({
40
42
  /** Property name in TS (camelCase); columnName is the Postgres name. */
41
43
  name: z.string().min(1).max(63),
42
- columnName: identifier,
44
+ columnName: columnIdentifier,
43
45
  type: z.union([columnTypeSchema, z.object({ enum: identifier })]),
44
46
  nullable: z.boolean().default(true),
45
47
  unique: z.boolean().default(false),
46
48
  /** Literal default, or one of the allowlisted SQL functions. */
47
49
  default: z.union([z.string(), z.number(), z.boolean(), z.null()]).optional(),
48
50
  defaultFn: z.enum(["gen_random_uuid()", "now()"]).optional(),
51
+ /**
52
+ * Stamp this column from the database clock on UPDATE (`now()`). The
53
+ * column stays insertable (defaults fill gaps) but is omitted from
54
+ * SetInput / settable so callers cannot backdate it.
55
+ */
56
+ onUpdateFn: z.enum(["now()"]).optional(),
49
57
  description: z.string().max(500).optional(),
50
58
  /** Migration hints — same semantics as v1. */
51
- renamedFrom: identifier.optional(),
59
+ renamedFrom: columnIdentifier.optional(),
52
60
  cast: z.string().max(200).optional(),
53
61
  deprecated: z.boolean().default(false)
62
+ }).superRefine((column, ctx) => {
63
+ if (column.onUpdateFn === void 0) return;
64
+ if (typeof column.type !== "string" || column.type !== "timestamptz" && column.type !== "date") {
65
+ ctx.addIssue({
66
+ code: "custom",
67
+ path: ["onUpdateFn"],
68
+ message: `onUpdateFn '${column.onUpdateFn}' is only valid on timestamptz/date columns`
69
+ });
70
+ }
54
71
  });
55
72
  var canonicalLinkSchema = z.object({
56
73
  name: z.string().min(1).max(63),
@@ -58,15 +75,15 @@ var canonicalLinkSchema = z.object({
58
75
  /** Target table name (namespace-local). Absent for toPlatformUser. */
59
76
  target: identifier.optional(),
60
77
  /** FK column on the owning side (toOne/toPlatformUser: on this table; toMany: on the target). */
61
- on: identifier,
78
+ on: columnIdentifier,
62
79
  /** Referenced column on the target (default: target's PK / platform user `sub`). */
63
- references: identifier.optional(),
80
+ references: columnIdentifier.optional(),
64
81
  /** enforce: true emits a real FK constraint (same-namespace toOne only). */
65
82
  enforce: z.boolean().default(false),
66
83
  description: z.string().max(500).optional()
67
84
  });
68
85
  var canonicalIndexSchema = z.object({
69
- columns: z.array(identifier).min(1),
86
+ columns: z.array(columnIdentifier).min(1),
70
87
  unique: z.boolean().default(false),
71
88
  name: identifier.optional()
72
89
  });
@@ -157,6 +174,13 @@ var canonicalCowMutationSchema = z.object({
157
174
  }
158
175
  }
159
176
  });
177
+ var canonicalForeignKeySchema = z.object({
178
+ name: identifier,
179
+ columns: z.array(columnIdentifier).min(1).max(8),
180
+ target: identifier,
181
+ references: z.array(columnIdentifier).min(1).max(8),
182
+ deferrable: z.enum(["initially-immediate", "initially-deferred"]).optional()
183
+ }).strict();
160
184
  var canonicalTableSchema = z.object({
161
185
  table: identifier,
162
186
  description: z.string().max(2e3).optional(),
@@ -165,7 +189,7 @@ var canonicalTableSchema = z.object({
165
189
  /** Synced/reflected read-only tables (`source: gold.*`) — v1 semantics. */
166
190
  source: z.string().regex(/^gold\.[a-z][a-z0-9_]*$/).optional(),
167
191
  columns: z.array(canonicalColumnSchema).default([]),
168
- primaryKey: z.array(identifier).min(1).max(8).optional(),
192
+ primaryKey: z.array(columnIdentifier).min(1).max(8).optional(),
169
193
  links: z.array(canonicalLinkSchema).default([]),
170
194
  indexes: z.array(canonicalIndexSchema).default([]),
171
195
  policies: canonicalPoliciesSchema.default({}),
@@ -176,14 +200,16 @@ var canonicalTableSchema = z.object({
176
200
  */
177
201
  actions: z.array(canonicalActionSchema).default([]),
178
202
  /** Optional (not defaulted) so pre-CoW canonical documents keep byte-identical hashes. */
179
- cowMutations: z.array(canonicalCowMutationSchema).optional()
203
+ cowMutations: z.array(canonicalCowMutationSchema).optional(),
204
+ /** Omitted by old definitions, preserving their existing content hashes. */
205
+ foreignKeys: z.array(canonicalForeignKeySchema).max(100).optional()
180
206
  });
181
207
  var canonicalViewSchema = z.object({
182
208
  view: identifier,
183
209
  description: z.string().max(2e3).optional(),
184
210
  columns: z.array(canonicalColumnSchema).min(1),
185
211
  /** Logical identity for GraphQL only; PostgreSQL views have no PK constraint. */
186
- primaryKey: z.array(identifier).min(1).max(8).optional(),
212
+ primaryKey: z.array(columnIdentifier).min(1).max(8).optional(),
187
213
  links: z.array(canonicalLinkSchema).default([]),
188
214
  policies: z.object({ read: policyExpressionSchema }).strict(),
189
215
  query: z.string().min(1).max(1e5).refine(isSingleViewQuerySql, {
@@ -196,6 +222,9 @@ var canonicalViewSchema = z.object({
196
222
  if (column.default !== void 0 || column.defaultFn !== void 0) {
197
223
  ctx.addIssue({ code: "custom", path: ["columns", index], message: `view column '${column.name}' cannot declare a default` });
198
224
  }
225
+ if (column.onUpdateFn !== void 0) {
226
+ ctx.addIssue({ code: "custom", path: ["columns", index], message: `view column '${column.name}' cannot declare onUpdateFn` });
227
+ }
199
228
  if (column.unique) {
200
229
  ctx.addIssue({ code: "custom", path: ["columns", index], message: `view column '${column.name}' cannot declare unique` });
201
230
  }
@@ -229,6 +258,45 @@ var canonicalDefinitionSchema = z.object({
229
258
  ctx.addIssue({ code: "custom", path: ["tables", index, "table"], message: `duplicate relation '${table.table}'` });
230
259
  }
231
260
  seen.add(table.table);
261
+ if (table.source !== void 0) {
262
+ for (const [columnIndex, column] of table.columns.entries()) {
263
+ if (column.onUpdateFn !== void 0) {
264
+ ctx.addIssue({
265
+ code: "custom",
266
+ path: ["tables", index, "columns", columnIndex],
267
+ message: `synced table '${table.table}' column '${column.name}' cannot declare onUpdateFn`
268
+ });
269
+ }
270
+ }
271
+ }
272
+ }
273
+ const fkTables = new Map(definition.tables.map((table) => [table.table, table]));
274
+ for (const [tableIndex, table] of definition.tables.entries()) {
275
+ const names = /* @__PURE__ */ new Set([
276
+ ...table.primaryKey?.length ? [`${table.table}_pkey`] : [],
277
+ ...table.columns.filter((column) => column.unique && !(table.primaryKey?.length === 1 && table.primaryKey[0] === column.columnName)).map((column) => `${table.table}_${column.columnName}_unique`),
278
+ ...table.links.filter((link) => link.enforce).map((link) => `fk_${table.table}_${link.name}`)
279
+ ]);
280
+ for (const [index, fk] of (table.foreignKeys ?? []).entries()) {
281
+ const fail2 = (message) => ctx.addIssue({ code: "custom", path: ["tables", tableIndex, "foreignKeys", index], message });
282
+ if (names.has(fk.name)) fail2(`duplicate foreign key '${fk.name}'`);
283
+ names.add(fk.name);
284
+ const target = fkTables.get(fk.target);
285
+ if (table.source || !target || target.source) {
286
+ fail2("foreign keys must connect owned tables in the same namespace");
287
+ continue;
288
+ }
289
+ if (fk.columns.length !== fk.references.length) fail2("foreign key columns and references must have the same number of columns");
290
+ if (new Set(fk.columns).size !== fk.columns.length || new Set(fk.references).size !== fk.references.length) fail2("foreign key columns must not repeat");
291
+ for (const [position, name] of fk.columns.entries()) {
292
+ const sourceColumn = table.columns.find((column) => column.columnName === name);
293
+ const targetColumn = target.columns.find((column) => column.columnName === fk.references[position]);
294
+ if (!sourceColumn || !targetColumn) fail2("foreign key references an unknown column");
295
+ else if (JSON.stringify(sourceColumn.type) !== JSON.stringify(targetColumn.type)) fail2("foreign key column types must match");
296
+ }
297
+ const uniqueKeys = [target.primaryKey ?? [], ...target.indexes.filter((index2) => index2.unique).map((index2) => index2.columns), ...target.columns.filter((column) => column.unique).map((column) => [column.columnName])];
298
+ if (!uniqueKeys.some((key) => key.length === fk.references.length && key.every((column) => fk.references.includes(column)))) fail2("foreign key target columns must form a unique key");
299
+ }
232
300
  }
233
301
  const tableByName = new Map(definition.tables.map((table) => [table.table, table]));
234
302
  for (const [tableIndex, table] of definition.tables.entries()) {
@@ -324,7 +392,7 @@ var canonicalDefinitionSchema = z.object({
324
392
  const visiting = /* @__PURE__ */ new Set();
325
393
  const visited = /* @__PURE__ */ new Set();
326
394
  let cycleReported = false;
327
- const visit = (name, path) => {
395
+ const visit2 = (name, path) => {
328
396
  if (visited.has(name) || cycleReported) return;
329
397
  if (visiting.has(name)) {
330
398
  cycleReported = true;
@@ -338,12 +406,12 @@ var canonicalDefinitionSchema = z.object({
338
406
  visiting.add(name);
339
407
  const view = viewByName.get(name);
340
408
  for (const dependency of view?.dependencies ?? []) {
341
- if (viewByName.has(dependency)) visit(dependency, [...path, name]);
409
+ if (viewByName.has(dependency)) visit2(dependency, [...path, name]);
342
410
  }
343
411
  visiting.delete(name);
344
412
  visited.add(name);
345
413
  };
346
- for (const view of views) visit(view.view, []);
414
+ for (const view of views) visit2(view.view, []);
347
415
  });
348
416
  function canonicalRelations(definition) {
349
417
  const views = (definition.views ?? []).map((view) => ({
@@ -364,12 +432,13 @@ function normalizeCanonical(definition) {
364
432
  ...definition,
365
433
  enums: sortByName(definition.enums),
366
434
  tables: [...definition.tables].sort((a, b) => a.table.localeCompare(b.table)).map((t) => {
367
- const { cowMutations, ...rest } = t;
435
+ const { cowMutations, foreignKeys, ...rest } = t;
368
436
  return {
369
437
  ...rest,
370
438
  links: sortByName(t.links),
371
439
  indexes: [...t.indexes].sort((a, b) => (a.name ?? a.columns.join("_")).localeCompare(b.name ?? b.columns.join("_"))),
372
440
  actions: sortByName(t.actions),
441
+ ...foreignKeys?.length ? { foreignKeys: sortByName(foreignKeys) } : {},
373
442
  // Omitted entirely when absent/empty so pre-CoW documents hash unchanged.
374
443
  ...cowMutations !== void 0 && cowMutations.length > 0 ? { cowMutations: sortByName(cowMutations) } : {}
375
444
  };
@@ -386,7 +455,18 @@ function normalizeCanonical(definition) {
386
455
  }
387
456
 
388
457
  // src/ir/index.ts
389
- var IR_VERSION = 1;
458
+ var IR_VERSION = 2;
459
+ var LEGACY_IR_VERSION = 1;
460
+ var irVersionSchema = z.union([z.literal(LEGACY_IR_VERSION), z.literal(IR_VERSION)]);
461
+ var operationVersionSchema = z.object({ irVersion: irVersionSchema });
462
+ function requiredArtifactIrVersion(operations) {
463
+ let required = LEGACY_IR_VERSION;
464
+ for (const operation of operations) {
465
+ const { irVersion } = operationVersionSchema.parse(operation);
466
+ if (irVersion > required) required = irVersion;
467
+ }
468
+ return required;
469
+ }
390
470
  var CLAIMS_CONTRACT = {
391
471
  sub: "string",
392
472
  org: "string?",
@@ -513,6 +593,33 @@ var aggregateRootSchema = z.object({
513
593
  nodes: selectionIrSchema.optional(),
514
594
  includeIf: includeIfSchema.optional()
515
595
  });
596
+ var DATE_BUCKETS = ["year", "quarter", "month", "week", "day", "hour"];
597
+ var groupByKeyIrSchema = z.object({
598
+ column: z.string(),
599
+ bucket: z.enum(DATE_BUCKETS).optional()
600
+ });
601
+ var aggregateGroupsRootSchema = z.object({
602
+ kind: z.literal("aggregateGroups"),
603
+ alias: z.string(),
604
+ table: z.string(),
605
+ /** Composed at compile time: AND(policy, documentWhere?, dynamicFilter?). */
606
+ where: predIrSchema,
607
+ groupBy: z.array(groupByKeyIrSchema).min(1),
608
+ /**
609
+ * The `key { … }` projection: which group columns appear in each entry's key
610
+ * object, in selection order. A subset of `groupBy` (the compiler rejects a
611
+ * key field that is not a group key — it would be indeterminate within the
612
+ * group). Empty when the document selects only `aggregate`.
613
+ */
614
+ key: z.array(z.object({ alias: z.string(), column: z.string() })).default([]),
615
+ count: z.object({ columns: z.array(z.string()).optional(), distinct: z.boolean().optional() }).optional(),
616
+ sum: z.array(z.string()).optional(),
617
+ avg: z.array(z.string()).optional(),
618
+ /** Bounds the GROUP count, not the scanned rows — see MAX_GROUPS. */
619
+ limit: limitIrSchema,
620
+ offset: z.object({ variable: z.string().optional(), value: z.number().int().optional() }).optional(),
621
+ includeIf: includeIfSchema.optional()
622
+ });
516
623
  var writeValuesSchema = z.union([
517
624
  z.object({ slot: z.string() }),
518
625
  z.object({ entries: z.array(z.object({ column: z.string(), value: valueIrSchema })) })
@@ -558,12 +665,15 @@ var mutationRootSchema = z.object({
558
665
  insertCheck: predIrSchema.optional(),
559
666
  returning: selectionIrSchema.nullable(),
560
667
  affectedRows: z.boolean().default(false),
668
+ /** v2: abort the transaction when the actual write count differs, regardless of selection. */
669
+ assertAffectedRows: z.number().int().min(0).max(2147483647).optional(),
561
670
  includeIf: includeIfSchema.optional()
562
671
  });
563
672
  var rootIrSchema = z.discriminatedUnion("kind", [
564
673
  selectRootSchema,
565
674
  byPkRootSchema,
566
675
  aggregateRootSchema,
676
+ aggregateGroupsRootSchema,
567
677
  mutationRootSchema
568
678
  ]);
569
679
  var INPUT_REF_KINDS = [
@@ -593,7 +703,7 @@ var variableIrSchema = z.object({
593
703
  defaultValue: variableDefaultIrSchema.optional()
594
704
  });
595
705
  var compiledOperationSchema = z.object({
596
- irVersion: z.literal(IR_VERSION),
706
+ irVersion: irVersionSchema,
597
707
  name: z.string(),
598
708
  opHash: z.string().regex(/^sha256:[0-9a-f]{64}$/),
599
709
  kind: z.enum(["query", "mutation"]),
@@ -605,6 +715,13 @@ var compiledOperationSchema = z.object({
605
715
  /** Every table the op touches — data-token scoping + audit. */
606
716
  tableSet: z.array(z.string()).min(1),
607
717
  roots: z.array(rootIrSchema).min(1)
718
+ }).superRefine((operation, ctx) => {
719
+ for (const [index, root] of operation.roots.entries()) {
720
+ if (root.kind !== "mutation" || root.assertAffectedRows === void 0) continue;
721
+ if (operation.irVersion < 2 || operation.kind !== "mutation" || root.includeIf !== void 0) {
722
+ ctx.addIssue({ code: "custom", path: ["roots", index], message: "affected-row assertions require unconditional mutation roots and IR v2" });
723
+ }
724
+ }
608
725
  });
609
726
  var tableMetaIrSchema = z.object({
610
727
  table: z.string(),
@@ -641,18 +758,29 @@ var tableMetaIrSchema = z.object({
641
758
  insertable: z.array(z.string()),
642
759
  settable: z.array(z.string()),
643
760
  appendable: z.array(z.string()),
761
+ /**
762
+ * Logical columns stamped with Postgres `now()` on UPDATE (canonical
763
+ * `onUpdateFn`). Omitted when empty so pre-onUpdate artifacts still parse.
764
+ */
765
+ touchOnUpdate: z.array(z.string()).optional(),
644
766
  constraints: z.array(z.object({ name: z.string(), columns: z.array(z.string()) })),
645
767
  /** Depth/node caps for dynamic filter trees on this table. */
646
768
  dynamicFilterMaxDepth: z.number().int().positive().default(6),
647
769
  dynamicFilterMaxNodes: z.number().int().positive().default(64)
648
770
  });
649
771
  var compiledArtifactSchema = z.object({
650
- irVersion: z.literal(IR_VERSION),
772
+ irVersion: irVersionSchema,
651
773
  namespace: z.string(),
652
774
  schemaHash: z.string(),
653
775
  opsHash: z.string(),
654
776
  tables: z.record(z.string(), tableMetaIrSchema),
655
777
  operations: z.record(z.string(), compiledOperationSchema)
778
+ }).superRefine((artifact, ctx) => {
779
+ for (const [name, operation] of Object.entries(artifact.operations)) {
780
+ if (operation.irVersion > artifact.irVersion) {
781
+ ctx.addIssue({ code: "custom", path: ["operations", name, "irVersion"], message: "operation requires a newer IR version than its artifact" });
782
+ }
783
+ }
656
784
  });
657
785
  var IrVersionError = class extends Error {
658
786
  constructor(found) {
@@ -663,19 +791,19 @@ var IrVersionError = class extends Error {
663
791
  function normalizeIr(raw) {
664
792
  if (!raw || typeof raw !== "object") throw new IrVersionError(void 0);
665
793
  const version = raw.irVersion;
666
- if (version !== IR_VERSION) throw new IrVersionError(version);
794
+ if (version !== LEGACY_IR_VERSION && version !== IR_VERSION) throw new IrVersionError(version);
667
795
  return compiledArtifactSchema.parse(raw);
668
796
  }
669
797
 
670
798
  // src/predicate-v2/ast.ts
671
799
  var POLICY_COMPARE_OPS = ["=", "!=", "<", "<=", ">", ">="];
672
- function walkPolicy(node, visit) {
673
- visit(node);
800
+ function walkPolicy(node, visit2) {
801
+ visit2(node);
674
802
  if (node.kind === "and" || node.kind === "or") {
675
- for (const operand of node.operands) walkPolicy(operand, visit);
803
+ for (const operand of node.operands) walkPolicy(operand, visit2);
676
804
  }
677
- if (node.kind === "not") walkPolicy(node.operand, visit);
678
- if (node.kind === "exists") walkPolicy(node.where, visit);
805
+ if (node.kind === "not") walkPolicy(node.operand, visit2);
806
+ if (node.kind === "exists") walkPolicy(node.where, visit2);
679
807
  }
680
808
 
681
809
  // src/predicate-v2/errors.ts
@@ -1181,21 +1309,21 @@ function andPred(...preds) {
1181
1309
  if (operands.length === 1) return operands[0];
1182
1310
  return { kind: "and", operands };
1183
1311
  }
1184
- function walkPred(pred, visit) {
1185
- visit(pred);
1312
+ function walkPred(pred, visit2) {
1313
+ visit2(pred);
1186
1314
  switch (pred.kind) {
1187
1315
  case "and":
1188
1316
  case "or":
1189
- for (const operand of pred.operands) walkPred(operand, visit);
1317
+ for (const operand of pred.operands) walkPred(operand, visit2);
1190
1318
  return;
1191
1319
  case "not":
1192
- walkPred(pred.operand, visit);
1320
+ walkPred(pred.operand, visit2);
1193
1321
  return;
1194
1322
  case "exists":
1195
- walkPred(pred.where, visit);
1323
+ walkPred(pred.where, visit2);
1196
1324
  return;
1197
1325
  case "aggPredicate":
1198
- if (pred.where) walkPred(pred.where, visit);
1326
+ if (pred.where) walkPred(pred.where, visit2);
1199
1327
  return;
1200
1328
  default:
1201
1329
  return;
@@ -1220,12 +1348,12 @@ function predContains(haystack, needle) {
1220
1348
  // src/compiler-gql/inject.ts
1221
1349
  function collectPinnedColumns(writePolicy) {
1222
1350
  const key = (pin) => `${pin.column}\0${pin.claim}`;
1223
- const visit = (node) => {
1351
+ const visit2 = (node) => {
1224
1352
  if (node.kind === "and") {
1225
1353
  const merged = [];
1226
1354
  const seen2 = /* @__PURE__ */ new Set();
1227
1355
  for (const operand of node.operands) {
1228
- for (const pin of visit(operand)) {
1356
+ for (const pin of visit2(operand)) {
1229
1357
  if (seen2.has(key(pin))) continue;
1230
1358
  seen2.add(key(pin));
1231
1359
  merged.push(pin);
@@ -1236,9 +1364,9 @@ function collectPinnedColumns(writePolicy) {
1236
1364
  if (node.kind === "or") {
1237
1365
  const [first, ...rest] = node.operands;
1238
1366
  if (first === void 0) return [];
1239
- let common = visit(first);
1367
+ let common = visit2(first);
1240
1368
  for (const operand of rest) {
1241
- const branch = new Set(visit(operand).map(key));
1369
+ const branch = new Set(visit2(operand).map(key));
1242
1370
  common = common.filter((pin) => branch.has(key(pin)));
1243
1371
  if (common.length === 0) break;
1244
1372
  }
@@ -1250,7 +1378,7 @@ function collectPinnedColumns(writePolicy) {
1250
1378
  return [];
1251
1379
  };
1252
1380
  const seen = /* @__PURE__ */ new Set();
1253
- return visit(writePolicy).filter((pin) => {
1381
+ return visit2(writePolicy).filter((pin) => {
1254
1382
  if (seen.has(pin.column)) return false;
1255
1383
  seen.add(pin.column);
1256
1384
  return true;
@@ -1344,6 +1472,11 @@ var PolicyInjector = class {
1344
1472
  where: andPred(this.read(root.table), this.injectDocPred(root.where, root.table)),
1345
1473
  ...root.nodes ? { nodes: this.injectSelection(root.nodes, root.table) } : {}
1346
1474
  };
1475
+ case "aggregateGroups":
1476
+ return {
1477
+ ...root,
1478
+ where: andPred(this.read(root.table), this.injectDocPred(root.where, root.table))
1479
+ };
1347
1480
  case "mutation":
1348
1481
  return this.injectMutation(root);
1349
1482
  }
@@ -1436,20 +1569,33 @@ function collectTableSet(roots, ctx) {
1436
1569
  };
1437
1570
  for (const root of roots) {
1438
1571
  tables.add(root.table);
1439
- if (root.kind === "select" || root.kind === "byPk") {
1440
- visitPred(root.where, root.table);
1441
- visitSelection(root.selection, root.table);
1442
- }
1443
- if (root.kind === "aggregate") {
1444
- visitPred(root.where, root.table);
1445
- if (root.nodes) visitSelection(root.nodes, root.table);
1446
- }
1447
- if (root.kind === "mutation") {
1448
- if (root.cowSynced !== void 0) tables.add(root.cowSynced);
1449
- if (root.where) visitPred(root.where, root.table);
1450
- if (root.insertCheck) visitPred(root.insertCheck, root.table);
1451
- if (root.cowCopyCheck) visitPred(root.cowCopyCheck, root.table);
1452
- if (root.returning) visitSelection(root.returning, root.table);
1572
+ switch (root.kind) {
1573
+ case "select":
1574
+ case "byPk":
1575
+ visitPred(root.where, root.table);
1576
+ visitSelection(root.selection, root.table);
1577
+ break;
1578
+ case "aggregate":
1579
+ visitPred(root.where, root.table);
1580
+ if (root.nodes) visitSelection(root.nodes, root.table);
1581
+ break;
1582
+ case "aggregateGroups":
1583
+ visitPred(root.where, root.table);
1584
+ break;
1585
+ case "mutation":
1586
+ if (root.cowSynced !== void 0) tables.add(root.cowSynced);
1587
+ if (root.where) visitPred(root.where, root.table);
1588
+ if (root.insertCheck) visitPred(root.insertCheck, root.table);
1589
+ if (root.cowCopyCheck) visitPred(root.cowCopyCheck, root.table);
1590
+ if (root.returning) visitSelection(root.returning, root.table);
1591
+ break;
1592
+ default: {
1593
+ const exhausted = root;
1594
+ throw new DocumentCompileError(
1595
+ `internal: unhandled root kind in tableSet collection ${JSON.stringify(exhausted)}`,
1596
+ "internal"
1597
+ );
1598
+ }
1453
1599
  }
1454
1600
  }
1455
1601
  return [...tables].sort();
@@ -1548,7 +1694,12 @@ function tableNames(table) {
1548
1694
  sumFields: `${type}SumFields`,
1549
1695
  avgFields: `${type}AvgFields`,
1550
1696
  aggregateBoolExp: `${type}AggregateBoolExp`,
1551
- aggregateBoolExpCount: `${type}AggregateBoolExpCount`
1697
+ aggregateBoolExpCount: `${type}AggregateBoolExpCount`,
1698
+ aggregateGroupsRoot: `${root}AggregateGroups`,
1699
+ groupByColumnEnum: `${type}GroupByColumn`,
1700
+ groupByInput: `${type}GroupBy`,
1701
+ groupKey: `${type}GroupKey`,
1702
+ aggregateGroup: `${type}AggregateGroup`
1552
1703
  };
1553
1704
  }
1554
1705
 
@@ -1559,7 +1710,12 @@ function typeNameForTable(table) {
1559
1710
  }
1560
1711
  function queryRootNames(table) {
1561
1712
  const names = tableNames(table);
1562
- return { select: names.selectRoot, byPk: names.byPkRoot, aggregate: names.aggregateRoot };
1713
+ return {
1714
+ select: names.selectRoot,
1715
+ byPk: names.byPkRoot,
1716
+ aggregate: names.aggregateRoot,
1717
+ aggregateGroups: names.aggregateGroupsRoot
1718
+ };
1563
1719
  }
1564
1720
  function mutationRootNames(table) {
1565
1721
  const names = tableNames(table);
@@ -1652,7 +1808,14 @@ function resolveColumnEntries(table, enums, diagnostics) {
1652
1808
  type = { enum: enumDef.name, values: [...enumDef.values] };
1653
1809
  }
1654
1810
  }
1655
- return { name: column.name, columnName: column.columnName, type, nullable: column.nullable, deprecated: column.deprecated };
1811
+ return {
1812
+ name: column.name,
1813
+ columnName: column.columnName,
1814
+ type,
1815
+ nullable: column.nullable,
1816
+ deprecated: column.deprecated,
1817
+ onUpdateFn: column.onUpdateFn !== void 0
1818
+ };
1656
1819
  });
1657
1820
  }
1658
1821
  function logicalColumn(entries, ref) {
@@ -1701,6 +1864,7 @@ function buildCompileContext(rawCanonical) {
1701
1864
  primaryKey.push(resolved);
1702
1865
  }
1703
1866
  const active = entries.filter((c) => !c.deprecated);
1867
+ const touchOnUpdate = isView ? [] : active.filter((c) => c.onUpdateFn).map((c) => c.name);
1704
1868
  const constraints = [];
1705
1869
  if (primaryKey.length > 0) constraints.push({ name: `${table.table}_pkey`, columns: [...primaryKey] });
1706
1870
  for (const column of table.columns) {
@@ -1721,14 +1885,15 @@ function buildCompileContext(rawCanonical) {
1721
1885
  // Synced twins (and views reading them, resolved transitively below)
1722
1886
  // are claims-gated — see tableMetaIrSchema.claimsGated.
1723
1887
  claimsGated: relationIsClaimsGated(table.table),
1724
- columns: entries.map(({ deprecated: _deprecated, ...column }) => column),
1888
+ columns: entries.map(({ deprecated: _deprecated, onUpdateFn: _onUpdateFn, ...column }) => column),
1725
1889
  primaryKey: isView ? primaryKey : primaryKey.length > 0 ? primaryKey : ["id"],
1726
1890
  links: [],
1727
1891
  filterable: entries.map((c) => c.name),
1728
1892
  orderable: entries.filter((c) => c.type !== "jsonb").map((c) => c.name),
1729
1893
  insertable: isView ? [] : active.map((c) => c.name),
1730
- settable: isView ? [] : active.map((c) => c.name),
1894
+ settable: isView ? [] : active.filter((c) => !c.onUpdateFn).map((c) => c.name),
1731
1895
  appendable: isView ? [] : active.filter((c) => c.type === "jsonb").map((c) => c.name),
1896
+ ...touchOnUpdate.length > 0 ? { touchOnUpdate } : {},
1732
1897
  constraints: isView ? [] : constraints,
1733
1898
  dynamicFilterMaxDepth: 6,
1734
1899
  dynamicFilterMaxNodes: 64
@@ -1749,7 +1914,7 @@ function buildCompileContext(rawCanonical) {
1749
1914
  });
1750
1915
  continue;
1751
1916
  }
1752
- const targetEntries = targetName === PLATFORM_USER_TABLE ? target.columns.map((c) => ({ ...c, deprecated: false })) : columnEntries.get(targetName);
1917
+ const targetEntries = targetName === PLATFORM_USER_TABLE ? target.columns.map((c) => ({ ...c, deprecated: false, onUpdateFn: false })) : columnEntries.get(targetName);
1753
1918
  const onEntries = link.kind === "toMany" ? targetEntries : entries;
1754
1919
  const referencesEntries = link.kind === "toMany" ? entries : targetEntries;
1755
1920
  const on = logicalColumn(onEntries, link.on);
@@ -1846,6 +2011,9 @@ function buildCompileContext(rawCanonical) {
1846
2011
  queryRoots.set(roots.byPk, { kind: "byPk", table: table.table });
1847
2012
  }
1848
2013
  queryRoots.set(roots.aggregate, { kind: "aggregate", table: table.table });
2014
+ if (table.columns.some((column) => column.type !== "jsonb")) {
2015
+ queryRoots.set(roots.aggregateGroups, { kind: "aggregateGroups", table: table.table });
2016
+ }
1849
2017
  const hasWritePolicy = table.policies.write !== void 0 || table.policies.insert !== void 0 || table.policies.update !== void 0;
1850
2018
  if (!viewNames.has(table.table) && table.source === void 0 && hasWritePolicy) {
1851
2019
  for (const [op, field] of Object.entries(mutationRootNames(table.table))) {
@@ -1981,6 +2149,16 @@ function computeOpsHash(opHashes) {
1981
2149
  }
1982
2150
  var DEFAULT_LIMIT = 100;
1983
2151
  var MAX_LIMIT = 1e3;
2152
+ var MAX_GROUPS = 1e3;
2153
+ var DATE_BUCKET_BY_ENUM = {
2154
+ YEAR: "year",
2155
+ QUARTER: "quarter",
2156
+ MONTH: "month",
2157
+ WEEK: "week",
2158
+ DAY: "day",
2159
+ HOUR: "hour"
2160
+ };
2161
+ var BUCKETABLE_TYPES = /* @__PURE__ */ new Set(["timestamptz", "date"]);
1984
2162
  var BOOLEXP_OPS = {
1985
2163
  _eq: "eq",
1986
2164
  _neq: "neq",
@@ -2012,6 +2190,14 @@ function fail(message, code, candidates = []) {
2012
2190
  function argsByName(field) {
2013
2191
  return new Map((field.arguments ?? []).map((arg) => [arg.name.value, arg]));
2014
2192
  }
2193
+ function* aggregateBlockFields(selectionSet) {
2194
+ for (const node of selectionSet.selections) {
2195
+ if (node.kind !== Kind.FIELD) fail("fragments must be flattened before lowering", "internal");
2196
+ if (node.name.value === "__typename") continue;
2197
+ if (node.alias) fail(`aliases are not supported inside aggregate blocks ('${node.alias.value}')`, "invalid-aggregate");
2198
+ yield node;
2199
+ }
2200
+ }
2015
2201
  function assertKnownArgs(field, allowed) {
2016
2202
  for (const arg of field.arguments ?? []) {
2017
2203
  if (!allowed.includes(arg.name.value)) {
@@ -2312,11 +2498,8 @@ var OperationLowerer = class {
2312
2498
  lowerAggregateSelection(selectionSet, table) {
2313
2499
  const result = {};
2314
2500
  let sawAggregate = false;
2315
- for (const node of selectionSet.selections) {
2316
- if (node.kind !== Kind.FIELD) fail("fragments must be flattened before lowering", "internal");
2501
+ for (const node of aggregateBlockFields(selectionSet)) {
2317
2502
  const name = node.name.value;
2318
- if (name === "__typename") continue;
2319
- if (node.alias) fail(`aliases are not supported inside aggregate blocks ('${node.alias.value}')`, "invalid-aggregate");
2320
2503
  if (name === "nodes") {
2321
2504
  if (!node.selectionSet) fail("'nodes' needs a selection set", "invalid-aggregate");
2322
2505
  result.nodes = this.lowerSelection(node.selectionSet, table);
@@ -2326,48 +2509,116 @@ var OperationLowerer = class {
2326
2509
  if (sawAggregate) fail("one aggregate block per field \u2014 alias the root instead", "invalid-aggregate");
2327
2510
  sawAggregate = true;
2328
2511
  if (!node.selectionSet) fail("'aggregate' needs a selection set", "invalid-aggregate");
2329
- for (const aggField of node.selectionSet.selections) {
2330
- if (aggField.kind !== Kind.FIELD) fail("fragments must be flattened before lowering", "internal");
2331
- const aggName = aggField.name.value;
2332
- if (aggName === "__typename") continue;
2333
- if (aggField.alias) fail(`aliases are not supported inside aggregate blocks ('${aggField.alias.value}')`, "invalid-aggregate");
2334
- if (aggName === "count") {
2335
- assertKnownArgs(aggField, ["columns", "distinct"]);
2336
- const args = argsByName(aggField);
2337
- const count = {};
2338
- const columns = args.get("columns");
2339
- if (columns) count.columns = this.lowerDistinctOn(columns.value, table);
2340
- const distinct = args.get("distinct");
2341
- if (distinct) {
2342
- if (distinct.value.kind !== Kind.BOOLEAN) fail("count(distinct:) must be a boolean literal", "invalid-aggregate");
2343
- count.distinct = distinct.value.value;
2344
- }
2345
- result.count = count;
2346
- continue;
2512
+ Object.assign(result, this.lowerAggregateFields(node.selectionSet, table));
2513
+ }
2514
+ if (!sawAggregate && !result.nodes) fail("aggregate selection needs an aggregate or nodes block", "invalid-aggregate");
2515
+ return result;
2516
+ }
2517
+ /**
2518
+ * The `{ key {…} aggregate {…} }` block of a grouped aggregate root. Same
2519
+ * `aggregate` sub-block as the ungrouped shape (shared via
2520
+ * `lowerAggregateFields`), but `key` replaces `nodes` — see the IR comment on
2521
+ * `aggregateGroupsRootSchema` for why grouped roots have no `nodes`.
2522
+ */
2523
+ lowerGroupedAggregateSelection(selectionSet, table, groupBy) {
2524
+ const groupColumns = new Set(groupBy.map((entry) => entry.column));
2525
+ const result = { key: [] };
2526
+ let sawAggregate = false;
2527
+ let sawKey = false;
2528
+ for (const node of aggregateBlockFields(selectionSet)) {
2529
+ const name = node.name.value;
2530
+ if (name === "key") {
2531
+ if (sawKey) fail("one key block per grouped aggregate", "invalid-aggregate");
2532
+ sawKey = true;
2533
+ if (!node.selectionSet) fail("'key' needs a selection set", "invalid-aggregate");
2534
+ result.key = this.lowerGroupKeySelection(node.selectionSet, table, groupColumns);
2535
+ continue;
2536
+ }
2537
+ if (name === "nodes") {
2538
+ fail("'nodes' is not available on a grouped aggregate \u2014 query the rows separately", "invalid-aggregate", ["key", "aggregate"]);
2539
+ }
2540
+ if (name !== "aggregate") fail(`unknown grouped-aggregate field '${name}'`, "invalid-aggregate", ["key", "aggregate"]);
2541
+ if (sawAggregate) fail("one aggregate block per field \u2014 alias the root instead", "invalid-aggregate");
2542
+ sawAggregate = true;
2543
+ if (!node.selectionSet) fail("'aggregate' needs a selection set", "invalid-aggregate");
2544
+ Object.assign(result, this.lowerAggregateFields(node.selectionSet, table));
2545
+ }
2546
+ const rendersAggregate = result.count !== void 0 || (result.sum?.length ?? 0) > 0 || (result.avg?.length ?? 0) > 0;
2547
+ if (result.key.length === 0 && !rendersAggregate) {
2548
+ fail(
2549
+ "grouped aggregate needs at least one key column or an aggregate \u2014 a block selecting only __typename produces nothing",
2550
+ "invalid-aggregate",
2551
+ ["key", "aggregate"]
2552
+ );
2553
+ }
2554
+ return result;
2555
+ }
2556
+ /**
2557
+ * `key { month officer }` → the projected group columns. Every field must be
2558
+ * one of the query's own group keys: any other column is indeterminate
2559
+ * within a group, so selecting it is a document error rather than an
2560
+ * arbitrary pick.
2561
+ */
2562
+ lowerGroupKeySelection(selectionSet, table, groupColumns) {
2563
+ const key = [];
2564
+ for (const node of selectionSet.selections) {
2565
+ if (node.kind !== Kind.FIELD) fail("fragments must be flattened before lowering", "internal");
2566
+ const name = node.name.value;
2567
+ if (name === "__typename") continue;
2568
+ if (!this.column(table, name)) {
2569
+ fail(`unknown key column '${name}' on '${table}'`, "unknown-column", [...groupColumns]);
2570
+ }
2571
+ if (!groupColumns.has(name)) {
2572
+ fail(
2573
+ `key column '${name}' is not one of this query's groupBy keys \u2014 it has no single value within a group`,
2574
+ "invalid-group-key",
2575
+ [...groupColumns]
2576
+ );
2577
+ }
2578
+ key.push({ alias: node.alias?.value ?? name, column: name });
2579
+ }
2580
+ return key;
2581
+ }
2582
+ /** The `count` / `sum {…}` / `avg {…}` fields inside an `aggregate { … }` block. */
2583
+ lowerAggregateFields(selectionSet, table) {
2584
+ const result = {};
2585
+ for (const aggField of aggregateBlockFields(selectionSet)) {
2586
+ const aggName = aggField.name.value;
2587
+ if (aggName === "count") {
2588
+ assertKnownArgs(aggField, ["columns", "distinct"]);
2589
+ const args = argsByName(aggField);
2590
+ const count = {};
2591
+ const columns = args.get("columns");
2592
+ if (columns) count.columns = this.lowerDistinctOn(columns.value, table);
2593
+ const distinct = args.get("distinct");
2594
+ if (distinct) {
2595
+ if (distinct.value.kind !== Kind.BOOLEAN) fail("count(distinct:) must be a boolean literal", "invalid-aggregate");
2596
+ count.distinct = distinct.value.value;
2347
2597
  }
2348
- if (aggName === "sum" || aggName === "avg") {
2349
- if (!aggField.selectionSet) fail(`'${aggName}' needs a column selection`, "invalid-aggregate");
2350
- const columns = [];
2351
- for (const columnField of aggField.selectionSet.selections) {
2352
- if (columnField.kind !== Kind.FIELD) fail("fragments must be flattened before lowering", "internal");
2353
- if (columnField.name.value === "__typename") continue;
2354
- const column = this.column(table, columnField.name.value);
2355
- if (!column || typeof column.type !== "string" || !NUMERIC_TYPES.has(column.type)) {
2356
- fail(
2357
- `'${aggName}' supports numeric columns only, got '${columnField.name.value}' on '${table}'`,
2358
- "invalid-aggregate",
2359
- this.meta(table).columns.filter((c) => typeof c.type === "string" && NUMERIC_TYPES.has(c.type)).map((c) => c.name)
2360
- );
2361
- }
2362
- columns.push(columnField.name.value);
2598
+ result.count = count;
2599
+ continue;
2600
+ }
2601
+ if (aggName === "sum" || aggName === "avg") {
2602
+ if (!aggField.selectionSet) fail(`'${aggName}' needs a column selection`, "invalid-aggregate");
2603
+ const columns = [];
2604
+ for (const columnField of aggField.selectionSet.selections) {
2605
+ if (columnField.kind !== Kind.FIELD) fail("fragments must be flattened before lowering", "internal");
2606
+ if (columnField.name.value === "__typename") continue;
2607
+ const column = this.column(table, columnField.name.value);
2608
+ if (!column || typeof column.type !== "string" || !NUMERIC_TYPES.has(column.type)) {
2609
+ fail(
2610
+ `'${aggName}' supports numeric columns only, got '${columnField.name.value}' on '${table}'`,
2611
+ "invalid-aggregate",
2612
+ this.meta(table).columns.filter((c) => typeof c.type === "string" && NUMERIC_TYPES.has(c.type)).map((c) => c.name)
2613
+ );
2363
2614
  }
2364
- result[aggName] = columns;
2365
- continue;
2615
+ columns.push(columnField.name.value);
2366
2616
  }
2367
- fail(`aggregate '${aggName}' is not supported in v2.0 (count/sum/avg only)`, "invalid-aggregate", ["count", "sum", "avg"]);
2617
+ result[aggName] = columns;
2618
+ continue;
2368
2619
  }
2620
+ fail(`aggregate '${aggName}' is not supported in v2.0 (count/sum/avg only)`, "invalid-aggregate", ["count", "sum", "avg"]);
2369
2621
  }
2370
- if (!sawAggregate && !result.nodes) fail("aggregate selection needs an aggregate or nodes block", "invalid-aggregate");
2371
2622
  return result;
2372
2623
  }
2373
2624
  // -------------------------------------------------------------------------
@@ -2377,19 +2628,49 @@ var OperationLowerer = class {
2377
2628
  const roots = [];
2378
2629
  for (const node of operation.selectionSet.selections) {
2379
2630
  if (node.kind !== Kind.FIELD) fail("fragments are not supported at the operation root", "invalid-root");
2631
+ if (node.selectionSet) visit(node.selectionSet, {
2632
+ Directive(directive) {
2633
+ if (directive.name.value === "assertAffectedRows") {
2634
+ fail("@assertAffectedRows is supported on mutation roots only", "invalid-affected-row-assertion");
2635
+ }
2636
+ }
2637
+ });
2638
+ const assertion = this.parseAffectedRowAssertion(node, kind);
2380
2639
  const gate = this.parseRootDirectives(node);
2381
2640
  if (gate.omit) continue;
2382
2641
  const root = kind === "query" ? this.lowerQueryRoot(node) : this.lowerMutationRoot(node);
2383
- roots.push(gate.includeIf ? { ...root, includeIf: gate.includeIf } : root);
2642
+ const guarded = root.kind === "mutation" && assertion !== void 0 ? { ...root, assertAffectedRows: assertion } : root;
2643
+ roots.push(gate.includeIf ? { ...guarded, includeIf: gate.includeIf } : guarded);
2384
2644
  }
2385
2645
  if (roots.length === 0) fail("operation has no roots (all statically skipped?)", "invalid-root");
2386
2646
  return roots;
2387
2647
  }
2648
+ parseAffectedRowAssertion(node, kind) {
2649
+ const assertions = node.directives?.filter((directive) => directive.name.value === "assertAffectedRows") ?? [];
2650
+ if (!assertions.length) return void 0;
2651
+ if (kind !== "mutation" || assertions.length !== 1) {
2652
+ fail("@assertAffectedRows is supported once per mutation root", "invalid-affected-row-assertion");
2653
+ }
2654
+ if (node.directives?.some((directive) => ["include", "skip"].includes(directive.name.value))) {
2655
+ fail("an @assertAffectedRows root cannot use @include or @skip", "invalid-affected-row-assertion");
2656
+ }
2657
+ const args = assertions[0].arguments;
2658
+ const arg = args?.[0];
2659
+ if (args?.length !== 1 || arg?.name.value !== "exactly" || arg.value.kind !== Kind.INT) {
2660
+ fail("@assertAffectedRows(exactly:) requires a document-authored nonnegative Int literal", "invalid-affected-row-assertion");
2661
+ }
2662
+ const count = Number(arg.value.value);
2663
+ if (!Number.isInteger(count) || count < 0 || count > 2147483647) {
2664
+ fail("@assertAffectedRows(exactly:) must be between 0 and 2147483647", "invalid-affected-row-assertion");
2665
+ }
2666
+ return count;
2667
+ }
2388
2668
  parseRootDirectives(node) {
2389
2669
  let includeIf;
2390
2670
  let omit = false;
2391
2671
  for (const directive of node.directives ?? []) {
2392
2672
  const name = directive.name.value;
2673
+ if (name === "assertAffectedRows") continue;
2393
2674
  if (name !== "include" && name !== "skip") {
2394
2675
  fail(`directive @${name} is not supported on fields`, "unknown-directive", ["include", "skip"]);
2395
2676
  }
@@ -2438,6 +2719,9 @@ var OperationLowerer = class {
2438
2719
  const pk = this.lowerPkArgs(node, table);
2439
2720
  return { kind: "byPk", alias, table, selection: this.lowerSelection(node.selectionSet, table), pk, where: CONST_TRUE };
2440
2721
  }
2722
+ if (rootInfo.kind === "aggregateGroups") {
2723
+ return this.lowerAggregateGroupsRoot(node, { alias, table });
2724
+ }
2441
2725
  assertKnownArgs(node, ["where"]);
2442
2726
  if (!node.selectionSet) fail(`'${name}' needs a selection set`, "invalid-selection");
2443
2727
  const whereArg = args.get("where");
@@ -2450,6 +2734,108 @@ var OperationLowerer = class {
2450
2734
  ...aggregate
2451
2735
  };
2452
2736
  }
2737
+ /** `<t>AggregateGroups(where:, groupBy:, limit:, offset:)` → an aggregateGroups root. */
2738
+ lowerAggregateGroupsRoot(node, { alias, table }) {
2739
+ assertKnownArgs(node, ["where", "groupBy", "limit", "offset"]);
2740
+ if (!node.selectionSet) fail(`'${node.name.value}' needs a selection set`, "invalid-selection");
2741
+ const args = argsByName(node);
2742
+ const groupByArg = args.get("groupBy");
2743
+ if (!groupByArg) fail(`'${node.name.value}' requires a 'groupBy' argument`, "missing-group-by");
2744
+ const whereArg = args.get("where");
2745
+ const groupBy = this.lowerGroupBy(groupByArg.value, table);
2746
+ const aggregate = this.lowerGroupedAggregateSelection(node.selectionSet, table, groupBy);
2747
+ const offset = this.lowerOffset(args.get("offset")?.value);
2748
+ return {
2749
+ kind: "aggregateGroups",
2750
+ alias,
2751
+ table,
2752
+ where: whereArg ? this.lowerBoolExp(whereArg.value, table) : CONST_TRUE,
2753
+ groupBy,
2754
+ key: aggregate.key,
2755
+ ...aggregate.count ? { count: aggregate.count } : {},
2756
+ ...aggregate.sum ? { sum: aggregate.sum } : {},
2757
+ ...aggregate.avg ? { avg: aggregate.avg } : {},
2758
+ limit: this.lowerGroupLimit(args.get("limit")?.value),
2759
+ ...offset ? { offset } : {}
2760
+ };
2761
+ }
2762
+ /**
2763
+ * `groupBy: [{ column: startedAt, bucket: MONTH }, …]` → GroupByKeyIr[].
2764
+ *
2765
+ * Document-authored only: a whole-argument variable is rejected rather than
2766
+ * becoming a §4f slot. Group keys choose which expressions reach GROUP BY,
2767
+ * and dynamic-input.ts refuses aggregation shapes from client input on the
2768
+ * same reasoning — `where` stays dynamic, which is what runtime filtering
2769
+ * actually needs.
2770
+ */
2771
+ lowerGroupBy(node, table) {
2772
+ if (node.kind === Kind.VARIABLE) {
2773
+ fail(
2774
+ `groupBy on '${table}' must be written in the document \u2014 it cannot come from a variable`,
2775
+ "dynamic-group-by"
2776
+ );
2777
+ }
2778
+ const items = node.kind === Kind.LIST ? node.values : [node];
2779
+ if (items.length === 0) fail(`groupBy on '${table}' needs at least one key`, "invalid-group-by");
2780
+ const seen = /* @__PURE__ */ new Set();
2781
+ return items.map((item) => {
2782
+ if (item.kind !== Kind.OBJECT) {
2783
+ fail(`groupBy entries on '${table}' must be objects like { column: foo }`, "invalid-group-by");
2784
+ }
2785
+ const fields = new Map(item.fields.map((field) => [field.name.value, field.value]));
2786
+ for (const key of fields.keys()) {
2787
+ if (key !== "column" && key !== "bucket") {
2788
+ fail(`unknown groupBy field '${key}' on '${table}'`, "invalid-group-by", ["column", "bucket"]);
2789
+ }
2790
+ }
2791
+ const columnNode = fields.get("column");
2792
+ if (!columnNode) fail(`groupBy entries on '${table}' need a 'column'`, "invalid-group-by");
2793
+ if (columnNode.kind !== Kind.ENUM) {
2794
+ fail(`groupBy column on '${table}' must be a group-by column enum value`, "invalid-group-by");
2795
+ }
2796
+ const column = this.column(table, columnNode.value);
2797
+ if (!column) {
2798
+ fail(`unknown groupBy column '${columnNode.value}' on '${table}'`, "unknown-column", this.meta(table).columns.map((c) => c.name));
2799
+ }
2800
+ if (column.type === "jsonb") {
2801
+ fail(`groupBy column '${table}.${columnNode.value}' is jsonb and cannot be a group key`, "invalid-group-by");
2802
+ }
2803
+ const bucketNode = fields.get("bucket");
2804
+ let bucket;
2805
+ if (bucketNode) {
2806
+ if (bucketNode.kind !== Kind.ENUM) {
2807
+ fail(`groupBy bucket on '${table}.${columnNode.value}' must be a DateBucket enum value`, "invalid-group-by", Object.keys(DATE_BUCKET_BY_ENUM));
2808
+ }
2809
+ bucket = DATE_BUCKET_BY_ENUM[bucketNode.value];
2810
+ if (!bucket) {
2811
+ fail(`unknown DateBucket '${bucketNode.value}'`, "invalid-group-by", Object.keys(DATE_BUCKET_BY_ENUM));
2812
+ }
2813
+ if (typeof column.type !== "string" || !BUCKETABLE_TYPES.has(column.type)) {
2814
+ fail(
2815
+ `groupBy bucket is only valid on date/timestamptz columns \u2014 '${table}.${columnNode.value}' is ${typeof column.type === "string" ? column.type : "an enum"}`,
2816
+ "invalid-group-by"
2817
+ );
2818
+ }
2819
+ }
2820
+ if (seen.has(columnNode.value)) {
2821
+ fail(`duplicate groupBy column '${columnNode.value}' on '${table}'`, "invalid-group-by");
2822
+ }
2823
+ seen.add(columnNode.value);
2824
+ return bucket ? { column: columnNode.value, bucket } : { column: columnNode.value };
2825
+ });
2826
+ }
2827
+ /** Like lowerLimit, but bounded by MAX_GROUPS and defaulting to it. */
2828
+ lowerGroupLimit(node) {
2829
+ const base = { default: MAX_GROUPS, max: MAX_GROUPS };
2830
+ if (!node) return base;
2831
+ if (node.kind === Kind.VARIABLE) return { ...base, variable: node.name.value };
2832
+ if (node.kind === Kind.INT) {
2833
+ const value = Number(node.value);
2834
+ if (value < 1) fail("limit must be a positive integer", "invalid-limit");
2835
+ return { ...base, value: Math.min(value, MAX_GROUPS) };
2836
+ }
2837
+ fail("limit must be an integer literal or a variable", "invalid-limit");
2838
+ }
2453
2839
  /** byPk-style pk arguments: one argument per primary-key column, exactly. */
2454
2840
  lowerPkArgs(node, table) {
2455
2841
  const meta = this.meta(table);
@@ -2959,7 +3345,7 @@ function compileNamespace({ canonical, sdl, documents }) {
2959
3345
  const loweredRoots = lowerOperation({ restricted: { ...restricted, definition: flattened }, ctx });
2960
3346
  const { roots, tableSet } = injectPolicies({ roots: loweredRoots, ctx });
2961
3347
  operations[restricted.name] = {
2962
- irVersion: IR_VERSION,
3348
+ irVersion: roots.some((root) => root.kind === "mutation" && root.assertAffectedRows !== void 0) ? IR_VERSION : LEGACY_IR_VERSION,
2963
3349
  name: restricted.name,
2964
3350
  opHash: computeOpHash({ definition: flattened, schemaHash }),
2965
3351
  kind: restricted.kind,
@@ -2986,7 +3372,7 @@ function compileNamespace({ canonical, sdl, documents }) {
2986
3372
  }
2987
3373
  if (diagnostics.some((d) => d.severity === "error")) return { ok: false, diagnostics };
2988
3374
  const artifact = {
2989
- irVersion: IR_VERSION,
3375
+ irVersion: requiredArtifactIrVersion(Object.values(operations)),
2990
3376
  namespace: ctx.namespace,
2991
3377
  schemaHash,
2992
3378
  opsHash: computeOpsHash(Object.values(operations).map((op) => op.opHash)),