@typicalday/firegraph 0.12.0 → 0.13.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 (70) hide show
  1. package/README.md +317 -73
  2. package/dist/backend-DuvHGgK1.d.cts +1897 -0
  3. package/dist/backend-DuvHGgK1.d.ts +1897 -0
  4. package/dist/backend.cjs +222 -3
  5. package/dist/backend.cjs.map +1 -1
  6. package/dist/backend.d.cts +25 -5
  7. package/dist/backend.d.ts +25 -5
  8. package/dist/backend.js +197 -4
  9. package/dist/backend.js.map +1 -1
  10. package/dist/chunk-2DHMNTV6.js +16 -0
  11. package/dist/chunk-2DHMNTV6.js.map +1 -0
  12. package/dist/chunk-4MMQ5W74.js +288 -0
  13. package/dist/chunk-4MMQ5W74.js.map +1 -0
  14. package/dist/chunk-D4J7Z4FE.js +67 -0
  15. package/dist/chunk-D4J7Z4FE.js.map +1 -0
  16. package/dist/chunk-N5HFDWQX.js +23 -0
  17. package/dist/chunk-N5HFDWQX.js.map +1 -0
  18. package/dist/chunk-PAD7WFFU.js +573 -0
  19. package/dist/chunk-PAD7WFFU.js.map +1 -0
  20. package/dist/{chunk-AWW4MUJ5.js → chunk-TK64DNVK.js} +12 -1
  21. package/dist/chunk-TK64DNVK.js.map +1 -0
  22. package/dist/{chunk-HONQY4HF.js → chunk-WRTFC5NG.js} +362 -17
  23. package/dist/chunk-WRTFC5NG.js.map +1 -0
  24. package/dist/client-BKi3vk0Q.d.ts +34 -0
  25. package/dist/client-BrsaXtDV.d.cts +34 -0
  26. package/dist/cloudflare/index.cjs +930 -3
  27. package/dist/cloudflare/index.cjs.map +1 -1
  28. package/dist/cloudflare/index.d.cts +213 -12
  29. package/dist/cloudflare/index.d.ts +213 -12
  30. package/dist/cloudflare/index.js +562 -281
  31. package/dist/cloudflare/index.js.map +1 -1
  32. package/dist/codegen/index.d.cts +1 -1
  33. package/dist/codegen/index.d.ts +1 -1
  34. package/dist/errors-BRc3I_eH.d.cts +73 -0
  35. package/dist/errors-BRc3I_eH.d.ts +73 -0
  36. package/dist/firestore-enterprise/index.cjs +3877 -0
  37. package/dist/firestore-enterprise/index.cjs.map +1 -0
  38. package/dist/firestore-enterprise/index.d.cts +141 -0
  39. package/dist/firestore-enterprise/index.d.ts +141 -0
  40. package/dist/firestore-enterprise/index.js +985 -0
  41. package/dist/firestore-enterprise/index.js.map +1 -0
  42. package/dist/firestore-standard/index.cjs +3117 -0
  43. package/dist/firestore-standard/index.cjs.map +1 -0
  44. package/dist/firestore-standard/index.d.cts +49 -0
  45. package/dist/firestore-standard/index.d.ts +49 -0
  46. package/dist/firestore-standard/index.js +283 -0
  47. package/dist/firestore-standard/index.js.map +1 -0
  48. package/dist/index.cjs +590 -550
  49. package/dist/index.cjs.map +1 -1
  50. package/dist/index.d.cts +9 -37
  51. package/dist/index.d.ts +9 -37
  52. package/dist/index.js +178 -555
  53. package/dist/index.js.map +1 -1
  54. package/dist/{registry-Fi074zVa.d.ts → registry-Bc7h6WTM.d.cts} +1 -1
  55. package/dist/{registry-B1qsVL0E.d.cts → registry-C2KUPVZj.d.ts} +1 -1
  56. package/dist/{scope-path-B1G3YiA7.d.cts → scope-path-CROFZGr9.d.cts} +1 -56
  57. package/dist/{scope-path-B1G3YiA7.d.ts → scope-path-CROFZGr9.d.ts} +1 -56
  58. package/dist/sqlite/index.cjs +3631 -0
  59. package/dist/sqlite/index.cjs.map +1 -0
  60. package/dist/sqlite/index.d.cts +111 -0
  61. package/dist/sqlite/index.d.ts +111 -0
  62. package/dist/sqlite/index.js +1164 -0
  63. package/dist/sqlite/index.js.map +1 -0
  64. package/package.json +33 -3
  65. package/dist/backend-BsR0lnFL.d.ts +0 -200
  66. package/dist/backend-Ct-fLlkG.d.cts +0 -200
  67. package/dist/chunk-AWW4MUJ5.js.map +0 -1
  68. package/dist/chunk-HONQY4HF.js.map +0 -1
  69. package/dist/types-DxYLy8Ol.d.cts +0 -770
  70. package/dist/types-DxYLy8Ol.d.ts +0 -770
