@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.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}`;
@@ -626,11 +791,16 @@ function createRegistry(input) {
626
791
  lookup(aType, axbType, bType) {
627
792
  return map.get(tripleKey(aType, axbType, bType))?.entry;
628
793
  },
629
- validate(aType, axbType, bType, data) {
794
+ validate(aType, axbType, bType, data, scopePath) {
630
795
  const rec = map.get(tripleKey(aType, axbType, bType));
631
796
  if (!rec) {
632
797
  throw new RegistryViolationError(aType, axbType, bType);
633
798
  }
799
+ if (scopePath !== void 0 && rec.entry.allowedIn && rec.entry.allowedIn.length > 0) {
800
+ if (!matchScopeAny(scopePath, rec.entry.allowedIn)) {
801
+ throw new RegistryScopeError(aType, axbType, bType, scopePath, rec.entry.allowedIn);
802
+ }
803
+ }
634
804
  if (rec.validate) {
635
805
  try {
636
806
  rec.validate(data);
@@ -658,7 +828,8 @@ function discoveryToEntries(discovery) {
658
828
  jsonSchema: entity.schema,
659
829
  description: entity.description,
660
830
  titleField: entity.titleField,
661
- subtitleField: entity.subtitleField
831
+ subtitleField: entity.subtitleField,
832
+ allowedIn: entity.allowedIn
662
833
  });
663
834
  }
664
835
  for (const [axbType, entity] of discovery.edges) {
@@ -676,7 +847,8 @@ function discoveryToEntries(discovery) {
676
847
  description: entity.description,
677
848
  inverseLabel: topology.inverseLabel,
678
849
  titleField: entity.titleField,
679
- subtitleField: entity.subtitleField
850
+ subtitleField: entity.subtitleField,
851
+ allowedIn: entity.allowedIn
680
852
  });
681
853
  }
682
854
  }
@@ -697,7 +869,8 @@ var NODE_TYPE_SCHEMA = {
697
869
  titleField: { type: "string" },
698
870
  subtitleField: { type: "string" },
699
871
  viewTemplate: { type: "string" },
700
- viewCss: { type: "string" }
872
+ viewCss: { type: "string" },
873
+ allowedIn: { type: "array", items: { type: "string", minLength: 1 } }
701
874
  },
702
875
  additionalProperties: false
703
876
  };
@@ -724,7 +897,8 @@ var EDGE_TYPE_SCHEMA = {
724
897
  titleField: { type: "string" },
725
898
  subtitleField: { type: "string" },
726
899
  viewTemplate: { type: "string" },
727
- viewCss: { type: "string" }
900
+ viewCss: { type: "string" },
901
+ allowedIn: { type: "array", items: { type: "string", minLength: 1 } }
728
902
  },
729
903
  additionalProperties: false
730
904
  };
@@ -766,7 +940,8 @@ async function createRegistryFromGraph(reader) {
766
940
  jsonSchema: data.jsonSchema,
767
941
  description: data.description,
768
942
  titleField: data.titleField,
769
- subtitleField: data.subtitleField
943
+ subtitleField: data.subtitleField,
944
+ allowedIn: data.allowedIn
770
945
  });
771
946
  }
772
947
  for (const record of edgeTypes) {
@@ -783,7 +958,8 @@ async function createRegistryFromGraph(reader) {
783
958
  description: data.description,
784
959
  inverseLabel: data.inverseLabel,
785
960
  titleField: data.titleField,
786
- subtitleField: data.subtitleField
961
+ subtitleField: data.subtitleField,
962
+ allowedIn: data.allowedIn
787
963
  });
788
964
  }
789
965
  }
@@ -794,9 +970,10 @@ async function createRegistryFromGraph(reader) {
794
970
  // src/client.ts
795
971
  var _standardModeWarned = false;
796
972
  var RESERVED_TYPE_NAMES = /* @__PURE__ */ new Set([META_NODE_TYPE, META_EDGE_TYPE]);
797
- var GraphClientImpl = class {
798
- constructor(db, collectionPath, options) {
973
+ var GraphClientImpl = class _GraphClientImpl {
974
+ constructor(db, collectionPath, options, scopePath = "") {
799
975
  this.db = db;
976
+ this.scopePath = scopePath;
800
977
  this.adapter = createFirestoreAdapter(db, collectionPath);
801
978
  if (options?.registry && options?.registryMode) {
802
979
  throw new DynamicRegistryError(
@@ -826,6 +1003,7 @@ var GraphClientImpl = class {
826
1003
  "[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
1004
  );
828
1005
  }
1006
+ this.scanProtection = options?.scanProtection ?? "error";
829
1007
  if (this.queryMode === "pipeline") {
830
1008
  this.pipelineAdapter = createPipelineQueryAdapter(db, collectionPath);
831
1009
  if (this.metaAdapter) {
@@ -839,6 +1017,7 @@ var GraphClientImpl = class {
839
1017
  adapter;
840
1018
  pipelineAdapter;
841
1019
  queryMode;
1020
+ scanProtection;
842
1021
  // Static mode
843
1022
  staticRegistry;
844
1023
  // Dynamic mode
@@ -847,6 +1026,8 @@ var GraphClientImpl = class {
847
1026
  dynamicRegistry;
848
1027
  metaAdapter;
849
1028
  metaPipelineAdapter;
1029
+ // Subgraph scope tracking
1030
+ scopePath;
850
1031
  // ---------------------------------------------------------------------------
851
1032
  // Registry routing
852
1033
  // ---------------------------------------------------------------------------
@@ -900,6 +1081,19 @@ var GraphClientImpl = class {
900
1081
  }
901
1082
  return this.adapter.query(filters, options);
902
1083
  }
1084
+ /**
1085
+ * Check whether a query's filter set is safe (matches a known index pattern).
1086
+ * Throws QuerySafetyError or logs a warning depending on scanProtection config.
1087
+ */
1088
+ checkQuerySafety(filters, allowCollectionScan) {
1089
+ if (allowCollectionScan || this.scanProtection === "off") return;
1090
+ const result = analyzeQuerySafety(filters);
1091
+ if (result.safe) return;
1092
+ if (this.scanProtection === "error") {
1093
+ throw new QuerySafetyError(result.reason);
1094
+ }
1095
+ console.warn(`[firegraph] Query safety warning: ${result.reason}`);
1096
+ }
903
1097
  // ---------------------------------------------------------------------------
904
1098
  // GraphReader
905
1099
  // ---------------------------------------------------------------------------
@@ -921,6 +1115,7 @@ var GraphClientImpl = class {
921
1115
  const record = await this.adapter.getDoc(plan.docId);
922
1116
  return record ? [record] : [];
923
1117
  }
1118
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
924
1119
  return this.executeQuery(plan.filters, plan.options);
925
1120
  }
926
1121
  async findNodes(params) {
@@ -929,6 +1124,7 @@ var GraphClientImpl = class {
929
1124
  const record = await this.adapter.getDoc(plan.docId);
930
1125
  return record ? [record] : [];
931
1126
  }
1127
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
932
1128
  return this.executeQuery(plan.filters, plan.options);
933
1129
  }
934
1130
  // ---------------------------------------------------------------------------
@@ -937,7 +1133,7 @@ var GraphClientImpl = class {
937
1133
  async putNode(aType, uid, data) {
938
1134
  const registry = this.getRegistryForType(aType);
939
1135
  if (registry) {
940
- registry.validate(aType, NODE_RELATION, aType, data);
1136
+ registry.validate(aType, NODE_RELATION, aType, data, this.scopePath);
941
1137
  }
942
1138
  const adapter = this.getAdapterForType(aType);
943
1139
  const docId = computeNodeDocId(uid);
@@ -947,7 +1143,7 @@ var GraphClientImpl = class {
947
1143
  async putEdge(aType, aUid, axbType, bType, bUid, data) {
948
1144
  const registry = this.getRegistryForType(aType);
949
1145
  if (registry) {
950
- registry.validate(aType, axbType, bType, data);
1146
+ registry.validate(aType, axbType, bType, data, this.scopePath);
951
1147
  }
952
1148
  const adapter = this.getAdapterForType(aType);
953
1149
  const docId = computeEdgeDocId(aUid, axbType, bUid);
@@ -979,13 +1175,42 @@ var GraphClientImpl = class {
979
1175
  this.adapter.collectionPath,
980
1176
  firestoreTx
981
1177
  );
982
- const graphTx = new GraphTransactionImpl(adapter, this.getCombinedRegistry());
1178
+ const graphTx = new GraphTransactionImpl(adapter, this.getCombinedRegistry(), this.scanProtection, this.scopePath);
983
1179
  return fn(graphTx);
984
1180
  });
985
1181
  }
986
1182
  batch() {
987
1183
  const adapter = createBatchAdapter(this.db, this.adapter.collectionPath);
988
- return new GraphBatchImpl(adapter, this.getCombinedRegistry());
1184
+ return new GraphBatchImpl(adapter, this.getCombinedRegistry(), this.scopePath);
1185
+ }
1186
+ // ---------------------------------------------------------------------------
1187
+ // Subgraph
1188
+ // ---------------------------------------------------------------------------
1189
+ subgraph(parentNodeUid, name = "graph") {
1190
+ if (!parentNodeUid || parentNodeUid.includes("/")) {
1191
+ throw new FiregraphError(
1192
+ `Invalid parentNodeUid for subgraph: "${parentNodeUid}". Must be a non-empty string without "/".`,
1193
+ "INVALID_SUBGRAPH"
1194
+ );
1195
+ }
1196
+ if (name.includes("/")) {
1197
+ throw new FiregraphError(
1198
+ `Subgraph name must not contain "/": got "${name}". Use chained .subgraph() calls for nested subgraphs.`,
1199
+ "INVALID_SUBGRAPH"
1200
+ );
1201
+ }
1202
+ const subCollectionPath = `${this.adapter.collectionPath}/${parentNodeUid}/${name}`;
1203
+ const newScopePath = this.scopePath ? `${this.scopePath}/${name}` : name;
1204
+ return new _GraphClientImpl(
1205
+ this.db,
1206
+ subCollectionPath,
1207
+ {
1208
+ registry: this.getCombinedRegistry(),
1209
+ queryMode: this.queryMode === "pipeline" ? "pipeline" : "standard",
1210
+ scanProtection: this.scanProtection
1211
+ },
1212
+ newScopePath
1213
+ );
989
1214
  }
990
1215
  // ---------------------------------------------------------------------------
991
1216
  // Bulk operations
@@ -1017,6 +1242,7 @@ var GraphClientImpl = class {
1017
1242
  if (options?.subtitleField !== void 0) data.subtitleField = options.subtitleField;
1018
1243
  if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
1019
1244
  if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
1245
+ if (options?.allowedIn !== void 0) data.allowedIn = options.allowedIn;
1020
1246
  await this.putNode(META_NODE_TYPE, uid, data);
1021
1247
  }
1022
1248
  async defineEdgeType(name, topology, jsonSchema, description, options) {
@@ -1043,6 +1269,7 @@ var GraphClientImpl = class {
1043
1269
  if (options?.subtitleField !== void 0) data.subtitleField = options.subtitleField;
1044
1270
  if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
1045
1271
  if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
1272
+ if (options?.allowedIn !== void 0) data.allowedIn = options.allowedIn;
1046
1273
  await this.putNode(META_EDGE_TYPE, uid, data);
1047
1274
  }
1048
1275
  async reloadRegistry() {
@@ -1201,7 +1428,9 @@ var TraversalBuilderImpl = class {
1201
1428
  }
1202
1429
  if (hop.orderBy) params.orderBy = hop.orderBy;
1203
1430
  const limit = hop.limit ?? DEFAULT_LIMIT;
1204
- if (!hop.filter) {
1431
+ if (hop.filter) {
1432
+ params.limit = 0;
1433
+ } else {
1205
1434
  params.limit = limit;
1206
1435
  }
1207
1436
  let edges = await this.reader.findEdges(params);
@@ -1451,7 +1680,8 @@ function loadNodeEntity(dir, name) {
1451
1680
  subtitleField: meta?.subtitleField,
1452
1681
  viewDefaults: meta?.viewDefaults,
1453
1682
  viewsPath,
1454
- sampleData
1683
+ sampleData,
1684
+ allowedIn: meta?.allowedIn
1455
1685
  };
1456
1686
  }
1457
1687
  function loadEdgeEntity(dir, name) {
@@ -1486,7 +1716,8 @@ function loadEdgeEntity(dir, name) {
1486
1716
  subtitleField: meta?.subtitleField,
1487
1717
  viewDefaults: meta?.viewDefaults,
1488
1718
  viewsPath,
1489
- sampleData
1719
+ sampleData,
1720
+ allowedIn: meta?.allowedIn
1490
1721
  };
1491
1722
  }
1492
1723
  function getSubdirectories(dir) {
@@ -1528,8 +1759,86 @@ function discoverEntities(entitiesDir) {
1528
1759
  warnings
1529
1760
  };
1530
1761
  }
1762
+
1763
+ // src/indexes.ts
1764
+ function baseIndexes(collection) {
1765
+ return [
1766
+ {
1767
+ collectionGroup: collection,
1768
+ queryScope: "COLLECTION",
1769
+ fields: [
1770
+ { fieldPath: "aUid", order: "ASCENDING" },
1771
+ { fieldPath: "axbType", order: "ASCENDING" }
1772
+ ]
1773
+ },
1774
+ {
1775
+ collectionGroup: collection,
1776
+ queryScope: "COLLECTION",
1777
+ fields: [
1778
+ { fieldPath: "axbType", order: "ASCENDING" },
1779
+ { fieldPath: "bUid", order: "ASCENDING" }
1780
+ ]
1781
+ },
1782
+ {
1783
+ collectionGroup: collection,
1784
+ queryScope: "COLLECTION",
1785
+ fields: [
1786
+ { fieldPath: "aType", order: "ASCENDING" },
1787
+ { fieldPath: "axbType", order: "ASCENDING" }
1788
+ ]
1789
+ },
1790
+ {
1791
+ collectionGroup: collection,
1792
+ queryScope: "COLLECTION",
1793
+ fields: [
1794
+ { fieldPath: "axbType", order: "ASCENDING" },
1795
+ { fieldPath: "bType", order: "ASCENDING" }
1796
+ ]
1797
+ }
1798
+ ];
1799
+ }
1800
+ function extractSchemaFields(schema) {
1801
+ const s = schema;
1802
+ if (s.type !== "object" || !s.properties) return [];
1803
+ return Object.keys(s.properties);
1804
+ }
1805
+ function generateIndexConfig(collection, entities) {
1806
+ const indexes = baseIndexes(collection);
1807
+ if (entities) {
1808
+ for (const [, entity] of entities.nodes) {
1809
+ const fields = extractSchemaFields(entity.schema);
1810
+ for (const field of fields) {
1811
+ indexes.push({
1812
+ collectionGroup: collection,
1813
+ queryScope: "COLLECTION",
1814
+ fields: [
1815
+ { fieldPath: "aType", order: "ASCENDING" },
1816
+ { fieldPath: "axbType", order: "ASCENDING" },
1817
+ { fieldPath: `data.${field}`, order: "ASCENDING" }
1818
+ ]
1819
+ });
1820
+ }
1821
+ }
1822
+ for (const [, entity] of entities.edges) {
1823
+ const fields = extractSchemaFields(entity.schema);
1824
+ for (const field of fields) {
1825
+ indexes.push({
1826
+ collectionGroup: collection,
1827
+ queryScope: "COLLECTION",
1828
+ fields: [
1829
+ { fieldPath: "aUid", order: "ASCENDING" },
1830
+ { fieldPath: "axbType", order: "ASCENDING" },
1831
+ { fieldPath: `data.${field}`, order: "ASCENDING" }
1832
+ ]
1833
+ });
1834
+ }
1835
+ }
1836
+ }
1837
+ return { indexes, fieldOverrides: [] };
1838
+ }
1531
1839
  export {
1532
1840
  BOOTSTRAP_ENTRIES,
1841
+ DEFAULT_QUERY_LIMIT,
1533
1842
  DiscoveryError,
1534
1843
  DynamicRegistryError,
1535
1844
  EDGE_TYPE_SCHEMA,
@@ -1542,9 +1851,12 @@ export {
1542
1851
  NodeNotFoundError,
1543
1852
  QueryClient,
1544
1853
  QueryClientError,
1854
+ QuerySafetyError,
1855
+ RegistryScopeError,
1545
1856
  RegistryViolationError,
1546
1857
  TraversalError,
1547
1858
  ValidationError,
1859
+ analyzeQuerySafety,
1548
1860
  buildEdgeQueryPlan,
1549
1861
  buildEdgeRecord,
1550
1862
  buildNodeQueryPlan,
@@ -1562,8 +1874,11 @@ export {
1562
1874
  discoverEntities,
1563
1875
  generateDeterministicUid,
1564
1876
  generateId,
1877
+ generateIndexConfig,
1565
1878
  generateTypes,
1566
1879
  jsonSchemaToFieldMeta,
1880
+ matchScope,
1881
+ matchScopeAny,
1567
1882
  resolveView
1568
1883
  };
1569
1884
  //# sourceMappingURL=index.js.map