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