@sequenceholdings/orm 0.1.3 → 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 (59) hide show
  1. package/README.md +108 -38
  2. package/dist/authoring.cjs +128 -16
  3. package/dist/authoring.cjs.map +1 -1
  4. package/dist/authoring.d.cts +21 -4
  5. package/dist/authoring.d.ts +21 -4
  6. package/dist/authoring.js +128 -16
  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 +816 -134
  15. package/dist/cli.cjs.map +1 -1
  16. package/dist/cli.js +818 -136
  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 +488 -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 +489 -97
  25. package/dist/compiler.js.map +1 -1
  26. package/dist/index.cjs +420 -71
  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 +410 -73
  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 +136 -11
  41. package/dist/predicate-v2.cjs.map +1 -1
  42. package/dist/predicate-v2.d.cts +3 -0
  43. package/dist/predicate-v2.d.ts +3 -0
  44. package/dist/predicate-v2.js +136 -11
  45. package/dist/predicate-v2.js.map +1 -1
  46. package/dist/sdl.cjs +460 -13
  47. package/dist/sdl.cjs.map +1 -1
  48. package/dist/sdl.d.cts +1 -1
  49. package/dist/sdl.d.ts +1 -1
  50. package/dist/sdl.js +459 -14
  51. package/dist/sdl.js.map +1 -1
  52. package/package.json +6 -4
  53. package/src/codegen-gql/preset.cjs +64 -0
  54. package/src/codegen-gql/server-fns.cjs +11 -6
  55. package/templates/orm-namespace/codegen.preset.cjs +17 -0
  56. package/templates/orm-namespace/codegen.ts +1 -1
  57. package/templates/orm-namespace/package.json +3 -0
  58. package/templates/orm-namespace/schema/tables.ts +1 -1
  59. package/templates/orm-namespace/sequence.config.ts +1 -1
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
@@ -815,6 +943,10 @@ var Parser = class {
815
943
  this.expect("rparen", "')'");
816
944
  return inner;
817
945
  }
