@typicalday/firegraph 0.1.0 → 0.3.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.js CHANGED
@@ -14,6 +14,16 @@ import { createHash } from "crypto";
14
14
 
15
15
  // src/internal/constants.ts
16
16
  var NODE_RELATION = "is";
17
+ var DEFAULT_QUERY_LIMIT = 500;
18
+ var BUILTIN_FIELDS = /* @__PURE__ */ new Set([
19
+ "aType",
20
+ "aUid",
21
+ "axbType",
22
+ "bType",
23
+ "bUid",
24
+ "createdAt",
25
+ "updatedAt"
26
+ ]);
17
27
  var SHARD_SEPARATOR = ":";
18
28
 
19
29
  // src/docid.ts
@@ -110,6 +120,21 @@ var DynamicRegistryError = class extends FiregraphError {
110
120
  this.name = "DynamicRegistryError";
111
121
  }
112
122
  };
123
+ var QuerySafetyError = class extends FiregraphError {
124
+ constructor(message) {
125
+ super(message, "QUERY_SAFETY");
126
+ this.name = "QuerySafetyError";
127
+ }
128
+ };
129
+ var RegistryScopeError = class extends FiregraphError {
130
+ constructor(aType, axbType, bType, scopePath, allowedIn) {
131
+ super(
132
+ `Type (${aType}) -[${axbType}]-> (${bType}) is not allowed at scope "${scopePath || "root"}". Allowed in: [${allowedIn.join(", ")}]`,
133
+ "REGISTRY_SCOPE"
134
+ );
135
+ this.name = "RegistryScopeError";
136
+ }
137
+ };
113
138
 
114
139
  // src/query.ts
