@typicalday/firegraph 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +253 -6
- package/dist/codegen/index.d.cts +1 -1
- package/dist/codegen/index.d.ts +1 -1
- package/dist/editor/server/index.mjs +63 -4
- package/dist/{index-wSlVH5Nv.d.cts → index-CQkofEC_.d.cts} +61 -0
- package/dist/{index-wSlVH5Nv.d.ts → index-CQkofEC_.d.ts} +61 -0
- package/dist/index.cjs +201 -25
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +70 -6
- package/dist/index.d.ts +70 -6
- package/dist/index.js +199 -25
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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,
|
|
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
|
|
|
@@ -341,6 +344,241 @@ Key behaviors:
|
|
|
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`
|
package/dist/codegen/index.d.cts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { l as CodegenOptions, J as generateTypes } from '../index-
|
|
1
|
+
export { l as CodegenOptions, J as generateTypes } from '../index-CQkofEC_.cjs';
|
|
2
2
|
import '@google-cloud/firestore';
|
package/dist/codegen/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { l as CodegenOptions, J as generateTypes } from '../index-
|
|
1
|
+
export { l as CodegenOptions, J as generateTypes } from '../index-CQkofEC_.js';
|
|
2
2
|
import '@google-cloud/firestore';
|
|
@@ -30082,14 +30082,35 @@ function createRegistry(input) {
|
|
|
30082
30082
|
}
|
|
30083
30083
|
const entryList = Object.freeze([...entries]);
|
|
30084
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
|
+
}
|
|
30085
30090
|
const key = tripleKey(entry.aType, entry.axbType, entry.bType);
|
|
30086
30091
|
const validator = entry.jsonSchema ? compileSchema(entry.jsonSchema, `(${entry.aType}) -[${entry.axbType}]-> (${entry.bType})`) : void 0;
|
|
30087
30092
|
map2.set(key, { entry, validate: validator });
|
|
30088
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
|
+
}
|
|
30089
30107
|
return {
|
|
30090
30108
|
lookup(aType, axbType, bType) {
|
|
30091
30109
|
return map2.get(tripleKey(aType, axbType, bType))?.entry;
|
|
30092
30110
|
},
|
|
30111
|
+
lookupByAxbType(axbType) {
|
|
30112
|
+
return axbIndex.get(axbType) ?? [];
|
|
30113
|
+
},
|
|
30093
30114
|
validate(aType, axbType, bType, data, scopePath) {
|
|
30094
30115
|
const rec = map2.get(tripleKey(aType, axbType, bType));
|
|
30095
30116
|
if (!rec) {
|
|
@@ -30136,6 +30157,12 @@ function discoveryToEntries(discovery) {
|
|
|
30136
30157
|
if (!topology) continue;
|
|
30137
30158
|
const fromTypes = Array.isArray(topology.from) ? topology.from : [topology.from];
|
|
30138
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
|
+
}
|
|
30139
30166
|
for (const aType of fromTypes) {
|
|
30140
30167
|
for (const bType of toTypes) {
|
|
30141
30168
|
entries.push({
|
|
@@ -30147,7 +30174,8 @@ function discoveryToEntries(discovery) {
|
|
|
30147
30174
|
inverseLabel: topology.inverseLabel,
|
|
30148
30175
|
titleField: entity.titleField,
|
|
30149
30176
|
subtitleField: entity.subtitleField,
|
|
30150
|
-
allowedIn: entity.allowedIn
|
|
30177
|
+
allowedIn: entity.allowedIn,
|
|
30178
|
+
targetGraph: resolvedTargetGraph
|
|
30151
30179
|
});
|
|
30152
30180
|
}
|
|
30153
30181
|
}
|
|
@@ -30197,7 +30225,8 @@ var EDGE_TYPE_SCHEMA = {
|
|
|
30197
30225
|
subtitleField: { type: "string" },
|
|
30198
30226
|
viewTemplate: { type: "string" },
|
|
30199
30227
|
viewCss: { type: "string" },
|
|
30200
|
-
allowedIn: { type: "array", items: { type: "string", minLength: 1 } }
|
|
30228
|
+
allowedIn: { type: "array", items: { type: "string", minLength: 1 } },
|
|
30229
|
+
targetGraph: { type: "string", minLength: 1, pattern: "^[^/]+$" }
|
|
30201
30230
|
},
|
|
30202
30231
|
additionalProperties: false
|
|
30203
30232
|
};
|
|
@@ -30258,7 +30287,8 @@ async function createRegistryFromGraph(reader) {
|
|
|
30258
30287
|
inverseLabel: data.inverseLabel,
|
|
30259
30288
|
titleField: data.titleField,
|
|
30260
30289
|
subtitleField: data.subtitleField,
|
|
30261
|
-
allowedIn: data.allowedIn
|
|
30290
|
+
allowedIn: data.allowedIn,
|
|
30291
|
+
targetGraph: data.targetGraph
|
|
30262
30292
|
});
|
|
30263
30293
|
}
|
|
30264
30294
|
}
|
|
@@ -30512,6 +30542,33 @@ var GraphClientImpl = class _GraphClientImpl {
|
|
|
30512
30542
|
);
|
|
30513
30543
|
}
|
|
30514
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());
|
|
30570
|
+
}
|
|
30571
|
+
// ---------------------------------------------------------------------------
|
|
30515
30572
|
// Bulk operations
|
|
30516
30573
|
// ---------------------------------------------------------------------------
|
|
30517
30574
|
async removeNodeCascade(uid, options) {
|
|
@@ -30563,6 +30620,7 @@ var GraphClientImpl = class _GraphClientImpl {
|
|
|
30563
30620
|
};
|
|
30564
30621
|
if (jsonSchema !== void 0) data.jsonSchema = jsonSchema;
|
|
30565
30622
|
if (topology.inverseLabel !== void 0) data.inverseLabel = topology.inverseLabel;
|
|
30623
|
+
if (topology.targetGraph !== void 0) data.targetGraph = topology.targetGraph;
|
|
30566
30624
|
if (description !== void 0) data.description = description;
|
|
30567
30625
|
if (options?.titleField !== void 0) data.titleField = options.titleField;
|
|
30568
30626
|
if (options?.subtitleField !== void 0) data.subtitleField = options.subtitleField;
|
|
@@ -30788,7 +30846,8 @@ function loadEdgeEntity(dir, name) {
|
|
|
30788
30846
|
viewDefaults: meta3?.viewDefaults,
|
|
30789
30847
|
viewsPath,
|
|
30790
30848
|
sampleData,
|
|
30791
|
-
allowedIn: meta3?.allowedIn
|
|
30849
|
+
allowedIn: meta3?.allowedIn,
|
|
30850
|
+
targetGraph: topology.targetGraph ?? meta3?.targetGraph
|
|
30792
30851
|
};
|
|
30793
30852
|
}
|
|
30794
30853
|
function getSubdirectories(dir) {
|
|
@@ -199,12 +199,38 @@ interface RegistryEntry {
|
|
|
199
199
|
* - `'**/agents'` — `**` matches zero or more segments
|
|
200
200
|
*/
|
|
201
201
|
allowedIn?: string[];
|
|
202
|
+
/**
|
|
203
|
+
* Subgraph name where cross-graph edges of this type live.
|
|
204
|
+
*
|
|
205
|
+
* When set, forward traversal queries the named subgraph under each
|
|
206
|
+
* source node (e.g., `{collection}/{sourceUid}/{targetGraph}`) instead
|
|
207
|
+
* of the current collection. The subgraph contains both the edge
|
|
208
|
+
* documents and the target nodes they reference.
|
|
209
|
+
*
|
|
210
|
+
* Reverse traversal is unaffected — if you're already in the subgraph,
|
|
211
|
+
* the edges are local.
|
|
212
|
+
*
|
|
213
|
+
* Only applies to edge entries (not node self-loop entries).
|
|
214
|
+
* Must be a single segment (no `/`).
|
|
215
|
+
*
|
|
216
|
+
* @example
|
|
217
|
+
* ```ts
|
|
218
|
+
* { aType: 'task', axbType: 'assignedTo', bType: 'agent', targetGraph: 'workflow' }
|
|
219
|
+
* // Forward traversal from task1: queries {collection}/task1/workflow
|
|
220
|
+
* ```
|
|
221
|
+
*/
|
|
222
|
+
targetGraph?: string;
|
|
202
223
|
}
|
|
203
224
|
/** Topology declaration for an edge (from edge.json). */
|
|
204
225
|
interface EdgeTopology {
|
|
205
226
|
from: string | string[];
|
|
206
227
|
to: string | string[];
|
|
207
228
|
inverseLabel?: string;
|
|
229
|
+
/**
|
|
230
|
+
* Subgraph name where cross-graph edges of this type live.
|
|
231
|
+
* See `RegistryEntry.targetGraph` for full documentation.
|
|
232
|
+
*/
|
|
233
|
+
targetGraph?: string;
|
|
208
234
|
}
|
|
209
235
|
/** A discovered entity from the per-entity folder convention. */
|
|
210
236
|
interface DiscoveredEntity {
|
|
@@ -227,6 +253,8 @@ interface DiscoveredEntity {
|
|
|
227
253
|
sampleData?: Record<string, unknown>;
|
|
228
254
|
/** Scope patterns constraining where this type can exist in subgraphs. */
|
|
229
255
|
allowedIn?: string[];
|
|
256
|
+
/** Subgraph name where cross-graph edges of this type live. */
|
|
257
|
+
targetGraph?: string;
|
|
230
258
|
}
|
|
231
259
|
/** Result of scanning an entities directory. */
|
|
232
260
|
interface DiscoveryResult {
|
|
@@ -284,6 +312,7 @@ interface EdgeTypeData {
|
|
|
284
312
|
viewTemplate?: string;
|
|
285
313
|
viewCss?: string;
|
|
286
314
|
allowedIn?: string[];
|
|
315
|
+
targetGraph?: string;
|
|
287
316
|
}
|
|
288
317
|
type ScanProtection = 'error' | 'warn' | 'off';
|
|
289
318
|
interface GraphClientOptions {
|
|
@@ -318,6 +347,8 @@ interface GraphClientOptions {
|
|
|
318
347
|
interface GraphRegistry {
|
|
319
348
|
validate(aType: string, axbType: string, bType: string, data: unknown, scopePath?: string): void;
|
|
320
349
|
lookup(aType: string, axbType: string, bType: string): RegistryEntry | undefined;
|
|
350
|
+
/** Return all entries matching the given axbType (edge relation name). */
|
|
351
|
+
lookupByAxbType(axbType: string): ReadonlyArray<RegistryEntry>;
|
|
321
352
|
entries(): ReadonlyArray<RegistryEntry>;
|
|
322
353
|
}
|
|
323
354
|
interface GraphReader {
|
|
@@ -356,6 +387,19 @@ interface GraphClient extends GraphReader, GraphWriter {
|
|
|
356
387
|
* @returns A `GraphClient` scoped to `{collectionPath}/{parentNodeUid}/{name}`
|
|
357
388
|
*/
|
|
358
389
|
subgraph(parentNodeUid: string, name?: string): GraphClient;
|
|
390
|
+
/**
|
|
391
|
+
* Find edges across all subgraphs using a Firestore collection group query.
|
|
392
|
+
*
|
|
393
|
+
* Queries all collections with the given name (defaults to `'graph'`) across
|
|
394
|
+
* the entire database. This is useful for cross-cutting reads that span
|
|
395
|
+
* multiple subgraphs.
|
|
396
|
+
*
|
|
397
|
+
* **Requires** a Firestore collection group index for the query pattern.
|
|
398
|
+
*
|
|
399
|
+
* @param params - Edge filter parameters (same as `findEdges`)
|
|
400
|
+
* @param collectionName - Collection name to query across (defaults to last segment of this client's collection path)
|
|
401
|
+
*/
|
|
402
|
+
findEdgesGlobal(params: FindEdgesParams, collectionName?: string): Promise<StoredGraphRecord[]>;
|
|
359
403
|
}
|
|
360
404
|
interface DynamicGraphClient extends GraphClient {
|
|
361
405
|
/** Define or update a node type in the dynamic registry. */
|
|
@@ -381,6 +425,23 @@ interface HopDefinition {
|
|
|
381
425
|
direction?: 'asc' | 'desc';
|
|
382
426
|
};
|
|
383
427
|
filter?: (edge: StoredGraphRecord) => boolean;
|
|
428
|
+
/**
|
|
429
|
+
* Subgraph name to cross into for this hop (forward traversal only).
|
|
430
|
+
*
|
|
431
|
+
* When set, the traversal queries the named subgraph under each source node
|
|
432
|
+
* instead of the current collection (`{collection}/{sourceUid}/{targetGraph}`).
|
|
433
|
+
*
|
|
434
|
+
* If omitted but the registry has a `targetGraph` for this `axbType`,
|
|
435
|
+
* the registry value is used automatically.
|
|
436
|
+
*
|
|
437
|
+
* **Context tracking:** Once a hop crosses into a subgraph, subsequent
|
|
438
|
+
* hops without `targetGraph` stay in that subgraph automatically. To
|
|
439
|
+
* cross into a different subgraph, set `targetGraph` explicitly on the
|
|
440
|
+
* next hop — explicit `targetGraph` always resolves relative to the
|
|
441
|
+
* root client, not the current subgraph. To return to the root graph,
|
|
442
|
+
* create a separate traversal from the root client.
|
|
443
|
+
*/
|
|
444
|
+
targetGraph?: string;
|
|
384
445
|
}
|
|
385
446
|
interface TraversalOptions {
|
|
386
447
|
maxReads?: number;
|
|
@@ -199,12 +199,38 @@ interface RegistryEntry {
|
|
|
199
199
|
* - `'**/agents'` — `**` matches zero or more segments
|
|
200
200
|
*/
|
|
201
201
|
allowedIn?: string[];
|
|
202
|
+
/**
|
|
203
|
+
* Subgraph name where cross-graph edges of this type live.
|
|
204
|
+
*
|
|
205
|
+
* When set, forward traversal queries the named subgraph under each
|
|
206
|
+
* source node (e.g., `{collection}/{sourceUid}/{targetGraph}`) instead
|
|
207
|
+
* of the current collection. The subgraph contains both the edge
|
|
208
|
+
* documents and the target nodes they reference.
|
|
209
|
+
*
|
|
210
|
+
* Reverse traversal is unaffected — if you're already in the subgraph,
|
|
211
|
+
* the edges are local.
|
|
212
|
+
*
|
|
213
|
+
* Only applies to edge entries (not node self-loop entries).
|
|
214
|
+
* Must be a single segment (no `/`).
|
|
215
|
+
*
|
|
216
|
+
* @example
|
|
217
|
+
* ```ts
|
|
218
|
+
* { aType: 'task', axbType: 'assignedTo', bType: 'agent', targetGraph: 'workflow' }
|
|
219
|
+
* // Forward traversal from task1: queries {collection}/task1/workflow
|
|
220
|
+
* ```
|
|
221
|
+
*/
|
|
222
|
+
targetGraph?: string;
|
|
202
223
|
}
|
|
203
224
|
/** Topology declaration for an edge (from edge.json). */
|
|
204
225
|
interface EdgeTopology {
|
|
205
226
|
from: string | string[];
|
|
206
227
|
to: string | string[];
|
|
207
228
|
inverseLabel?: string;
|
|
229
|
+
/**
|
|
230
|
+
* Subgraph name where cross-graph edges of this type live.
|
|
231
|
+
* See `RegistryEntry.targetGraph` for full documentation.
|
|
232
|
+
*/
|
|
233
|
+
targetGraph?: string;
|
|
208
234
|
}
|
|
209
235
|
/** A discovered entity from the per-entity folder convention. */
|
|
210
236
|
interface DiscoveredEntity {
|
|
@@ -227,6 +253,8 @@ interface DiscoveredEntity {
|
|
|
227
253
|
sampleData?: Record<string, unknown>;
|
|
228
254
|
/** Scope patterns constraining where this type can exist in subgraphs. */
|
|
229
255
|
allowedIn?: string[];
|
|
256
|
+
/** Subgraph name where cross-graph edges of this type live. */
|
|
257
|
+
targetGraph?: string;
|
|
230
258
|
}
|
|
231
259
|
/** Result of scanning an entities directory. */
|
|
232
260
|
interface DiscoveryResult {
|
|
@@ -284,6 +312,7 @@ interface EdgeTypeData {
|
|
|
284
312
|
viewTemplate?: string;
|
|
285
313
|
viewCss?: string;
|
|
286
314
|
allowedIn?: string[];
|
|
315
|
+
targetGraph?: string;
|
|
287
316
|
}
|
|
288
317
|
type ScanProtection = 'error' | 'warn' | 'off';
|
|
289
318
|
interface GraphClientOptions {
|
|
@@ -318,6 +347,8 @@ interface GraphClientOptions {
|
|
|
318
347
|
interface GraphRegistry {
|
|
319
348
|
validate(aType: string, axbType: string, bType: string, data: unknown, scopePath?: string): void;
|
|
320
349
|
lookup(aType: string, axbType: string, bType: string): RegistryEntry | undefined;
|
|
350
|
+
/** Return all entries matching the given axbType (edge relation name). */
|
|
351
|
+
lookupByAxbType(axbType: string): ReadonlyArray<RegistryEntry>;
|
|
321
352
|
entries(): ReadonlyArray<RegistryEntry>;
|
|
322
353
|
}
|
|
323
354
|
interface GraphReader {
|
|
@@ -356,6 +387,19 @@ interface GraphClient extends GraphReader, GraphWriter {
|
|
|
356
387
|
* @returns A `GraphClient` scoped to `{collectionPath}/{parentNodeUid}/{name}`
|
|
357
388
|
*/
|
|
358
389
|
subgraph(parentNodeUid: string, name?: string): GraphClient;
|
|
390
|
+
/**
|
|
391
|
+
* Find edges across all subgraphs using a Firestore collection group query.
|
|
392
|
+
*
|
|
393
|
+
* Queries all collections with the given name (defaults to `'graph'`) across
|
|
394
|
+
* the entire database. This is useful for cross-cutting reads that span
|
|
395
|
+
* multiple subgraphs.
|
|
396
|
+
*
|
|
397
|
+
* **Requires** a Firestore collection group index for the query pattern.
|
|
398
|
+
*
|
|
399
|
+
* @param params - Edge filter parameters (same as `findEdges`)
|
|
400
|
+
* @param collectionName - Collection name to query across (defaults to last segment of this client's collection path)
|
|
401
|
+
*/
|
|
402
|
+
findEdgesGlobal(params: FindEdgesParams, collectionName?: string): Promise<StoredGraphRecord[]>;
|
|
359
403
|
}
|
|
360
404
|
interface DynamicGraphClient extends GraphClient {
|
|
361
405
|
/** Define or update a node type in the dynamic registry. */
|
|
@@ -381,6 +425,23 @@ interface HopDefinition {
|
|
|
381
425
|
direction?: 'asc' | 'desc';
|
|
382
426
|
};
|
|
383
427
|
filter?: (edge: StoredGraphRecord) => boolean;
|
|
428
|
+
/**
|
|
429
|
+
* Subgraph name to cross into for this hop (forward traversal only).
|
|
430
|
+
*
|
|
431
|
+
* When set, the traversal queries the named subgraph under each source node
|
|
432
|
+
* instead of the current collection (`{collection}/{sourceUid}/{targetGraph}`).
|
|
433
|
+
*
|
|
434
|
+
* If omitted but the registry has a `targetGraph` for this `axbType`,
|
|
435
|
+
* the registry value is used automatically.
|
|
436
|
+
*
|
|
437
|
+
* **Context tracking:** Once a hop crosses into a subgraph, subsequent
|
|
438
|
+
* hops without `targetGraph` stay in that subgraph automatically. To
|
|
439
|
+
* cross into a different subgraph, set `targetGraph` explicitly on the
|
|
440
|
+
* next hop — explicit `targetGraph` always resolves relative to the
|
|
441
|
+
* root client, not the current subgraph. To return to the root graph,
|
|
442
|
+
* create a separate traversal from the root client.
|
|
443
|
+
*/
|
|
444
|
+
targetGraph?: string;
|
|
384
445
|
}
|
|
385
446
|
interface TraversalOptions {
|
|
386
447
|
maxReads?: number;
|