946
+ if (this.peekKeyword("true") || this.peekKeyword("false")) {
947
+ const token = this.advance();
948
+ return { kind: "literal", value: token.value === "true" };
949
+ }
818
950
  if (this.peekKeyword("capability") || this.peekKeyword("role")) {
819
951
  const fn = this.peek().value;
820
952
  const node = this.parseBooleanCall(fn);
@@ -998,6 +1130,8 @@ var PolicyCompiler = class {
998
1130
  }
999
1131
  compile(node, table, depth) {
1000
1132
  switch (node.kind) {
1133
+ case "literal":
1134
+ return { kind: "const", value: node.value };
1001
1135
  case "and":
1002
1136
  case "or":
1003
1137
  return { kind: node.kind, operands: node.operands.map((operand) => this.compile(operand, table, depth)) };
@@ -1177,21 +1311,21 @@ function andPred(...preds) {
1177
1311
  if (operands.length === 1) return operands[0];
1178
1312
  return { kind: "and", operands };
1179
1313
  }
1180
- function walkPred(pred, visit) {
1181
- visit(pred);
1314
+ function walkPred(pred, visit2) {
1315
+ visit2(pred);
1182
1316
  switch (pred.kind) {
1183
1317
  case "and":
1184
1318
  case "or":
1185
- for (const operand of pred.operands) walkPred(operand, visit);
1319
+ for (const operand of pred.operands) walkPred(operand, visit2);
1186
1320
  return;
1187
1321
  case "not":
1188
- walkPred(pred.operand, visit);
1322
+ walkPred(pred.operand, visit2);
1189
1323
  return;
1190
1324
  case "exists":
1191
- walkPred(pred.where, visit);
1325
+ walkPred(pred.where, visit2);
1192
1326
  return;
1193
1327
  case "aggPredicate":
1194
- if (pred.where) walkPred(pred.where, visit);
1328
+ if (pred.where) walkPred(pred.where, visit2);
1195
1329
  return;
1196
1330
  default:
1197
1331
  return;
@@ -1216,12 +1350,12 @@ function predContains(haystack, needle) {
1216
1350
  // src/compiler-gql/inject.ts
1217
1351
  function collectPinnedColumns(writePolicy) {
1218
1352
  const key = (pin) => `${pin.column}\0${pin.claim}`;
1219
- const visit = (node) => {
1353
+ const visit2 = (node) => {
1220
1354
  if (node.kind === "and") {
1221
1355
  const merged = [];
1222
1356
  const seen2 = /* @__PURE__ */ new Set();
1223
1357
  for (const operand of node.operands) {
1224
- for (const pin of visit(operand)) {
1358
+ for (const pin of visit2(operand)) {
1225
1359
  if (seen2.has(key(pin))) continue;
1226
1360
  seen2.add(key(pin));
1227
1361
  merged.push(pin);
@@ -1232,9 +1366,9 @@ function collectPinnedColumns(writePolicy) {
1232
1366
  if (node.kind === "or") {
1233
1367
  const [first, ...rest] = node.operands;
1234
1368
  if (first === void 0) return [];
1235
- let common = visit(first);
1369
+ let common = visit2(first);
1236
1370
  for (const operand of rest) {
1237
- const branch = new Set(visit(operand).map(key));
1371
+ const branch = new Set(visit2(operand).map(key));
1238
1372
  common = common.filter((pin) => branch.has(key(pin)));
1239
1373
  if (common.length === 0) break;
1240
1374
  }
@@ -1246,7 +1380,7 @@ function collectPinnedColumns(writePolicy) {
1246
1380
  return [];
1247
1381
  };
1248
1382
  const seen = /* @__PURE__ */ new Set();
1249
- return visit(writePolicy).filter((pin) => {
1383
+ return visit2(writePolicy).filter((pin) => {
1250
1384
  if (seen.has(pin.column)) return false;
1251
1385
  seen.add(pin.column);
1252
1386
  return true;
@@ -1340,6 +1474,11 @@ var PolicyInjector = class {
1340
1474
  where: andPred(this.read(root.table), this.injectDocPred(root.where, root.table)),
1341
1475
  ...root.nodes ? { nodes: this.injectSelection(root.nodes, root.table) } : {}
1342
1476
  };
1477
+ case "aggregateGroups":
1478
+ return {
1479
+ ...root,
1480
+ where: andPred(this.read(root.table), this.injectDocPred(root.where, root.table))
1481
+ };
1343
1482
  case "mutation":
1344
1483
  return this.injectMutation(root);
1345
1484
  }
@@ -1432,20 +1571,33 @@ function collectTableSet(roots, ctx) {
1432
1571
  };
1433
1572
  for (const root of roots) {
1434
1573
  tables.add(root.table);
1435
- if (root.kind === "select" || root.kind === "byPk") {
1436
- visitPred(root.where, root.table);
1437
- visitSelection(root.selection, root.table);
1438
- }
1439
- if (root.kind === "aggregate") {
1440
- visitPred(root.where, root.table);
1441
- if (root.nodes) visitSelection(root.nodes, root.table);
1442
- }
1443
- if (root.kind === "mutation") {
1444
- if (root.cowSynced !== void 0) tables.add(root.cowSynced);
1445
- if (root.where) visitPred(root.where, root.table);
1446
- if (root.insertCheck) visitPred(root.insertCheck, root.table);
1447
- if (root.cowCopyCheck) visitPred(root.cowCopyCheck, root.table);
1448
- 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
+ }
1449
1601
  }
1450
1602
  }
1451
1603
  return [...tables].sort();
@@ -1544,7 +1696,12 @@ function tableNames(table) {
1544
1696
  sumFields: `${type}SumFields`,
1545
1697
  avgFields: `${type}AvgFields`,
1546
1698
  aggregateBoolExp: `${type}AggregateBoolExp`,
1547
- 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`
1548
1705
  };
1549
1706
  }
1550
1707
 
@@ -1555,7 +1712,12 @@ function typeNameForTable(table) {
1555
1712
  }
1556
1713
  function queryRootNames(table) {
1557
1714
  const names = tableNames(table);
1558
- 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
+ };
1559
1721
  }
1560
1722
  function mutationRootNames(table) {
1561
1723
  const names = tableNames(table);
@@ -1648,7 +1810,14 @@ function resolveColumnEntries(table, enums, diagnostics) {
1648
1810
  type = { enum: enumDef.name, values: [...enumDef.values] };
1649
1811
  }
1650
1812
  }
1651
- 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
+ };
1652
1821
  });
1653
1822
  }
1654
1823
  function logicalColumn(entries, ref) {
@@ -1697,6 +1866,7 @@ function buildCompileContext(rawCanonical) {
1697
1866
  primaryKey.push(resolved);
1698
1867
  }
1699
1868
  const active = entries.filter((c) => !c.deprecated);
1869
+ const touchOnUpdate = isView ? [] : active.filter((c) => c.onUpdateFn).map((c) => c.name);
1700
1870
  const constraints = [];
1701
1871
  if (primaryKey.length > 0) constraints.push({ name: `${table.table}_pkey`, columns: [...primaryKey] });
1702
1872
  for (const column of table.columns) {
@@ -1717,14 +1887,15 @@ function buildCompileContext(rawCanonical) {
1717
1887
  // Synced twins (and views reading them, resolved transitively below)
1718
1888
  // are claims-gated — see tableMetaIrSchema.claimsGated.
1719
1889
  claimsGated: relationIsClaimsGated(table.table),
1720
- columns: entries.map(({ deprecated: _deprecated, ...column }) => column),
1890
+ columns: entries.map(({ deprecated: _deprecated, onUpdateFn: _onUpdateFn, ...column }) => column),
1721
1891
  primaryKey: isView ? primaryKey : primaryKey.length > 0 ? primaryKey : ["id"],
1722
1892
  links: [],
1723
1893
  filterable: entries.map((c) => c.name),
1724
1894
  orderable: entries.filter((c) => c.type !== "jsonb").map((c) => c.name),
1725
1895
  insertable: isView ? [] : active.map((c) => c.name),
1726
- settable: isView ? [] : active.map((c) => c.name),
1896
+ settable: isView ? [] : active.filter((c) => !c.onUpdateFn).map((c) => c.name),
1727
1897
  appendable: isView ? [] : active.filter((c) => c.type === "jsonb").map((c) => c.name),
1898
+ ...touchOnUpdate.length > 0 ? { touchOnUpdate } : {},
1728
1899
  constraints: isView ? [] : constraints,
1729
1900
  dynamicFilterMaxDepth: 6,
1730
1901
  dynamicFilterMaxNodes: 64
@@ -1745,7 +1916,7 @@ function buildCompileContext(rawCanonical) {
1745
1916
  });
1746
1917
  continue;
1747
1918
  }
1748
- 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);
1749
1920
  const onEntries = link.kind === "toMany" ? targetEntries : entries;
1750
1921
  const referencesEntries = link.kind === "toMany" ? entries : targetEntries;
1751
1922
  const on = logicalColumn(onEntries, link.on);
@@ -1842,6 +2013,9 @@ function buildCompileContext(rawCanonical) {
1842
2013
  queryRoots.set(roots.byPk, { kind: "byPk", table: table.table });
1843
2014
  }
1844
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
+ }
1845
2019
  const hasWritePolicy = table.policies.write !== void 0 || table.policies.insert !== void 0 || table.policies.update !== void 0;
1846
2020
  if (!viewNames.has(table.table) && table.source === void 0 && hasWritePolicy) {
1847
2021
  for (const [op, field] of Object.entries(mutationRootNames(table.table))) {
@@ -1977,6 +2151,16 @@ function computeOpsHash(opHashes) {
1977
2151
  }
1978
2152
  var DEFAULT_LIMIT = 100;
1979
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"]);
1980
2164
  var BOOLEXP_OPS = {
1981
2165
  _eq: "eq",
1982
2166
  _neq: "neq",
@@ -2008,6 +2192,14 @@ function fail(message, code, candidates = []) {
2008
2192
  function argsByName(field) {
2009
2193
  return new Map((field.arguments ?? []).map((arg) => [arg.name.value, arg]));
2010
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
+ }
2011
2203
  function assertKnownArgs(field, allowed) {
2012
2204
  for (const arg of field.arguments ?? []) {
2013
2205
  if (!allowed.includes(arg.name.value)) {
@@ -2308,11 +2500,8 @@ var OperationLowerer = class {
2308
2500
  lowerAggregateSelection(selectionSet, table) {
2309
2501
  const result = {};
2310
2502
  let sawAggregate = false;
2311
- for (const node of selectionSet.selections) {
2312
- if (node.kind !== graphql.Kind.FIELD) fail("fragments must be flattened before lowering", "internal");
2503
+ for (const node of aggregateBlockFields(selectionSet)) {
2313
2504
  const name = node.name.value;
2314
- if (name === "__typename") continue;
2315
- if (node.alias) fail(`aliases are not supported inside aggregate blocks ('${node.alias.value}')`, "invalid-aggregate");
2316
2505
  if (name === "nodes") {
2317
2506
  if (!node.selectionSet) fail("'nodes' needs a selection set", "invalid-aggregate");
2318
2507
  result.nodes = this.lowerSelection(node.selectionSet, table);
@@ -2322,48 +2511,116 @@ var OperationLowerer = class {
2322
2511
  if (sawAggregate) fail("one aggregate block per field \u2014 alias the root instead", "invalid-aggregate");
2323
2512
  sawAggregate = true;
2324
2513
  if (!node.selectionSet) fail("'aggregate' needs a selection set", "invalid-aggregate");
2325
- for (const aggField of node.selectionSet.selections) {
2326
- if (aggField.kind !== graphql.Kind.FIELD) fail("fragments must be flattened before lowering", "internal");
2327
- const aggName = aggField.name.value;
2328
- if (aggName === "__typename") continue;
2329
- if (aggField.alias) fail(`aliases are not supported inside aggregate blocks ('${aggField.alias.value}')`, "invalid-aggregate");
2330
- if (aggName === "count") {
2331
- assertKnownArgs(aggField, ["columns", "distinct"]);
2332
- const args = argsByName(aggField);
2333
- const count = {};
2334
- const columns = args.get("columns");
2335
- if (columns) count.columns = this.lowerDistinctOn(columns.value, table);
2336
- const distinct = args.get("distinct");
2337
- if (distinct) {
2338
- if (distinct.value.kind !== graphql.Kind.BOOLEAN) fail("count(distinct:) must be a boolean literal", "invalid-aggregate");
2339
- count.distinct = distinct.value.value;
2340
- }
2341
- result.count = count;
2342
- 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;
2343
2599
  }
2344
- if (aggName === "sum" || aggName === "avg") {
2345
- if (!aggField.selectionSet) fail(`'${aggName}' needs a column selection`, "invalid-aggregate");
2346
- const columns = [];
2347
- for (const columnField of aggField.selectionSet.selections) {
2348
- if (columnField.kind !== graphql.Kind.FIELD) fail("fragments must be flattened before lowering", "internal");
2349
- if (columnField.name.value === "__typename") continue;
2350
- const column = this.column(table, columnField.name.value);
2351
- if (!column || typeof column.type !== "string" || !NUMERIC_TYPES.has(column.type)) {
2352
- fail(
2353
- `'${aggName}' supports numeric columns only, got '${columnField.name.value}' on '${table}'`,
2354
- "invalid-aggregate",
2355
- this.meta(table).columns.filter((c) => typeof c.type === "string" && NUMERIC_TYPES.has(c.type)).map((c) => c.name)
2356
- );
2357
- }
2358
- 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
+ );
2359
2616
  }
2360
- result[aggName] = columns;
2361
- continue;
2617
+ columns.push(columnField.name.value);
2362
2618
  }
2363
- fail(`aggregate '${aggName}' is not supported in v2.0 (count/sum/avg only)`, "invalid-aggregate", ["count", "sum", "avg"]);
2619
+ result[aggName] = columns;
2620
+ continue;
2364
2621
  }
2622
+ fail(`aggregate '${aggName}' is not supported in v2.0 (count/sum/avg only)`, "invalid-aggregate", ["count", "sum", "avg"]);
2365
2623
  }
2366
- if (!sawAggregate && !result.nodes) fail("aggregate selection needs an aggregate or nodes block", "invalid-aggregate");
2367
2624
  return result;
2368
2625
  }
2369
2626
  // -------------------------------------------------------------------------
@@ -2373,19 +2630,49 @@ var OperationLowerer = class {
2373
2630
  const roots = [];
2374
2631
  for (const node of operation.selectionSet.selections) {
2375
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);
2376
2641
  const gate = this.parseRootDirectives(node);
2377
2642
  if (gate.omit) continue;
2378
2643
  const root = kind === "query" ? this.lowerQueryRoot(node) : this.lowerMutationRoot(node);
2379
- 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);
2380
2646
  }
2381
2647
  if (roots.length === 0) fail("operation has no roots (all statically skipped?)", "invalid-root");
2382
2648
  return roots;
2383
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
+ }
2384
2670
  parseRootDirectives(node) {
2385
2671
  let includeIf;
2386
2672
  let omit = false;
2387
2673
  for (const directive of node.directives ?? []) {
2388
2674
  const name = directive.name.value;
2675
+ if (name === "assertAffectedRows") continue;
2389
2676
  if (name !== "include" && name !== "skip") {
2390
2677
  fail(`directive @${name} is not supported on fields`, "unknown-directive", ["include", "skip"]);
2391
2678
  }
@@ -2434,6 +2721,9 @@ var OperationLowerer = class {
2434
2721
  const pk = this.lowerPkArgs(node, table);
2435
2722
  return { kind: "byPk", alias, table, selection: this.lowerSelection(node.selectionSet, table), pk, where: CONST_TRUE };
2436
2723
  }
2724
+ if (rootInfo.kind === "aggregateGroups") {
2725
+ return this.lowerAggregateGroupsRoot(node, { alias, table });
2726
+ }
2437
2727
  assertKnownArgs(node, ["where"]);
2438
2728
  if (!node.selectionSet) fail(`'${name}' needs a selection set`, "invalid-selection");
2439
2729
  const whereArg = args.get("where");
@@ -2446,6 +2736,108 @@ var OperationLowerer = class {
2446
2736
  ...aggregate
2447
2737
  };
2448
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
+ }
2449
2841
  /** byPk-style pk arguments: one argument per primary-key column, exactly. */
2450
2842
  lowerPkArgs(node, table) {
2451
2843
  const meta = this.meta(table);
@@ -2955,7 +3347,7 @@ function compileNamespace({ canonical, sdl, documents }) {
2955
3347
  const loweredRoots = lowerOperation({ restricted: { ...restricted, definition: flattened }, ctx });
2956
3348
  const { roots, tableSet } = injectPolicies({ roots: loweredRoots, ctx });
2957
3349
  operations[restricted.name] = {
2958
- irVersion: IR_VERSION,
3350
+ irVersion: roots.some((root) => root.kind === "mutation" && root.assertAffectedRows !== void 0) ? IR_VERSION : LEGACY_IR_VERSION,
2959
3351
  name: restricted.name,
2960
3352
  opHash: computeOpHash({ definition: flattened, schemaHash }),
2961
3353
  kind: restricted.kind,
@@ -2982,7 +3374,7 @@ function compileNamespace({ canonical, sdl, documents }) {
2982
3374
  }
2983
3375
  if (diagnostics.some((d) => d.severity === "error")) return { ok: false, diagnostics };
2984
3376
  const artifact = {
2985
- irVersion: IR_VERSION,
3377
+ irVersion: requiredArtifactIrVersion(Object.values(operations)),
2986
3378
  namespace: ctx.namespace,
2987
3379
  schemaHash,
2988
3380
  opsHash: computeOpsHash(Object.values(operations).map((op) => op.opHash)),