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