@typicalday/firegraph 0.2.0 → 0.4.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > **Warning:** This library is experimental. APIs may change without notice between releases.
4
4
 
5
- A typed graph data layer for Firebase Cloud Firestore. Store nodes and edges in a single collection with smart query planning, sharded document IDs, optional schema validation, and multi-hop traversal.
5
+ A typed graph data layer for Firebase Cloud Firestore. Store nodes and edges as triples in a Firestore collection with smart query planning, sharded document IDs, optional schema validation, multi-hop traversal, and nested subgraphs.
6
6
 
7
7
  ## Install
8
8
 
@@ -57,7 +57,7 @@ const departures = await g.findEdges({ aUid: tourId, axbType: 'hasDeparture' });
57
57
 
58
58
  ### Graph Model
59
59
 
60
- Firegraph stores everything as **triples** in a single Firestore collection:
60
+ Firegraph stores everything as **triples** in a Firestore collection (with optional nested subcollections for [subgraphs](#subgraphs)):
61
61
 
62
62
  ```
63
63
  (aType, aUid) -[axbType]-> (bType, bUid)
@@ -193,7 +193,7 @@ await batch.commit();
193
193
 
194
194
  ### Graph Traversal
195
195
 
196
- Multi-hop traversal with budget enforcement, concurrency control, and in-memory filtering:
196
+ Multi-hop traversal with budget enforcement, concurrency control, in-memory filtering, and cross-graph hops:
197
197
 
198
198
  ```typescript
199
199
  import { createTraversal } from 'firegraph';
@@ -213,6 +213,8 @@ result.totalReads; // number — Firestore reads consumed
213
213
  result.truncated; // boolean — true if budget was hit
214
214
  ```
215
215
 
216
+ `createTraversal` accepts a `GraphClient` or `GraphReader`. When passed a `GraphClient`, cross-graph hops via `targetGraph` are supported (see [Cross-Graph Edges](#cross-graph-edges)).
217
+
216
218
  #### Reverse Traversal
217
219
 
218
220
  Walk edges backwards to find parents:
@@ -249,6 +251,7 @@ await g.runTransaction(async (tx) => {
249
251
  | `limit` | `number` | `10` | Max edges per source node |
250
252
  | `orderBy` | `{ field, direction? }` | — | Firestore-level ordering |
251
253
  | `filter` | `(edge) => boolean` | — | In-memory post-filter |
254
+ | `targetGraph` | `string` | — | Subgraph to cross into (forward only). See [Cross-Graph Edges](#cross-graph-edges) |
252
255
 
253
256
  #### Run Options
254
257
 
@@ -337,10 +340,245 @@ Key behaviors:
337
340
  - **After `reloadRegistry()`**: Domain writes are validated against the compiled registry. Unknown types are always rejected.
338
341
  - **Upsert semantics**: Calling `defineNodeType('tour', ...)` twice overwrites the previous definition. After reloading, the latest schema is used.
339
342
  - **Separate collection**: Meta-nodes can be stored in a different collection via `registryMode: { mode: 'dynamic', collection: 'meta' }`.
340
- - **Mutual exclusivity**: `registry` (static) and `registryMode` (dynamic) cannot be used together.
343
+ - **Merged mode**: Provide both `registry` (static) and `registryMode` (dynamic) to get a merged registry where static entries take priority and dynamic definitions can only add new types — not override existing ones.
341
344
 
342
345
  Dynamic registry returns a `DynamicGraphClient` which extends `GraphClient` with `defineNodeType()`, `defineEdgeType()`, and `reloadRegistry()`. Transactions and batches also validate against the compiled dynamic registry.
343
346
 
347
+ ### Subgraphs
348
+
349
+ Create isolated graph namespaces inside a parent node's Firestore document as subcollections. Each subgraph is a full `GraphClient` scoped to its own collection path.
350
+
351
+ ```typescript
352
+ const agentId = generateId();
353
+ await g.putNode('agent', agentId, { name: 'ResearchBot' });
354
+
355
+ // Create a subgraph under the agent's document
356
+ const memories = g.subgraph(agentId, 'memories');
357
+
358
+ // CRUD works exactly like the parent client
359
+ const memId = generateId();
360
+ await memories.putNode('memory', memId, { text: 'The sky is blue' });
361
+ const mem = await memories.getNode(memId);
362
+
363
+ // Subgraph data is isolated — parent can't see it
364
+ const parentNodes = await g.findNodes({ aType: 'memory', allowCollectionScan: true });
365
+ // → [] (empty — memories live in the subcollection)
366
+ ```
367
+
368
+ #### Nested Subgraphs
369
+
370
+ Subgraphs can be nested to any depth:
371
+
372
+ ```typescript
373
+ const workspace = g.subgraph(agentId, 'workspace');
374
+ const taskId = generateId();
375
+ await workspace.putNode('task', taskId, { name: 'Analyze data' });
376
+
377
+ // Nest further
378
+ const subtasks = workspace.subgraph(taskId, 'subtasks');
379
+ await subtasks.putNode('subtask', generateId(), { name: 'Parse CSV' });
380
+ ```
381
+
382
+ #### Scope Constraints (`allowedIn`)
383
+
384
+ Registry entries support `allowedIn` patterns that restrict where a type can be used:
385
+
386
+ ```typescript
387
+ const registry = createRegistry([
388
+ { aType: 'agent', axbType: 'is', bType: 'agent', allowedIn: ['root'] },
389
+ { aType: 'memory', axbType: 'is', bType: 'memory', allowedIn: ['**/memories'] },
390
+ { aType: 'task', axbType: 'is', bType: 'task', allowedIn: ['workspace', '**/workspace'] },
391
+ ]);
392
+
393
+ const g = createGraphClient(db, 'graph', { registry });
394
+
395
+ // Agent only at root
396
+ await g.putNode('agent', agentId, {}); // OK
397
+ await memories.putNode('agent', generateId(), {}); // throws RegistryScopeError
398
+
399
+ // Memory only in 'memories' subgraphs
400
+ await memories.putNode('memory', generateId(), {}); // OK
401
+ await g.putNode('memory', generateId(), {}); // throws RegistryScopeError
402
+ ```
403
+
404
+ **Pattern syntax:**
405
+
406
+ | Pattern | Matches |
407
+ |---------|---------|
408
+ | `root` | Top-level collection only |
409
+ | `memories` | Exact subgraph name |
410
+ | `workspace/tasks` | Exact path |
411
+ | `*/memories` | `*` matches one segment |
412
+ | `**/memories` | `**` matches zero or more segments |
413
+ | `**` | Everything including root |
414
+
415
+ Omitting `allowedIn` (or passing an empty array) means the type is allowed everywhere.
416
+
417
+ #### Transactions & Batches in Subgraphs
418
+
419
+ ```typescript
420
+ const sub = g.subgraph(agentId, 'memories');
421
+
422
+ // Transaction
423
+ await sub.runTransaction(async (tx) => {
424
+ const node = await tx.getNode(memId);
425
+ await tx.putNode('memory', memId, { text: 'updated' });
426
+ });
427
+
428
+ // Batch
429
+ const batch = sub.batch();
430
+ await batch.putNode('memory', generateId(), { text: 'first' });
431
+ await batch.putNode('memory', generateId(), { text: 'second' });
432
+ await batch.commit();
433
+ ```
434
+
435
+ #### Cascade Delete
436
+
437
+ `removeNodeCascade` recursively deletes subcollections by default:
438
+
439
+ ```typescript
440
+ // Deletes the agent node, all its edges, and all subgraph data
441
+ await g.removeNodeCascade(agentId);
442
+
443
+ // To preserve subgraph data:
444
+ await g.removeNodeCascade(agentId, { deleteSubcollections: false });
445
+ ```
446
+
447
+ #### Firestore Path Layout
448
+
449
+ ```
450
+ graph/ ← root collection
451
+ {agentId} ← agent node document
452
+ {agentId}/memories/ ← subgraph subcollection
453
+ {memId} ← memory node document
454
+ {shard:aUid:rel:bUid} ← edge document
455
+ {agentId}/workspace/ ← another subgraph
456
+ {taskId} ← task node document
457
+ {taskId}/subtasks/ ← nested subgraph
458
+ {subtaskId} ← subtask node document
459
+ ```
460
+
461
+ ### Cross-Graph Edges
462
+
463
+ Edges that connect nodes across different subgraphs. The key rule: **edges live with the target node**. A cross-graph edge is stored in the same collection as its target (bUid), while the source (aUid) may be a parent node in an ancestor graph.
464
+
465
+ ```typescript
466
+ import { createGraphClient, createRegistry, createTraversal, generateId } from 'firegraph';
467
+
468
+ // Registry declares that 'assignedTo' edges live in the 'workflow' subgraph
469
+ const registry = createRegistry([
470
+ { aType: 'task', axbType: 'is', bType: 'task', jsonSchema: taskSchema },
471
+ { aType: 'agent', axbType: 'is', bType: 'agent', jsonSchema: agentSchema },
472
+ { aType: 'task', axbType: 'assignedTo', bType: 'agent', targetGraph: 'workflow' },
473
+ ]);
474
+
475
+ const g = createGraphClient(db, 'graph', { registry });
476
+
477
+ // Create a task in the root graph
478
+ const taskId = generateId();
479
+ await g.putNode('task', taskId, { title: 'Build API' });
480
+
481
+ // Create agents in a workflow subgraph under the task
482
+ const workflow = g.subgraph(taskId, 'workflow');
483
+ const agentId = generateId();
484
+ await workflow.putNode('agent', agentId, { name: 'Backend Dev' });
485
+
486
+ // Create the cross-graph edge in the workflow subgraph
487
+ // The edge lives alongside the target (agent), source (task) is an ancestor
488
+ await workflow.putEdge('task', taskId, 'assignedTo', 'agent', agentId, { role: 'lead' });
489
+
490
+ // Forward traversal: task → agents (automatically crosses into workflow subgraph)
491
+ const result = await createTraversal(g, taskId, registry)
492
+ .follow('assignedTo')
493
+ .run();
494
+ // result.nodes contains the agent edges from the workflow subgraph
495
+ ```
496
+
497
+ #### How It Works
498
+
499
+ 1. **Writing**: You explicitly call `putEdge` on the subgraph client where the target node lives. The caller decides where the edge goes.
500
+
501
+ 2. **Reverse traversal is free**: Since the edge lives with the target, querying from the agent's perspective (`findEdges({ bUid: agentId })` on the workflow client) finds it locally.
502
+
503
+ 3. **Forward traversal uses `targetGraph`**: When traversing from the task, the engine sees `targetGraph: 'workflow'` on the registry entry and queries `g.subgraph(taskId, 'workflow')` automatically.
504
+
505
+ 4. **Path-scanning resolution**: To determine if an edge's `aUid` is an ancestor node, firegraph parses the Firestore collection path. The path `graph/taskId/workflow` reveals that `taskId` is a document in the `graph` collection.
506
+
507
+ #### Registry `targetGraph`
508
+
509
+ The `targetGraph` field on a `RegistryEntry` tells forward traversal which subgraph to query under each source node:
510
+
511
+ ```typescript
512
+ createRegistry([
513
+ // When traversing forward from a task along 'assignedTo',
514
+ // look in the 'workflow' subgraph under each task
515
+ { aType: 'task', axbType: 'assignedTo', bType: 'agent', targetGraph: 'workflow' },
516
+ ]);
517
+ ```
518
+
519
+ `targetGraph` must be a single segment (no `/`). It can also be set in entity discovery via `edge.json`:
520
+
521
+ ```json
522
+ { "from": "task", "to": "agent", "targetGraph": "workflow" }
523
+ ```
524
+
525
+ #### Explicit Hop Override
526
+
527
+ You can override the registry's `targetGraph` on a per-hop basis:
528
+
529
+ ```typescript
530
+ // Use 'team' subgraph instead of registry's default
531
+ const result = await createTraversal(g, taskId)
532
+ .follow('assignedTo', { targetGraph: 'team' })
533
+ .run();
534
+ ```
535
+
536
+ Resolution priority: explicit hop `targetGraph` > registry `targetGraph` > no cross-graph.
537
+
538
+ #### `findEdgesGlobal` — Collection Group Queries
539
+
540
+ For cross-cutting reads across all subgraphs, use `findEdgesGlobal`:
541
+
542
+ ```typescript
543
+ // Find all 'assignedTo' edges across all 'workflow' subgraphs in the database
544
+ const allAssignments = await g.findEdgesGlobal(
545
+ { axbType: 'assignedTo', allowCollectionScan: true },
546
+ 'workflow', // collection name to query across
547
+ );
548
+ ```
549
+
550
+ This uses Firestore collection group queries and requires collection group indexes. The collection name defaults to the last segment of the client's collection path if omitted.
551
+
552
+ #### Multi-Hop Limitation
553
+
554
+ Each hop resolves its reader from the root client. If hop 1 crosses into a subgraph, hop 2 does **not** stay in that subgraph — it reverts to the root. To chain hops within a subgraph, create a separate traversal from the subgraph client:
555
+
556
+ ```typescript
557
+ // This traversal finds agents in the workflow subgraph
558
+ const agents = await createTraversal(g, taskId, registry)
559
+ .follow('assignedTo')
560
+ .run();
561
+
562
+ // To continue traversing within the workflow subgraph,
563
+ // create a new traversal from the subgraph client
564
+ const workflow = g.subgraph(taskId, 'workflow');
565
+ for (const agent of agents.nodes) {
566
+ const mentees = await createTraversal(workflow, agent.bUid)
567
+ .follow('mentors')
568
+ .run();
569
+ }
570
+ ```
571
+
572
+ #### Firestore Path Layout
573
+
574
+ ```
575
+ graph/ <- root collection
576
+ {taskId} <- task node
577
+ {taskId}/workflow/ <- workflow subgraph
578
+ {agentId} <- agent node
579
+ {shard:taskId:assignedTo:agentId} <- cross-graph edge
580
+ ```
581
+
344
582
  ### ID Generation
345
583
 
346
584
  ```typescript
@@ -360,8 +598,10 @@ All errors extend `FiregraphError` with a `code` property:
360
598
  | `EdgeNotFoundError` | `EDGE_NOT_FOUND` | Edge lookup fails |
361
599
  | `ValidationError` | `VALIDATION_ERROR` | Schema validation fails (registry + Zod) |
362
600
  | `RegistryViolationError` | `REGISTRY_VIOLATION` | Triple not registered |
601
+ | `RegistryScopeError` | `REGISTRY_SCOPE` | Type not allowed at this subgraph scope |
363
602
  | `DynamicRegistryError` | `DYNAMIC_REGISTRY_ERROR` | Dynamic registry misconfiguration or misuse |
364
603
  | `InvalidQueryError` | `INVALID_QUERY` | `findEdges` called with no filters |
604
+ | `QuerySafetyError` | `QUERY_SAFETY` | Query would cause a full collection scan |
365
605
  | `TraversalError` | `TRAVERSAL_ERROR` | `run()` called with zero hops |
366
606
 
367
607
  ```typescript
@@ -403,8 +643,9 @@ import type {
403
643
  GraphClientOptions,
404
644
 
405
645
  // Registry
406
- RegistryEntry,
407
- GraphRegistry,
646
+ RegistryEntry, // includes targetGraph, allowedIn
647
+ GraphRegistry, // includes lookupByAxbType
648
+ EdgeTopology, // includes targetGraph
408
649
 
409
650
  // Dynamic Registry
410
651
  DynamicGraphClient,
@@ -413,11 +654,15 @@ import type {
413
654
  EdgeTypeData,
414
655
 
415
656
  // Traversal
416
- HopDefinition,
657
+ HopDefinition, // includes targetGraph
417
658
  TraversalOptions,
418
659
  HopResult,
419
660
  TraversalResult,
420
661
  TraversalBuilder,
662
+
663
+ // Entity Discovery
664
+ DiscoveredEntity,
665
+ DiscoveryResult,
421
666
  } from 'firegraph';
422
667
  ```
423
668
 
@@ -449,6 +694,8 @@ When you call `findEdges`, the query planner decides the strategy:
449
694
 
450
695
  1. Start with `sourceUids = [startUid]`
451
696
  2. For each hop in sequence:
697
+ - Resolve `targetGraph`: check hop override, then registry, then none
698
+ - If cross-graph (forward + `targetGraph` + `GraphClient` reader): create a subgraph reader via `reader.subgraph(sourceUid, targetGraph)` for each source
452
699
  - Fan out: query edges for each source UID (parallel, bounded by semaphore)
453
700
  - Each `findEdges` call counts as 1 read against the budget
454
701
  - Apply in-memory `filter` if specified, then apply `limit`
@@ -1,2 +1,2 @@
1
- export { l as CodegenOptions, J as generateTypes } from '../index-wSlVH5Nv.cjs';
1
+ export { l as CodegenOptions, J as generateTypes } from '../index-DR3jF5_b.cjs';
2
2
  import '@google-cloud/firestore';
@@ -1,2 +1,2 @@
1
- export { l as CodegenOptions, J as generateTypes } from '../index-wSlVH5Nv.js';
1
+ export { l as CodegenOptions, J as generateTypes } from '../index-DR3jF5_b.js';
2
2
  import '@google-cloud/firestore';
@@ -30072,6 +30072,9 @@ function matchSegments(path4, pi, pattern, qi) {
30072
30072
  function tripleKey(aType, axbType, bType) {
30073
30073
  return `${aType}:${axbType}:${bType}`;
30074
30074
  }
30075
+ function tripleKeyFor(e) {
30076
+ return tripleKey(e.aType, e.axbType, e.bType);
30077
+ }
30075
30078
  function createRegistry(input) {
30076
30079
  const map2 = /* @__PURE__ */ new Map();
30077
30080
  let entries;
@@ -30082,14 +30085,35 @@ function createRegistry(input) {
30082
30085
  }
30083
30086
  const entryList = Object.freeze([...entries]);
30084
30087
  for (const entry of entries) {
30088
+ if (entry.targetGraph && entry.targetGraph.includes("/")) {
30089
+ throw new ValidationError(
30090
+ `Entry (${entry.aType}) -[${entry.axbType}]-> (${entry.bType}) has invalid targetGraph "${entry.targetGraph}" \u2014 must be a single segment (no "/")`
30091
+ );
30092
+ }
30085
30093
  const key = tripleKey(entry.aType, entry.axbType, entry.bType);
30086
30094
  const validator = entry.jsonSchema ? compileSchema(entry.jsonSchema, `(${entry.aType}) -[${entry.axbType}]-> (${entry.bType})`) : void 0;
30087
30095
  map2.set(key, { entry, validate: validator });
30088
30096
  }
30097
+ const axbIndex = /* @__PURE__ */ new Map();
30098
+ const axbBuild = /* @__PURE__ */ new Map();
30099
+ for (const entry of entries) {
30100
+ const existing = axbBuild.get(entry.axbType);
30101
+ if (existing) {
30102
+ existing.push(entry);
30103
+ } else {
30104
+ axbBuild.set(entry.axbType, [entry]);
30105
+ }
30106
+ }
30107
+ for (const [key, arr] of axbBuild) {
30108
+ axbIndex.set(key, Object.freeze(arr));
30109
+ }
30089
30110
  return {
30090
30111
  lookup(aType, axbType, bType) {
30091
30112
  return map2.get(tripleKey(aType, axbType, bType))?.entry;
30092
30113
  },
30114
+ lookupByAxbType(axbType) {
30115
+ return axbIndex.get(axbType) ?? [];
30116
+ },
30093
30117
  validate(aType, axbType, bType, data, scopePath) {
30094
30118
  const rec = map2.get(tripleKey(aType, axbType, bType));
30095
30119
  if (!rec) {
@@ -30117,6 +30141,45 @@ function createRegistry(input) {
30117
30141
  }
30118
30142
  };
30119
30143
  }
30144
+ function createMergedRegistry(base, extension) {
30145
+ const baseKeys = new Set(base.entries().map(tripleKeyFor));
30146
+ return {
30147
+ lookup(aType, axbType, bType) {
30148
+ return base.lookup(aType, axbType, bType) ?? extension.lookup(aType, axbType, bType);
30149
+ },
30150
+ lookupByAxbType(axbType) {
30151
+ const baseResults = base.lookupByAxbType(axbType);
30152
+ const extResults = extension.lookupByAxbType(axbType);
30153
+ if (extResults.length === 0) return baseResults;
30154
+ if (baseResults.length === 0) return extResults;
30155
+ const seen = new Set(baseResults.map(tripleKeyFor));
30156
+ const merged = [...baseResults];
30157
+ for (const entry of extResults) {
30158
+ if (!seen.has(tripleKeyFor(entry))) {
30159
+ merged.push(entry);
30160
+ }
30161
+ }
30162
+ return Object.freeze(merged);
30163
+ },
30164
+ validate(aType, axbType, bType, data, scopePath) {
30165
+ if (baseKeys.has(tripleKey(aType, axbType, bType))) {
30166
+ return base.validate(aType, axbType, bType, data, scopePath);
30167
+ }
30168
+ return extension.validate(aType, axbType, bType, data, scopePath);
30169
+ },
30170
+ entries() {
30171
+ const extEntries = extension.entries();
30172
+ if (extEntries.length === 0) return base.entries();
30173
+ const merged = [...base.entries()];
30174
+ for (const entry of extEntries) {
30175
+ if (!baseKeys.has(tripleKeyFor(entry))) {
30176
+ merged.push(entry);
30177
+ }
30178
+ }
30179
+ return Object.freeze(merged);
30180
+ }
30181
+ };
30182
+ }
30120
30183
  function discoveryToEntries(discovery) {
30121
30184
  const entries = [];
30122
30185
  for (const [name, entity] of discovery.nodes) {
@@ -30136,6 +30199,12 @@ function discoveryToEntries(discovery) {
30136
30199
  if (!topology) continue;
30137
30200
  const fromTypes = Array.isArray(topology.from) ? topology.from : [topology.from];
30138
30201
  const toTypes = Array.isArray(topology.to) ? topology.to : [topology.to];
30202
+ const resolvedTargetGraph = entity.targetGraph ?? topology.targetGraph;
30203
+ if (resolvedTargetGraph && resolvedTargetGraph.includes("/")) {
30204
+ throw new ValidationError(
30205
+ `Edge "${axbType}" has invalid targetGraph "${resolvedTargetGraph}" \u2014 must be a single segment (no "/")`
30206
+ );
30207
+ }
30139
30208
  for (const aType of fromTypes) {
30140
30209
  for (const bType of toTypes) {
30141
30210
  entries.push({
@@ -30147,7 +30216,8 @@ function discoveryToEntries(discovery) {
30147
30216
  inverseLabel: topology.inverseLabel,
30148
30217
  titleField: entity.titleField,
30149
30218
  subtitleField: entity.subtitleField,
30150
- allowedIn: entity.allowedIn
30219
+ allowedIn: entity.allowedIn,
30220
+ targetGraph: resolvedTargetGraph
30151
30221
  });
30152
30222
  }
30153
30223
  }
@@ -30197,7 +30267,8 @@ var EDGE_TYPE_SCHEMA = {
30197
30267
  subtitleField: { type: "string" },
30198
30268
  viewTemplate: { type: "string" },
30199
30269
  viewCss: { type: "string" },
30200
- allowedIn: { type: "array", items: { type: "string", minLength: 1 } }
30270
+ allowedIn: { type: "array", items: { type: "string", minLength: 1 } },
30271
+ targetGraph: { type: "string", minLength: 1, pattern: "^[^/]+$" }
30201
30272
  },
30202
30273
  additionalProperties: false
30203
30274
  };
@@ -30258,7 +30329,8 @@ async function createRegistryFromGraph(reader) {
30258
30329
  inverseLabel: data.inverseLabel,
30259
30330
  titleField: data.titleField,
30260
30331
  subtitleField: data.subtitleField,
30261
- allowedIn: data.allowedIn
30332
+ allowedIn: data.allowedIn,
30333
+ targetGraph: data.targetGraph
30262
30334
  });
30263
30335
  }
30264
30336
  }
@@ -30274,14 +30346,12 @@ var GraphClientImpl = class _GraphClientImpl {
30274
30346
  this.db = db2;
30275
30347
  this.scopePath = scopePath;
30276
30348
  this.adapter = createFirestoreAdapter(db2, collectionPath);
30277
- if (options?.registry && options?.registryMode) {
30278
- throw new DynamicRegistryError(
30279
- 'Cannot provide both "registry" and "registryMode". Use "registry" for static mode or "registryMode" for dynamic mode.'
30280
- );
30281
- }
30282
30349
  if (options?.registryMode) {
30283
30350
  this.dynamicConfig = options.registryMode;
30284
30351
  this.bootstrapRegistry = createBootstrapRegistry();
30352
+ if (options.registry) {
30353
+ this.staticRegistry = options.registry;
30354
+ }
30285
30355
  const metaCollectionPath = options.registryMode.collection;
30286
30356
  if (metaCollectionPath && metaCollectionPath !== collectionPath) {
30287
30357
  this.metaAdapter = createFirestoreAdapter(db2, metaCollectionPath);
@@ -30333,18 +30403,20 @@ var GraphClientImpl = class _GraphClientImpl {
30333
30403
  /**
30334
30404
  * Get the appropriate registry for validating a write to the given type.
30335
30405
  *
30336
- * - Static mode: returns staticRegistry (or undefined if none set)
30337
- * - Dynamic mode:
30406
+ * - Static-only mode: returns staticRegistry (or undefined if none set)
30407
+ * - Dynamic mode (pure or merged):
30338
30408
  * - Meta-types (nodeType, edgeType): validated against bootstrapRegistry
30339
30409
  * - Domain types: validated against dynamicRegistry (falls back to
30340
30410
  * bootstrapRegistry which rejects unknown types)
30411
+ * - Merged mode: dynamicRegistry is a merged wrapper (static + dynamic
30412
+ * extension), so static entries take priority automatically.
30341
30413
  */
30342
30414
  getRegistryForType(aType) {
30343
30415
  if (!this.dynamicConfig) return this.staticRegistry;
30344
30416
  if (aType === META_NODE_TYPE || aType === META_EDGE_TYPE) {
30345
30417
  return this.bootstrapRegistry;
30346
30418
  }
30347
- return this.dynamicRegistry ?? this.bootstrapRegistry;
30419
+ return this.dynamicRegistry ?? this.staticRegistry ?? this.bootstrapRegistry;
30348
30420
  }
30349
30421
  /**
30350
30422
  * Get the Firestore adapter for writing the given type.
@@ -30358,13 +30430,13 @@ var GraphClientImpl = class _GraphClientImpl {
30358
30430
  }
30359
30431
  /**
30360
30432
  * Get the combined registry for transaction/batch context.
30361
- * In static mode, returns staticRegistry.
30433
+ * In static-only mode, returns staticRegistry.
30362
30434
  * In dynamic mode, returns dynamicRegistry (which includes bootstrap entries)
30363
- * or bootstrapRegistry if not yet reloaded.
30435
+ * or falls back to staticRegistry (merged mode) or bootstrapRegistry.
30364
30436
  */
30365
30437
  getCombinedRegistry() {
30366
30438
  if (!this.dynamicConfig) return this.staticRegistry;
30367
- return this.dynamicRegistry ?? this.bootstrapRegistry;
30439
+ return this.dynamicRegistry ?? this.staticRegistry ?? this.bootstrapRegistry;
30368
30440
  }
30369
30441
  // ---------------------------------------------------------------------------
30370
30442
  // Query dispatch
@@ -30512,6 +30584,33 @@ var GraphClientImpl = class _GraphClientImpl {
30512
30584
  );
30513
30585
  }
30514
30586
  // ---------------------------------------------------------------------------
30587
+ // Collection group query
30588
+ // ---------------------------------------------------------------------------
30589
+ async findEdgesGlobal(params, collectionName) {
30590
+ const name = collectionName ?? this.adapter.collectionPath.split("/").pop();
30591
+ const plan = buildEdgeQueryPlan(params);
30592
+ if (plan.strategy === "get") {
30593
+ throw new FiregraphError(
30594
+ "findEdgesGlobal() requires a query, not a direct document lookup. Omit one of aUid/axbType/bUid to force a query strategy.",
30595
+ "INVALID_QUERY"
30596
+ );
30597
+ }
30598
+ this.checkQuerySafety(plan.filters, params.allowCollectionScan);
30599
+ const collectionGroupRef = this.db.collectionGroup(name);
30600
+ let q = collectionGroupRef;
30601
+ for (const f of plan.filters) {
30602
+ q = q.where(f.field, f.op, f.value);
30603
+ }
30604
+ if (plan.options?.orderBy) {
30605
+ q = q.orderBy(plan.options.orderBy.field, plan.options.orderBy.direction ?? "asc");
30606
+ }
30607
+ if (plan.options?.limit !== void 0) {
30608
+ q = q.limit(plan.options.limit);
30609
+ }
30610
+ const snap = await q.get();
30611
+ return snap.docs.map((doc) => doc.data());
30612
+ }
30613
+ // ---------------------------------------------------------------------------
30515
30614
  // Bulk operations
30516
30615
  // ---------------------------------------------------------------------------
30517
30616
  async removeNodeCascade(uid, options) {
@@ -30534,6 +30633,11 @@ var GraphClientImpl = class _GraphClientImpl {
30534
30633
  `Cannot define type "${name}": this name is reserved for the meta-registry.`
30535
30634
  );
30536
30635
  }
30636
+ if (this.staticRegistry?.lookup(name, NODE_RELATION, name)) {
30637
+ throw new DynamicRegistryError(
30638
+ `Cannot define node type "${name}": already defined in the static registry.`
30639
+ );
30640
+ }
30537
30641
  const uid = generateDeterministicUid(META_NODE_TYPE, name);
30538
30642
  const data = { name, jsonSchema };
30539
30643
  if (description !== void 0) data.description = description;
@@ -30555,6 +30659,19 @@ var GraphClientImpl = class _GraphClientImpl {
30555
30659
  `Cannot define type "${name}": this name is reserved for the meta-registry.`
30556
30660
  );
30557
30661
  }
30662
+ if (this.staticRegistry) {
30663
+ const fromTypes = Array.isArray(topology.from) ? topology.from : [topology.from];
30664
+ const toTypes = Array.isArray(topology.to) ? topology.to : [topology.to];
30665
+ for (const aType of fromTypes) {
30666
+ for (const bType of toTypes) {
30667
+ if (this.staticRegistry.lookup(aType, name, bType)) {
30668
+ throw new DynamicRegistryError(
30669
+ `Cannot define edge type "${name}" for (${aType}) -> (${bType}): already defined in the static registry.`
30670
+ );
30671
+ }
30672
+ }
30673
+ }
30674
+ }
30558
30675
  const uid = generateDeterministicUid(META_EDGE_TYPE, name);
30559
30676
  const data = {
30560
30677
  name,
@@ -30563,6 +30680,7 @@ var GraphClientImpl = class _GraphClientImpl {
30563
30680
  };
30564
30681
  if (jsonSchema !== void 0) data.jsonSchema = jsonSchema;
30565
30682
  if (topology.inverseLabel !== void 0) data.inverseLabel = topology.inverseLabel;
30683
+ if (topology.targetGraph !== void 0) data.targetGraph = topology.targetGraph;
30566
30684
  if (description !== void 0) data.description = description;
30567
30685
  if (options?.titleField !== void 0) data.titleField = options.titleField;
30568
30686
  if (options?.subtitleField !== void 0) data.subtitleField = options.subtitleField;
@@ -30578,7 +30696,12 @@ var GraphClientImpl = class _GraphClientImpl {
30578
30696
  );
30579
30697
  }
30580
30698
  const reader = this.createMetaReader();
30581
- this.dynamicRegistry = await createRegistryFromGraph(reader);
30699
+ const dynamicOnly = await createRegistryFromGraph(reader);
30700
+ if (this.staticRegistry) {
30701
+ this.dynamicRegistry = createMergedRegistry(this.staticRegistry, dynamicOnly);
30702
+ } else {
30703
+ this.dynamicRegistry = dynamicOnly;
30704
+ }
30582
30705
  }
30583
30706
  /**
30584
30707
  * Create a GraphReader for the meta-collection.
@@ -30788,7 +30911,8 @@ function loadEdgeEntity(dir, name) {
30788
30911
  viewDefaults: meta3?.viewDefaults,
30789
30912
  viewsPath,
30790
30913
  sampleData,
30791
- allowedIn: meta3?.allowedIn
30914
+ allowedIn: meta3?.allowedIn,
30915
+ targetGraph: topology.targetGraph ?? meta3?.targetGraph
30792
30916
  };
30793
30917
  }
30794
30918
  function getSubdirectories(dir) {