115
140
  function buildEdgeQueryPlan(params) {
@@ -123,27 +148,32 @@ function buildEdgeQueryPlan(params) {
123
148
  if (axbType) filters.push({ field: "axbType", op: "==", value: axbType });
124
149
  if (bType) filters.push({ field: "bType", op: "==", value: bType });
125
150
  if (bUid) filters.push({ field: "bUid", op: "==", value: bUid });
126
- const builtinFields = ["aType", "aUid", "axbType", "bType", "bUid", "createdAt", "updatedAt"];
127
151
  if (params.where) {
128
152
  for (const clause of params.where) {
129
- const field = builtinFields.includes(clause.field) ? clause.field : clause.field.startsWith("data.") ? clause.field : `data.${clause.field}`;
153
+ const field = BUILTIN_FIELDS.has(clause.field) ? clause.field : clause.field.startsWith("data.") ? clause.field : `data.${clause.field}`;
130
154
  filters.push({ field, op: clause.op, value: clause.value });
131
155
  }
132
156
  }
133
157
  if (filters.length === 0) {
134
158
  throw new InvalidQueryError("findEdges requires at least one filter parameter");
135
159
  }
136
- const options = limit !== void 0 || orderBy ? { limit, orderBy } : void 0;
137
- return { strategy: "query", filters, options };
160
+ const effectiveLimit = limit === void 0 ? DEFAULT_QUERY_LIMIT : limit || void 0;
161
+ return { strategy: "query", filters, options: { limit: effectiveLimit, orderBy } };
138
162
  }
139
163
  function buildNodeQueryPlan(params) {
140
- return {
141
- strategy: "query",
142
- filters: [
143
- { field: "aType", op: "==", value: params.aType },
144
- { field: "axbType", op: "==", value: NODE_RELATION }
145
- ]
146
- };
164
+ const { aType, limit, orderBy } = params;
165
+ const filters = [
166
+ { field: "aType", op: "==", value: aType },
167
+ { field: "axbType", op: "==", value: NODE_RELATION }
168
+ ];
169
+ if (params.where) {
170
+ for (const clause of params.where) {
171
+ const field = BUILTIN_FIELDS.has(clause.field) ? clause.field : clause.field.startsWith("data.") ? clause.field : `data.${clause.field}`;
172
+ filters.push({ field, op: clause.op, value: clause.value });
173
+ }
174
+ }
175
+ const effectiveLimit = limit === void 0 ? DEFAULT_QUERY_LIMIT : limit || void 0;
176
+ return { strategy: "query", filters, options: { limit: effectiveLimit, orderBy } };
147
177
  }
148
178
 
149
179
  // src/internal/firestore-adapter.ts
@@ -296,10 +326,62 @@ function createPipelineQueryAdapter(db, collectionPath) {
296
326
 
297
327
  // src/transaction.ts
298
328
  import { FieldValue as FieldValue2 } from "@google-cloud/firestore";
329
+
330
+ // src/query-safety.ts
331
+ var SAFE_INDEX_PATTERNS = [
332
+ /* @__PURE__ */ new Set(["aUid", "axbType"]),
333
+ /* @__PURE__ */ new Set(["axbType", "bUid"]),
334
+ /* @__PURE__ */ new Set(["aType", "axbType"]),
335
+ /* @__PURE__ */ new Set(["axbType", "bType"])
336
+ ];
337
+ function analyzeQuerySafety(filters) {
338
+ const builtinFieldsPresent = /* @__PURE__ */ new Set();
339
+ let hasDataFilters = false;
340
+ for (const f of filters) {
341
+ if (BUILTIN_FIELDS.has(f.field)) {
342
+ builtinFieldsPresent.add(f.field);
343
+ } else {
344
+ hasDataFilters = true;
345
+ }
346
+ }
347
+ for (const pattern of SAFE_INDEX_PATTERNS) {
348
+ let matched = true;
349
+ for (const field of pattern) {
350
+ if (!builtinFieldsPresent.has(field)) {
351
+ matched = false;
352
+ break;
353
+ }
354
+ }
355
+ if (matched) {
356
+ return { safe: true };
357
+ }
358
+ }
359
+ const presentFields = [...builtinFieldsPresent];
360
+ if (presentFields.length === 0 && hasDataFilters) {
361
+ return {
362
+ safe: false,
363
+ 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."
364
+ };
365
+ }
366
+ if (hasDataFilters) {
367
+ return {
368
+ safe: false,
369
+ 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.`
370
+ };
371
+ }
372
+ return {
373
+ safe: false,
374
+ 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.`
375
+ };
376
+ }
377
+
378
+ // src/transaction.ts
299
379
  var GraphTransactionImpl = class {
300
- constructor(adapter, registry) {
380
+ constructor(adapter, registry, scanProtection = "error", scopePath = "") {
301
381
  this.adapter = adapter;
302
382
  this.registry = registry;
383
+ this.scanProtection = scanProtection;
384
+ this.scopePath = scopePath;
303
385
  }
304
386
  async getNode(uid) {
305
387
  const docId = computeNodeDocId(uid);
@@ -313,12 +395,22 @@ var GraphTransactionImpl = class {
313
395
  const record = await this.getEdge(aUid, axbType, bUid);
314
396
  return record !== null;
315
397
  }
398
+ checkQuerySafety(filters, allowCollectionScan) {
399
+ if (allowCollectionScan || this.scanProtection === "off") return;
400
+ const result = analyzeQuerySafety(filters);
401
+ if (result.safe) return;
402
+ if (this.scanProtection === "error") {
403
+ throw new QuerySafetyError(result.reason);
404
+ }
405
+ console.warn(`[firegraph] Query safety warning: ${result.reason}`);
406
+ }
316
407
  async findEdges(params) {
317
408
  const plan = buildEdgeQueryPlan(params);
318
409
  if (plan.strategy === "get") {
319
410
  const record = await this.adapter.getDoc(plan.docId);
320
411
  return record ? [record] : [];
321
412
  }
413
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
322
414
  return this.adapter.query(plan.filters, plan.options);
323
415
  }
324
416
  async findNodes(params) {
@@ -327,11 +419,12 @@ var GraphTransactionImpl = class {
327
419
  const record = await this.adapter.getDoc(plan.docId);
328
420
  return record ? [record] : [];
329
421
  }
422
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
330
423
  return this.adapter.query(plan.filters, plan.options);
331
424
  }
332
425
  async putNode(aType, uid, data) {
333
426
  if (this.registry) {
334
- this.registry.validate(aType, NODE_RELATION, aType, data);
427
+ this.registry.validate(aType, NODE_RELATION, aType, data, this.scopePath);
335
428
  }
336
429
  const docId = computeNodeDocId(uid);
337
430
  const record = buildNodeRecord(aType, uid, data);
@@ -339,7 +432,7 @@ var GraphTransactionImpl = class {
339
432
  }
340
433
  async putEdge(aType, aUid, axbType, bType, bUid, data) {
341
434
  if (this.registry) {
342
- this.registry.validate(aType, axbType, bType, data);
435
+ this.registry.validate(aType, axbType, bType, data, this.scopePath);
343
436
  }
344
437
  const docId = computeEdgeDocId(aUid, axbType, bUid);
345
438
  const record = buildEdgeRecord(aType, aUid, axbType, bType, bUid, data);
@@ -365,13 +458,14 @@ var GraphTransactionImpl = class {
365
458
  // src/batch.ts
366
459
  import { FieldValue as FieldValue3 } from "@google-cloud/firestore";
367
460
  var GraphBatchImpl = class {
368
- constructor(adapter, registry) {
461
+ constructor(adapter, registry, scopePath = "") {
369
462
  this.adapter = adapter;
370
463
  this.registry = registry;
464
+ this.scopePath = scopePath;
371
465
  }
372
466
  async putNode(aType, uid, data) {
373
467
  if (this.registry) {
374
- this.registry.validate(aType, NODE_RELATION, aType, data);
468
+ this.registry.validate(aType, NODE_RELATION, aType, data, this.scopePath);
375
469
  }
376
470
  const docId = computeNodeDocId(uid);
377
471
  const record = buildNodeRecord(aType, uid, data);
@@ -379,7 +473,7 @@ var GraphBatchImpl = class {
379
473
  }
380
474
  async putEdge(aType, aUid, axbType, bType, bUid, data) {
381
475
  if (this.registry) {
382
- this.registry.validate(aType, axbType, bType, data);
476
+ this.registry.validate(aType, axbType, bType, data, this.scopePath);
383
477
  }
384
478
  const docId = computeEdgeDocId(aUid, axbType, bUid);
385
479
  const record = buildEdgeRecord(aType, aUid, axbType, bType, bUid, data);
@@ -471,14 +565,39 @@ async function bulkDeleteDocIds(db, collectionPath, docIds, options) {
471
565
  return { deleted, batches: completedBatches, errors };
472
566
  }
473
567
  async function bulkRemoveEdges(db, collectionPath, reader, params, options) {
474
- const edges = await reader.findEdges(params);
568
+ const effectiveParams = params.limit !== void 0 ? { ...params, allowCollectionScan: params.allowCollectionScan ?? true } : { ...params, limit: 0, allowCollectionScan: params.allowCollectionScan ?? true };
569
+ const edges = await reader.findEdges(effectiveParams);
475
570
  const docIds = edges.map((e) => computeEdgeDocId(e.aUid, e.axbType, e.bUid));
476
571
  return bulkDeleteDocIds(db, collectionPath, docIds, options);
477
572
  }
573
+ async function deleteSubcollectionsRecursive(db, collectionPath, docId, options) {
574
+ const docRef = db.collection(collectionPath).doc(docId);
575
+ const subcollections = await docRef.listCollections();
576
+ if (subcollections.length === 0) return { deleted: 0, errors: [] };
577
+ let totalDeleted = 0;
578
+ const allErrors = [];
579
+ const subOptions = options ? { batchSize: options.batchSize, maxRetries: options.maxRetries } : void 0;
580
+ for (const subCollRef of subcollections) {
581
+ const subCollPath = subCollRef.path;
582
+ const snapshot = await subCollRef.select().get();
583
+ const subDocIds = snapshot.docs.map((d) => d.id);
584
+ for (const subDocId of subDocIds) {
585
+ const subResult = await deleteSubcollectionsRecursive(db, subCollPath, subDocId, subOptions);
586
+ totalDeleted += subResult.deleted;
587
+ allErrors.push(...subResult.errors);
588
+ }
589
+ if (subDocIds.length > 0) {
590
+ const result = await bulkDeleteDocIds(db, subCollPath, subDocIds, subOptions);
591
+ totalDeleted += result.deleted;
592
+ allErrors.push(...result.errors);
593
+ }
594
+ }
595
+ return { deleted: totalDeleted, errors: allErrors };
596
+ }
478
597
  async function removeNodeCascade(db, collectionPath, reader, uid, options) {
479
598
  const [outgoingRaw, incomingRaw] = await Promise.all([
480
- reader.findEdges({ aUid: uid }),
481
- reader.findEdges({ bUid: uid })
599
+ reader.findEdges({ aUid: uid, allowCollectionScan: true, limit: 0 }),
600
+ reader.findEdges({ bUid: uid, allowCollectionScan: true, limit: 0 })
482
601
  ]);
483
602
  const outgoing = outgoingRaw.filter((e) => e.axbType !== NODE_RELATION);
484
603
  const incoming = incomingRaw.filter((e) => e.axbType !== NODE_RELATION);
@@ -491,8 +610,18 @@ async function removeNodeCascade(db, collectionPath, reader, uid, options) {
491
610
  allEdges.push(edge);
492
611
  }
493
612
  }
494
- const edgeDocIds = allEdges.map((e) => computeEdgeDocId(e.aUid, e.axbType, e.bUid));
613
+ const shouldDeleteSubcollections = options?.deleteSubcollections !== false;
495
614
  const nodeDocId = computeNodeDocId(uid);
615
+ let subcollectionResult = { deleted: 0, errors: [] };
616
+ if (shouldDeleteSubcollections) {
617
+ subcollectionResult = await deleteSubcollectionsRecursive(
618
+ db,
619
+ collectionPath,
620
+ nodeDocId,
621
+ options
622
+ );
623
+ }
624
+ const edgeDocIds = allEdges.map((e) => computeEdgeDocId(e.aUid, e.axbType, e.bUid));
496
625
  const allDocIds = [...edgeDocIds, nodeDocId];
497
626
  const batchSize = Math.min(options?.batchSize ?? MAX_BATCH_SIZE, MAX_BATCH_SIZE);
498
627
  const result = await bulkDeleteDocIds(db, collectionPath, allDocIds, {
@@ -502,9 +631,12 @@ async function removeNodeCascade(db, collectionPath, reader, uid, options) {
502
631
  const totalChunks = Math.ceil(allDocIds.length / batchSize);
503
632
  const nodeChunkIndex = totalChunks - 1;
504
633
  const nodeDeleted = !result.errors.some((e) => e.batchIndex === nodeChunkIndex);
634
+ const topLevelEdgesDeleted = nodeDeleted ? result.deleted - 1 : result.deleted;
505
635
  return {
506
- ...result,
507
- edgesDeleted: nodeDeleted ? result.deleted - 1 : result.deleted,
636
+ deleted: result.deleted + subcollectionResult.deleted,
637
+ batches: result.batches,
638
+ errors: [...result.errors, ...subcollectionResult.errors],
639
+ edgesDeleted: topLevelEdgesDeleted,
508
640
  nodeDeleted
509
641
  };
510
642
  }
@@ -604,6 +736,39 @@ function propertyToFieldMeta(name, prop, required) {
604
736
  return { name, type: "unknown", required, description: prop.description };
605
737
  }
606
738
 
739
+ // src/scope.ts
740
+ function matchScope(scopePath, pattern) {
741
+ if (pattern === "root") return scopePath === "";
742
+ if (pattern === "**") return true;
743
+ const pathSegments = scopePath === "" ? [] : scopePath.split("/");
744
+ const patternSegments = pattern.split("/");
745
+ return matchSegments(pathSegments, 0, patternSegments, 0);
746
+ }
747
+ function matchScopeAny(scopePath, patterns) {
748
+ if (!patterns || patterns.length === 0) return true;
749
+ return patterns.some((p) => matchScope(scopePath, p));
750
+ }
751
+ function matchSegments(path, pi, pattern, qi) {
752
+ if (pi === path.length && qi === pattern.length) return true;
753
+ if (qi === pattern.length) return false;
754
+ const seg = pattern[qi];
755
+ if (seg === "**") {
756
+ if (qi === pattern.length - 1) return true;
757
+ for (let skip = 0; skip <= path.length - pi; skip++) {
758
+ if (matchSegments(path, pi + skip, pattern, qi + 1)) return true;
759
+ }
760
+ return false;
761
+ }
762
+ if (pi === path.length) return false;
763
+ if (seg === "*") {
764
+ return matchSegments(path, pi + 1, pattern, qi + 1);
765
+ }
766
+ if (path[pi] === seg) {
767
+ return matchSegments(path, pi + 1, pattern, qi + 1);
768
+ }
769
+ return false;
770
+ }
771
+
607
772
  // src/registry.ts
608
773
  function tripleKey(aType, axbType, bType) {
609
774
  return `${aType}:${axbType}:${bType}`;
@@ -618,19 +783,45 @@ function createRegistry(input) {
618
783
  }
619
784
  const entryList = Object.freeze([...entries]);
620
785
  for (const entry of entries) {
786
+ if (entry.targetGraph && entry.targetGraph.includes("/")) {
787
+ throw new ValidationError(
788
+ `Entry (${entry.aType}) -[${entry.axbType}]-> (${entry.bType}) has invalid targetGraph "${entry.targetGraph}" \u2014 must be a single segment (no "/")`
789
+ );
790
+ }
621
791
  const key = tripleKey(entry.aType, entry.axbType, entry.bType);
622
792
  const validator = entry.jsonSchema ? compileSchema(entry.jsonSchema, `(${entry.aType}) -[${entry.axbType}]-> (${entry.bType})`) : void 0;
623
793
  map.set(key, { entry, validate: validator });
624
794
  }
795
+ const axbIndex = /* @__PURE__ */ new Map();
796
+ const axbBuild = /* @__PURE__ */ new Map();
797
+ for (const entry of entries) {
798
+ const existing = axbBuild.get(entry.axbType);
799
+ if (existing) {
800
+ existing.push(entry);
801
+ } else {
802
+ axbBuild.set(entry.axbType, [entry]);
803
+ }
804
+ }
805
+ for (const [key, arr] of axbBuild) {
806
+ axbIndex.set(key, Object.freeze(arr));
807
+ }
625
808
  return {
626
809
  lookup(aType, axbType, bType) {
627
810
  return map.get(tripleKey(aType, axbType, bType))?.entry;
628
811
  },
629
- validate(aType, axbType, bType, data) {
812
+ lookupByAxbType(axbType) {
813
+ return axbIndex.get(axbType) ?? [];
814
+ },
815
+ validate(aType, axbType, bType, data, scopePath) {
630
816
  const rec = map.get(tripleKey(aType, axbType, bType));
631
817
  if (!rec) {
632
818
  throw new RegistryViolationError(aType, axbType, bType);
633
819
  }
820
+ if (scopePath !== void 0 && rec.entry.allowedIn && rec.entry.allowedIn.length > 0) {
821
+ if (!matchScopeAny(scopePath, rec.entry.allowedIn)) {
822
+ throw new RegistryScopeError(aType, axbType, bType, scopePath, rec.entry.allowedIn);
823
+ }
824
+ }
634
825
  if (rec.validate) {
635
826
  try {
636
827
  rec.validate(data);
@@ -658,7 +849,8 @@ function discoveryToEntries(discovery) {
658
849
  jsonSchema: entity.schema,
659
850
  description: entity.description,
660
851
  titleField: entity.titleField,
661
- subtitleField: entity.subtitleField
852
+ subtitleField: entity.subtitleField,
853
+ allowedIn: entity.allowedIn
662
854
  });
663
855
  }
664
856
  for (const [axbType, entity] of discovery.edges) {
@@ -666,6 +858,12 @@ function discoveryToEntries(discovery) {
666
858
  if (!topology) continue;
667
859
  const fromTypes = Array.isArray(topology.from) ? topology.from : [topology.from];
668
860
  const toTypes = Array.isArray(topology.to) ? topology.to : [topology.to];
861
+ const resolvedTargetGraph = entity.targetGraph ?? topology.targetGraph;
862
+ if (resolvedTargetGraph && resolvedTargetGraph.includes("/")) {
863
+ throw new ValidationError(
864
+ `Edge "${axbType}" has invalid targetGraph "${resolvedTargetGraph}" \u2014 must be a single segment (no "/")`
865
+ );
866
+ }
669
867
  for (const aType of fromTypes) {
670
868
  for (const bType of toTypes) {
671
869
  entries.push({
@@ -676,7 +874,9 @@ function discoveryToEntries(discovery) {
676
874
  description: entity.description,
677
875
  inverseLabel: topology.inverseLabel,
678
876
  titleField: entity.titleField,
679
- subtitleField: entity.subtitleField
877
+ subtitleField: entity.subtitleField,
878
+ allowedIn: entity.allowedIn,
879
+ targetGraph: resolvedTargetGraph
680
880
  });
681
881
  }
682
882
  }
@@ -697,7 +897,8 @@ var NODE_TYPE_SCHEMA = {
697
897
  titleField: { type: "string" },
698
898
  subtitleField: { type: "string" },
699
899
  viewTemplate: { type: "string" },
700
- viewCss: { type: "string" }
900
+ viewCss: { type: "string" },
901
+ allowedIn: { type: "array", items: { type: "string", minLength: 1 } }
701
902
  },
702
903
  additionalProperties: false
703
904
  };
@@ -724,7 +925,9 @@ var EDGE_TYPE_SCHEMA = {
724
925
  titleField: { type: "string" },
725
926
  subtitleField: { type: "string" },
726
927
  viewTemplate: { type: "string" },
727
- viewCss: { type: "string" }
928
+ viewCss: { type: "string" },
929
+ allowedIn: { type: "array", items: { type: "string", minLength: 1 } },
930
+ targetGraph: { type: "string", minLength: 1, pattern: "^[^/]+$" }
728
931
  },
729
932
  additionalProperties: false
730
933
  };
@@ -766,7 +969,8 @@ async function createRegistryFromGraph(reader) {
766
969
  jsonSchema: data.jsonSchema,
767
970
  description: data.description,
768
971
  titleField: data.titleField,
769
- subtitleField: data.subtitleField
972
+ subtitleField: data.subtitleField,
973
+ allowedIn: data.allowedIn
770
974
  });
771
975
  }
772
976
  for (const record of edgeTypes) {
@@ -783,7 +987,9 @@ async function createRegistryFromGraph(reader) {
783
987
  description: data.description,
784
988
  inverseLabel: data.inverseLabel,
785
989
  titleField: data.titleField,
786
- subtitleField: data.subtitleField
990
+ subtitleField: data.subtitleField,
991
+ allowedIn: data.allowedIn,
992
+ targetGraph: data.targetGraph
787
993
  });
788
994
  }
789
995
  }
@@ -794,9 +1000,10 @@ async function createRegistryFromGraph(reader) {
794
1000
  // src/client.ts
795
1001
  var _standardModeWarned = false;
796
1002
  var RESERVED_TYPE_NAMES = /* @__PURE__ */ new Set([META_NODE_TYPE, META_EDGE_TYPE]);
797
- var GraphClientImpl = class {
798
- constructor(db, collectionPath, options) {
1003
+ var GraphClientImpl = class _GraphClientImpl {
1004
+ constructor(db, collectionPath, options, scopePath = "") {
799
1005
  this.db = db;
1006
+ this.scopePath = scopePath;
800
1007
  this.adapter = createFirestoreAdapter(db, collectionPath);
801
1008
  if (options?.registry && options?.registryMode) {
802
1009
  throw new DynamicRegistryError(
@@ -826,6 +1033,7 @@ var GraphClientImpl = class {
826
1033
  "[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"
827
1034
  );
828
1035
  }
1036
+ this.scanProtection = options?.scanProtection ?? "error";
829
1037
  if (this.queryMode === "pipeline") {
830
1038
  this.pipelineAdapter = createPipelineQueryAdapter(db, collectionPath);
831
1039
  if (this.metaAdapter) {
@@ -839,6 +1047,7 @@ var GraphClientImpl = class {
839
1047
  adapter;
840
1048
  pipelineAdapter;
841
1049
  queryMode;
1050
+ scanProtection;
842
1051
  // Static mode
843
1052
  staticRegistry;
844
1053
  // Dynamic mode
@@ -847,6 +1056,8 @@ var GraphClientImpl = class {
847
1056
  dynamicRegistry;
848
1057
  metaAdapter;
849
1058
  metaPipelineAdapter;
1059
+ // Subgraph scope tracking
1060
+ scopePath;
850
1061
  // ---------------------------------------------------------------------------
851
1062
  // Registry routing
852
1063
  // ---------------------------------------------------------------------------
@@ -900,6 +1111,19 @@ var GraphClientImpl = class {
900
1111
  }
901
1112
  return this.adapter.query(filters, options);
902
1113
  }
1114
+ /**
1115
+ * Check whether a query's filter set is safe (matches a known index pattern).
1116
+ * Throws QuerySafetyError or logs a warning depending on scanProtection config.
1117
+ */
1118
+ checkQuerySafety(filters, allowCollectionScan) {
1119
+ if (allowCollectionScan || this.scanProtection === "off") return;
1120
+ const result = analyzeQuerySafety(filters);
1121
+ if (result.safe) return;
1122
+ if (this.scanProtection === "error") {
1123
+ throw new QuerySafetyError(result.reason);
1124
+ }
1125
+ console.warn(`[firegraph] Query safety warning: ${result.reason}`);
1126
+ }
903
1127
  // ---------------------------------------------------------------------------
904
1128
  // GraphReader
905
1129
  // ---------------------------------------------------------------------------
@@ -921,6 +1145,7 @@ var GraphClientImpl = class {
921
1145
  const record = await this.adapter.getDoc(plan.docId);
922
1146
  return record ? [record] : [];
923
1147
  }
1148
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
924
1149
  return this.executeQuery(plan.filters, plan.options);
925
1150
  }
926
1151
  async findNodes(params) {
@@ -929,6 +1154,7 @@ var GraphClientImpl = class {
929
1154
  const record = await this.adapter.getDoc(plan.docId);
930
1155
  return record ? [record] : [];
931
1156
  }
1157
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
932
1158
  return this.executeQuery(plan.filters, plan.options);
933
1159
  }
934
1160
  // ---------------------------------------------------------------------------
@@ -937,7 +1163,7 @@ var GraphClientImpl = class {
937
1163
  async putNode(aType, uid, data) {
938
1164
  const registry = this.getRegistryForType(aType);
939
1165
  if (registry) {
940
- registry.validate(aType, NODE_RELATION, aType, data);
1166
+ registry.validate(aType, NODE_RELATION, aType, data, this.scopePath);
941
1167
  }
942
1168
  const adapter = this.getAdapterForType(aType);
943
1169
  const docId = computeNodeDocId(uid);
@@ -947,7 +1173,7 @@ var GraphClientImpl = class {
947
1173
  async putEdge(aType, aUid, axbType, bType, bUid, data) {
948
1174
  const registry = this.getRegistryForType(aType);
949
1175
  if (registry) {
950
- registry.validate(aType, axbType, bType, data);
1176
+ registry.validate(aType, axbType, bType, data, this.scopePath);
951
1177
  }
952
1178
  const adapter = this.getAdapterForType(aType);
953
1179
  const docId = computeEdgeDocId(aUid, axbType, bUid);
@@ -979,13 +1205,69 @@ var GraphClientImpl = class {
979
1205
  this.adapter.collectionPath,
980
1206
  firestoreTx
981
1207
  );
982
- const graphTx = new GraphTransactionImpl(adapter, this.getCombinedRegistry());
1208
+ const graphTx = new GraphTransactionImpl(adapter, this.getCombinedRegistry(), this.scanProtection, this.scopePath);
983
1209
  return fn(graphTx);
984
1210
  });
985
1211
  }
986
1212
  batch() {
987
1213
  const adapter = createBatchAdapter(this.db, this.adapter.collectionPath);
988
- return new GraphBatchImpl(adapter, this.getCombinedRegistry());
1214
+ return new GraphBatchImpl(adapter, this.getCombinedRegistry(), this.scopePath);
1215
+ }
1216
+ // ---------------------------------------------------------------------------
1217
+ // Subgraph
1218
+ // ---------------------------------------------------------------------------
1219
+ subgraph(parentNodeUid, name = "graph") {
1220
+ if (!parentNodeUid || parentNodeUid.includes("/")) {
1221
+ throw new FiregraphError(
1222
+ `Invalid parentNodeUid for subgraph: "${parentNodeUid}". Must be a non-empty string without "/".`,
1223
+ "INVALID_SUBGRAPH"
1224
+ );
1225
+ }
1226
+ if (name.includes("/")) {
1227
+ throw new FiregraphError(
1228
+ `Subgraph name must not contain "/": got "${name}". Use chained .subgraph() calls for nested subgraphs.`,
1229
+ "INVALID_SUBGRAPH"
1230
+ );
1231
+ }
1232
+ const subCollectionPath = `${this.adapter.collectionPath}/${parentNodeUid}/${name}`;
1233
+ const newScopePath = this.scopePath ? `${this.scopePath}/${name}` : name;
1234
+ return new _GraphClientImpl(
1235
+ this.db,
1236
+ subCollectionPath,
1237
+ {
1238
+ registry: this.getCombinedRegistry(),
1239
+ queryMode: this.queryMode === "pipeline" ? "pipeline" : "standard",
1240
+ scanProtection: this.scanProtection
1241
+ },
1242
+ newScopePath
1243
+ );
1244
+ }
1245
+ // ---------------------------------------------------------------------------
1246
+ // Collection group query
1247
+ // ---------------------------------------------------------------------------
1248
+ async findEdgesGlobal(params, collectionName) {
1249
+ const name = collectionName ?? this.adapter.collectionPath.split("/").pop();
1250
+ const plan = buildEdgeQueryPlan(params);
1251
+ if (plan.strategy === "get") {
1252
+ throw new FiregraphError(
1253
+ "findEdgesGlobal() requires a query, not a direct document lookup. Omit one of aUid/axbType/bUid to force a query strategy.",
1254
+ "INVALID_QUERY"
1255
+ );
1256
+ }
1257
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
1258
+ const collectionGroupRef = this.db.collectionGroup(name);
1259
+ let q = collectionGroupRef;
1260
+ for (const f of plan.filters) {
1261
+ q = q.where(f.field, f.op, f.value);
1262
+ }
1263
+ if (plan.options?.orderBy) {
1264
+ q = q.orderBy(plan.options.orderBy.field, plan.options.orderBy.direction ?? "asc");
1265
+ }
1266
+ if (plan.options?.limit !== void 0) {
1267
+ q = q.limit(plan.options.limit);
1268
+ }
1269
+ const snap = await q.get();
1270
+ return snap.docs.map((doc) => doc.data());
989
1271
  }
990
1272
  // ---------------------------------------------------------------------------
991
1273
  // Bulk operations
@@ -1017,6 +1299,7 @@ var GraphClientImpl = class {
1017
1299
  if (options?.subtitleField !== void 0) data.subtitleField = options.subtitleField;
1018
1300
  if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
1019
1301
  if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
1302
+ if (options?.allowedIn !== void 0) data.allowedIn = options.allowedIn;
1020
1303
  await this.putNode(META_NODE_TYPE, uid, data);
1021
1304
  }
1022
1305
  async defineEdgeType(name, topology, jsonSchema, description, options) {
@@ -1038,11 +1321,13 @@ var GraphClientImpl = class {
1038
1321
  };
1039
1322
  if (jsonSchema !== void 0) data.jsonSchema = jsonSchema;
1040
1323
  if (topology.inverseLabel !== void 0) data.inverseLabel = topology.inverseLabel;
1324
+ if (topology.targetGraph !== void 0) data.targetGraph = topology.targetGraph;
1041
1325
  if (description !== void 0) data.description = description;
1042
1326
  if (options?.titleField !== void 0) data.titleField = options.titleField;
1043
1327
  if (options?.subtitleField !== void 0) data.subtitleField = options.subtitleField;
1044
1328
  if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
1045
1329
  if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
1330
+ if (options?.allowedIn !== void 0) data.allowedIn = options.allowedIn;
1046
1331
  await this.putNode(META_EDGE_TYPE, uid, data);
1047
1332
  }
1048
1333
  async reloadRegistry() {
@@ -1111,6 +1396,10 @@ function generateId() {
1111
1396
  var DEFAULT_LIMIT = 10;
1112
1397
  var DEFAULT_MAX_READS = 100;
1113
1398
  var DEFAULT_CONCURRENCY = 5;
1399
+ var _crossGraphWarned = false;
1400
+ function isGraphClient(reader) {
1401
+ return "subgraph" in reader && typeof reader.subgraph === "function";
1402
+ }
1114
1403
  var Semaphore = class {
1115
1404
  constructor(slots) {
1116
1405
  this.slots = slots;
@@ -1136,9 +1425,10 @@ var Semaphore = class {
1136
1425
  }
1137
1426
  };
1138
1427
  var TraversalBuilderImpl = class {
1139
- constructor(reader, startUid) {
1428
+ constructor(reader, startUid, registry) {
1140
1429
  this.reader = reader;
1141
1430
  this.startUid = startUid;
1431
+ this.registry = registry;
1142
1432
  }
1143
1433
  hops = [];
1144
1434
  follow(axbType, options) {
@@ -1155,11 +1445,13 @@ var TraversalBuilderImpl = class {
1155
1445
  const semaphore = new Semaphore(concurrency);
1156
1446
  let totalReads = 0;
1157
1447
  let truncated = false;
1158
- let sourceUids = [this.startUid];
1448
+ let sources = [
1449
+ { uid: this.startUid, reader: this.reader }
1450
+ ];
1159
1451
  const hopResults = [];
1160
1452
  for (let depth = 0; depth < this.hops.length; depth++) {
1161
1453
  const hop = this.hops[depth];
1162
- if (sourceUids.length === 0) {
1454
+ if (sources.length === 0) {
1163
1455
  hopResults.push({
1164
1456
  axbType: hop.axbType,
1165
1457
  depth,
@@ -1170,9 +1462,12 @@ var TraversalBuilderImpl = class {
1170
1462
  continue;
1171
1463
  }
1172
1464
  const hopEdges = [];
1173
- const sourceCount = sourceUids.length;
1465
+ const sourceCount = sources.length;
1174
1466
  let hopTruncated = false;
1175
- const tasks = sourceUids.map((uid) => async () => {
1467
+ const resolvedTargetGraph = this.resolveTargetGraph(hop);
1468
+ const direction = hop.direction ?? "forward";
1469
+ const isCrossGraph = direction === "forward" && !!resolvedTargetGraph;
1470
+ const tasks = sources.map(({ uid, reader: sourceReader }) => async () => {
1176
1471
  if (totalReads >= maxReads) {
1177
1472
  hopTruncated = true;
1178
1473
  return;
@@ -1184,51 +1479,79 @@ var TraversalBuilderImpl = class {
1184
1479
  return;
1185
1480
  }
1186
1481
  totalReads++;
1187
- const direction2 = hop.direction ?? "forward";
1188
1482
  const params = { axbType: hop.axbType };
1189
- if (direction2 === "forward") {
1483
+ if (direction === "forward") {
1190
1484
  params.aUid = uid;
1191
1485
  if (hop.bType) params.bType = hop.bType;
1192
1486
  } else {
1193
1487
  params.bUid = uid;
1194
1488
  if (hop.aType) params.aType = hop.aType;
1195
1489
  }
1196
- if (direction2 === "forward" && hop.aType) {
1490
+ if (direction === "forward" && hop.aType) {
1197
1491
  params.aType = hop.aType;
1198
1492
  }
1199
- if (direction2 === "reverse" && hop.bType) {
1493
+ if (direction === "reverse" && hop.bType) {
1200
1494
  params.bType = hop.bType;
1201
1495
  }
1202
1496
  if (hop.orderBy) params.orderBy = hop.orderBy;
1203
1497
  const limit = hop.limit ?? DEFAULT_LIMIT;
1204
- if (!hop.filter) {
1498
+ if (hop.filter) {
1499
+ params.limit = 0;
1500
+ } else {
1205
1501
  params.limit = limit;
1206
1502
  }
1207
- let edges = await this.reader.findEdges(params);
1503
+ let hopReader;
1504
+ let nextReader;
1505
+ if (isCrossGraph) {
1506
+ if (isGraphClient(this.reader)) {
1507
+ hopReader = this.reader.subgraph(uid, resolvedTargetGraph);
1508
+ nextReader = hopReader;
1509
+ } else {
1510
+ hopReader = sourceReader;
1511
+ nextReader = sourceReader;
1512
+ if (!_crossGraphWarned) {
1513
+ _crossGraphWarned = true;
1514
+ console.warn(
1515
+ `[firegraph] Traversal hop "${hop.axbType}" has targetGraph "${resolvedTargetGraph}" but the reader does not support subgraph(). Cross-graph hop will query the current collection instead. Pass a GraphClient to createTraversal() to enable cross-graph traversal.`
1516
+ );
1517
+ }
1518
+ }
1519
+ } else {
1520
+ hopReader = sourceReader;
1521
+ nextReader = sourceReader;
1522
+ }
1523
+ let edges2 = await hopReader.findEdges(params);
1208
1524
  if (hop.filter) {
1209
- edges = edges.filter(hop.filter);
1210
- edges = edges.slice(0, limit);
1525
+ edges2 = edges2.filter(hop.filter);
1526
+ edges2 = edges2.slice(0, limit);
1527
+ }
1528
+ for (const edge of edges2) {
1529
+ hopEdges.push({ edge, reader: nextReader });
1211
1530
  }
1212
- hopEdges.push(...edges);
1213
1531
  } finally {
1214
1532
  semaphore.release();
1215
1533
  }
1216
1534
  });
1217
1535
  await Promise.all(tasks.map((task) => task()));
1536
+ const edges = hopEdges.map((h) => h.edge);
1218
1537
  hopResults.push({
1219
1538
  axbType: hop.axbType,
1220
1539
  depth,
1221
- edges: returnIntermediates ? [...hopEdges] : hopEdges,
1540
+ edges: returnIntermediates ? [...edges] : edges,
1222
1541
  sourceCount,
1223
1542
  truncated: hopTruncated
1224
1543
  });
1225
1544
  if (hopTruncated) {
1226
1545
  truncated = true;
1227
1546
  }
1228
- const direction = hop.direction ?? "forward";
1229
- sourceUids = [...new Set(
1230
- hopEdges.map((e) => direction === "forward" ? e.bUid : e.aUid)
1231
- )];
1547
+ const seen = /* @__PURE__ */ new Map();
1548
+ for (const { edge, reader: edgeReader } of hopEdges) {
1549
+ const nextUid = direction === "forward" ? edge.bUid : edge.aUid;
1550
+ if (!seen.has(nextUid)) {
1551
+ seen.set(nextUid, edgeReader);
1552
+ }
1553
+ }
1554
+ sources = [...seen.entries()].map(([uid, reader]) => ({ uid, reader }));
1232
1555
  }
1233
1556
  const lastHop = hopResults[hopResults.length - 1];
1234
1557
  return {
@@ -1238,9 +1561,25 @@ var TraversalBuilderImpl = class {
1238
1561
  truncated
1239
1562
  };
1240
1563
  }
1564
+ /**
1565
+ * Resolve the targetGraph for a hop. Priority:
1566
+ * 1. Explicit `hop.targetGraph` (user override)
1567
+ * 2. Registry `targetGraph` for the axbType (if registry available)
1568
+ * 3. undefined (no cross-graph)
1569
+ */
1570
+ resolveTargetGraph(hop) {
1571
+ if (hop.targetGraph) return hop.targetGraph;
1572
+ if (this.registry) {
1573
+ const entries = this.registry.lookupByAxbType(hop.axbType);
1574
+ for (const entry of entries) {
1575
+ if (entry.targetGraph) return entry.targetGraph;
1576
+ }
1577
+ }
1578
+ return void 0;
1579
+ }
1241
1580
  };
1242
- function createTraversal(reader, startUid) {
1243
- return new TraversalBuilderImpl(reader, startUid);
1581
+ function createTraversal(reader, startUid, registry) {
1582
+ return new TraversalBuilderImpl(reader, startUid, registry);
1244
1583
  }
1245
1584
 
1246
1585
  // src/views.ts
@@ -1451,7 +1790,8 @@ function loadNodeEntity(dir, name) {
1451
1790
  subtitleField: meta?.subtitleField,
1452
1791
  viewDefaults: meta?.viewDefaults,
1453
1792
  viewsPath,
1454
- sampleData
1793
+ sampleData,
1794
+ allowedIn: meta?.allowedIn
1455
1795
  };
1456
1796
  }
1457
1797
  function loadEdgeEntity(dir, name) {
@@ -1486,7 +1826,9 @@ function loadEdgeEntity(dir, name) {
1486
1826
  subtitleField: meta?.subtitleField,
1487
1827
  viewDefaults: meta?.viewDefaults,
1488
1828
  viewsPath,
1489
- sampleData
1829
+ sampleData,
1830
+ allowedIn: meta?.allowedIn,
1831
+ targetGraph: topology.targetGraph ?? meta?.targetGraph
1490
1832
  };
1491
1833
  }
1492
1834
  function getSubdirectories(dir) {
@@ -1528,8 +1870,147 @@ function discoverEntities(entitiesDir) {
1528
1870
  warnings
1529
1871
  };
1530
1872
  }
1873
+
1874
+ // src/cross-graph.ts
1875
+ function resolveAncestorCollection(collectionPath, uid) {
1876
+ const segments = collectionPath.split("/");
1877
+ for (let i = 1; i < segments.length; i += 2) {
1878
+ if (segments[i] === uid) {
1879
+ return segments.slice(0, i).join("/");
1880
+ }
1881
+ }
1882
+ return null;
1883
+ }
1884
+ function isAncestorUid(collectionPath, uid) {
1885
+ return resolveAncestorCollection(collectionPath, uid) !== null;
1886
+ }
1887
+
1888
+ // src/indexes.ts
1889
+ function baseIndexes(collection) {
1890
+ return [
1891
+ {
1892
+ collectionGroup: collection,
1893
+ queryScope: "COLLECTION",
1894
+ fields: [
1895
+ { fieldPath: "aUid", order: "ASCENDING" },
1896
+ { fieldPath: "axbType", order: "ASCENDING" }
1897
+ ]
1898
+ },
1899
+ {
1900
+ collectionGroup: collection,
1901
+ queryScope: "COLLECTION",
1902
+ fields: [
1903
+ { fieldPath: "axbType", order: "ASCENDING" },
1904
+ { fieldPath: "bUid", order: "ASCENDING" }
1905
+ ]
1906
+ },
1907
+ {
1908
+ collectionGroup: collection,
1909
+ queryScope: "COLLECTION",
1910
+ fields: [
1911
+ { fieldPath: "aType", order: "ASCENDING" },
1912
+ { fieldPath: "axbType", order: "ASCENDING" }
1913
+ ]
1914
+ },
1915
+ {
1916
+ collectionGroup: collection,
1917
+ queryScope: "COLLECTION",
1918
+ fields: [
1919
+ { fieldPath: "axbType", order: "ASCENDING" },
1920
+ { fieldPath: "bType", order: "ASCENDING" }
1921
+ ]
1922
+ }
1923
+ ];
1924
+ }
1925
+ function extractSchemaFields(schema) {
1926
+ const s = schema;
1927
+ if (s.type !== "object" || !s.properties) return [];
1928
+ return Object.keys(s.properties);
1929
+ }
1930
+ function collectionGroupIndexes(collectionName) {
1931
+ return [
1932
+ {
1933
+ collectionGroup: collectionName,
1934
+ queryScope: "COLLECTION_GROUP",
1935
+ fields: [
1936
+ { fieldPath: "aUid", order: "ASCENDING" },
1937
+ { fieldPath: "axbType", order: "ASCENDING" }
1938
+ ]
1939
+ },
1940
+ {
1941
+ collectionGroup: collectionName,
1942
+ queryScope: "COLLECTION_GROUP",
1943
+ fields: [
1944
+ { fieldPath: "axbType", order: "ASCENDING" },
1945
+ { fieldPath: "bUid", order: "ASCENDING" }
1946
+ ]
1947
+ },
1948
+ {
1949
+ collectionGroup: collectionName,
1950
+ queryScope: "COLLECTION_GROUP",
1951
+ fields: [
1952
+ { fieldPath: "aType", order: "ASCENDING" },
1953
+ { fieldPath: "axbType", order: "ASCENDING" }
1954
+ ]
1955
+ },
1956
+ {
1957
+ collectionGroup: collectionName,
1958
+ queryScope: "COLLECTION_GROUP",
1959
+ fields: [
1960
+ { fieldPath: "axbType", order: "ASCENDING" },
1961
+ { fieldPath: "bType", order: "ASCENDING" }
1962
+ ]
1963
+ }
1964
+ ];
1965
+ }
1966
+ function generateIndexConfig(collection, entities, registryEntries) {
1967
+ const indexes = baseIndexes(collection);
1968
+ if (entities) {
1969
+ for (const [, entity] of entities.nodes) {
1970
+ const fields = extractSchemaFields(entity.schema);
1971
+ for (const field of fields) {
1972
+ indexes.push({
1973
+ collectionGroup: collection,
1974
+ queryScope: "COLLECTION",
1975
+ fields: [
1976
+ { fieldPath: "aType", order: "ASCENDING" },
1977
+ { fieldPath: "axbType", order: "ASCENDING" },
1978
+ { fieldPath: `data.${field}`, order: "ASCENDING" }
1979
+ ]
1980
+ });
1981
+ }
1982
+ }
1983
+ for (const [, entity] of entities.edges) {
1984
+ const fields = extractSchemaFields(entity.schema);
1985
+ for (const field of fields) {
1986
+ indexes.push({
1987
+ collectionGroup: collection,
1988
+ queryScope: "COLLECTION",
1989
+ fields: [
1990
+ { fieldPath: "aUid", order: "ASCENDING" },
1991
+ { fieldPath: "axbType", order: "ASCENDING" },
1992
+ { fieldPath: `data.${field}`, order: "ASCENDING" }
1993
+ ]
1994
+ });
1995
+ }
1996
+ }
1997
+ }
1998
+ if (registryEntries) {
1999
+ const targetGraphNames = /* @__PURE__ */ new Set();
2000
+ for (const entry of registryEntries) {
2001
+ if (entry.targetGraph) {
2002
+ targetGraphNames.add(entry.targetGraph);
2003
+ }
2004
+ }
2005
+ for (const name of targetGraphNames) {
2006
+ indexes.push(...collectionGroupIndexes(name));
2007
+ }
2008
+ }
2009
+ return { indexes, fieldOverrides: [] };
2010
+ }
1531
2011
  export {
1532
2012
  BOOTSTRAP_ENTRIES,
2013
+ DEFAULT_QUERY_LIMIT,
1533
2014
  DiscoveryError,
1534
2015
  DynamicRegistryError,
1535
2016
  EDGE_TYPE_SCHEMA,
@@ -1542,9 +2023,12 @@ export {
1542
2023
  NodeNotFoundError,
1543
2024
  QueryClient,
1544
2025
  QueryClientError,
2026
+ QuerySafetyError,
2027
+ RegistryScopeError,
1545
2028
  RegistryViolationError,
1546
2029
  TraversalError,
1547
2030
  ValidationError,
2031
+ analyzeQuerySafety,
1548
2032
  buildEdgeQueryPlan,
1549
2033
  buildEdgeRecord,
1550
2034
  buildNodeQueryPlan,
@@ -1562,8 +2046,13 @@ export {
1562
2046
  discoverEntities,
1563
2047
  generateDeterministicUid,
1564
2048
  generateId,
2049
+ generateIndexConfig,
1565
2050
  generateTypes,
2051
+ isAncestorUid,
1566
2052
  jsonSchemaToFieldMeta,
2053
+ matchScope,
2054
+ matchScopeAny,
2055
+ resolveAncestorCollection,
1567
2056
  resolveView
1568
2057
  };
1569
2058
  //# sourceMappingURL=index.js.map