@typicalday/firegraph 0.1.0 → 0.2.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.
package/dist/index.cjs CHANGED
@@ -31,6 +31,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  BOOTSTRAP_ENTRIES: () => BOOTSTRAP_ENTRIES,
34
+ DEFAULT_QUERY_LIMIT: () => DEFAULT_QUERY_LIMIT,
34
35
  DiscoveryError: () => DiscoveryError,
35
36
  DynamicRegistryError: () => DynamicRegistryError,
36
37
  EDGE_TYPE_SCHEMA: () => EDGE_TYPE_SCHEMA,
@@ -43,9 +44,12 @@ __export(index_exports, {
43
44
  NodeNotFoundError: () => NodeNotFoundError,
44
45
  QueryClient: () => QueryClient,
45
46
  QueryClientError: () => QueryClientError,
47
+ QuerySafetyError: () => QuerySafetyError,
48
+ RegistryScopeError: () => RegistryScopeError,
46
49
  RegistryViolationError: () => RegistryViolationError,
47
50
  TraversalError: () => TraversalError,
48
51
  ValidationError: () => ValidationError,
52
+ analyzeQuerySafety: () => analyzeQuerySafety,
49
53
  buildEdgeQueryPlan: () => buildEdgeQueryPlan,
50
54
  buildEdgeRecord: () => buildEdgeRecord,
51
55
  buildNodeQueryPlan: () => buildNodeQueryPlan,
@@ -63,8 +67,11 @@ __export(index_exports, {
63
67
  discoverEntities: () => discoverEntities,
64
68
  generateDeterministicUid: () => generateDeterministicUid,
65
69
  generateId: () => generateId,
70
+ generateIndexConfig: () => generateIndexConfig,
66
71
  generateTypes: () => generateTypes,
67
72
  jsonSchemaToFieldMeta: () => jsonSchemaToFieldMeta,
73
+ matchScope: () => matchScope,
74
+ matchScopeAny: () => matchScopeAny,
68
75
  resolveView: () => resolveView
69
76
  });
70
77
  module.exports = __toCommonJS(index_exports);
@@ -77,6 +84,16 @@ var import_node_crypto = require("crypto");
77
84
 
78
85
  // src/internal/constants.ts
79
86
  var NODE_RELATION = "is";
87
+ var DEFAULT_QUERY_LIMIT = 500;
88
+ var BUILTIN_FIELDS = /* @__PURE__ */ new Set([
89
+ "aType",
90
+ "aUid",
91
+ "axbType",
92
+ "bType",
93
+ "bUid",
94
+ "createdAt",
95
+ "updatedAt"
96
+ ]);
80
97
  var SHARD_SEPARATOR = ":";
81
98
 
82
99
  // src/docid.ts
@@ -173,6 +190,21 @@ var DynamicRegistryError = class extends FiregraphError {
173
190
  this.name = "DynamicRegistryError";
174
191
  }
175
192
  };
193
+ var QuerySafetyError = class extends FiregraphError {
194
+ constructor(message) {
195
+ super(message, "QUERY_SAFETY");
196
+ this.name = "QuerySafetyError";
197
+ }
198
+ };
199
+ var RegistryScopeError = class extends FiregraphError {
200
+ constructor(aType, axbType, bType, scopePath, allowedIn) {
201
+ super(
202
+ `Type (${aType}) -[${axbType}]-> (${bType}) is not allowed at scope "${scopePath || "root"}". Allowed in: [${allowedIn.join(", ")}]`,
203
+ "REGISTRY_SCOPE"
204
+ );
205
+ this.name = "RegistryScopeError";
206
+ }
207
+ };
176
208
 
177
209
  // src/query.ts
178
210
  function buildEdgeQueryPlan(params) {
@@ -186,27 +218,32 @@ function buildEdgeQueryPlan(params) {
186
218
  if (axbType) filters.push({ field: "axbType", op: "==", value: axbType });
187
219
  if (bType) filters.push({ field: "bType", op: "==", value: bType });
188
220
  if (bUid) filters.push({ field: "bUid", op: "==", value: bUid });
189
- const builtinFields = ["aType", "aUid", "axbType", "bType", "bUid", "createdAt", "updatedAt"];
190
221
  if (params.where) {
191
222
  for (const clause of params.where) {
192
- const field = builtinFields.includes(clause.field) ? clause.field : clause.field.startsWith("data.") ? clause.field : `data.${clause.field}`;
223
+ const field = BUILTIN_FIELDS.has(clause.field) ? clause.field : clause.field.startsWith("data.") ? clause.field : `data.${clause.field}`;
193
224
  filters.push({ field, op: clause.op, value: clause.value });
194
225
  }
195
226
  }
196
227
  if (filters.length === 0) {
197
228
  throw new InvalidQueryError("findEdges requires at least one filter parameter");
198
229
  }
199
- const options = limit !== void 0 || orderBy ? { limit, orderBy } : void 0;
200
- return { strategy: "query", filters, options };
230
+ const effectiveLimit = limit === void 0 ? DEFAULT_QUERY_LIMIT : limit || void 0;
231
+ return { strategy: "query", filters, options: { limit: effectiveLimit, orderBy } };
201
232
  }
202
233
  function buildNodeQueryPlan(params) {
203
- return {
204
- strategy: "query",
205
- filters: [
206
- { field: "aType", op: "==", value: params.aType },
207
- { field: "axbType", op: "==", value: NODE_RELATION }
208
- ]
209
- };
234
+ const { aType, limit, orderBy } = params;
235
+ const filters = [
236
+ { field: "aType", op: "==", value: aType },
237
+ { field: "axbType", op: "==", value: NODE_RELATION }
238
+ ];
239
+ if (params.where) {
240
+ for (const clause of params.where) {
241
+ const field = BUILTIN_FIELDS.has(clause.field) ? clause.field : clause.field.startsWith("data.") ? clause.field : `data.${clause.field}`;
242
+ filters.push({ field, op: clause.op, value: clause.value });
243
+ }
244
+ }
245
+ const effectiveLimit = limit === void 0 ? DEFAULT_QUERY_LIMIT : limit || void 0;
246
+ return { strategy: "query", filters, options: { limit: effectiveLimit, orderBy } };
210
247
  }
211
248
 
212
249
  // src/internal/firestore-adapter.ts
@@ -359,10 +396,62 @@ function createPipelineQueryAdapter(db, collectionPath) {
359
396
 
360
397
  // src/transaction.ts
361
398
  var import_firestore2 = require("@google-cloud/firestore");
399
+
400
+ // src/query-safety.ts
401
+ var SAFE_INDEX_PATTERNS = [
402
+ /* @__PURE__ */ new Set(["aUid", "axbType"]),
403
+ /* @__PURE__ */ new Set(["axbType", "bUid"]),
404
+ /* @__PURE__ */ new Set(["aType", "axbType"]),
405
+ /* @__PURE__ */ new Set(["axbType", "bType"])
406
+ ];
407
+ function analyzeQuerySafety(filters) {
408
+ const builtinFieldsPresent = /* @__PURE__ */ new Set();
409
+ let hasDataFilters = false;
410
+ for (const f of filters) {
411
+ if (BUILTIN_FIELDS.has(f.field)) {
412
+ builtinFieldsPresent.add(f.field);
413
+ } else {
414
+ hasDataFilters = true;
415
+ }
416
+ }
417
+ for (const pattern of SAFE_INDEX_PATTERNS) {
418
+ let matched = true;
419
+ for (const field of pattern) {
420
+ if (!builtinFieldsPresent.has(field)) {
421
+ matched = false;
422
+ break;
423
+ }
424
+ }
425
+ if (matched) {
426
+ return { safe: true };
427
+ }
428
+ }
429
+ const presentFields = [...builtinFieldsPresent];
430
+ if (presentFields.length === 0 && hasDataFilters) {
431
+ return {
432
+ safe: false,
433
+ reason: "Query filters only use data.* fields with no builtin field constraints. This requires a full collection scan. Add aType, aUid, axbType, bType, or bUid filters, or set allowCollectionScan: true."
434
+ };
435
+ }
436
+ if (hasDataFilters) {
437
+ return {
438
+ safe: false,
439
+ reason: `Query filters on [${presentFields.join(", ")}] do not match any indexed pattern. data.* filters without an indexed base require a full collection scan. Safe patterns: (aUid + axbType), (axbType + bUid), (aType + axbType), (axbType + bType). Set allowCollectionScan: true to override.`
440
+ };
441
+ }
442
+ return {
443
+ safe: false,
444
+ reason: `Query filters on [${presentFields.join(", ")}] do not match any indexed pattern. This may cause a full collection scan on Firestore Enterprise. Safe patterns: (aUid + axbType), (axbType + bUid), (aType + axbType), (axbType + bType). Set allowCollectionScan: true to override.`
445
+ };
446
+ }
447
+
448
+ // src/transaction.ts
362
449
  var GraphTransactionImpl = class {
363
- constructor(adapter, registry) {
450
+ constructor(adapter, registry, scanProtection = "error", scopePath = "") {
364
451
  this.adapter = adapter;
365
452
  this.registry = registry;
453
+ this.scanProtection = scanProtection;
454
+ this.scopePath = scopePath;
366
455
  }
367
456
  async getNode(uid) {
368
457
  const docId = computeNodeDocId(uid);
@@ -376,12 +465,22 @@ var GraphTransactionImpl = class {
376
465
  const record = await this.getEdge(aUid, axbType, bUid);
377
466
  return record !== null;
378
467
  }
468
+ checkQuerySafety(filters, allowCollectionScan) {
469
+ if (allowCollectionScan || this.scanProtection === "off") return;
470
+ const result = analyzeQuerySafety(filters);
471
+ if (result.safe) return;
472
+ if (this.scanProtection === "error") {
473
+ throw new QuerySafetyError(result.reason);
474
+ }
475
+ console.warn(`[firegraph] Query safety warning: ${result.reason}`);
476
+ }
379
477
  async findEdges(params) {
380
478
  const plan = buildEdgeQueryPlan(params);
381
479
  if (plan.strategy === "get") {
382
480
  const record = await this.adapter.getDoc(plan.docId);
383
481
  return record ? [record] : [];
384
482
  }
483
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
385
484
  return this.adapter.query(plan.filters, plan.options);
386
485
  }
387
486
  async findNodes(params) {
@@ -390,11 +489,12 @@ var GraphTransactionImpl = class {
390
489
  const record = await this.adapter.getDoc(plan.docId);
391
490
  return record ? [record] : [];
392
491
  }
492
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
393
493
  return this.adapter.query(plan.filters, plan.options);
394
494
  }
395
495
  async putNode(aType, uid, data) {
396
496
  if (this.registry) {
397
- this.registry.validate(aType, NODE_RELATION, aType, data);
497
+ this.registry.validate(aType, NODE_RELATION, aType, data, this.scopePath);
398
498
  }
399
499
  const docId = computeNodeDocId(uid);
400
500
  const record = buildNodeRecord(aType, uid, data);
@@ -402,7 +502,7 @@ var GraphTransactionImpl = class {
402
502
  }
403
503
  async putEdge(aType, aUid, axbType, bType, bUid, data) {
404
504
  if (this.registry) {
405
- this.registry.validate(aType, axbType, bType, data);
505
+ this.registry.validate(aType, axbType, bType, data, this.scopePath);
406
506
  }
407
507
  const docId = computeEdgeDocId(aUid, axbType, bUid);
408
508
  const record = buildEdgeRecord(aType, aUid, axbType, bType, bUid, data);
@@ -428,13 +528,14 @@ var GraphTransactionImpl = class {
428
528
  // src/batch.ts
429
529
  var import_firestore3 = require("@google-cloud/firestore");
430
530
  var GraphBatchImpl = class {
431
- constructor(adapter, registry) {
531
+ constructor(adapter, registry, scopePath = "") {
432
532
  this.adapter = adapter;
433
533
  this.registry = registry;
534
+ this.scopePath = scopePath;
434
535
  }
435
536
  async putNode(aType, uid, data) {
436
537
  if (this.registry) {
437
- this.registry.validate(aType, NODE_RELATION, aType, data);
538
+ this.registry.validate(aType, NODE_RELATION, aType, data, this.scopePath);
438
539
  }
439
540
  const docId = computeNodeDocId(uid);
440
541
  const record = buildNodeRecord(aType, uid, data);
@@ -442,7 +543,7 @@ var GraphBatchImpl = class {
442
543
  }
443
544
  async putEdge(aType, aUid, axbType, bType, bUid, data) {
444
545
  if (this.registry) {
445
- this.registry.validate(aType, axbType, bType, data);
546
+ this.registry.validate(aType, axbType, bType, data, this.scopePath);
446
547
  }
447
548
  const docId = computeEdgeDocId(aUid, axbType, bUid);
448
549
  const record = buildEdgeRecord(aType, aUid, axbType, bType, bUid, data);
@@ -534,14 +635,39 @@ async function bulkDeleteDocIds(db, collectionPath, docIds, options) {
534
635
  return { deleted, batches: completedBatches, errors };
535
636
  }
536
637
  async function bulkRemoveEdges(db, collectionPath, reader, params, options) {
537
- const edges = await reader.findEdges(params);
638
+ const effectiveParams = params.limit !== void 0 ? { ...params, allowCollectionScan: params.allowCollectionScan ?? true } : { ...params, limit: 0, allowCollectionScan: params.allowCollectionScan ?? true };
639
+ const edges = await reader.findEdges(effectiveParams);
538
640
  const docIds = edges.map((e) => computeEdgeDocId(e.aUid, e.axbType, e.bUid));
539
641
  return bulkDeleteDocIds(db, collectionPath, docIds, options);
540
642
  }
643
+ async function deleteSubcollectionsRecursive(db, collectionPath, docId, options) {
644
+ const docRef = db.collection(collectionPath).doc(docId);
645
+ const subcollections = await docRef.listCollections();
646
+ if (subcollections.length === 0) return { deleted: 0, errors: [] };
647
+ let totalDeleted = 0;
648
+ const allErrors = [];
649
+ const subOptions = options ? { batchSize: options.batchSize, maxRetries: options.maxRetries } : void 0;
650
+ for (const subCollRef of subcollections) {
651
+ const subCollPath = subCollRef.path;
652
+ const snapshot = await subCollRef.select().get();
653
+ const subDocIds = snapshot.docs.map((d) => d.id);
654
+ for (const subDocId of subDocIds) {
655
+ const subResult = await deleteSubcollectionsRecursive(db, subCollPath, subDocId, subOptions);
656
+ totalDeleted += subResult.deleted;
657
+ allErrors.push(...subResult.errors);
658
+ }
659
+ if (subDocIds.length > 0) {
660
+ const result = await bulkDeleteDocIds(db, subCollPath, subDocIds, subOptions);
661
+ totalDeleted += result.deleted;
662
+ allErrors.push(...result.errors);
663
+ }
664
+ }
665
+ return { deleted: totalDeleted, errors: allErrors };
666
+ }
541
667
  async function removeNodeCascade(db, collectionPath, reader, uid, options) {
542
668
  const [outgoingRaw, incomingRaw] = await Promise.all([
543
- reader.findEdges({ aUid: uid }),
544
- reader.findEdges({ bUid: uid })
669
+ reader.findEdges({ aUid: uid, allowCollectionScan: true, limit: 0 }),
670
+ reader.findEdges({ bUid: uid, allowCollectionScan: true, limit: 0 })
545
671
  ]);
546
672
  const outgoing = outgoingRaw.filter((e) => e.axbType !== NODE_RELATION);
547
673
  const incoming = incomingRaw.filter((e) => e.axbType !== NODE_RELATION);
@@ -554,8 +680,18 @@ async function removeNodeCascade(db, collectionPath, reader, uid, options) {
554
680
  allEdges.push(edge);
555
681
  }
556
682
  }
557
- const edgeDocIds = allEdges.map((e) => computeEdgeDocId(e.aUid, e.axbType, e.bUid));
683
+ const shouldDeleteSubcollections = options?.deleteSubcollections !== false;
558
684
  const nodeDocId = computeNodeDocId(uid);
685
+ let subcollectionResult = { deleted: 0, errors: [] };
686
+ if (shouldDeleteSubcollections) {
687
+ subcollectionResult = await deleteSubcollectionsRecursive(
688
+ db,
689
+ collectionPath,
690
+ nodeDocId,
691
+ options
692
+ );
693
+ }
694
+ const edgeDocIds = allEdges.map((e) => computeEdgeDocId(e.aUid, e.axbType, e.bUid));
559
695
  const allDocIds = [...edgeDocIds, nodeDocId];
560
696
  const batchSize = Math.min(options?.batchSize ?? MAX_BATCH_SIZE, MAX_BATCH_SIZE);
561
697
  const result = await bulkDeleteDocIds(db, collectionPath, allDocIds, {
@@ -565,9 +701,12 @@ async function removeNodeCascade(db, collectionPath, reader, uid, options) {
565
701
  const totalChunks = Math.ceil(allDocIds.length / batchSize);
566
702
  const nodeChunkIndex = totalChunks - 1;
567
703
  const nodeDeleted = !result.errors.some((e) => e.batchIndex === nodeChunkIndex);
704
+ const topLevelEdgesDeleted = nodeDeleted ? result.deleted - 1 : result.deleted;
568
705
  return {
569
- ...result,
570
- edgesDeleted: nodeDeleted ? result.deleted - 1 : result.deleted,
706
+ deleted: result.deleted + subcollectionResult.deleted,
707
+ batches: result.batches,
708
+ errors: [...result.errors, ...subcollectionResult.errors],
709
+ edgesDeleted: topLevelEdgesDeleted,
571
710
  nodeDeleted
572
711
  };
573
712
  }
@@ -667,6 +806,39 @@ function propertyToFieldMeta(name, prop, required) {
667
806
  return { name, type: "unknown", required, description: prop.description };
668
807
  }
669
808
 
809
+ // src/scope.ts
810
+ function matchScope(scopePath, pattern) {
811
+ if (pattern === "root") return scopePath === "";
812
+ if (pattern === "**") return true;
813
+ const pathSegments = scopePath === "" ? [] : scopePath.split("/");
814
+ const patternSegments = pattern.split("/");
815
+ return matchSegments(pathSegments, 0, patternSegments, 0);
816
+ }
817
+ function matchScopeAny(scopePath, patterns) {
818
+ if (!patterns || patterns.length === 0) return true;
819
+ return patterns.some((p) => matchScope(scopePath, p));
820
+ }
821
+ function matchSegments(path, pi, pattern, qi) {
822
+ if (pi === path.length && qi === pattern.length) return true;
823
+ if (qi === pattern.length) return false;
824
+ const seg = pattern[qi];
825
+ if (seg === "**") {
826
+ if (qi === pattern.length - 1) return true;
827
+ for (let skip = 0; skip <= path.length - pi; skip++) {
828
+ if (matchSegments(path, pi + skip, pattern, qi + 1)) return true;
829
+ }
830
+ return false;
831
+ }
832
+ if (pi === path.length) return false;
833
+ if (seg === "*") {
834
+ return matchSegments(path, pi + 1, pattern, qi + 1);
835
+ }
836
+ if (path[pi] === seg) {
837
+ return matchSegments(path, pi + 1, pattern, qi + 1);
838
+ }
839
+ return false;
840
+ }
841
+
670
842
  // src/registry.ts
671
843
  function tripleKey(aType, axbType, bType) {
672
844
  return `${aType}:${axbType}:${bType}`;
@@ -689,11 +861,16 @@ function createRegistry(input) {
689
861
  lookup(aType, axbType, bType) {
690
862
  return map.get(tripleKey(aType, axbType, bType))?.entry;
691
863
  },
692
- validate(aType, axbType, bType, data) {
864
+ validate(aType, axbType, bType, data, scopePath) {
693
865
  const rec = map.get(tripleKey(aType, axbType, bType));
694
866
  if (!rec) {
695
867
  throw new RegistryViolationError(aType, axbType, bType);
696
868
  }
869
+ if (scopePath !== void 0 && rec.entry.allowedIn && rec.entry.allowedIn.length > 0) {
870
+ if (!matchScopeAny(scopePath, rec.entry.allowedIn)) {
871
+ throw new RegistryScopeError(aType, axbType, bType, scopePath, rec.entry.allowedIn);
872
+ }
873
+ }
697
874
  if (rec.validate) {
698
875
  try {
699
876
  rec.validate(data);
@@ -721,7 +898,8 @@ function discoveryToEntries(discovery) {
721
898
  jsonSchema: entity.schema,
722
899
  description: entity.description,
723
900
  titleField: entity.titleField,
724
- subtitleField: entity.subtitleField
901
+ subtitleField: entity.subtitleField,
902
+ allowedIn: entity.allowedIn
725
903
  });
726
904
  }
727
905
  for (const [axbType, entity] of discovery.edges) {
@@ -739,7 +917,8 @@ function discoveryToEntries(discovery) {
739
917
  description: entity.description,
740
918
  inverseLabel: topology.inverseLabel,
741
919
  titleField: entity.titleField,
742
- subtitleField: entity.subtitleField
920
+ subtitleField: entity.subtitleField,
921
+ allowedIn: entity.allowedIn
743
922
  });
744
923
  }
745
924
  }
@@ -760,7 +939,8 @@ var NODE_TYPE_SCHEMA = {
760
939
  titleField: { type: "string" },
761
940
  subtitleField: { type: "string" },
762
941
  viewTemplate: { type: "string" },
763
- viewCss: { type: "string" }
942
+ viewCss: { type: "string" },
943
+ allowedIn: { type: "array", items: { type: "string", minLength: 1 } }
764
944
  },
765
945
  additionalProperties: false
766
946
  };
@@ -787,7 +967,8 @@ var EDGE_TYPE_SCHEMA = {
787
967
  titleField: { type: "string" },
788
968
  subtitleField: { type: "string" },
789
969
  viewTemplate: { type: "string" },
790
- viewCss: { type: "string" }
970
+ viewCss: { type: "string" },
971
+ allowedIn: { type: "array", items: { type: "string", minLength: 1 } }
791
972
  },
792
973
  additionalProperties: false
793
974
  };
@@ -829,7 +1010,8 @@ async function createRegistryFromGraph(reader) {
829
1010
  jsonSchema: data.jsonSchema,
830
1011
  description: data.description,
831
1012
  titleField: data.titleField,
832
- subtitleField: data.subtitleField
1013
+ subtitleField: data.subtitleField,
1014
+ allowedIn: data.allowedIn
833
1015
  });
834
1016
  }
835
1017
  for (const record of edgeTypes) {
@@ -846,7 +1028,8 @@ async function createRegistryFromGraph(reader) {
846
1028
  description: data.description,
847
1029
  inverseLabel: data.inverseLabel,
848
1030
  titleField: data.titleField,
849
- subtitleField: data.subtitleField
1031
+ subtitleField: data.subtitleField,
1032
+ allowedIn: data.allowedIn
850
1033
  });
851
1034
  }
852
1035
  }
@@ -857,9 +1040,10 @@ async function createRegistryFromGraph(reader) {
857
1040
  // src/client.ts
858
1041
  var _standardModeWarned = false;
859
1042
  var RESERVED_TYPE_NAMES = /* @__PURE__ */ new Set([META_NODE_TYPE, META_EDGE_TYPE]);
860
- var GraphClientImpl = class {
861
- constructor(db, collectionPath, options) {
1043
+ var GraphClientImpl = class _GraphClientImpl {
1044
+ constructor(db, collectionPath, options, scopePath = "") {
862
1045
  this.db = db;
1046
+ this.scopePath = scopePath;
863
1047
  this.adapter = createFirestoreAdapter(db, collectionPath);
864
1048
  if (options?.registry && options?.registryMode) {
865
1049
  throw new DynamicRegistryError(
@@ -889,6 +1073,7 @@ var GraphClientImpl = class {
889
1073
  "[firegraph] Standard query mode enabled. This is NOT recommended for production:\n - Enterprise Firestore: data.* filters cause full collection scans (high billing)\n - Standard Firestore: data.* filters without composite indexes will fail\n See: https://github.com/typicalday/firegraph#query-modes"
890
1074
  );
891
1075
  }
1076
+ this.scanProtection = options?.scanProtection ?? "error";
892
1077
  if (this.queryMode === "pipeline") {
893
1078
  this.pipelineAdapter = createPipelineQueryAdapter(db, collectionPath);
894
1079
  if (this.metaAdapter) {
@@ -902,6 +1087,7 @@ var GraphClientImpl = class {
902
1087
  adapter;
903
1088
  pipelineAdapter;
904
1089
  queryMode;
1090
+ scanProtection;
905
1091
  // Static mode
906
1092
  staticRegistry;
907
1093
  // Dynamic mode
@@ -910,6 +1096,8 @@ var GraphClientImpl = class {
910
1096
  dynamicRegistry;
911
1097
  metaAdapter;
912
1098
  metaPipelineAdapter;
1099
+ // Subgraph scope tracking
1100
+ scopePath;
913
1101
  // ---------------------------------------------------------------------------
914
1102
  // Registry routing
915
1103
  // ---------------------------------------------------------------------------
@@ -963,6 +1151,19 @@ var GraphClientImpl = class {
963
1151
  }
964
1152
  return this.adapter.query(filters, options);
965
1153
  }
1154
+ /**
1155
+ * Check whether a query's filter set is safe (matches a known index pattern).
1156
+ * Throws QuerySafetyError or logs a warning depending on scanProtection config.
1157
+ */
1158
+ checkQuerySafety(filters, allowCollectionScan) {
1159
+ if (allowCollectionScan || this.scanProtection === "off") return;
1160
+ const result = analyzeQuerySafety(filters);
1161
+ if (result.safe) return;
1162
+ if (this.scanProtection === "error") {
1163
+ throw new QuerySafetyError(result.reason);
1164
+ }
1165
+ console.warn(`[firegraph] Query safety warning: ${result.reason}`);
1166
+ }
966
1167
  // ---------------------------------------------------------------------------
967
1168
  // GraphReader
968
1169
  // ---------------------------------------------------------------------------
@@ -984,6 +1185,7 @@ var GraphClientImpl = class {
984
1185
  const record = await this.adapter.getDoc(plan.docId);
985
1186
  return record ? [record] : [];
986
1187
  }
1188
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
987
1189
  return this.executeQuery(plan.filters, plan.options);
988
1190
  }
989
1191
  async findNodes(params) {
@@ -992,6 +1194,7 @@ var GraphClientImpl = class {
992
1194
  const record = await this.adapter.getDoc(plan.docId);
993
1195
  return record ? [record] : [];
994
1196
  }
1197
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
995
1198
  return this.executeQuery(plan.filters, plan.options);
996
1199
  }
997
1200
  // ---------------------------------------------------------------------------
@@ -1000,7 +1203,7 @@ var GraphClientImpl = class {
1000
1203
  async putNode(aType, uid, data) {
1001
1204
  const registry = this.getRegistryForType(aType);
1002
1205
  if (registry) {
1003
- registry.validate(aType, NODE_RELATION, aType, data);
1206
+ registry.validate(aType, NODE_RELATION, aType, data, this.scopePath);
1004
1207
  }
1005
1208
  const adapter = this.getAdapterForType(aType);
1006
1209
  const docId = computeNodeDocId(uid);
@@ -1010,7 +1213,7 @@ var GraphClientImpl = class {
1010
1213
  async putEdge(aType, aUid, axbType, bType, bUid, data) {
1011
1214
  const registry = this.getRegistryForType(aType);
1012
1215
  if (registry) {
1013
- registry.validate(aType, axbType, bType, data);
1216
+ registry.validate(aType, axbType, bType, data, this.scopePath);
1014
1217
  }
1015
1218
  const adapter = this.getAdapterForType(aType);
1016
1219
  const docId = computeEdgeDocId(aUid, axbType, bUid);
@@ -1042,13 +1245,42 @@ var GraphClientImpl = class {
1042
1245
  this.adapter.collectionPath,
1043
1246
  firestoreTx
1044
1247
  );
1045
- const graphTx = new GraphTransactionImpl(adapter, this.getCombinedRegistry());
1248
+ const graphTx = new GraphTransactionImpl(adapter, this.getCombinedRegistry(), this.scanProtection, this.scopePath);
1046
1249
  return fn(graphTx);
1047
1250
  });
1048
1251
  }
1049
1252
  batch() {
1050
1253
  const adapter = createBatchAdapter(this.db, this.adapter.collectionPath);
1051
- return new GraphBatchImpl(adapter, this.getCombinedRegistry());
1254
+ return new GraphBatchImpl(adapter, this.getCombinedRegistry(), this.scopePath);
1255
+ }
1256
+ // ---------------------------------------------------------------------------
1257
+ // Subgraph
1258
+ // ---------------------------------------------------------------------------
1259
+ subgraph(parentNodeUid, name = "graph") {
1260
+ if (!parentNodeUid || parentNodeUid.includes("/")) {
1261
+ throw new FiregraphError(
1262
+ `Invalid parentNodeUid for subgraph: "${parentNodeUid}". Must be a non-empty string without "/".`,
1263
+ "INVALID_SUBGRAPH"
1264
+ );
1265
+ }
1266
+ if (name.includes("/")) {
1267
+ throw new FiregraphError(
1268
+ `Subgraph name must not contain "/": got "${name}". Use chained .subgraph() calls for nested subgraphs.`,
1269
+ "INVALID_SUBGRAPH"
1270
+ );
1271
+ }
1272
+ const subCollectionPath = `${this.adapter.collectionPath}/${parentNodeUid}/${name}`;
1273
+ const newScopePath = this.scopePath ? `${this.scopePath}/${name}` : name;
1274
+ return new _GraphClientImpl(
1275
+ this.db,
1276
+ subCollectionPath,
1277
+ {
1278
+ registry: this.getCombinedRegistry(),
1279
+ queryMode: this.queryMode === "pipeline" ? "pipeline" : "standard",
1280
+ scanProtection: this.scanProtection
1281
+ },
1282
+ newScopePath
1283
+ );
1052
1284
  }
1053
1285
  // ---------------------------------------------------------------------------
1054
1286
  // Bulk operations
@@ -1080,6 +1312,7 @@ var GraphClientImpl = class {
1080
1312
  if (options?.subtitleField !== void 0) data.subtitleField = options.subtitleField;
1081
1313
  if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
1082
1314
  if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
1315
+ if (options?.allowedIn !== void 0) data.allowedIn = options.allowedIn;
1083
1316
  await this.putNode(META_NODE_TYPE, uid, data);
1084
1317
  }
1085
1318
  async defineEdgeType(name, topology, jsonSchema, description, options) {
@@ -1106,6 +1339,7 @@ var GraphClientImpl = class {
1106
1339
  if (options?.subtitleField !== void 0) data.subtitleField = options.subtitleField;
1107
1340
  if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
1108
1341
  if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
1342
+ if (options?.allowedIn !== void 0) data.allowedIn = options.allowedIn;
1109
1343
  await this.putNode(META_EDGE_TYPE, uid, data);
1110
1344
  }
1111
1345
  async reloadRegistry() {
@@ -1264,7 +1498,9 @@ var TraversalBuilderImpl = class {
1264
1498
  }
1265
1499
  if (hop.orderBy) params.orderBy = hop.orderBy;
1266
1500
  const limit = hop.limit ?? DEFAULT_LIMIT;
1267
- if (!hop.filter) {
1501
+ if (hop.filter) {
1502
+ params.limit = 0;
1503
+ } else {
1268
1504
  params.limit = limit;
1269
1505
  }
1270
1506
  let edges = await this.reader.findEdges(params);
@@ -1515,7 +1751,8 @@ function loadNodeEntity(dir, name) {
1515
1751
  subtitleField: meta?.subtitleField,
1516
1752
  viewDefaults: meta?.viewDefaults,
1517
1753
  viewsPath,
1518
- sampleData
1754
+ sampleData,
1755
+ allowedIn: meta?.allowedIn
1519
1756
  };
1520
1757
  }
1521
1758
  function loadEdgeEntity(dir, name) {
@@ -1550,7 +1787,8 @@ function loadEdgeEntity(dir, name) {
1550
1787
  subtitleField: meta?.subtitleField,
1551
1788
  viewDefaults: meta?.viewDefaults,
1552
1789
  viewsPath,
1553
- sampleData
1790
+ sampleData,
1791
+ allowedIn: meta?.allowedIn
1554
1792
  };
1555
1793
  }
1556
1794
  function getSubdirectories(dir) {
@@ -1636,6 +1874,83 @@ async function generateTypes(discovery, options = {}) {
1636
1874
  return chunks.join("\n").trimEnd() + "\n";
1637
1875
  }
1638
1876
 
1877
+ // src/indexes.ts
1878
+ function baseIndexes(collection) {
1879
+ return [
1880
+ {
1881
+ collectionGroup: collection,
1882
+ queryScope: "COLLECTION",
1883
+ fields: [
1884
+ { fieldPath: "aUid", order: "ASCENDING" },
1885
+ { fieldPath: "axbType", order: "ASCENDING" }
1886
+ ]
1887
+ },
1888
+ {
1889
+ collectionGroup: collection,
1890
+ queryScope: "COLLECTION",
1891
+ fields: [
1892
+ { fieldPath: "axbType", order: "ASCENDING" },
1893
+ { fieldPath: "bUid", order: "ASCENDING" }
1894
+ ]
1895
+ },
1896
+ {
1897
+ collectionGroup: collection,
1898
+ queryScope: "COLLECTION",
1899
+ fields: [
1900
+ { fieldPath: "aType", order: "ASCENDING" },
1901
+ { fieldPath: "axbType", order: "ASCENDING" }
1902
+ ]
1903
+ },
1904
+ {
1905
+ collectionGroup: collection,
1906
+ queryScope: "COLLECTION",
1907
+ fields: [
1908
+ { fieldPath: "axbType", order: "ASCENDING" },
1909
+ { fieldPath: "bType", order: "ASCENDING" }
1910
+ ]
1911
+ }
1912
+ ];
1913
+ }
1914
+ function extractSchemaFields(schema) {
1915
+ const s = schema;
1916
+ if (s.type !== "object" || !s.properties) return [];
1917
+ return Object.keys(s.properties);
1918
+ }
1919
+ function generateIndexConfig(collection, entities) {
1920
+ const indexes = baseIndexes(collection);
1921
+ if (entities) {
1922
+ for (const [, entity] of entities.nodes) {
1923
+ const fields = extractSchemaFields(entity.schema);
1924
+ for (const field of fields) {
1925
+ indexes.push({
1926
+ collectionGroup: collection,
1927
+ queryScope: "COLLECTION",
1928
+ fields: [
1929
+ { fieldPath: "aType", order: "ASCENDING" },
1930
+ { fieldPath: "axbType", order: "ASCENDING" },
1931
+ { fieldPath: `data.${field}`, order: "ASCENDING" }
1932
+ ]
1933
+ });
1934
+ }
1935
+ }
1936
+ for (const [, entity] of entities.edges) {
1937
+ const fields = extractSchemaFields(entity.schema);
1938
+ for (const field of fields) {
1939
+ indexes.push({
1940
+ collectionGroup: collection,
1941
+ queryScope: "COLLECTION",
1942
+ fields: [
1943
+ { fieldPath: "aUid", order: "ASCENDING" },
1944
+ { fieldPath: "axbType", order: "ASCENDING" },
1945
+ { fieldPath: `data.${field}`, order: "ASCENDING" }
1946
+ ]
1947
+ });
1948
+ }
1949
+ }
1950
+ }
1951
+ return { indexes, fieldOverrides: [] };
1952
+ }
1953
+
1639
1954
  // src/query-client/client.ts
1640
1955
  var import_node_http = __toESM(require("http"), 1);
1641
1956
 
@@ -1914,6 +2229,7 @@ var QueryClient = class {
1914
2229
  // Annotate the CommonJS export names for ESM import in node:
1915
2230
  0 && (module.exports = {
1916
2231
  BOOTSTRAP_ENTRIES,
2232
+ DEFAULT_QUERY_LIMIT,
1917
2233
  DiscoveryError,
1918
2234
  DynamicRegistryError,
1919
2235
  EDGE_TYPE_SCHEMA,
@@ -1926,9 +2242,12 @@ var QueryClient = class {
1926
2242
  NodeNotFoundError,
1927
2243
  QueryClient,
1928
2244
  QueryClientError,
2245
+ QuerySafetyError,
2246
+ RegistryScopeError,
1929
2247
  RegistryViolationError,
1930
2248
  TraversalError,
1931
2249
  ValidationError,
2250
+ analyzeQuerySafety,
1932
2251
  buildEdgeQueryPlan,
1933
2252
  buildEdgeRecord,
1934
2253
  buildNodeQueryPlan,
@@ -1946,8 +2265,11 @@ var QueryClient = class {
1946
2265
  discoverEntities,
1947
2266
  generateDeterministicUid,
1948
2267
  generateId,
2268
+ generateIndexConfig,
1949
2269
  generateTypes,
1950
2270
  jsonSchemaToFieldMeta,
2271
+ matchScope,
2272
+ matchScopeAny,
1951
2273
  resolveView
1952
2274
  });
1953
2275
  //# sourceMappingURL=index.cjs.map