@@ -167,17 +167,20 @@ var init_serialization = __esm({
167
167
  // src/cloudflare/index.ts
168
168
  var cloudflare_exports = {};
169
169
  __export(cloudflare_exports, {
170
+ CapabilityNotSupportedError: () => CapabilityNotSupportedError,
170
171
  DORPCBackend: () => DORPCBackend,
171
172
  FiregraphDO: () => FiregraphDO,
172
173
  META_EDGE_TYPE: () => META_EDGE_TYPE,
173
174
  META_NODE_TYPE: () => META_NODE_TYPE,
174
175
  buildDOSchemaStatements: () => buildDOSchemaStatements,
176
+ createCapabilities: () => createCapabilities,
175
177
  createDOClient: () => createDOClient,
176
178
  createMergedRegistry: () => createMergedRegistry,
177
179
  createRegistry: () => createRegistry,
178
180
  createSiblingClient: () => createSiblingClient,
179
181
  deleteField: () => deleteField,
180
- generateId: () => generateId
182
+ generateId: () => generateId,
183
+ intersectCapabilities: () => intersectCapabilities
181
184
  });
182
185
  module.exports = __toCommonJS(cloudflare_exports);
183
186
 
@@ -235,6 +238,34 @@ var MigrationError = class extends FiregraphError {
235
238
  this.name = "MigrationError";
236
239
  }
237
240
  };
241
+ var CapabilityNotSupportedError = class extends FiregraphError {
242
+ constructor(capability, backendDescription) {
243
+ super(
244
+ `Capability "${capability}" is not supported by ${backendDescription}.`,
245
+ "CAPABILITY_NOT_SUPPORTED"
246
+ );
247
+ this.capability = capability;
248
+ this.name = "CapabilityNotSupportedError";
249
+ }
250
+ };
251
+
252
+ // src/internal/backend.ts
253
+ function createCapabilities(caps) {
254
+ return {
255
+ has: (capability) => caps.has(capability),
256
+ values: () => caps.values()
257
+ };
258
+ }
259
+ function intersectCapabilities(parts) {
260
+ if (parts.length === 0) return createCapabilities(/* @__PURE__ */ new Set());
261
+ const sets = parts.map((p) => new Set(p.values()));
262
+ const [first, ...rest] = sets;
263
+ const intersection = /* @__PURE__ */ new Set();
264
+ for (const c of first) {
265
+ if (rest.every((s) => s.has(c))) intersection.add(c);
266
+ }
267
+ return createCapabilities(intersection);
268
+ }
238
269
 
239
270
  // src/internal/constants.ts
240
271
  var NODE_RELATION = "is";
@@ -696,6 +727,9 @@ function quoteDOIdent(name) {
696
727
  validateDOTableName(name);
697
728
  return `"${name}"`;
698
729
  }
730
+ function quoteDOColumnAlias(label) {
731
+ return `"${label.replace(/"/g, '""')}"`;
732
+ }
699
733
  function buildDOSchemaStatements(table, options = {}) {
700
734
  const t = quoteDOIdent(table);
701
735
  const statements = [
@@ -842,12 +876,223 @@ function compileDOSelect(table, filters, options) {
842
876
  sql += compileLimit(options, params);
843
877
  return { sql, params };
844
878
  }
879
+ function compileDOExpand(table, params) {
880
+ if (params.sources.length === 0) {
881
+ throw new FiregraphError(
882
+ "compileDOExpand requires a non-empty sources list \u2014 empty IN () is invalid SQL.",
883
+ "INVALID_QUERY"
884
+ );
885
+ }
886
+ const direction = params.direction ?? "forward";
887
+ const aUidCol = compileFieldRef("aUid").expr;
888
+ const bUidCol = compileFieldRef("bUid").expr;
889
+ const aTypeCol = compileFieldRef("aType").expr;
890
+ const bTypeCol = compileFieldRef("bType").expr;
891
+ const axbTypeCol = compileFieldRef("axbType").expr;
892
+ const sourceColumn = direction === "forward" ? aUidCol : bUidCol;
893
+ const sqlParams = [params.axbType];
894
+ const conditions = [`${axbTypeCol} = ?`];
895
+ const placeholders = params.sources.map(() => "?").join(", ");
896
+ conditions.push(`${sourceColumn} IN (${placeholders})`);
897
+ for (const uid of params.sources) sqlParams.push(uid);
898
+ if (params.aType !== void 0) {
899
+ conditions.push(`${aTypeCol} = ?`);
900
+ sqlParams.push(params.aType);
901
+ }
902
+ if (params.bType !== void 0) {
903
+ conditions.push(`${bTypeCol} = ?`);
904
+ sqlParams.push(params.bType);
905
+ }
906
+ if (params.axbType === NODE_RELATION) {
907
+ conditions.push(`${aUidCol} != ${bUidCol}`);
908
+ }
909
+ let sql = `SELECT * FROM ${quoteDOIdent(table)} WHERE ${conditions.join(" AND ")}`;
910
+ if (params.orderBy) {
911
+ sql += compileOrderBy({ orderBy: params.orderBy }, sqlParams);
912
+ }
913
+ if (params.limitPerSource !== void 0) {
914
+ const totalLimit = params.sources.length * params.limitPerSource;
915
+ sql += ` LIMIT ?`;
916
+ sqlParams.push(totalLimit);
917
+ }
918
+ return { sql, params: sqlParams };
919
+ }
920
+ function compileDOExpandHydrate(table, targetUids) {
921
+ if (targetUids.length === 0) {
922
+ throw new FiregraphError(
923
+ "compileDOExpandHydrate requires a non-empty target list \u2014 empty IN () is invalid SQL.",
924
+ "INVALID_QUERY"
925
+ );
926
+ }
927
+ const placeholders = targetUids.map(() => "?").join(", ");
928
+ const sqlParams = [NODE_RELATION];
929
+ for (const uid of targetUids) sqlParams.push(uid);
930
+ const aUidCol = compileFieldRef("aUid").expr;
931
+ const bUidCol = compileFieldRef("bUid").expr;
932
+ const axbTypeCol = compileFieldRef("axbType").expr;
933
+ return {
934
+ sql: `SELECT * FROM ${quoteDOIdent(table)} WHERE ${axbTypeCol} = ? AND ${aUidCol} = ${bUidCol} AND ${bUidCol} IN (${placeholders})`,
935
+ params: sqlParams
936
+ };
937
+ }
845
938
  function compileDOSelectByDocId(table, docId) {
846
939
  return {
847
940
  sql: `SELECT * FROM ${quoteDOIdent(table)} WHERE "doc_id" = ? LIMIT 1`,
848
941
  params: [docId]
849
942
  };
850
943
  }
944
+ function normalizeDOProjectionField(field) {
945
+ if (field in DO_FIELD_TO_COLUMN) return field;
946
+ if (field === "data" || field.startsWith("data.")) return field;
947
+ return `data.${field}`;
948
+ }
949
+ function compileDOFindEdgesProjected(table, select, filters, options) {
950
+ if (select.length === 0) {
951
+ throw new FiregraphError(
952
+ "compileDOFindEdgesProjected requires a non-empty select list \u2014 an empty projection has no SQL representation distinct from `findEdges`.",
953
+ "INVALID_QUERY"
954
+ );
955
+ }
956
+ const seen = /* @__PURE__ */ new Set();
957
+ const uniqueFields = [];
958
+ for (const f of select) {
959
+ if (!seen.has(f)) {
960
+ seen.add(f);
961
+ uniqueFields.push(f);
962
+ }
963
+ }
964
+ const projections = [];
965
+ const columns = [];
966
+ for (let idx = 0; idx < uniqueFields.length; idx++) {
967
+ const field = uniqueFields[idx];
968
+ const canonical = normalizeDOProjectionField(field);
969
+ const { expr } = compileFieldRef(canonical);
970
+ const alias = quoteDOColumnAlias(field);
971
+ projections.push(`${expr} AS ${alias}`);
972
+ let kind;
973
+ let typeAliasName;
974
+ if (canonical === "data") {
975
+ kind = "data";
976
+ } else if (canonical.startsWith("data.")) {
977
+ kind = "json";
978
+ typeAliasName = `__fg_t_${idx}`;
979
+ const typeAlias = quoteDOColumnAlias(typeAliasName);
980
+ projections.push(`json_type("data", '$.${canonical.slice(5)}') AS ${typeAlias}`);
981
+ } else {
982
+ if (canonical === "v") kind = "builtin-int";
983
+ else if (canonical === "createdAt" || canonical === "updatedAt") kind = "builtin-timestamp";
984
+ else kind = "builtin-text";
985
+ }
986
+ columns.push({ field, kind, typeAlias: typeAliasName });
987
+ }
988
+ const params = [];
989
+ const conditions = [];
990
+ for (const f of filters) {
991
+ conditions.push(compileFilter(f, params));
992
+ }
993
+ const where = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
994
+ let sql = `SELECT ${projections.join(", ")} FROM ${quoteDOIdent(table)}${where}`;
995
+ sql += compileOrderBy(options, params);
996
+ sql += compileLimit(options, params);
997
+ return { stmt: { sql, params }, columns };
998
+ }
999
+ function decodeDOProjectedRow(row, columns) {
1000
+ const out = {};
1001
+ for (const c of columns) {
1002
+ const raw = row[c.field];
1003
+ switch (c.kind) {
1004
+ case "builtin-text":
1005
+ out[c.field] = raw === null || raw === void 0 ? null : String(raw);
1006
+ break;
1007
+ case "builtin-int":
1008
+ if (raw === null || raw === void 0) {
1009
+ out[c.field] = null;
1010
+ } else if (typeof raw === "bigint") {
1011
+ out[c.field] = Number(raw);
1012
+ } else if (typeof raw === "number") {
1013
+ out[c.field] = raw;
1014
+ } else {
1015
+ out[c.field] = Number(raw);
1016
+ }
1017
+ break;
1018
+ case "builtin-timestamp": {
1019
+ const ms = toMillis(raw);
1020
+ out[c.field] = GraphTimestampImpl.fromMillis(ms);
1021
+ break;
1022
+ }
1023
+ case "data":
1024
+ if (raw === null || raw === void 0 || raw === "") {
1025
+ out[c.field] = {};
1026
+ } else {
1027
+ out[c.field] = JSON.parse(raw);
1028
+ }
1029
+ break;
1030
+ case "json": {
1031
+ const t = row[c.typeAlias];
1032
+ if (raw === null || raw === void 0) {
1033
+ out[c.field] = null;
1034
+ } else if (t === "object" || t === "array") {
1035
+ out[c.field] = typeof raw === "string" ? JSON.parse(raw) : raw;
1036
+ } else if (t === "integer" && typeof raw === "bigint") {
1037
+ out[c.field] = Number(raw);
1038
+ } else {
1039
+ out[c.field] = raw;
1040
+ }
1041
+ break;
1042
+ }
1043
+ }
1044
+ }
1045
+ return out;
1046
+ }
1047
+ function compileDOAggregate(table, spec, filters) {
1048
+ const aliases = Object.keys(spec);
1049
+ if (aliases.length === 0) {
1050
+ throw new FiregraphError(
1051
+ "aggregate() requires at least one aggregation in the `aggregates` map.",
1052
+ "INVALID_QUERY"
1053
+ );
1054
+ }
1055
+ const projections = [];
1056
+ for (const alias of aliases) {
1057
+ const { op, field } = spec[alias];
1058
+ validateJsonPathKey(alias, DO_BACKEND_ERR_LABEL);
1059
+ if (op === "count") {
1060
+ if (field !== void 0) {
1061
+ throw new FiregraphError(
1062
+ `Aggregate '${alias}' op 'count' must not specify a field \u2014 count operates on rows, not a column expression.`,
1063
+ "INVALID_QUERY"
1064
+ );
1065
+ }
1066
+ projections.push(`COUNT(*) AS ${quoteDOIdent(alias)}`);
1067
+ continue;
1068
+ }
1069
+ if (!field) {
1070
+ throw new FiregraphError(
1071
+ `Aggregate '${alias}' op '${op}' requires a field.`,
1072
+ "INVALID_QUERY"
1073
+ );
1074
+ }
1075
+ const { expr } = compileFieldRef(field);
1076
+ const numeric = `CAST(${expr} AS REAL)`;
1077
+ if (op === "sum") projections.push(`SUM(${numeric}) AS ${quoteDOIdent(alias)}`);
1078
+ else if (op === "avg") projections.push(`AVG(${numeric}) AS ${quoteDOIdent(alias)}`);
1079
+ else if (op === "min") projections.push(`MIN(${numeric}) AS ${quoteDOIdent(alias)}`);
1080
+ else if (op === "max") projections.push(`MAX(${numeric}) AS ${quoteDOIdent(alias)}`);
1081
+ else
1082
+ throw new FiregraphError(
1083
+ `DO SQLite backend does not support aggregate op: ${String(op)}`,
1084
+ "INVALID_QUERY"
1085
+ );
1086
+ }
1087
+ const params = [];
1088
+ const conditions = [];
1089
+ for (const f of filters) {
1090
+ conditions.push(compileFilter(f, params));
1091
+ }
1092
+ const where = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
1093
+ const sql = `SELECT ${projections.join(", ")} FROM ${quoteDOIdent(table)}${where}`;
1094
+ return { stmt: { sql, params }, aliases };
1095
+ }
851
1096
  function compileDOSet(table, docId, record, nowMillis, mode) {
852
1097
  assertJsonSafePayload(record.data, DO_BACKEND_LABEL);
853
1098
  if (mode === "replace") {
@@ -938,6 +1183,55 @@ function compileDODelete(table, docId) {
938
1183
  params: [docId]
939
1184
  };
940
1185
  }
1186
+ function compileDOBulkDelete(table, filters) {
1187
+ const params = [];
1188
+ const conditions = [];
1189
+ for (const f of filters) {
1190
+ conditions.push(compileFilter(f, params));
1191
+ }
1192
+ const where = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
1193
+ return {
1194
+ sql: `DELETE FROM ${quoteDOIdent(table)}${where}`,
1195
+ params
1196
+ };
1197
+ }
1198
+ function compileDOBulkUpdate(table, filters, patchData, nowMillis) {
1199
+ const dataOps = flattenPatch(patchData);
1200
+ if (dataOps.length === 0) {
1201
+ throw new FiregraphError(
1202
+ "bulkUpdate() patch.data must contain at least one leaf \u2014 an empty patch would only rewrite `updated_at`, which is almost certainly a bug. Use `setDoc` with merge mode if you want to stamp without editing data.",
1203
+ "INVALID_QUERY"
1204
+ );
1205
+ }
1206
+ for (const op of dataOps) {
1207
+ if (!op.delete) assertJsonSafePayload(op.value, DO_BACKEND_LABEL);
1208
+ }
1209
+ const setParams = [];
1210
+ const expr = compileDataOpsExpr(
1211
+ dataOps,
1212
+ `COALESCE("data", '{}')`,
1213
+ setParams,
1214
+ DO_BACKEND_ERR_LABEL
1215
+ );
1216
+ if (expr === null) {
1217
+ throw new FiregraphError(
1218
+ "bulkUpdate() patch produced no SQL operations \u2014 internal invariant violated.",
1219
+ "INVALID_ARGUMENT"
1220
+ );
1221
+ }
1222
+ const setClauses = [`"data" = ${expr}`, `"updated_at" = ?`];
1223
+ setParams.push(nowMillis);
1224
+ const whereParams = [];
1225
+ const conditions = [];
1226
+ for (const f of filters) {
1227
+ conditions.push(compileFilter(f, whereParams));
1228
+ }
1229
+ const where = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
1230
+ return {
1231
+ sql: `UPDATE ${quoteDOIdent(table)} SET ${setClauses.join(", ")}${where}`,
1232
+ params: [...setParams, ...whereParams]
1233
+ };
1234
+ }
941
1235
  function compileDODeleteAll(table) {
942
1236
  return {
943
1237
  sql: `DELETE FROM ${quoteDOIdent(table)}`,
@@ -1029,7 +1323,18 @@ var DORPCBatchBackend = class {
1029
1323
  await this.getStub()._fgBatch(ops);
1030
1324
  }
1031
1325
  };
1326
+ var DO_CAPS = /* @__PURE__ */ new Set([
1327
+ "core.read",
1328
+ "core.write",
1329
+ "core.batch",
1330
+ "core.subgraph",
1331
+ "query.aggregate",
1332
+ "query.dml",
1333
+ "query.join",
1334
+ "query.select"
1335
+ ]);
1032
1336
  var DORPCBackend = class _DORPCBackend {
1337
+ capabilities = createCapabilities(DO_CAPS);
1033
1338
  collectionPath = "firegraph";
1034
1339
  scopePath;
1035
1340
  /** @internal */
@@ -1063,6 +1368,34 @@ var DORPCBackend = class _DORPCBackend {
1063
1368
  const wires = await this.stub._fgQuery(filters, options);
1064
1369
  return wires.map(hydrateDORecord);
1065
1370
  }
1371
+ // --- Aggregate ---
1372
+ /**
1373
+ * Run an aggregate query inside the backing DO. The DO returns a row of
1374
+ * `{ alias: number | null }` (null = SQLite NULL for SUM/MIN/MAX over an
1375
+ * empty set, or the count being literally 0); this method resolves NULL
1376
+ * to 0 for SUM/MIN/MAX and to NaN for AVG, matching the SQLite backend
1377
+ * and the Firestore Standard helper.
1378
+ */
1379
+ async aggregate(spec, filters) {
1380
+ const stub = this.stub;
1381
+ if (!stub._fgAggregate) {
1382
+ throw new FiregraphError(
1383
+ "aggregate() not supported by this Durable Object stub. The wrapped stub does not implement `_fgAggregate`. If you control the stub wrapper, forward `_fgAggregate` to the underlying DO.",
1384
+ "UNSUPPORTED_OPERATION"
1385
+ );
1386
+ }
1387
+ const wire = await stub._fgAggregate(spec, filters);
1388
+ const out = {};
1389
+ for (const [alias, { op }] of Object.entries(spec)) {
1390
+ const v = wire[alias];
1391
+ if (v === null || v === void 0) {
1392
+ out[alias] = op === "avg" ? Number.NaN : 0;
1393
+ } else {
1394
+ out[alias] = v;
1395
+ }
1396
+ }
1397
+ return out;
1398
+ }
1066
1399
  // --- Writes ---
1067
1400
  async setDoc(docId, record, mode) {
1068
1401
  return this.stub._fgSetDoc(docId, record, mode);
@@ -1119,6 +1452,105 @@ var DORPCBackend = class _DORPCBackend {
1119
1452
  void _reader;
1120
1453
  return this.stub._fgBulkRemoveEdges(params, options);
1121
1454
  }
1455
+ // --- Server-side DML (capability: query.dml) ---
1456
+ /**
1457
+ * Single-statement bulk DELETE inside the backing DO. The DO compiles
1458
+ * the filter list to one `DELETE … WHERE …` statement and returns a
1459
+ * `BulkResult` whose `deleted` is the affected-row count.
1460
+ *
1461
+ * Defensive `_fgBulkDelete` presence check mirrors `aggregate()`: the
1462
+ * RPC method is optional on `FiregraphStub` so external worker code with
1463
+ * a hand-rolled stub wrapper still type-checks. Surface a clear
1464
+ * `UNSUPPORTED_OPERATION` rather than `TypeError: stub._fgBulkDelete is
1465
+ * not a function` when the wrapper hasn't forwarded the method.
1466
+ */
1467
+ async bulkDelete(filters, options) {
1468
+ const stub = this.stub;
1469
+ if (!stub._fgBulkDelete) {
1470
+ throw new FiregraphError(
1471
+ "bulkDelete() not supported by this Durable Object stub. The wrapped stub does not implement `_fgBulkDelete`. If you control the stub wrapper, forward `_fgBulkDelete` to the underlying DO.",
1472
+ "UNSUPPORTED_OPERATION"
1473
+ );
1474
+ }
1475
+ return stub._fgBulkDelete(filters, options);
1476
+ }
1477
+ /**
1478
+ * Single-statement bulk UPDATE inside the backing DO. Same contract as
1479
+ * `bulkDelete` for the missing-method case; the DO compiles the patch to
1480
+ * one `UPDATE … SET data = json_patch(...) WHERE …` statement.
1481
+ */
1482
+ async bulkUpdate(filters, patch, options) {
1483
+ const stub = this.stub;
1484
+ if (!stub._fgBulkUpdate) {
1485
+ throw new FiregraphError(
1486
+ "bulkUpdate() not supported by this Durable Object stub. The wrapped stub does not implement `_fgBulkUpdate`. If you control the stub wrapper, forward `_fgBulkUpdate` to the underlying DO.",
1487
+ "UNSUPPORTED_OPERATION"
1488
+ );
1489
+ }
1490
+ return stub._fgBulkUpdate(filters, patch, options);
1491
+ }
1492
+ /**
1493
+ * Multi-source fan-out — `query.join` capability. Routes the call through
1494
+ * the DO's `_fgExpand` RPC, which compiles to one `SELECT … WHERE
1495
+ * "aUid" IN (?, …)` statement (plus, when `params.hydrate === true`, a
1496
+ * second IN-clause statement against the node rows).
1497
+ *
1498
+ * Defensive `_fgExpand` presence check matches the bulk-DML pattern: the
1499
+ * RPC method is optional on `FiregraphStub` so external worker code with
1500
+ * a hand-rolled stub wrapper still type-checks. We surface a clear
1501
+ * `UNSUPPORTED_OPERATION` rather than `TypeError: stub._fgExpand is not a
1502
+ * function` if the wrapper hasn't forwarded the method.
1503
+ */
1504
+ async expand(params) {
1505
+ const stub = this.stub;
1506
+ if (!stub._fgExpand) {
1507
+ throw new FiregraphError(
1508
+ "expand() not supported by this Durable Object stub. The wrapped stub does not implement `_fgExpand`. If you control the stub wrapper, forward `_fgExpand` to the underlying DO.",
1509
+ "UNSUPPORTED_OPERATION"
1510
+ );
1511
+ }
1512
+ if (params.sources.length === 0) {
1513
+ return params.hydrate ? { edges: [], targets: [] } : { edges: [] };
1514
+ }
1515
+ const wire = await stub._fgExpand(params);
1516
+ const edges = wire.edges.map(hydrateDORecord);
1517
+ if (!params.hydrate) {
1518
+ return { edges };
1519
+ }
1520
+ const targets = (wire.targets ?? []).map((row) => row ? hydrateDORecord(row) : null);
1521
+ return { edges, targets };
1522
+ }
1523
+ // --- Server-side projection (capability: query.select) ---
1524
+ /**
1525
+ * Server-side projection — `query.select` capability. Forwards the call to
1526
+ * the DO's `_fgFindEdgesProjected` RPC, which compiles to a single
1527
+ * `SELECT json_extract(...) AS …, json_type(...) AS …__t FROM <table>
1528
+ * WHERE …` statement. The DO returns raw rows + per-column metadata; this
1529
+ * method decodes each row locally via `decodeDOProjectedRow`.
1530
+ *
1531
+ * Decoding lives on this side (not inside the DO) because
1532
+ * `GraphTimestampImpl` is a class — its prototype does not survive
1533
+ * workerd's structured-clone boundary — so timestamp rehydration must
1534
+ * happen wherever the rows are consumed by the GraphClient.
1535
+ *
1536
+ * Defensive `_fgFindEdgesProjected` presence check matches the `expand` /
1537
+ * bulk-DML / aggregate pattern: the RPC method is optional on
1538
+ * `FiregraphStub` so external worker code with a hand-rolled stub wrapper
1539
+ * still type-checks. Surface a clear `UNSUPPORTED_OPERATION` rather than
1540
+ * `TypeError: stub._fgFindEdgesProjected is not a function` if the
1541
+ * wrapper hasn't forwarded the method.
1542
+ */
1543
+ async findEdgesProjected(select, filters, options) {
1544
+ const stub = this.stub;
1545
+ if (!stub._fgFindEdgesProjected) {
1546
+ throw new FiregraphError(
1547
+ "findEdgesProjected() not supported by this Durable Object stub. The wrapped stub does not implement `_fgFindEdgesProjected`. If you control the stub wrapper, forward `_fgFindEdgesProjected` to the underlying DO.",
1548
+ "UNSUPPORTED_OPERATION"
1549
+ );
1550
+ }
1551
+ const { rows, columns } = await stub._fgFindEdgesProjected(select, filters, options);
1552
+ return rows.map((row) => decodeDOProjectedRow(row, columns));
1553
+ }
1122
1554
  // --- Cross-scope queries ---
1123
1555
  //
1124
1556
  // `findEdgesGlobal` is deliberately NOT defined on this class. The
@@ -2275,6 +2707,15 @@ var GraphClientImpl = class _GraphClientImpl {
2275
2707
  this.scanProtection = options?.scanProtection ?? "error";
2276
2708
  }
2277
2709
  scanProtection;
2710
+ /**
2711
+ * Capability set of the underlying backend. Mirrors `backend.capabilities`
2712
+ * verbatim so callers can portability-check (`client.capabilities.has(
2713
+ * 'query.join')`) without reaching for the backend handle. Static for the
2714
+ * lifetime of the client.
2715
+ */
2716
+ get capabilities() {
2717
+ return this.backend.capabilities;
2718
+ }
2278
2719
  // Static mode
2279
2720
  staticRegistry;
2280
2721
  // Dynamic mode
@@ -2569,6 +3010,33 @@ var GraphClientImpl = class _GraphClientImpl {
2569
3010
  return this.applyMigrations(records);
2570
3011
  }
2571
3012
  // ---------------------------------------------------------------------------
3013
+ // Aggregate query (capability: query.aggregate)
3014
+ // ---------------------------------------------------------------------------
3015
+ async aggregate(params) {
3016
+ if (!this.backend.aggregate) {
3017
+ throw new FiregraphError(
3018
+ "aggregate() is not supported by the current storage backend.",
3019
+ "UNSUPPORTED_OPERATION"
3020
+ );
3021
+ }
3022
+ const hasAnyFilter = params.aType || params.aUid || params.axbType || params.bType || params.bUid || params.where && params.where.length > 0;
3023
+ if (!hasAnyFilter) {
3024
+ this.checkQuerySafety([], params.allowCollectionScan);
3025
+ const result2 = await this.backend.aggregate(params.aggregates, []);
3026
+ return result2;
3027
+ }
3028
+ const plan = buildEdgeQueryPlan(params);
3029
+ if (plan.strategy === "get") {
3030
+ throw new FiregraphError(
3031
+ "aggregate() requires a query, not a direct document lookup. Omit one of aUid/axbType/bUid to force a query strategy.",
3032
+ "INVALID_QUERY"
3033
+ );
3034
+ }
3035
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
3036
+ const result = await this.backend.aggregate(params.aggregates, plan.filters);
3037
+ return result;
3038
+ }
3039
+ // ---------------------------------------------------------------------------
2572
3040
  // Bulk operations
2573
3041
  // ---------------------------------------------------------------------------
2574
3042
  async removeNodeCascade(uid, options) {
@@ -2578,6 +3046,327 @@ var GraphClientImpl = class _GraphClientImpl {
2578
3046
  return this.backend.bulkRemoveEdges(params, this, options);
2579
3047
  }
2580
3048
  // ---------------------------------------------------------------------------
3049
+ // Server-side DML (capability: query.dml)
3050
+ // ---------------------------------------------------------------------------
3051
+ /**
3052
+ * Single-statement bulk DELETE. Translates `params` to a filter list via
3053
+ * `buildEdgeQueryPlan` (the same plan `findEdges` uses) and dispatches to
3054
+ * `backend.bulkDelete`. The fetch-then-delete loop in `bulkRemoveEdges`
3055
+ * is the cap-less fallback; this method is the fast path on backends
3056
+ * declaring `query.dml`.
3057
+ *
3058
+ * Scan-protection rules match `findEdges`: a query with no identifying
3059
+ * fields requires `allowCollectionScan: true` to pass. A bare-empty
3060
+ * filter set (no `aType`, `aUid`, etc., no `where`) is allowed at this
3061
+ * layer — shared SQLite bounds the blast radius via its leading `scope`
3062
+ * predicate — but the DO RPC backend rejects empty filters at the wire
3063
+ * boundary as defense-in-depth. To wipe a routed subgraph DO, use
3064
+ * `removeNodeCascade` on the parent node instead.
3065
+ */
3066
+ async bulkDelete(params, options) {
3067
+ if (!this.backend.bulkDelete) {
3068
+ throw new FiregraphError(
3069
+ "bulkDelete() is not supported by the current storage backend. Fall back to bulkRemoveEdges() for backends without query.dml (e.g. Firestore Standard).",
3070
+ "UNSUPPORTED_OPERATION"
3071
+ );
3072
+ }
3073
+ const filters = this.buildDmlFilters(params);
3074
+ return this.backend.bulkDelete(filters, options);
3075
+ }
3076
+ /**
3077
+ * Single-statement bulk UPDATE. Same translation path as `bulkDelete`,
3078
+ * but the patch is deep-merged into each matching row's `data` via the
3079
+ * shared `flattenPatch` pipeline. Identifying columns are immutable
3080
+ * through this path (see `BulkUpdatePatch` JSDoc).
3081
+ *
3082
+ * Empty-patch rejection happens inside the backend (`compileBulkUpdate`)
3083
+ * — a `data: {}` payload would only rewrite `updated_at`, which is
3084
+ * almost certainly a bug.
3085
+ */
3086
+ async bulkUpdate(params, patch, options) {
3087
+ if (!this.backend.bulkUpdate) {
3088
+ throw new FiregraphError(
3089
+ "bulkUpdate() is not supported by the current storage backend.",
3090
+ "UNSUPPORTED_OPERATION"
3091
+ );
3092
+ }
3093
+ const filters = this.buildDmlFilters(params);
3094
+ return this.backend.bulkUpdate(filters, patch, options);
3095
+ }
3096
+ // ---------------------------------------------------------------------------
3097
+ // Multi-source fan-out (capability: query.join)
3098
+ // ---------------------------------------------------------------------------
3099
+ /**
3100
+ * Fan out from `params.sources` over a single edge type in one round trip.
3101
+ * On backends without `query.join`, throws `UNSUPPORTED_OPERATION` — the
3102
+ * cap-less fallback is the per-source `findEdges` loop, which lives in
3103
+ * `traverse.ts` (the higher-level traversal walker) rather than here.
3104
+ *
3105
+ * `expand()` is intentionally edge-type-only — the source set is a flat
3106
+ * UID list and the hop matches one `axbType`. Multi-axbType expansions
3107
+ * become multiple `expand()` calls, one per relation.
3108
+ *
3109
+ * `params.sources.length === 0` short-circuits to an empty result. The
3110
+ * backend never sees the call. (`compileExpand` itself rejects empty
3111
+ * because `IN ()` is not valid SQL.)
3112
+ */
3113
+ async expand(params) {
3114
+ if (!this.backend.expand) {
3115
+ throw new FiregraphError(
3116
+ "expand() is not supported by the current storage backend. Backends without `query.join` can use createTraversal() instead \u2014 the per-hop loop is functionally equivalent (just slower).",
3117
+ "UNSUPPORTED_OPERATION"
3118
+ );
3119
+ }
3120
+ if (params.sources.length === 0) {
3121
+ return params.hydrate ? { edges: [], targets: [] } : { edges: [] };
3122
+ }
3123
+ return this.backend.expand(params);
3124
+ }
3125
+ // ---------------------------------------------------------------------------
3126
+ // Engine-level multi-hop traversal (capability: traversal.serverSide)
3127
+ // ---------------------------------------------------------------------------
3128
+ /**
3129
+ * Compile a multi-hop traversal spec into one server-side nested
3130
+ * Pipeline and dispatch a single round trip.
3131
+ *
3132
+ * Backends declaring `traversal.serverSide` (Firestore Enterprise
3133
+ * today) install this method; everywhere else, it throws
3134
+ * `UNSUPPORTED_OPERATION`. The capability gate matches the type-level
3135
+ * surface — `GraphClient<C>` only exposes `runEngineTraversal` when
3136
+ * `'traversal.serverSide' extends C`.
3137
+ *
3138
+ * Most callers should not invoke this method directly; the
3139
+ * `createTraversal(...).run()` builder routes through it
3140
+ * automatically when `engineTraversal: 'auto'` (the default) and
3141
+ * the spec is eligible per `firestore-traverse-compiler.ts`. Calling
3142
+ * directly is appropriate for benchmarking or for callers that have
3143
+ * already shaped their hop chain into the strict
3144
+ * `EngineTraversalParams` shape.
3145
+ *
3146
+ * `params.sources.length === 0` short-circuits to empty per-hop
3147
+ * arrays. The backend never sees the call.
3148
+ */
3149
+ async runEngineTraversal(params) {
3150
+ if (!this.backend.runEngineTraversal) {
3151
+ throw new FiregraphError(
3152
+ "runEngineTraversal() is not supported by the current storage backend. Backends without `traversal.serverSide` can use createTraversal() instead \u2014 the per-hop loop is functionally equivalent for in-graph specs (different round-trip profile).",
3153
+ "UNSUPPORTED_OPERATION"
3154
+ );
3155
+ }
3156
+ if (params.sources.length === 0) {
3157
+ return {
3158
+ hops: params.hops.map(() => ({ edges: [], sourceCount: 0 })),
3159
+ totalReads: 0
3160
+ };
3161
+ }
3162
+ return this.backend.runEngineTraversal(params);
3163
+ }
3164
+ // ---------------------------------------------------------------------------
3165
+ // Server-side projection (capability: query.select)
3166
+ // ---------------------------------------------------------------------------
3167
+ /**
3168
+ * Server-side projection — fetch only the requested fields from each
3169
+ * matching edge. The backend translates the call into a projecting query
3170
+ * (`SELECT json_extract(...)` on SQLite/DO, `Query.select(...)` on
3171
+ * Firestore Standard, classic projection on Enterprise) so the wire
3172
+ * payload is reduced to just the requested fields.
3173
+ *
3174
+ * Resolution rules for `select` (mirrored across all backends):
3175
+ *
3176
+ * - Built-in envelope fields (`aType`, `aUid`, `axbType`, `bType`,
3177
+ * `bUid`, `createdAt`, `updatedAt`, `v`) → resolve to the typed
3178
+ * column / Firestore field directly.
3179
+ * - `'data'` literal → returns the whole user payload.
3180
+ * - `'data.<x>'` → explicit nested path, returned at the same shape.
3181
+ * - bare name → rewritten to `data.<name>` (the canonical "give me a
3182
+ * few keys out of the JSON payload" shape).
3183
+ *
3184
+ * Empty `select: []` is rejected with `INVALID_QUERY`. Duplicate entries
3185
+ * are de-duped (first-occurrence order preserved); the result row carries
3186
+ * one slot per unique field.
3187
+ *
3188
+ * Migrations are *not* applied to the result. The caller asked for a
3189
+ * partial shape, and rehydrating it through the migration pipeline would
3190
+ * require synthesising every absent field — see
3191
+ * `StorageBackend.findEdgesProjected` for the rationale.
3192
+ *
3193
+ * Scan protection follows the `findEdges` rules: a query with no
3194
+ * identifying fields requires `allowCollectionScan: true` to pass. The
3195
+ * cap-less fallback would be `findEdges` + JS-side projection, but that
3196
+ * defeats the wire-payload reduction; backends without `query.select`
3197
+ * throw `UNSUPPORTED_OPERATION` rather than silently materialising full
3198
+ * rows.
3199
+ */
3200
+ async findEdgesProjected(params) {
3201
+ if (!this.backend.findEdgesProjected) {
3202
+ throw new FiregraphError(
3203
+ "findEdgesProjected() is not supported by the current storage backend. There is no client-side fallback because the wire-payload reduction is the entire point of the API \u2014 use findEdges() and project in JS if the backend does not declare `query.select`.",
3204
+ "UNSUPPORTED_OPERATION"
3205
+ );
3206
+ }
3207
+ if (params.select.length === 0) {
3208
+ throw new FiregraphError(
3209
+ "findEdgesProjected() requires a non-empty `select` list.",
3210
+ "INVALID_QUERY"
3211
+ );
3212
+ }
3213
+ const plan = buildEdgeQueryPlan(params);
3214
+ let filters;
3215
+ let options;
3216
+ if (plan.strategy === "get") {
3217
+ filters = [
3218
+ { field: "aUid", op: "==", value: params.aUid },
3219
+ { field: "axbType", op: "==", value: params.axbType },
3220
+ { field: "bUid", op: "==", value: params.bUid }
3221
+ ];
3222
+ if (params.aType) filters.push({ field: "aType", op: "==", value: params.aType });
3223
+ if (params.bType) filters.push({ field: "bType", op: "==", value: params.bType });
3224
+ options = void 0;
3225
+ } else {
3226
+ filters = plan.filters;
3227
+ options = plan.options;
3228
+ }
3229
+ this.checkQuerySafety(filters, params.allowCollectionScan);
3230
+ const rows = await this.backend.findEdgesProjected(params.select, filters, options);
3231
+ return rows;
3232
+ }
3233
+ /**
3234
+ * Native vector / nearest-neighbour search (capability `search.vector`).
3235
+ *
3236
+ * Resolves to the top-K records by similarity, sorted nearest-first
3237
+ * (`EUCLIDEAN` / `COSINE`) or highest-first (`DOT_PRODUCT`). The wrapper
3238
+ * is intentionally thin: capability check, scan-protection, then forward
3239
+ * `params` verbatim to the backend. All field-path normalisation and
3240
+ * SDK-shape validation lives in the shared
3241
+ * `runFirestoreFindNearest` helper that both Firestore editions call —
3242
+ * keeping it there means the validation surface stays in lockstep with
3243
+ * the SDK call site, regardless of which backend is plugged in.
3244
+ *
3245
+ * Migrations are NOT applied. The vector index walked the raw stored
3246
+ * shape; rehydrating each row through the migration pipeline would
3247
+ * change the candidate set the index already chose. If you need
3248
+ * migrated shape, follow up with `getNode` / `findEdges` on the
3249
+ * returned UIDs — those paths apply migrations normally.
3250
+ *
3251
+ * Scan-protection mirrors `findEdges`: if no identifying filters
3252
+ * (`aType` / `axbType` / `bType`) and no `where` clauses are supplied,
3253
+ * the request must opt in via `allowCollectionScan: true`. The ANN
3254
+ * query still walks the candidate set the WHERE clause produces, so
3255
+ * an unfiltered nearest-neighbour search over a million-row collection
3256
+ * is the same scan trap as an unfiltered `findEdges`.
3257
+ *
3258
+ * Backends without `search.vector` throw `UNSUPPORTED_OPERATION` —
3259
+ * there is no client-side fallback because emulating ANN over the
3260
+ * generic backend surface (`findEdges` + JS-side cosine) doesn't scale
3261
+ * past trivial datasets and would give callers the wrong mental model
3262
+ * about cost.
3263
+ */
3264
+ async findNearest(params) {
3265
+ if (!this.backend.findNearest) {
3266
+ throw new FiregraphError(
3267
+ "findNearest() is not supported by the current storage backend. Vector search requires a backend that declares `search.vector` (currently Firestore Standard and Enterprise). There is no client-side fallback because emulating ANN on top of the generic backend surface does not scale beyond toy datasets.",
3268
+ "UNSUPPORTED_OPERATION"
3269
+ );
3270
+ }
3271
+ const filters = [];
3272
+ if (params.aType) filters.push({ field: "aType", op: "==", value: params.aType });
3273
+ if (params.axbType) filters.push({ field: "axbType", op: "==", value: params.axbType });
3274
+ if (params.bType) filters.push({ field: "bType", op: "==", value: params.bType });
3275
+ if (params.where) filters.push(...params.where);
3276
+ this.checkQuerySafety(filters, params.allowCollectionScan);
3277
+ return this.backend.findNearest(params);
3278
+ }
3279
+ /**
3280
+ * Native full-text search (capability `search.fullText`).
3281
+ *
3282
+ * Returns the top-N records by relevance, ordered by the search
3283
+ * index's score. Only Firestore Enterprise declares this capability
3284
+ * today — the underlying Pipelines `search({ query: documentMatches(...) })`
3285
+ * stage requires Enterprise's FTS index. Standard does not declare
3286
+ * the cap (FTS is an Enterprise-only product feature, not a
3287
+ * typed-API gap), and the SQLite-shaped backends have no native
3288
+ * FTS index. Backends without `search.fullText` throw
3289
+ * `UNSUPPORTED_OPERATION` from this wrapper.
3290
+ *
3291
+ * Scan-protection mirrors `findNearest`: a search with no
3292
+ * identifying filters (`aType` / `axbType` / `bType`) walks every
3293
+ * row the index scored, so the request must opt in via
3294
+ * `allowCollectionScan: true`.
3295
+ *
3296
+ * Migrations are NOT applied. The FTS index walked the raw stored
3297
+ * shape; rehydrating each row through the migration pipeline would
3298
+ * change the candidate set the index already scored. If you need
3299
+ * migrated shape, follow up with `getNode` / `findEdges` on the
3300
+ * returned UIDs.
3301
+ */
3302
+ async fullTextSearch(params) {
3303
+ if (!this.backend.fullTextSearch) {
3304
+ throw new FiregraphError(
3305
+ "fullTextSearch() is not supported by the current storage backend. Full-text search requires a backend that declares `search.fullText` (currently Firestore Enterprise only \u2014 FTS is an Enterprise product feature). There is no client-side fallback because emulating FTS over the generic backend surface would not scale beyond toy datasets.",
3306
+ "UNSUPPORTED_OPERATION"
3307
+ );
3308
+ }
3309
+ const filters = [];
3310
+ if (params.aType) filters.push({ field: "aType", op: "==", value: params.aType });
3311
+ if (params.axbType) filters.push({ field: "axbType", op: "==", value: params.axbType });
3312
+ if (params.bType) filters.push({ field: "bType", op: "==", value: params.bType });
3313
+ this.checkQuerySafety(filters, params.allowCollectionScan);
3314
+ return this.backend.fullTextSearch(params);
3315
+ }
3316
+ /**
3317
+ * Native geospatial distance search (capability `search.geo`).
3318
+ *
3319
+ * Returns rows whose `geoField` lies within `radiusMeters` of
3320
+ * `point`, ordered nearest-first by default. Only Firestore
3321
+ * Enterprise declares this capability — same Enterprise-only
3322
+ * gating as `fullTextSearch`. Backends without `search.geo` throw
3323
+ * `UNSUPPORTED_OPERATION` from this wrapper.
3324
+ *
3325
+ * Scan-protection mirrors `findNearest` and `fullTextSearch`.
3326
+ *
3327
+ * Migrations are NOT applied — same rationale as the other search
3328
+ * extensions.
3329
+ */
3330
+ async geoSearch(params) {
3331
+ if (!this.backend.geoSearch) {
3332
+ throw new FiregraphError(
3333
+ "geoSearch() is not supported by the current storage backend. Geospatial search requires a backend that declares `search.geo` (currently Firestore Enterprise only \u2014 geo queries are an Enterprise product feature). There is no client-side fallback because emulating geo over the generic backend surface (haversine over `findEdges`) would not scale beyond trivial datasets.",
3334
+ "UNSUPPORTED_OPERATION"
3335
+ );
3336
+ }
3337
+ const filters = [];
3338
+ if (params.aType) filters.push({ field: "aType", op: "==", value: params.aType });
3339
+ if (params.axbType) filters.push({ field: "axbType", op: "==", value: params.axbType });
3340
+ if (params.bType) filters.push({ field: "bType", op: "==", value: params.bType });
3341
+ this.checkQuerySafety(filters, params.allowCollectionScan);
3342
+ return this.backend.geoSearch(params);
3343
+ }
3344
+ /**
3345
+ * Translate a `FindEdgesParams` into the `QueryFilter[]` shape the
3346
+ * backend `bulkDelete` / `bulkUpdate` methods expect. Mirrors the
3347
+ * `aggregate()` plan: a bare-empty params object becomes an empty
3348
+ * filter list (after a scan-protection check); a GET-shape (all three
3349
+ * identifiers) is rejected so we never silently turn a single-row
3350
+ * lookup into a server-side DML; otherwise we run `buildEdgeQueryPlan`
3351
+ * and surface its filters.
3352
+ */
3353
+ buildDmlFilters(params) {
3354
+ const hasAnyFilter = params.aType || params.aUid || params.axbType || params.bType || params.bUid || params.where && params.where.length > 0;
3355
+ if (!hasAnyFilter) {
3356
+ this.checkQuerySafety([], params.allowCollectionScan);
3357
+ return [];
3358
+ }
3359
+ const plan = buildEdgeQueryPlan(params);
3360
+ if (plan.strategy === "get") {
3361
+ throw new FiregraphError(
3362
+ "bulkDelete() / bulkUpdate() require a query, not a direct document lookup. Use removeEdge() / updateEdge() for single-row operations, or omit one of aUid/axbType/bUid to force a query strategy.",
3363
+ "INVALID_QUERY"
3364
+ );
3365
+ }
3366
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
3367
+ return plan.filters;
3368
+ }
3369
+ // ---------------------------------------------------------------------------
2581
3370
  // Dynamic registry methods
2582
3371
  // ---------------------------------------------------------------------------
2583
3372
  async defineNodeType(name, jsonSchema, description, options) {
@@ -2717,9 +3506,10 @@ var GraphClientImpl = class _GraphClientImpl {
2717
3506
  };
2718
3507
  }
2719
3508
  };
2720
- function createGraphClientFromBackend(backend, options, metaBackend) {
3509
+ function createGraphClient(backend, options, metaBackend) {
2721
3510
  return new GraphClientImpl(backend, options, metaBackend);
2722
3511
  }
3512
+ var createGraphClientFromBackend = createGraphClient;
2723
3513
 
2724
3514
  // src/cloudflare/client.ts
2725
3515
  function createDOClient(namespace, rootKey, options = {}) {
@@ -2865,6 +3655,27 @@ var FiregraphDO = class extends import_cloudflare_workers.DurableObject {
2865
3655
  const rows = this.execAll(stmt);
2866
3656
  return rows.map(rowToDORecord);
2867
3657
  }
3658
+ /**
3659
+ * Aggregate query (capability `query.aggregate`). Compiles a single
3660
+ * `SELECT` projecting one column per alias; SQLite handles count, sum,
3661
+ * avg, min, max natively. Empty-set fix-ups (NULL → 0 for sum/min/max,
3662
+ * NaN for avg) happen on the client side in `DORPCBackend.aggregate` so
3663
+ * the wire payload stays a plain row of (alias → number | null).
3664
+ */
3665
+ async _fgAggregate(spec, filters) {
3666
+ const { stmt, aliases } = compileDOAggregate(this.table, spec, filters);
3667
+ const rows = this.execAll(stmt);
3668
+ const row = rows[0] ?? {};
3669
+ const out = {};
3670
+ for (const alias of aliases) {
3671
+ const v = row[alias];
3672
+ if (v === null || v === void 0) out[alias] = null;
3673
+ else if (typeof v === "bigint") out[alias] = Number(v);
3674
+ else if (typeof v === "number") out[alias] = v;
3675
+ else out[alias] = Number(v);
3676
+ }
3677
+ return out;
3678
+ }
2868
3679
  // ---------------------------------------------------------------------------
2869
3680
  // RPC: writes
2870
3681
  // ---------------------------------------------------------------------------
@@ -3015,6 +3826,119 @@ var FiregraphDO = class extends import_cloudflare_workers.DurableObject {
3015
3826
  }
3016
3827
  }
3017
3828
  // ---------------------------------------------------------------------------
3829
+ // RPC: server-side DML (capability `query.dml`)
3830
+ //
3831
+ // Single-statement DELETE/UPDATE WHERE that the SQLite engine handles in
3832
+ // one shot — the cap-less alternative is `_fgBulkRemoveEdges` which fetches
3833
+ // doc IDs first, then deletes them one-by-one inside a transaction. The
3834
+ // DML path skips the round-trip and lets SQLite optimize the WHERE.
3835
+ //
3836
+ // RETURNING "doc_id" gives us an authoritative affected-row count; SQLite
3837
+ // ≥3.35 supports it for both DELETE and UPDATE and DO SQLite is always
3838
+ // recent enough.
3839
+ //
3840
+ // Retry policy: unlike `SqliteBackendImpl.bulkDelete` / `bulkUpdate`, which
3841
+ // wrap a chunked retry/backoff loop around each batch (D1's 1000-statement
3842
+ // cap forces chunking, so a single transient failure shouldn't kill the
3843
+ // whole job), the DO path runs a single un-chunked statement against
3844
+ // `state.storage.sql` synchronously. There's nothing to retry inside the
3845
+ // DO — the engine commits or it doesn't. If a caller wants retry semantics
3846
+ // on the wire, they wrap the `bulkDelete` / `bulkUpdate` call themselves.
3847
+ // ---------------------------------------------------------------------------
3848
+ async _fgBulkDelete(filters, _options) {
3849
+ void _options;
3850
+ if (filters.length === 0) {
3851
+ throw new FiregraphError(
3852
+ "bulkDelete() requires at least one filter when targeting a Durable Object backend. An empty filter list would wipe every row in the DO. To wipe a routed subgraph DO, use `removeNodeCascade` on the parent node or `_fgDestroy` directly on the stub.",
3853
+ "INVALID_ARGUMENT"
3854
+ );
3855
+ }
3856
+ const stmt = compileDOBulkDelete(this.table, filters);
3857
+ return this.execDmlWithReturning(stmt);
3858
+ }
3859
+ async _fgBulkUpdate(filters, patch, _options) {
3860
+ void _options;
3861
+ const stmt = compileDOBulkUpdate(this.table, filters, patch.data, Date.now());
3862
+ return this.execDmlWithReturning(stmt);
3863
+ }
3864
+ // ---------------------------------------------------------------------------
3865
+ // RPC: multi-source fan-out (`query.join`)
3866
+ //
3867
+ // One `SELECT … WHERE "aUid" IN (?, ?, …)` (or `"bUid"` for reverse)
3868
+ // collapses N per-source `findEdges` round trips into one. When the
3869
+ // caller asks for hydration, a second IN-clause statement fetches the
3870
+ // target node rows; the DO does the alignment in JS so the wire payload
3871
+ // is two `DORecordWire[]` arrays instead of a JOIN-shaped row that
3872
+ // would force a custom client-side decoder.
3873
+ // ---------------------------------------------------------------------------
3874
+ async _fgExpand(params) {
3875
+ if (params.sources.length === 0) {
3876
+ return params.hydrate ? { edges: [], targets: [] } : { edges: [] };
3877
+ }
3878
+ const stmt = compileDOExpand(this.table, params);
3879
+ const rows = this.state.storage.sql.exec(stmt.sql, ...stmt.params).toArray();
3880
+ const edges = rows.map((row) => rowToDORecord(row));
3881
+ if (!params.hydrate) {
3882
+ return { edges };
3883
+ }
3884
+ const direction = params.direction ?? "forward";
3885
+ const targetUids = edges.map((e) => direction === "forward" ? e.bUid : e.aUid);
3886
+ const uniqueTargets = [...new Set(targetUids)];
3887
+ if (uniqueTargets.length === 0) {
3888
+ return { edges, targets: [] };
3889
+ }
3890
+ const hydrateStmt = compileDOExpandHydrate(this.table, uniqueTargets);
3891
+ const hydrateRows = this.state.storage.sql.exec(hydrateStmt.sql, ...hydrateStmt.params).toArray();
3892
+ const byUid = /* @__PURE__ */ new Map();
3893
+ for (const row of hydrateRows) {
3894
+ const node = rowToDORecord(row);
3895
+ byUid.set(node.bUid, node);
3896
+ }
3897
+ const targets = targetUids.map((uid) => byUid.get(uid) ?? null);
3898
+ return { edges, targets };
3899
+ }
3900
+ // ---------------------------------------------------------------------------
3901
+ // RPC: server-side projection (`query.select`)
3902
+ //
3903
+ // One `SELECT json_extract(data, '$.f1'), …` returns the projected fields.
3904
+ // The DO leaves decoding to the client because timestamp values need to
3905
+ // rewrap as `GraphTimestampImpl` (a class instance, lost by structured
3906
+ // clone) — instead of inventing per-field timestamp sentinels, we send the
3907
+ // raw rows and the column spec, and let `DORPCBackend.findEdgesProjected`
3908
+ // call `decodeDOProjectedRow` once. The spec is small (≤ ~100 bytes for
3909
+ // a typical projection); structured clone copes happily.
3910
+ // ---------------------------------------------------------------------------
3911
+ async _fgFindEdgesProjected(select, filters, options) {
3912
+ const { stmt, columns } = compileDOFindEdgesProjected(this.table, select, filters, options);
3913
+ const rows = this.state.storage.sql.exec(stmt.sql, ...stmt.params).toArray();
3914
+ return { rows, columns };
3915
+ }
3916
+ /**
3917
+ * Run a DML statement with `RETURNING "doc_id"` so the affected-row count
3918
+ * comes back authoritatively. Errors are caught and surfaced via the
3919
+ * `BulkResult.errors` array (single batch, batchIndex 0) so the wire
3920
+ * payload stays a regular `BulkResult` and the client doesn't have to
3921
+ * differentiate "RPC threw" from "single-statement failure."
3922
+ */
3923
+ execDmlWithReturning(stmt) {
3924
+ const sqlWithReturning = `${stmt.sql} RETURNING "doc_id"`;
3925
+ try {
3926
+ const rows = this.state.storage.sql.exec(sqlWithReturning, ...stmt.params).toArray();
3927
+ return { deleted: rows.length, batches: 1, errors: [] };
3928
+ } catch (err) {
3929
+ const error = err instanceof Error ? err : new Error(String(err));
3930
+ return {
3931
+ deleted: 0,
3932
+ batches: 0,
3933
+ // Like `_fgBulkRemoveEdges`'s catch arm: a single failed statement
3934
+ // is one batch, and the operationCount is "unknown" for a server-
3935
+ // side DML — we report 0 as the lower bound. Callers that care
3936
+ // about partial state should re-query and reconcile.
3937
+ errors: [{ batchIndex: 0, error, operationCount: 0 }]
3938
+ };
3939
+ }
3940
+ }
3941
+ // ---------------------------------------------------------------------------
3018
3942
  // RPC: admin
3019
3943
  // ---------------------------------------------------------------------------
3020
3944
  /**
@@ -3053,16 +3977,19 @@ function generateId() {
3053
3977
  }
3054
3978
  // Annotate the CommonJS export names for ESM import in node:
3055
3979
  0 && (module.exports = {
3980
+ CapabilityNotSupportedError,
3056
3981
  DORPCBackend,
3057
3982
  FiregraphDO,
3058
3983
  META_EDGE_TYPE,
3059
3984
  META_NODE_TYPE,
3060
3985
  buildDOSchemaStatements,
3986
+ createCapabilities,
3061
3987
  createDOClient,
3062
3988
  createMergedRegistry,
3063
3989
  createRegistry,
3064
3990
  createSiblingClient,
3065
3991
  deleteField,
3066
- generateId
3992
+ generateId,
3993
+ intersectCapabilities
3067
3994
  });
3068
3995
  //# sourceMappingURL=index.cjs.map