@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.
@@ -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}`;
@@ -29917,19 +30082,45 @@ function createRegistry(input) {
29917
30082
  }
29918
30083
  const entryList = Object.freeze([...entries]);
29919
30084
  for (const entry of entries) {
30085
+ if (entry.targetGraph && entry.targetGraph.includes("/")) {
30086
+ throw new ValidationError(
30087
+ `Entry (${entry.aType}) -[${entry.axbType}]-> (${entry.bType}) has invalid targetGraph "${entry.targetGraph}" \u2014 must be a single segment (no "/")`
30088
+ );
30089
+ }
29920
30090
  const key = tripleKey(entry.aType, entry.axbType, entry.bType);
29921
30091
  const validator = entry.jsonSchema ? compileSchema(entry.jsonSchema, `(${entry.aType}) -[${entry.axbType}]-> (${entry.bType})`) : void 0;
29922
30092
  map2.set(key, { entry, validate: validator });
29923
30093
  }
30094
+ const axbIndex = /* @__PURE__ */ new Map();
30095
+ const axbBuild = /* @__PURE__ */ new Map();
30096
+ for (const entry of entries) {
30097
+ const existing = axbBuild.get(entry.axbType);
30098
+ if (existing) {
30099
+ existing.push(entry);
30100
+ } else {
30101
+ axbBuild.set(entry.axbType, [entry]);
30102
+ }
30103
+ }
30104
+ for (const [key, arr] of axbBuild) {
30105
+ axbIndex.set(key, Object.freeze(arr));
30106
+ }
29924
30107
  return {
29925
30108
  lookup(aType, axbType, bType) {
29926
30109
  return map2.get(tripleKey(aType, axbType, bType))?.entry;
29927
30110
  },
29928
- validate(aType, axbType, bType, data) {
30111
+ lookupByAxbType(axbType) {
30112
+ return axbIndex.get(axbType) ?? [];
30113
+ },
30114
+ validate(aType, axbType, bType, data, scopePath) {
29929
30115
  const rec = map2.get(tripleKey(aType, axbType, bType));
29930
30116
  if (!rec) {
29931
30117
  throw new RegistryViolationError(aType, axbType, bType);
29932
30118
  }
30119
+ if (scopePath !== void 0 && rec.entry.allowedIn && rec.entry.allowedIn.length > 0) {
30120
+ if (!matchScopeAny(scopePath, rec.entry.allowedIn)) {
30121
+ throw new RegistryScopeError(aType, axbType, bType, scopePath, rec.entry.allowedIn);
30122
+ }
30123
+ }
29933
30124
  if (rec.validate) {
29934
30125
  try {
29935
30126
  rec.validate(data);
@@ -29957,7 +30148,8 @@ function discoveryToEntries(discovery) {
29957
30148
  jsonSchema: entity.schema,
29958
30149
  description: entity.description,
29959
30150
  titleField: entity.titleField,
29960
- subtitleField: entity.subtitleField
30151
+ subtitleField: entity.subtitleField,
30152
+ allowedIn: entity.allowedIn
29961
30153
  });
29962
30154
  }
29963
30155
  for (const [axbType, entity] of discovery.edges) {
@@ -29965,6 +30157,12 @@ function discoveryToEntries(discovery) {
29965
30157
  if (!topology) continue;
29966
30158
  const fromTypes = Array.isArray(topology.from) ? topology.from : [topology.from];
29967
30159
  const toTypes = Array.isArray(topology.to) ? topology.to : [topology.to];
30160
+ const resolvedTargetGraph = entity.targetGraph ?? topology.targetGraph;
30161
+ if (resolvedTargetGraph && resolvedTargetGraph.includes("/")) {
30162
+ throw new ValidationError(
30163
+ `Edge "${axbType}" has invalid targetGraph "${resolvedTargetGraph}" \u2014 must be a single segment (no "/")`
30164
+ );
30165
+ }
29968
30166
  for (const aType of fromTypes) {
29969
30167
  for (const bType of toTypes) {
29970
30168
  entries.push({
@@ -29975,7 +30173,9 @@ function discoveryToEntries(discovery) {
29975
30173
  description: entity.description,
29976
30174
  inverseLabel: topology.inverseLabel,
29977
30175
  titleField: entity.titleField,
29978
- subtitleField: entity.subtitleField
30176
+ subtitleField: entity.subtitleField,
30177
+ allowedIn: entity.allowedIn,
30178
+ targetGraph: resolvedTargetGraph
29979
30179
  });
29980
30180
  }
29981
30181
  }
@@ -29996,7 +30196,8 @@ var NODE_TYPE_SCHEMA = {
29996
30196
  titleField: { type: "string" },
29997
30197
  subtitleField: { type: "string" },
29998
30198
  viewTemplate: { type: "string" },
29999
- viewCss: { type: "string" }
30199
+ viewCss: { type: "string" },
30200
+ allowedIn: { type: "array", items: { type: "string", minLength: 1 } }
30000
30201
  },
30001
30202
  additionalProperties: false
30002
30203
  };
@@ -30023,7 +30224,9 @@ var EDGE_TYPE_SCHEMA = {
30023
30224
  titleField: { type: "string" },
30024
30225
  subtitleField: { type: "string" },
30025
30226
  viewTemplate: { type: "string" },
30026
- viewCss: { type: "string" }
30227
+ viewCss: { type: "string" },
30228
+ allowedIn: { type: "array", items: { type: "string", minLength: 1 } },
30229
+ targetGraph: { type: "string", minLength: 1, pattern: "^[^/]+$" }
30027
30230
  },
30028
30231
  additionalProperties: false
30029
30232
  };
@@ -30065,7 +30268,8 @@ async function createRegistryFromGraph(reader) {
30065
30268
  jsonSchema: data.jsonSchema,
30066
30269
  description: data.description,
30067
30270
  titleField: data.titleField,
30068
- subtitleField: data.subtitleField
30271
+ subtitleField: data.subtitleField,
30272
+ allowedIn: data.allowedIn
30069
30273
  });
30070
30274
  }
30071
30275
  for (const record2 of edgeTypes) {
@@ -30082,7 +30286,9 @@ async function createRegistryFromGraph(reader) {
30082
30286
  description: data.description,
30083
30287
  inverseLabel: data.inverseLabel,
30084
30288
  titleField: data.titleField,
30085
- subtitleField: data.subtitleField
30289
+ subtitleField: data.subtitleField,
30290
+ allowedIn: data.allowedIn,
30291
+ targetGraph: data.targetGraph
30086
30292
  });
30087
30293
  }
30088
30294
  }
@@ -30093,9 +30299,10 @@ async function createRegistryFromGraph(reader) {
30093
30299
  // src/client.ts
30094
30300
  var _standardModeWarned = false;
30095
30301
  var RESERVED_TYPE_NAMES = /* @__PURE__ */ new Set([META_NODE_TYPE, META_EDGE_TYPE]);
30096
- var GraphClientImpl = class {
30097
- constructor(db2, collectionPath, options) {
30302
+ var GraphClientImpl = class _GraphClientImpl {
30303
+ constructor(db2, collectionPath, options, scopePath = "") {
30098
30304
  this.db = db2;
30305
+ this.scopePath = scopePath;
30099
30306
  this.adapter = createFirestoreAdapter(db2, collectionPath);
30100
30307
  if (options?.registry && options?.registryMode) {
30101
30308
  throw new DynamicRegistryError(
@@ -30125,6 +30332,7 @@ var GraphClientImpl = class {
30125
30332
  "[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
30333
  );
30127
30334
  }
30335
+ this.scanProtection = options?.scanProtection ?? "error";
30128
30336
  if (this.queryMode === "pipeline") {
30129
30337
  this.pipelineAdapter = createPipelineQueryAdapter(db2, collectionPath);
30130
30338
  if (this.metaAdapter) {
@@ -30138,6 +30346,7 @@ var GraphClientImpl = class {
30138
30346
  adapter;
30139
30347
  pipelineAdapter;
30140
30348
  queryMode;
30349
+ scanProtection;
30141
30350
  // Static mode
30142
30351
  staticRegistry;
30143
30352
  // Dynamic mode
@@ -30146,6 +30355,8 @@ var GraphClientImpl = class {
30146
30355
  dynamicRegistry;
30147
30356
  metaAdapter;
30148
30357
  metaPipelineAdapter;
30358
+ // Subgraph scope tracking
30359
+ scopePath;
30149
30360
  // ---------------------------------------------------------------------------
30150
30361
  // Registry routing
30151
30362
  // ---------------------------------------------------------------------------
@@ -30199,6 +30410,19 @@ var GraphClientImpl = class {
30199
30410
  }
30200
30411
  return this.adapter.query(filters, options);
30201
30412
  }
30413
+ /**
30414
+ * Check whether a query's filter set is safe (matches a known index pattern).
30415
+ * Throws QuerySafetyError or logs a warning depending on scanProtection config.
30416
+ */
30417
+ checkQuerySafety(filters, allowCollectionScan) {
30418
+ if (allowCollectionScan || this.scanProtection === "off") return;
30419
+ const result = analyzeQuerySafety(filters);
30420
+ if (result.safe) return;
30421
+ if (this.scanProtection === "error") {
30422
+ throw new QuerySafetyError(result.reason);
30423
+ }
30424
+ console.warn(`[firegraph] Query safety warning: ${result.reason}`);
30425
+ }
30202
30426
  // ---------------------------------------------------------------------------
30203
30427
  // GraphReader
30204
30428
  // ---------------------------------------------------------------------------
@@ -30220,6 +30444,7 @@ var GraphClientImpl = class {
30220
30444
  const record2 = await this.adapter.getDoc(plan.docId);
30221
30445
  return record2 ? [record2] : [];
30222
30446
  }
30447
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
30223
30448
  return this.executeQuery(plan.filters, plan.options);
30224
30449
  }
30225
30450
  async findNodes(params) {
@@ -30228,6 +30453,7 @@ var GraphClientImpl = class {
30228
30453
  const record2 = await this.adapter.getDoc(plan.docId);
30229
30454
  return record2 ? [record2] : [];
30230
30455
  }
30456
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
30231
30457
  return this.executeQuery(plan.filters, plan.options);
30232
30458
  }
30233
30459
  // ---------------------------------------------------------------------------
@@ -30236,7 +30462,7 @@ var GraphClientImpl = class {
30236
30462
  async putNode(aType, uid, data) {
30237
30463
  const registry2 = this.getRegistryForType(aType);
30238
30464
  if (registry2) {
30239
- registry2.validate(aType, NODE_RELATION, aType, data);
30465
+ registry2.validate(aType, NODE_RELATION, aType, data, this.scopePath);
30240
30466
  }
30241
30467
  const adapter = this.getAdapterForType(aType);
30242
30468
  const docId = computeNodeDocId(uid);
@@ -30246,7 +30472,7 @@ var GraphClientImpl = class {
30246
30472
  async putEdge(aType, aUid, axbType, bType, bUid, data) {
30247
30473
  const registry2 = this.getRegistryForType(aType);
30248
30474
  if (registry2) {
30249
- registry2.validate(aType, axbType, bType, data);
30475
+ registry2.validate(aType, axbType, bType, data, this.scopePath);
30250
30476
  }
30251
30477
  const adapter = this.getAdapterForType(aType);
30252
30478
  const docId = computeEdgeDocId(aUid, axbType, bUid);
@@ -30278,13 +30504,69 @@ var GraphClientImpl = class {
30278
30504
  this.adapter.collectionPath,
30279
30505
  firestoreTx
30280
30506
  );
30281
- const graphTx = new GraphTransactionImpl(adapter, this.getCombinedRegistry());
30507
+ const graphTx = new GraphTransactionImpl(adapter, this.getCombinedRegistry(), this.scanProtection, this.scopePath);
30282
30508
  return fn(graphTx);
30283
30509
  });
30284
30510
  }
30285
30511
  batch() {
30286
30512
  const adapter = createBatchAdapter(this.db, this.adapter.collectionPath);
30287
- return new GraphBatchImpl(adapter, this.getCombinedRegistry());
30513
+ return new GraphBatchImpl(adapter, this.getCombinedRegistry(), this.scopePath);
30514
+ }
30515
+ // ---------------------------------------------------------------------------
30516
+ // Subgraph
30517
+ // ---------------------------------------------------------------------------
30518
+ subgraph(parentNodeUid, name = "graph") {
30519
+ if (!parentNodeUid || parentNodeUid.includes("/")) {
30520
+ throw new FiregraphError(
30521
+ `Invalid parentNodeUid for subgraph: "${parentNodeUid}". Must be a non-empty string without "/".`,
30522
+ "INVALID_SUBGRAPH"
30523
+ );
30524
+ }
30525
+ if (name.includes("/")) {
30526
+ throw new FiregraphError(
30527
+ `Subgraph name must not contain "/": got "${name}". Use chained .subgraph() calls for nested subgraphs.`,
30528
+ "INVALID_SUBGRAPH"
30529
+ );
30530
+ }
30531
+ const subCollectionPath = `${this.adapter.collectionPath}/${parentNodeUid}/${name}`;
30532
+ const newScopePath = this.scopePath ? `${this.scopePath}/${name}` : name;
30533
+ return new _GraphClientImpl(
30534
+ this.db,
30535
+ subCollectionPath,
30536
+ {
30537
+ registry: this.getCombinedRegistry(),
30538
+ queryMode: this.queryMode === "pipeline" ? "pipeline" : "standard",
30539
+ scanProtection: this.scanProtection
30540
+ },
30541
+ newScopePath
30542
+ );
30543
+ }
30544
+ // ---------------------------------------------------------------------------
30545
+ // Collection group query
30546
+ // ---------------------------------------------------------------------------
30547
+ async findEdgesGlobal(params, collectionName) {
30548
+ const name = collectionName ?? this.adapter.collectionPath.split("/").pop();
30549
+ const plan = buildEdgeQueryPlan(params);
30550
+ if (plan.strategy === "get") {
30551
+ throw new FiregraphError(
30552
+ "findEdgesGlobal() requires a query, not a direct document lookup. Omit one of aUid/axbType/bUid to force a query strategy.",
30553
+ "INVALID_QUERY"
30554
+ );
30555
+ }
30556
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
30557
+ const collectionGroupRef = this.db.collectionGroup(name);
30558
+ let q = collectionGroupRef;
30559
+ for (const f of plan.filters) {
30560
+ q = q.where(f.field, f.op, f.value);
30561
+ }
30562
+ if (plan.options?.orderBy) {
30563
+ q = q.orderBy(plan.options.orderBy.field, plan.options.orderBy.direction ?? "asc");
30564
+ }
30565
+ if (plan.options?.limit !== void 0) {
30566
+ q = q.limit(plan.options.limit);
30567
+ }
30568
+ const snap = await q.get();
30569
+ return snap.docs.map((doc) => doc.data());
30288
30570
  }
30289
30571
  // ---------------------------------------------------------------------------
30290
30572
  // Bulk operations
@@ -30316,6 +30598,7 @@ var GraphClientImpl = class {
30316
30598
  if (options?.subtitleField !== void 0) data.subtitleField = options.subtitleField;
30317
30599
  if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
30318
30600
  if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
30601
+ if (options?.allowedIn !== void 0) data.allowedIn = options.allowedIn;
30319
30602
  await this.putNode(META_NODE_TYPE, uid, data);
30320
30603
  }
30321
30604
  async defineEdgeType(name, topology, jsonSchema, description, options) {
@@ -30337,11 +30620,13 @@ var GraphClientImpl = class {
30337
30620
  };
30338
30621
  if (jsonSchema !== void 0) data.jsonSchema = jsonSchema;
30339
30622
  if (topology.inverseLabel !== void 0) data.inverseLabel = topology.inverseLabel;
30623
+ if (topology.targetGraph !== void 0) data.targetGraph = topology.targetGraph;
30340
30624
  if (description !== void 0) data.description = description;
30341
30625
  if (options?.titleField !== void 0) data.titleField = options.titleField;
30342
30626
  if (options?.subtitleField !== void 0) data.subtitleField = options.subtitleField;
30343
30627
  if (options?.viewTemplate !== void 0) data.viewTemplate = options.viewTemplate;
30344
30628
  if (options?.viewCss !== void 0) data.viewCss = options.viewCss;
30629
+ if (options?.allowedIn !== void 0) data.allowedIn = options.allowedIn;
30345
30630
  await this.putNode(META_EDGE_TYPE, uid, data);
30346
30631
  }
30347
30632
  async reloadRegistry() {
@@ -30524,7 +30809,8 @@ function loadNodeEntity(dir, name) {
30524
30809
  subtitleField: meta3?.subtitleField,
30525
30810
  viewDefaults: meta3?.viewDefaults,
30526
30811
  viewsPath,
30527
- sampleData
30812
+ sampleData,
30813
+ allowedIn: meta3?.allowedIn
30528
30814
  };
30529
30815
  }
30530
30816
  function loadEdgeEntity(dir, name) {
@@ -30559,7 +30845,9 @@ function loadEdgeEntity(dir, name) {
30559
30845
  subtitleField: meta3?.subtitleField,
30560
30846
  viewDefaults: meta3?.viewDefaults,
30561
30847
  viewsPath,
30562
- sampleData
30848
+ sampleData,
30849
+ allowedIn: meta3?.allowedIn,
30850
+ targetGraph: topology.targetGraph ?? meta3?.targetGraph
30563
30851
  };
30564
30852
  }
30565
30853
  function getSubdirectories(dir) {