@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/bin/firegraph.mjs CHANGED
@@ -86,6 +86,45 @@ if (subcommand === 'editor') {
86
86
  console.error(`Error: ${err.message}`);
87
87
  process.exit(1);
88
88
  }
89
+ } else if (subcommand === 'indexes') {
90
+ const args = parseArgs(process.argv.slice(3));
91
+ const entitiesDir = args.entities ? path.resolve(args.entities) : null;
92
+ const collection = args.collection || 'graph';
93
+ const outPath = args.out || null;
94
+
95
+ const distIndex = path.join(__dirname, '..', 'dist', 'index.js');
96
+ const { generateIndexConfig, discoverEntities } = await import(distIndex);
97
+
98
+ try {
99
+ let entities = undefined;
100
+ if (entitiesDir) {
101
+ const { result, warnings } = discoverEntities(entitiesDir);
102
+ for (const w of warnings) {
103
+ console.warn(` warning: ${w.message}`);
104
+ }
105
+ entities = result;
106
+ const nodeCount = result.nodes.size;
107
+ const edgeCount = result.edges.size;
108
+ if (nodeCount > 0 || edgeCount > 0) {
109
+ console.error(`Discovered ${nodeCount} node type(s) + ${edgeCount} edge type(s)`);
110
+ }
111
+ }
112
+
113
+ const config = generateIndexConfig(collection, entities);
114
+ const output = JSON.stringify(config, null, 2) + '\n';
115
+
116
+ if (outPath) {
117
+ const resolved = path.resolve(outPath);
118
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
119
+ fs.writeFileSync(resolved, output, 'utf-8');
120
+ console.log(`Generated ${config.indexes.length} index(es) → ${resolved}`);
121
+ } else {
122
+ process.stdout.write(output);
123
+ }
124
+ } catch (err) {
125
+ console.error(`Error: ${err.message}`);
126
+ process.exit(1);
127
+ }
89
128
  } else if (subcommand === '--help' || subcommand === '-h' || !subcommand) {
90
129
  console.log('');
91
130
  console.log(' Usage: firegraph <command> [options]');
@@ -94,6 +133,7 @@ if (subcommand === 'editor') {
94
133
  console.log(' editor Launch the Firegraph Editor UI');
95
134
  console.log(' query Query the graph via the editor API');
96
135
  console.log(' codegen Generate TypeScript types from entity schemas');
136
+ console.log(' indexes Generate recommended Firestore index definitions');
97
137
  console.log('');
98
138
  console.log(' Editor options:');
99
139
  console.log(' --config <path> Path to firegraph.config.ts (default: auto-discover in cwd)');
@@ -111,6 +151,11 @@ if (subcommand === 'editor') {
111
151
  console.log(' --entities <path> Path to entities directory (default: ./entities)');
112
152
  console.log(' --out <path> Output file path (default: stdout)');
113
153
  console.log('');
154
+ console.log(' Indexes options:');
155
+ console.log(' --entities <path> Path to entities directory (adds per-entity data field indexes)');
156
+ console.log(' --collection <name> Firestore collection name (default: graph)');
157
+ console.log(' --out <path> Output file path (default: stdout)');
158
+ console.log('');
114
159
  console.log(' Config file:');
115
160
  console.log(' Create a firegraph.config.ts in your project root to avoid passing');
116
161
  console.log(' flags every time. CLI flags override config file values.');
@@ -121,6 +166,8 @@ if (subcommand === 'editor') {
121
166
  console.log(' npx firegraph editor --entities ./entities # per-entity convention');
122
167
  console.log(' npx firegraph codegen --entities ./entities # types to stdout');
123
168
  console.log(' npx firegraph codegen --entities ./entities --out src/generated/types.ts');
169
+ console.log(' npx firegraph indexes # 4 base indexes to stdout');
170
+ console.log(' npx firegraph indexes --entities ./entities --out firestore.indexes.json');
124
171
  console.log('');
125
172
  } else {
126
173
  console.error(`Unknown command: ${subcommand}`);
@@ -1,2 +1,2 @@
1
- export { k as CodegenOptions, I as generateTypes } from '../index-CG3R68Hu.cjs';
1
+ export { l as CodegenOptions, J as generateTypes } from '../index-wSlVH5Nv.cjs';
2
2
  import '@google-cloud/firestore';
@@ -1,2 +1,2 @@
1
- export { k as CodegenOptions, I as generateTypes } from '../index-CG3R68Hu.js';
1
+ export { l as CodegenOptions, J as generateTypes } from '../index-wSlVH5Nv.js';
2
2
  import '@google-cloud/firestore';
@@ -29331,6 +29331,16 @@ import { createHash } from "node:crypto";
29331
29331
 
29332
29332
  // src/internal/constants.ts
29333
29333
  var NODE_RELATION = "is";
29334
+ var DEFAULT_QUERY_LIMIT = 500;
29335
+ var BUILTIN_FIELDS = /* @__PURE__ */ new Set([
29336
+ "aType",
29337
+ "aUid",
29338
+ "axbType",
29339
+ "bType",
29340
+ "bUid",
29341
+ "createdAt",
29342
+ "updatedAt"
29343
+ ]);
29334
29344
  var SHARD_SEPARATOR = ":";
29335
29345
 
29336
29346
  // src/docid.ts
@@ -29409,6 +29419,21 @@ var DynamicRegistryError = class extends FiregraphError {
29409
29419
  this.name = "DynamicRegistryError";
29410
29420
  }
29411
29421
  };
29422
+ var QuerySafetyError = class extends FiregraphError {
29423
+ constructor(message) {
29424
+ super(message, "QUERY_SAFETY");
29425
+ this.name = "QuerySafetyError";
29426
+ }
29427
+ };
29428
+ var RegistryScopeError = class extends FiregraphError {
29429
+ constructor(aType, axbType, bType, scopePath, allowedIn) {
29430
+ super(
29431
+ `Type (${aType}) -[${axbType}]-> (${bType}) is not allowed at scope "${scopePath || "root"}". Allowed in: [${allowedIn.join(", ")}]`,
29432
+ "REGISTRY_SCOPE"
29433
+ );
29434
+ this.name = "RegistryScopeError";
29435
+ }
29436
+ };
29412
29437
 
29413
29438
  // src/query.ts
29414
29439
  function buildEdgeQueryPlan(params) {
@@ -29422,27 +29447,32 @@ function buildEdgeQueryPlan(params) {
29422
29447
  if (axbType) filters.push({ field: "axbType", op: "==", value: axbType });
29423
29448
  if (bType) filters.push({ field: "bType", op: "==", value: bType });
29424
29449
  if (bUid) filters.push({ field: "bUid", op: "==", value: bUid });
29425
- const builtinFields = ["aType", "aUid", "axbType", "bType", "bUid", "createdAt", "updatedAt"];
29426
29450
  if (params.where) {
29427
29451
  for (const clause of params.where) {
29428
- const field = builtinFields.includes(clause.field) ? clause.field : clause.field.startsWith("data.") ? clause.field : `data.${clause.field}`;
29452
+ const field = BUILTIN_FIELDS.has(clause.field) ? clause.field : clause.field.startsWith("data.") ? clause.field : `data.${clause.field}`;
29429
29453
  filters.push({ field, op: clause.op, value: clause.value });
29430
29454
  }
29431
29455
  }
29432
29456
  if (filters.length === 0) {
29433
29457
  throw new InvalidQueryError("findEdges requires at least one filter parameter");
29434
29458
  }
29435
- const options = limit !== void 0 || orderBy ? { limit, orderBy } : void 0;
29436
- return { strategy: "query", filters, options };
29459
+ const effectiveLimit = limit === void 0 ? DEFAULT_QUERY_LIMIT : limit || void 0;
29460
+ return { strategy: "query", filters, options: { limit: effectiveLimit, orderBy } };
29437
29461
  }
29438
29462
  function buildNodeQueryPlan(params) {
29439
- return {
29440
- strategy: "query",
29441
- filters: [
29442
- { field: "aType", op: "==", value: params.aType },
29443
- { field: "axbType", op: "==", value: NODE_RELATION }
29444
- ]
29445
- };
29463
+ const { aType, limit, orderBy } = params;
29464
+ const filters = [
29465
+ { field: "aType", op: "==", value: aType },
29466
+ { field: "axbType", op: "==", value: NODE_RELATION }
29467
+ ];
29468
+ if (params.where) {
29469
+ for (const clause of params.where) {
29470
+ const field = BUILTIN_FIELDS.has(clause.field) ? clause.field : clause.field.startsWith("data.") ? clause.field : `data.${clause.field}`;
29471
+ filters.push({ field, op: clause.op, value: clause.value });
29472
+ }
29473
+ }
29474
+ const effectiveLimit = limit === void 0 ? DEFAULT_QUERY_LIMIT : limit || void 0;
29475
+ return { strategy: "query", filters, options: { limit: effectiveLimit, orderBy } };
29446
29476
  }
29447
29477
 
29448
29478
  // src/internal/firestore-adapter.ts
@@ -29595,10 +29625,62 @@ function createPipelineQueryAdapter(db2, collectionPath) {
29595
29625
 
29596
29626
  // src/transaction.ts
29597
29627
  import { FieldValue as FieldValue2 } from "@google-cloud/firestore";
29628
+
29629
+ // src/query-safety.ts
29630
+ var SAFE_INDEX_PATTERNS = [
29631
+ /* @__PURE__ */ new Set(["aUid", "axbType"]),
29632
+ /* @__PURE__ */ new Set(["axbType", "bUid"]),
29633
+ /* @__PURE__ */ new Set(["aType", "axbType"]),
29634
+ /* @__PURE__ */ new Set(["axbType", "bType"])
29635
+ ];
29636
+ function analyzeQuerySafety(filters) {
29637
+ const builtinFieldsPresent = /* @__PURE__ */ new Set();
29638
+ let hasDataFilters = false;
29639
+ for (const f of filters) {
29640
+ if (BUILTIN_FIELDS.has(f.field)) {
29641
+ builtinFieldsPresent.add(f.field);
29642
+ } else {
29643
+ hasDataFilters = true;
29644
+ }
29645
+ }
29646
+ for (const pattern of SAFE_INDEX_PATTERNS) {
29647
+ let matched = true;
29648
+ for (const field of pattern) {
29649
+ if (!builtinFieldsPresent.has(field)) {
29650
+ matched = false;
29651
+ break;
29652
+ }
29653
+ }
29654
+ if (matched) {
29655
+ return { safe: true };
29656
+ }
29657
+ }
29658
+ const presentFields = [...builtinFieldsPresent];
29659
+ if (presentFields.length === 0 && hasDataFilters) {
29660
+ return {
29661
+ safe: false,
29662
+ 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."
29663
+ };
29664
+ }
29665
+ if (hasDataFilters) {
29666
+ return {
29667
+ safe: false,
29668
+ 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.`
29669
+ };
29670
+ }
29671
+ return {
29672
+ safe: false,
29673
+ 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.`
29674
+ };
29675
+ }
29676
+
29677
+ // src/transaction.ts
29598
29678
  var GraphTransactionImpl = class {
29599
- constructor(adapter, registry2) {
29679
+ constructor(adapter, registry2, scanProtection = "error", scopePath = "") {
29600
29680
  this.adapter = adapter;
29601
29681
  this.registry = registry2;
29682
+ this.scanProtection = scanProtection;
29683
+ this.scopePath = scopePath;
29602
29684
  }
29603
29685
  async getNode(uid) {
29604
29686
  const docId = computeNodeDocId(uid);
@@ -29612,12 +29694,22 @@ var GraphTransactionImpl = class {
29612
29694
  const record2 = await this.getEdge(aUid, axbType, bUid);
29613
29695
  return record2 !== null;
29614
29696
  }
29697
+ checkQuerySafety(filters, allowCollectionScan) {
29698
+ if (allowCollectionScan || this.scanProtection === "off") return;
29699
+ const result = analyzeQuerySafety(filters);
29700
+ if (result.safe) return;
29701
+ if (this.scanProtection === "error") {
29702
+ throw new QuerySafetyError(result.reason);
29703
+ }
29704
+ console.warn(`[firegraph] Query safety warning: ${result.reason}`);
29705
+ }
29615
29706
  async findEdges(params) {
29616
29707
  const plan = buildEdgeQueryPlan(params);
29617
29708
  if (plan.strategy === "get") {
29618
29709
  const record2 = await this.adapter.getDoc(plan.docId);
29619
29710
  return record2 ? [record2] : [];
29620
29711
  }
29712
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
29621
29713
  return this.adapter.query(plan.filters, plan.options);
29622
29714
  }
29623
29715
  async findNodes(params) {
@@ -29626,11 +29718,12 @@ var GraphTransactionImpl = class {
29626
29718
  const record2 = await this.adapter.getDoc(plan.docId);
29627
29719
  return record2 ? [record2] : [];
29628
29720
  }
29721
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
29629
29722
  return this.adapter.query(plan.filters, plan.options);
29630
29723
  }
29631
29724
  async putNode(aType, uid, data) {
29632
29725
  if (this.registry) {
29633
- this.registry.validate(aType, NODE_RELATION, aType, data);
29726
+ this.registry.validate(aType, NODE_RELATION, aType, data, this.scopePath);
29634
29727
  }
29635
29728
  const docId = computeNodeDocId(uid);
29636
29729
  const record2 = buildNodeRecord(aType, uid, data);
@@ -29638,7 +29731,7 @@ var GraphTransactionImpl = class {
29638
29731
  }
29639
29732
  async putEdge(aType, aUid, axbType, bType, bUid, data) {
29640
29733
  if (this.registry) {
29641
- this.registry.validate(aType, axbType, bType, data);
29734
+ this.registry.validate(aType, axbType, bType, data, this.scopePath);
29642
29735
  }
29643
29736
  const docId = computeEdgeDocId(aUid, axbType, bUid);
29644
29737
  const record2 = buildEdgeRecord(aType, aUid, axbType, bType, bUid, data);
@@ -29664,13 +29757,14 @@ var GraphTransactionImpl = class {
29664
29757
  // src/batch.ts
29665
29758
  import { FieldValue as FieldValue3 } from "@google-cloud/firestore";
29666
29759
  var GraphBatchImpl = class {
29667
- constructor(adapter, registry2) {
29760
+ constructor(adapter, registry2, scopePath = "") {
29668
29761
  this.adapter = adapter;
29669
29762
  this.registry = registry2;
29763
+ this.scopePath = scopePath;
29670
29764
  }
29671
29765
  async putNode(aType, uid, data) {
29672
29766
  if (this.registry) {
29673
- this.registry.validate(aType, NODE_RELATION, aType, data);
29767
+ this.registry.validate(aType, NODE_RELATION, aType, data, this.scopePath);
29674
29768
  }
29675
29769
  const docId = computeNodeDocId(uid);
29676
29770
  const record2 = buildNodeRecord(aType, uid, data);
@@ -29678,7 +29772,7 @@ var GraphBatchImpl = class {
29678
29772
  }
29679
29773
  async putEdge(aType, aUid, axbType, bType, bUid, data) {
29680
29774
  if (this.registry) {
29681
- this.registry.validate(aType, axbType, bType, data);
29775
+ this.registry.validate(aType, axbType, bType, data, this.scopePath);
29682
29776
  }
29683
29777
  const docId = computeEdgeDocId(aUid, axbType, bUid);
29684
29778
  const record2 = buildEdgeRecord(aType, aUid, axbType, bType, bUid, data);
@@ -29770,14 +29864,39 @@ async function bulkDeleteDocIds(db2, collectionPath, docIds, options) {
29770
29864
  return { deleted, batches: completedBatches, errors };
29771
29865
  }
29772
29866
  async function bulkRemoveEdges(db2, collectionPath, reader, params, options) {
29773
- const edges = await reader.findEdges(params);
29867
+ const effectiveParams = params.limit !== void 0 ? { ...params, allowCollectionScan: params.allowCollectionScan ?? true } : { ...params, limit: 0, allowCollectionScan: params.allowCollectionScan ?? true };
29868
+ const edges = await reader.findEdges(effectiveParams);
29774
29869
  const docIds = edges.map((e) => computeEdgeDocId(e.aUid, e.axbType, e.bUid));
29775
29870
  return bulkDeleteDocIds(db2, collectionPath, docIds, options);
29776
29871
  }
29872
+ async function deleteSubcollectionsRecursive(db2, collectionPath, docId, options) {
29873
+ const docRef = db2.collection(collectionPath).doc(docId);
29874
+ const subcollections = await docRef.listCollections();
29875
+ if (subcollections.length === 0) return { deleted: 0, errors: [] };
29876
+ let totalDeleted = 0;
29877
+ const allErrors = [];
29878
+ const subOptions = options ? { batchSize: options.batchSize, maxRetries: options.maxRetries } : void 0;
29879
+ for (const subCollRef of subcollections) {
29880
+ const subCollPath = subCollRef.path;
29881
+ const snapshot = await subCollRef.select().get();
29882
+ const subDocIds = snapshot.docs.map((d) => d.id);
29883
+ for (const subDocId of subDocIds) {
29884
+ const subResult = await deleteSubcollectionsRecursive(db2, subCollPath, subDocId, subOptions);
29885
+ totalDeleted += subResult.deleted;
29886
+ allErrors.push(...subResult.errors);
29887
+ }
29888
+ if (subDocIds.length > 0) {
29889
+ const result = await bulkDeleteDocIds(db2, subCollPath, subDocIds, subOptions);
29890
+ totalDeleted += result.deleted;
29891
+ allErrors.push(...result.errors);
29892
+ }
29893
+ }
29894
+ return { deleted: totalDeleted, errors: allErrors };
29895
+ }
29777
29896
  async function removeNodeCascade(db2, collectionPath, reader, uid, options) {
29778
29897
  const [outgoingRaw, incomingRaw] = await Promise.all([
29779
- reader.findEdges({ aUid: uid }),
29780
- reader.findEdges({ bUid: uid })
29898
+ reader.findEdges({ aUid: uid, allowCollectionScan: true, limit: 0 }),
29899
+ reader.findEdges({ bUid: uid, allowCollectionScan: true, limit: 0 })
29781
29900
  ]);
29782
29901
  const outgoing = outgoingRaw.filter((e) => e.axbType !== NODE_RELATION);
29783
29902
  const incoming = incomingRaw.filter((e) => e.axbType !== NODE_RELATION);
@@ -29790,8 +29909,18 @@ async function removeNodeCascade(db2, collectionPath, reader, uid, options) {
29790
29909
  allEdges.push(edge);
29791
29910
  }
29792
29911
  }
29793
- const edgeDocIds = allEdges.map((e) => computeEdgeDocId(e.aUid, e.axbType, e.bUid));
29912
+ const shouldDeleteSubcollections = options?.deleteSubcollections !== false;
29794
29913
  const nodeDocId = computeNodeDocId(uid);
29914
+ let subcollectionResult = { deleted: 0, errors: [] };
29915
+ if (shouldDeleteSubcollections) {
29916
+ subcollectionResult = await deleteSubcollectionsRecursive(
29917
+ db2,
29918
+ collectionPath,
29919
+ nodeDocId,
29920
+ options
29921
+ );
29922
+ }
29923
+ const edgeDocIds = allEdges.map((e) => computeEdgeDocId(e.aUid, e.axbType, e.bUid));
29795
29924
  const allDocIds = [...edgeDocIds, nodeDocId];
29796
29925
  const batchSize = Math.min(options?.batchSize ?? MAX_BATCH_SIZE, MAX_BATCH_SIZE);
29797
29926
  const result = await bulkDeleteDocIds(db2, collectionPath, allDocIds, {
@@ -29801,9 +29930,12 @@ async function removeNodeCascade(db2, collectionPath, reader, uid, options) {
29801
29930
  const totalChunks = Math.ceil(allDocIds.length / batchSize);
29802
29931
  const nodeChunkIndex = totalChunks - 1;
29803
29932
  const nodeDeleted = !result.errors.some((e) => e.batchIndex === nodeChunkIndex);
29933
+ const topLevelEdgesDeleted = nodeDeleted ? result.deleted - 1 : result.deleted;
29804
29934
  return {
29805
- ...result,
29806
- edgesDeleted: nodeDeleted ? result.deleted - 1 : result.deleted,
29935
+ deleted: result.deleted + subcollectionResult.deleted,
29936
+ batches: result.batches,
29937
+ errors: [...result.errors, ...subcollectionResult.errors],
29938
+ edgesDeleted: topLevelEdgesDeleted,
29807
29939
  nodeDeleted
29808
29940
  };
29809
29941
  }
@@ -29903,6 +30035,39 @@ function propertyToFieldMeta(name, prop, required2) {
29903
30035
  return { name, type: "unknown", required: required2, description: prop.description };
29904
30036
  }
29905
30037
 
30038
+ // src/scope.ts
30039
+ function matchScope(scopePath, pattern) {
30040
+ if (pattern === "root") return scopePath === "";
30041
+ if (pattern === "**") return true;
30042
+ const pathSegments = scopePath === "" ? [] : scopePath.split("/");
30043
+ const patternSegments = pattern.split("/");
30044
+ return matchSegments(pathSegments, 0, patternSegments, 0);
30045
+ }
30046
+ function matchScopeAny(scopePath, patterns) {
30047
+ if (!patterns || patterns.length === 0) return true;
30048
+ return patterns.some((p) => matchScope(scopePath, p));
30049
+ }
30050
+ function matchSegments(path4, pi, pattern, qi) {
30051
+ if (pi === path4.length && qi === pattern.length) return true;
30052
+ if (qi === pattern.length) return false;
30053
+ const seg = pattern[qi];
30054
+ if (seg === "**") {
30055
+ if (qi === pattern.length - 1) return true;
30056
+ for (let skip = 0; skip <= path4.length - pi; skip++) {
30057
+ if (matchSegments(path4, pi + skip, pattern, qi + 1)) return true;
30058
+ }
30059
+ return false;
30060
+ }
30061
+ if (pi === path4.length) return false;
30062
+ if (seg === "*") {
30063
+ return matchSegments(path4, pi + 1, pattern, qi + 1);
30064
+ }
30065
+ if (path4[pi] === seg) {
30066
+ return matchSegments(path4, pi + 1, pattern, qi + 1);
30067
+ }
30068
+ return false;
30069
+ }
30070
+
29906
30071
  // src/registry.ts
29907
30072
  function tripleKey(aType, axbType, bType) {
29908
30073
  return `${aType}:${axbType}:${bType}`;
@@ -29925,11 +30090,16 @@ function createRegistry(input) {
29925
30090
  lookup(aType, axbType, bType) {
29926
30091
  return map2.get(tripleKey(aType, axbType, bType))?.entry;
29927
30092
  },
29928
- validate(aType, axbType, bType, data) {
30093
+ validate(aType, axbType, bType, data, scopePath) {
29929
30094
  const rec = map2.get(tripleKey(aType, axbType, bType));
29930
30095
  if (!rec) {
29931
30096
  throw new RegistryViolationError(aType, axbType, bType);
29932
30097
  }
30098
+ if (scopePath !== void 0 && rec.entry.allowedIn && rec.entry.allowedIn.length > 0) {
30099
+ if (!matchScopeAny(scopePath, rec.entry.allowedIn)) {
30100
+ throw new RegistryScopeError(aType, axbType, bType, scopePath, rec.entry.allowedIn);
30101
+ }
30102
+ }
29933
30103
  if (rec.validate) {
29934
30104
  try {
29935
30105
  rec.validate(data);
@@ -29957,7 +30127,8 @@ function discoveryToEntries(discovery) {
29957
30127
  jsonSchema: entity.schema,
29958
30128
  description: entity.description,
29959
30129
  titleField: entity.titleField,
29960
- subtitleField: entity.subtitleField
30130
+ subtitleField: entity.subtitleField,
30131
+ allowedIn: entity.allowedIn
29961
30132
  });
29962
30133
  }
29963
30134
  for (const [axbType, entity] of discovery.edges) {
@@ -29975,7 +30146,8 @@ function discoveryToEntries(discovery) {
29975
30146
  description: entity.description,
29976
30147
  inverseLabel: topology.inverseLabel,
29977
30148
  titleField: entity.titleField,
29978
- subtitleField: entity.subtitleField
30149
+ subtitleField: entity.subtitleField,
30150
+ allowedIn: entity.allowedIn
29979
30151
  });
29980
30152
  }
29981
30153
  }
@@ -29996,7 +30168,8 @@ var NODE_TYPE_SCHEMA = {
29996
30168
  titleField: { type: "string" },
29997
30169
  subtitleField: { type: "string" },
29998
30170
  viewTemplate: { type: "string" },
29999
- viewCss: { type: "string" }
30171
+ viewCss: { type: "string" },
30172
+ allowedIn: { type: "array", items: { type: "string", minLength: 1 } }
30000
30173
  },
30001
30174
  additionalProperties: false
30002
30175
  };
@@ -30023,7 +30196,8 @@ var EDGE_TYPE_SCHEMA = {
30023
30196
  titleField: { type: "string" },
30024
30197
  subtitleField: { type: "string" },
30025
30198
  viewTemplate: { type: "string" },
30026
- viewCss: { type: "string" }
30199
+ viewCss: { type: "string" },
30200
+ allowedIn: { type: "array", items: { type: "string", minLength: 1 } }
30027
30201
  },
30028
30202
  additionalProperties: false
30029
30203
  };
@@ -30065,7 +30239,8 @@ async function createRegistryFromGraph(reader) {
30065
30239
  jsonSchema: data.jsonSchema,
30066
30240
  description: data.description,
30067
30241
  titleField: data.titleField,
30068
- subtitleField: data.subtitleField
30242
+ subtitleField: data.subtitleField,
30243
+ allowedIn: data.allowedIn
30069
30244
  });
30070
30245
  }
30071
30246
  for (const record2 of edgeTypes) {
@@ -30082,7 +30257,8 @@ async function createRegistryFromGraph(reader) {
30082
30257
  description: data.description,
30083
30258
  inverseLabel: data.inverseLabel,
30084
30259
  titleField: data.titleField,
30085
- subtitleField: data.subtitleField
30260
+ subtitleField: data.subtitleField,
30261
+ allowedIn: data.allowedIn
30086
30262
  });
30087
30263
  }
30088
30264
  }
@@ -30093,9 +30269,10 @@ async function createRegistryFromGraph(reader) {
30093
30269
  // src/client.ts
30094
30270
  var _standardModeWarned = false;
30095
30271
  var RESERVED_TYPE_NAMES = /* @__PURE__ */ new Set([META_NODE_TYPE, META_EDGE_TYPE]);
30096
- var GraphClientImpl = class {
30097
- constructor(db2, collectionPath, options) {
30272
+ var GraphClientImpl = class _GraphClientImpl {
30273
+ constructor(db2, collectionPath, options, scopePath = "") {
30098
30274
  this.db = db2;
30275
+ this.scopePath = scopePath;
30099
30276
  this.adapter = createFirestoreAdapter(db2, collectionPath);
30100
30277
  if (options?.registry && options?.registryMode) {
30101
30278
  throw new DynamicRegistryError(
@@ -30125,6 +30302,7 @@ var GraphClientImpl = class {
30125
30302
  "[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"
30126
30303
  );
30127
30304
  }
30305
+ this.scanProtection = options?.scanProtection ?? "error";
30128
30306
  if (this.queryMode === "pipeline") {
30129
30307
  this.pipelineAdapter = createPipelineQueryAdapter(db2, collectionPath);
30130
30308
  if (this.metaAdapter) {
@@ -30138,6 +30316,7 @@ var GraphClientImpl = class {
30138
30316
  adapter;
30139
30317
  pipelineAdapter;
30140
30318
  queryMode;
30319
+ scanProtection;
30141
30320
  // Static mode
30142
30321
  staticRegistry;
30143
30322
  // Dynamic mode
@@ -30146,6 +30325,8 @@ var GraphClientImpl = class {
30146
30325
  dynamicRegistry;
30147
30326
  metaAdapter;
30148
30327
  metaPipelineAdapter;
30328
+ // Subgraph scope tracking
30329
+ scopePath;
30149
30330
  // ---------------------------------------------------------------------------
30150
30331
  // Registry routing
30151
30332
  // ---------------------------------------------------------------------------
@@ -30199,6 +30380,19 @@ var GraphClientImpl = class {
30199
30380
  }
30200
30381
  return this.adapter.query(filters, options);
30201
30382
  }
30383
+ /**
30384
+ * Check whether a query's filter set is safe (matches a known index pattern).
30385
+ * Throws QuerySafetyError or logs a warning depending on scanProtection config.
30386
+ */
30387
+ checkQuerySafety(filters, allowCollectionScan) {
30388
+ if (allowCollectionScan || this.scanProtection === "off") return;
30389
+ const result = analyzeQuerySafety(filters);
30390
+ if (result.safe) return;
30391
+ if (this.scanProtection === "error") {
30392
+ throw new QuerySafetyError(result.reason);
30393
+ }
30394
+ console.warn(`[firegraph] Query safety warning: ${result.reason}`);
30395
+ }
30202
30396
  // ---------------------------------------------------------------------------
30203
30397
  // GraphReader
30204
30398
  // ---------------------------------------------------------------------------
@@ -30220,6 +30414,7 @@ var GraphClientImpl = class {
30220
30414
  const record2 = await this.adapter.getDoc(plan.docId);
30221
30415
  return record2 ? [record2] : [];
30222
30416
  }
30417
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
30223
30418
  return this.executeQuery(plan.filters, plan.options);
30224
30419
  }
30225
30420
  async findNodes(params) {
@@ -30228,6 +30423,7 @@ var GraphClientImpl = class {
30228
30423
  const record2 = await this.adapter.getDoc(plan.docId);
30229
30424
  return record2 ? [record2] : [];
30230
30425
  }
30426
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
30231
30427
  return this.executeQuery(plan.filters, plan.options);
30232
30428
  }
30233
30429
  // ---------------------------------------------------------------------------
@@ -30236,7 +30432,7 @@ var GraphClientImpl = class {
30236
30432
  async putNode(aType, uid, data) {
30237
30433
  const registry2 = this.getRegistryForType(aType);
30238
30434
  if (registry2) {
30239
- registry2.validate(aType, NODE_RELATION, aType, data);
30435
+ registry2.validate(aType, NODE_RELATION, aType, data, this.scopePath);
30240
30436
  }
30241
30437
  const adapter = this.getAdapterForType(aType);
30242
30438
  const docId = computeNodeDocId(uid);
@@ -30246,7 +30442,7 @@ var GraphClientImpl = class {
30246
30442
  async putEdge(aType, aUid, axbType, bType, bUid, data) {
30247
30443
  const registry2 = this.getRegistryForType(aType);
30248
30444
  if (registry2) {
30249
- registry2.validate(aType, axbType, bType, data);
30445
+ registry2.validate(aType, axbType, bType, data, this.scopePath);
30250
30446
  }
30251
30447
  const adapter = this.getAdapterForType(aType);
30252
30448
  const docId = computeEdgeDocId(aUid, axbType, bUid);
@@ -30278,13 +30474,42 @@ var GraphClientImpl = class {
30278
30474
  this.adapter.collectionPath,
30279
30475
  firestoreTx
30280
30476
  );
30281
- const graphTx = new GraphTransactionImpl(adapter, this.getCombinedRegistry());
30477
+ const graphTx = new GraphTransactionImpl(adapter, this.getCombinedRegistry(), this.scanProtection, this.scopePath);
30282
30478
  return fn(graphTx);
30283
30479
  });
30284
30480
  }
30285
30481
  batch() {
30286
30482
  const adapter = createBatchAdapter(this.db, this.adapter.collectionPath);
30287
- return new GraphBatchImpl(adapter, this.getCombinedRegistry());
30483
+ return new GraphBatchImpl(adapter, this.getCombinedRegistry(), this.scopePath);
30484
+ }
30485
+ // ---------------------------------------------------------------------------
30486
+ // Subgraph
30487
+ // ---------------------------------------------------------------------------
30488
+ subgraph(parentNodeUid, name = "graph") {
30489
+ if (!parentNodeUid || parentNodeUid.includes("/")) {
30490
+ throw new FiregraphError(
30491
+ `Invalid parentNodeUid for subgraph: "${parentNodeUid}". Must be a non-empty string without "/".`,
30492
+ "INVALID_SUBGRAPH"
30493
+ );
30494
+ }
30495
+ if (name.includes("/")) {
30496
+ throw new FiregraphError(
30497
+ `Subgraph name must not contain "/": got "${name}". Use chained .subgraph() calls for nested subgraphs.`,
30498
+ "INVALID_SUBGRAPH"
30499
+ );
30500
+ }
30501
+ const subCollectionPath = `${this.adapter.collectionPath}/${parentNodeUid}/${name}`;
30502
+ const newScopePath = this.scopePath ? `${this.scopePath}/${name}` : name;
30503
+ return new _GraphClientImpl(
30504
+ this.db,
30505
+ subCollectionPath,
30506
+ {
30507
+ registry: this.getCombinedRegistry(),
30508
+ queryMode: this.queryMode === "pipeline" ? "pipeline" : "standard",
30509
+ scanProtection: this.scanProtection
30510
+ },
30511
+ newScopePath
30512
+ );
30288
30513
  }
30289
30514
  // ---------------------------------------------------------------------------
30290
30515
  // Bulk operations
@@ -30316,6 +30541,7 @@ var GraphClientImpl = class {
30316
30541
  if (options?.subtitleField !== void 0) data.subtitleField = options.subtitleField;
30317
30542
  if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
30318
30543
  if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
30544
+ if (options?.allowedIn !== void 0) data.allowedIn = options.allowedIn;
30319
30545
  await this.putNode(META_NODE_TYPE, uid, data);
30320
30546
  }
30321
30547
  async defineEdgeType(name, topology, jsonSchema, description, options) {
@@ -30342,6 +30568,7 @@ var GraphClientImpl = class {
30342
30568
  if (options?.subtitleField !== void 0) data.subtitleField = options.subtitleField;
30343
30569
  if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
30344
30570
  if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
30571
+ if (options?.allowedIn !== void 0) data.allowedIn = options.allowedIn;
30345
30572
  await this.putNode(META_EDGE_TYPE, uid, data);
30346
30573
  }
30347
30574
  async reloadRegistry() {
@@ -30524,7 +30751,8 @@ function loadNodeEntity(dir, name) {
30524
30751
  subtitleField: meta3?.subtitleField,
30525
30752
  viewDefaults: meta3?.viewDefaults,
30526
30753
  viewsPath,
30527
- sampleData
30754
+ sampleData,
30755
+ allowedIn: meta3?.allowedIn
30528
30756
  };
30529
30757
  }
30530
30758
  function loadEdgeEntity(dir, name) {
@@ -30559,7 +30787,8 @@ function loadEdgeEntity(dir, name) {
30559
30787
  subtitleField: meta3?.subtitleField,
30560
30788
  viewDefaults: meta3?.viewDefaults,
30561
30789
  viewsPath,
30562
- sampleData
30790
+ sampleData,
30791
+ allowedIn: meta3?.allowedIn
30563
30792
  };
30564
30793
  }
30565
30794
  function getSubdirectories(dir) {