@typicalday/firegraph 0.1.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/LICENSE +27 -0
- package/README.md +527 -0
- package/bin/firegraph.mjs +129 -0
- package/dist/chunk-KFA7G37W.js +443 -0
- package/dist/chunk-KFA7G37W.js.map +1 -0
- package/dist/chunk-YLGXLEUE.js +47 -0
- package/dist/chunk-YLGXLEUE.js.map +1 -0
- package/dist/client-Bk2Cm6xv.d.cts +131 -0
- package/dist/client-Bk2Cm6xv.d.ts +131 -0
- package/dist/codegen/index.cjs +81 -0
- package/dist/codegen/index.cjs.map +1 -0
- package/dist/codegen/index.d.cts +2 -0
- package/dist/codegen/index.d.ts +2 -0
- package/dist/codegen/index.js +7 -0
- package/dist/codegen/index.js.map +1 -0
- package/dist/editor/client/assets/index-DJJ_b0jI.js +411 -0
- package/dist/editor/client/assets/index-Q0QBYrMV.css +1 -0
- package/dist/editor/client/index.html +16 -0
- package/dist/editor/server/index.mjs +49597 -0
- package/dist/index-CG3R68Hu.d.cts +414 -0
- package/dist/index-CG3R68Hu.d.ts +414 -0
- package/dist/index.cjs +1953 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +186 -0
- package/dist/index.d.ts +186 -0
- package/dist/index.js +1569 -0
- package/dist/index.js.map +1 -0
- package/dist/query-client/index.cjs +484 -0
- package/dist/query-client/index.cjs.map +1 -0
- package/dist/query-client/index.d.cts +15 -0
- package/dist/query-client/index.d.ts +15 -0
- package/dist/query-client/index.js +17 -0
- package/dist/query-client/index.js.map +1 -0
- package/dist/react.cjs +85 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +44 -0
- package/dist/react.d.ts +44 -0
- package/dist/react.js +60 -0
- package/dist/react.js.map +1 -0
- package/dist/svelte.cjs +90 -0
- package/dist/svelte.cjs.map +1 -0
- package/dist/svelte.d.cts +46 -0
- package/dist/svelte.d.ts +46 -0
- package/dist/svelte.js +65 -0
- package/dist/svelte.js.map +1 -0
- package/dist/views-DL60k0cf.d.cts +91 -0
- package/dist/views-DL60k0cf.d.ts +91 -0
- package/package.json +122 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Typical Day
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
NOTE: This MIT license applies to the core firegraph library (everything
|
|
26
|
+
outside the `editor/` directory). The firegraph editor is licensed separately
|
|
27
|
+
under the Firegraph Editor License — see `editor/LICENSE` for details.
|
package/README.md
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
# Firegraph
|
|
2
|
+
|
|
3
|
+
> **Warning:** This library is experimental. APIs may change without notice between releases.
|
|
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.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install firegraph
|
|
11
|
+
# or
|
|
12
|
+
pnpm add firegraph
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Firegraph requires `@google-cloud/firestore` `^8.0.0` as a peer dependency. npm 7+ and pnpm auto-install peer deps, so this is typically handled for you.
|
|
16
|
+
|
|
17
|
+
When installing from git (not npm), firegraph builds itself via a `prepare` script. The consuming project needs `tsup` and `typescript` as dev dependencies:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install -D tsup typescript
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**pnpm 10+** blocks dependency build scripts by default. Allow `firegraph` and `esbuild` in your `package.json`:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"pnpm": {
|
|
28
|
+
"onlyBuiltDependencies": ["esbuild", "firegraph"]
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
import { Firestore } from '@google-cloud/firestore';
|
|
37
|
+
import { createGraphClient, generateId } from 'firegraph';
|
|
38
|
+
|
|
39
|
+
const db = new Firestore();
|
|
40
|
+
const g = createGraphClient(db, 'graph');
|
|
41
|
+
|
|
42
|
+
// Create nodes
|
|
43
|
+
const tourId = generateId();
|
|
44
|
+
await g.putNode('tour', tourId, { name: 'Dolomites Classic', difficulty: 'hard' });
|
|
45
|
+
|
|
46
|
+
const depId = generateId();
|
|
47
|
+
await g.putNode('departure', depId, { date: '2025-07-15', maxCapacity: 30 });
|
|
48
|
+
|
|
49
|
+
// Create an edge
|
|
50
|
+
await g.putEdge('tour', tourId, 'hasDeparture', 'departure', depId, { order: 0 });
|
|
51
|
+
|
|
52
|
+
// Query edges
|
|
53
|
+
const departures = await g.findEdges({ aUid: tourId, axbType: 'hasDeparture' });
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Core Concepts
|
|
57
|
+
|
|
58
|
+
### Graph Model
|
|
59
|
+
|
|
60
|
+
Firegraph stores everything as **triples** in a single Firestore collection:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
(aType, aUid) -[axbType]-> (bType, bUid)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- **Nodes** are self-referencing edges with the special relation `is`:
|
|
67
|
+
`(tour, Kj7vNq2mP9xR4wL1tY8s3) -[is]-> (tour, Kj7vNq2mP9xR4wL1tY8s3)`
|
|
68
|
+
- **Edges** are directed relationships between nodes:
|
|
69
|
+
`(tour, Kj7vNq2mP9xR4wL1tY8s3) -[hasDeparture]-> (departure, Xp4nTk8qW2vR7mL9jY5a1)`
|
|
70
|
+
|
|
71
|
+
Every record carries a `data` payload (arbitrary JSON), plus `createdAt` and `updatedAt` server timestamps.
|
|
72
|
+
|
|
73
|
+
### Document IDs
|
|
74
|
+
|
|
75
|
+
UIDs **must** be generated via `generateId()` (21-char nanoid). Short sequential strings like `tour1` create Firestore write hotspots.
|
|
76
|
+
|
|
77
|
+
- **Nodes**: The UID itself (e.g., `Kj7vNq2mP9xR4wL1tY8s3`)
|
|
78
|
+
- **Edges**: `shard:aUid:axbType:bUid` where the shard prefix (0–f) is derived from SHA-256, distributing writes across 16 buckets to avoid Firestore hotspots
|
|
79
|
+
|
|
80
|
+
## API Reference
|
|
81
|
+
|
|
82
|
+
### Creating a Client
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
import { createGraphClient } from 'firegraph';
|
|
86
|
+
|
|
87
|
+
const g = createGraphClient(db, 'graph');
|
|
88
|
+
// or with options:
|
|
89
|
+
const g = createGraphClient(db, 'graph', { registry });
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Parameters:**
|
|
93
|
+
- `db` — A `Firestore` instance from `@google-cloud/firestore`
|
|
94
|
+
- `collectionPath` — Firestore collection path for all graph data
|
|
95
|
+
- `options.registry` — Optional `GraphRegistry` for schema validation
|
|
96
|
+
- `options.queryMode` — Query backend: `'pipeline'` (default) or `'standard'`
|
|
97
|
+
|
|
98
|
+
### Nodes
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
const tourId = generateId();
|
|
102
|
+
|
|
103
|
+
// Create or overwrite a node
|
|
104
|
+
await g.putNode('tour', tourId, { name: 'Dolomites Classic' });
|
|
105
|
+
|
|
106
|
+
// Read a node
|
|
107
|
+
const node = await g.getNode(tourId);
|
|
108
|
+
// → StoredGraphRecord | null
|
|
109
|
+
|
|
110
|
+
// Update fields (merge)
|
|
111
|
+
await g.updateNode(tourId, { 'data.difficulty': 'extreme' });
|
|
112
|
+
|
|
113
|
+
// Delete a node
|
|
114
|
+
await g.removeNode(tourId);
|
|
115
|
+
|
|
116
|
+
// Find all nodes of a type
|
|
117
|
+
const tours = await g.findNodes({ aType: 'tour' });
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Edges
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
const depId = generateId();
|
|
124
|
+
|
|
125
|
+
// Create or overwrite an edge
|
|
126
|
+
await g.putEdge('tour', tourId, 'hasDeparture', 'departure', depId, { order: 0 });
|
|
127
|
+
|
|
128
|
+
// Read a specific edge
|
|
129
|
+
const edge = await g.getEdge(tourId, 'hasDeparture', depId);
|
|
130
|
+
// → StoredGraphRecord | null
|
|
131
|
+
|
|
132
|
+
// Check existence
|
|
133
|
+
const exists = await g.edgeExists(tourId, 'hasDeparture', depId);
|
|
134
|
+
|
|
135
|
+
// Delete an edge
|
|
136
|
+
await g.removeEdge(tourId, 'hasDeparture', depId);
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Querying Edges
|
|
140
|
+
|
|
141
|
+
`findEdges` accepts any combination of filters. When all three identifiers (`aUid`, `axbType`, `bUid`) are provided, it uses a direct document lookup instead of a query scan.
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
// Forward: all departures of a tour
|
|
145
|
+
await g.findEdges({ aUid: tourId, axbType: 'hasDeparture' });
|
|
146
|
+
|
|
147
|
+
// Reverse: all tours that have this departure
|
|
148
|
+
await g.findEdges({ axbType: 'hasDeparture', bUid: depId });
|
|
149
|
+
|
|
150
|
+
// Type-scoped: all hasDeparture edges from any tour
|
|
151
|
+
await g.findEdges({ aType: 'tour', axbType: 'hasDeparture' });
|
|
152
|
+
|
|
153
|
+
// With limit and ordering
|
|
154
|
+
await g.findEdges({
|
|
155
|
+
aUid: tourId,
|
|
156
|
+
axbType: 'hasDeparture',
|
|
157
|
+
limit: 5,
|
|
158
|
+
orderBy: { field: 'data.order', direction: 'asc' },
|
|
159
|
+
});
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Transactions
|
|
163
|
+
|
|
164
|
+
Full read-write transactions with automatic retry:
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
await g.runTransaction(async (tx) => {
|
|
168
|
+
const dep = await tx.getNode(depId);
|
|
169
|
+
const count = (dep?.data.registeredRiders as number) || 0;
|
|
170
|
+
|
|
171
|
+
if (count < 30) {
|
|
172
|
+
await tx.putEdge('departure', depId, 'hasRider', 'rider', riderId, {});
|
|
173
|
+
await tx.updateNode(depId, { 'data.registeredRiders': count + 1 });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The transaction object (`tx`) has the same read/write methods as the client. Writes are synchronous within the transaction and committed atomically.
|
|
179
|
+
|
|
180
|
+
### Batches
|
|
181
|
+
|
|
182
|
+
Atomic batch writes (no reads):
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
const batch = g.batch();
|
|
186
|
+
const aliceId = generateId();
|
|
187
|
+
const bobId = generateId();
|
|
188
|
+
await batch.putNode('rider', aliceId, { name: 'Alice' });
|
|
189
|
+
await batch.putNode('rider', bobId, { name: 'Bob' });
|
|
190
|
+
await batch.putEdge('rider', aliceId, 'friends', 'rider', bobId, {});
|
|
191
|
+
await batch.commit();
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Graph Traversal
|
|
195
|
+
|
|
196
|
+
Multi-hop traversal with budget enforcement, concurrency control, and in-memory filtering:
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
import { createTraversal } from 'firegraph';
|
|
200
|
+
|
|
201
|
+
// Tour → Departures → Riders (2 hops)
|
|
202
|
+
const result = await createTraversal(g, tourId)
|
|
203
|
+
.follow('hasDeparture', { limit: 5, bType: 'departure' })
|
|
204
|
+
.follow('hasRider', {
|
|
205
|
+
limit: 20,
|
|
206
|
+
filter: (edge) => edge.data.status === 'confirmed',
|
|
207
|
+
})
|
|
208
|
+
.run({ maxReads: 200, returnIntermediates: true });
|
|
209
|
+
|
|
210
|
+
result.nodes; // StoredGraphRecord[] — edges from the final hop
|
|
211
|
+
result.hops; // HopResult[] — per-hop breakdown
|
|
212
|
+
result.totalReads; // number — Firestore reads consumed
|
|
213
|
+
result.truncated; // boolean — true if budget was hit
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
#### Reverse Traversal
|
|
217
|
+
|
|
218
|
+
Walk edges backwards to find parents:
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
// Rider → Departures → Tours
|
|
222
|
+
const result = await createTraversal(g, riderId)
|
|
223
|
+
.follow('hasRider', { direction: 'reverse' })
|
|
224
|
+
.follow('hasDeparture', { direction: 'reverse' })
|
|
225
|
+
.run();
|
|
226
|
+
|
|
227
|
+
// result.nodes contains the tour edges
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
#### Traversal in Transactions
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
await g.runTransaction(async (tx) => {
|
|
234
|
+
const result = await createTraversal(tx, tourId)
|
|
235
|
+
.follow('hasDeparture')
|
|
236
|
+
.follow('hasRider')
|
|
237
|
+
.run();
|
|
238
|
+
// Use result to make transactional writes...
|
|
239
|
+
});
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
#### Hop Options
|
|
243
|
+
|
|
244
|
+
| Option | Type | Default | Description |
|
|
245
|
+
|--------|------|---------|-------------|
|
|
246
|
+
| `direction` | `'forward' \| 'reverse'` | `'forward'` | Edge direction |
|
|
247
|
+
| `aType` | `string` | — | Filter source node type |
|
|
248
|
+
| `bType` | `string` | — | Filter target node type |
|
|
249
|
+
| `limit` | `number` | `10` | Max edges per source node |
|
|
250
|
+
| `orderBy` | `{ field, direction? }` | — | Firestore-level ordering |
|
|
251
|
+
| `filter` | `(edge) => boolean` | — | In-memory post-filter |
|
|
252
|
+
|
|
253
|
+
#### Run Options
|
|
254
|
+
|
|
255
|
+
| Option | Type | Default | Description |
|
|
256
|
+
|--------|------|---------|-------------|
|
|
257
|
+
| `maxReads` | `number` | `100` | Total Firestore read budget |
|
|
258
|
+
| `concurrency` | `number` | `5` | Max parallel queries per hop |
|
|
259
|
+
| `returnIntermediates` | `boolean` | `false` | Include edges from all hops |
|
|
260
|
+
|
|
261
|
+
When `filter` is set, the `limit` is applied after filtering (in-memory), so Firestore returns all matching edges and the filter + slice happens client-side.
|
|
262
|
+
|
|
263
|
+
### Schema Registry
|
|
264
|
+
|
|
265
|
+
Optional type validation using Zod (or any object with a `.parse()` method):
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
import { createRegistry, createGraphClient } from 'firegraph';
|
|
269
|
+
import { z } from 'zod';
|
|
270
|
+
|
|
271
|
+
const registry = createRegistry([
|
|
272
|
+
{
|
|
273
|
+
aType: 'tour',
|
|
274
|
+
axbType: 'is',
|
|
275
|
+
bType: 'tour',
|
|
276
|
+
dataSchema: z.object({
|
|
277
|
+
name: z.string(),
|
|
278
|
+
difficulty: z.enum(['easy', 'medium', 'hard']),
|
|
279
|
+
}),
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
aType: 'tour',
|
|
283
|
+
axbType: 'hasDeparture',
|
|
284
|
+
bType: 'departure',
|
|
285
|
+
// No dataSchema = any data allowed for this edge type
|
|
286
|
+
},
|
|
287
|
+
]);
|
|
288
|
+
|
|
289
|
+
const g = createGraphClient(db, 'graph', { registry });
|
|
290
|
+
|
|
291
|
+
// This validates against the registry before writing:
|
|
292
|
+
const id = generateId();
|
|
293
|
+
await g.putNode('tour', id, { name: 'Alps', difficulty: 'hard' }); // OK
|
|
294
|
+
await g.putNode('tour', id, { name: 123 }); // throws ValidationError
|
|
295
|
+
|
|
296
|
+
// Unregistered triples are rejected:
|
|
297
|
+
await g.putEdge('tour', id, 'unknownRel', 'x', generateId(), {}); // throws RegistryViolationError
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### Dynamic Registry
|
|
301
|
+
|
|
302
|
+
For agent-driven or runtime-extensible schemas, firegraph supports a **dynamic registry** where node and edge types are defined as graph data itself (meta-nodes). The workflow is: **define → reload → write**.
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
305
|
+
import { createGraphClient } from 'firegraph';
|
|
306
|
+
|
|
307
|
+
const g = createGraphClient(db, 'graph', {
|
|
308
|
+
registryMode: { mode: 'dynamic' },
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// 1. Define types (stored as meta-nodes in the graph)
|
|
312
|
+
await g.defineNodeType('tour', {
|
|
313
|
+
type: 'object',
|
|
314
|
+
required: ['name'],
|
|
315
|
+
properties: { name: { type: 'string' } },
|
|
316
|
+
additionalProperties: false,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
await g.defineEdgeType(
|
|
320
|
+
'hasDeparture',
|
|
321
|
+
{ from: 'tour', to: 'departure' },
|
|
322
|
+
{ type: 'object', properties: { order: { type: 'number' } } },
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
// 2. Compile the registry from stored definitions
|
|
326
|
+
await g.reloadRegistry();
|
|
327
|
+
|
|
328
|
+
// 3. Write domain data — validated against the compiled registry
|
|
329
|
+
const tourId = generateId();
|
|
330
|
+
await g.putNode('tour', tourId, { name: 'Dolomites Classic' }); // OK
|
|
331
|
+
await g.putNode('booking', generateId(), { total: 500 }); // throws RegistryViolationError
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
Key behaviors:
|
|
335
|
+
|
|
336
|
+
- **Before `reloadRegistry()`**: Domain writes are rejected. Only meta-type writes (`defineNodeType`, `defineEdgeType`) are allowed.
|
|
337
|
+
- **After `reloadRegistry()`**: Domain writes are validated against the compiled registry. Unknown types are always rejected.
|
|
338
|
+
- **Upsert semantics**: Calling `defineNodeType('tour', ...)` twice overwrites the previous definition. After reloading, the latest schema is used.
|
|
339
|
+
- **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.
|
|
341
|
+
|
|
342
|
+
Dynamic registry returns a `DynamicGraphClient` which extends `GraphClient` with `defineNodeType()`, `defineEdgeType()`, and `reloadRegistry()`. Transactions and batches also validate against the compiled dynamic registry.
|
|
343
|
+
|
|
344
|
+
### ID Generation
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
import { generateId } from 'firegraph';
|
|
348
|
+
|
|
349
|
+
const id = generateId(); // 21-char URL-safe nanoid
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
## Error Handling
|
|
353
|
+
|
|
354
|
+
All errors extend `FiregraphError` with a `code` property:
|
|
355
|
+
|
|
356
|
+
| Error Class | Code | When |
|
|
357
|
+
|------------|------|------|
|
|
358
|
+
| `FiregraphError` | varies | Base class |
|
|
359
|
+
| `NodeNotFoundError` | `NODE_NOT_FOUND` | Node lookup fails (not thrown by `getNode` — it returns `null`) |
|
|
360
|
+
| `EdgeNotFoundError` | `EDGE_NOT_FOUND` | Edge lookup fails |
|
|
361
|
+
| `ValidationError` | `VALIDATION_ERROR` | Schema validation fails (registry + Zod) |
|
|
362
|
+
| `RegistryViolationError` | `REGISTRY_VIOLATION` | Triple not registered |
|
|
363
|
+
| `DynamicRegistryError` | `DYNAMIC_REGISTRY_ERROR` | Dynamic registry misconfiguration or misuse |
|
|
364
|
+
| `InvalidQueryError` | `INVALID_QUERY` | `findEdges` called with no filters |
|
|
365
|
+
| `TraversalError` | `TRAVERSAL_ERROR` | `run()` called with zero hops |
|
|
366
|
+
|
|
367
|
+
```typescript
|
|
368
|
+
import { FiregraphError, ValidationError } from 'firegraph';
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
await g.putNode('tour', generateId(), { name: 123 });
|
|
372
|
+
} catch (err) {
|
|
373
|
+
if (err instanceof ValidationError) {
|
|
374
|
+
console.error(err.code); // 'VALIDATION_ERROR'
|
|
375
|
+
console.error(err.details); // Zod error details
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
## Types
|
|
381
|
+
|
|
382
|
+
All types are exported for use in your own code:
|
|
383
|
+
|
|
384
|
+
```typescript
|
|
385
|
+
import type {
|
|
386
|
+
// Data models
|
|
387
|
+
GraphRecord,
|
|
388
|
+
StoredGraphRecord,
|
|
389
|
+
|
|
390
|
+
// Query
|
|
391
|
+
FindEdgesParams,
|
|
392
|
+
FindNodesParams,
|
|
393
|
+
QueryPlan,
|
|
394
|
+
QueryFilter,
|
|
395
|
+
QueryOptions,
|
|
396
|
+
|
|
397
|
+
// Client interfaces
|
|
398
|
+
GraphReader,
|
|
399
|
+
GraphWriter,
|
|
400
|
+
GraphClient,
|
|
401
|
+
GraphTransaction,
|
|
402
|
+
GraphBatch,
|
|
403
|
+
GraphClientOptions,
|
|
404
|
+
|
|
405
|
+
// Registry
|
|
406
|
+
RegistryEntry,
|
|
407
|
+
GraphRegistry,
|
|
408
|
+
|
|
409
|
+
// Dynamic Registry
|
|
410
|
+
DynamicGraphClient,
|
|
411
|
+
DynamicRegistryConfig,
|
|
412
|
+
NodeTypeData,
|
|
413
|
+
EdgeTypeData,
|
|
414
|
+
|
|
415
|
+
// Traversal
|
|
416
|
+
HopDefinition,
|
|
417
|
+
TraversalOptions,
|
|
418
|
+
HopResult,
|
|
419
|
+
TraversalResult,
|
|
420
|
+
TraversalBuilder,
|
|
421
|
+
} from 'firegraph';
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
## How It Works
|
|
425
|
+
|
|
426
|
+
### Storage Layout
|
|
427
|
+
|
|
428
|
+
All data lives in one Firestore collection. Each document has these fields:
|
|
429
|
+
|
|
430
|
+
| Field | Type | Description |
|
|
431
|
+
|-------|------|-------------|
|
|
432
|
+
| `aType` | string | Source node type |
|
|
433
|
+
| `aUid` | string | Source node ID |
|
|
434
|
+
| `axbType` | string | Relationship type (`is` for nodes) |
|
|
435
|
+
| `bType` | string | Target node type |
|
|
436
|
+
| `bUid` | string | Target node ID |
|
|
437
|
+
| `data` | object | User payload |
|
|
438
|
+
| `createdAt` | Timestamp | Server-set on create |
|
|
439
|
+
| `updatedAt` | Timestamp | Server-set on create/update |
|
|
440
|
+
|
|
441
|
+
### Query Planning
|
|
442
|
+
|
|
443
|
+
When you call `findEdges`, the query planner decides the strategy:
|
|
444
|
+
|
|
445
|
+
1. **Direct get** — If `aUid`, `axbType`, and `bUid` are all provided, the edge document ID can be computed directly. This is a single-document read (fastest).
|
|
446
|
+
2. **Filtered query** — Otherwise, a Firestore query is built from whichever fields are provided, with optional `limit` and `orderBy` applied server-side.
|
|
447
|
+
|
|
448
|
+
### Traversal Execution
|
|
449
|
+
|
|
450
|
+
1. Start with `sourceUids = [startUid]`
|
|
451
|
+
2. For each hop in sequence:
|
|
452
|
+
- Fan out: query edges for each source UID (parallel, bounded by semaphore)
|
|
453
|
+
- Each `findEdges` call counts as 1 read against the budget
|
|
454
|
+
- Apply in-memory `filter` if specified, then apply `limit`
|
|
455
|
+
- Collect edges, extract next source UIDs (deduplicated)
|
|
456
|
+
- If budget exceeded, mark `truncated` and stop
|
|
457
|
+
3. Return final hop edges as `nodes`, all hop data in `hops`
|
|
458
|
+
|
|
459
|
+
## Query Modes
|
|
460
|
+
|
|
461
|
+
Firegraph supports two query backends. The mode is set when creating a client:
|
|
462
|
+
|
|
463
|
+
```typescript
|
|
464
|
+
// Pipeline mode (default) — requires Enterprise Firestore
|
|
465
|
+
const g = createGraphClient(db, 'graph');
|
|
466
|
+
|
|
467
|
+
// Standard mode (opt-in) — for emulator or small datasets
|
|
468
|
+
const g = createGraphClient(db, 'graph', { queryMode: 'standard' });
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
### Pipeline Mode (Default)
|
|
472
|
+
|
|
473
|
+
Uses the Firestore Pipeline API (`db.pipeline()`). This is the recommended mode for production.
|
|
474
|
+
|
|
475
|
+
- Enables queries on `data.*` fields without composite indexes
|
|
476
|
+
- Requires **Firestore Enterprise** edition
|
|
477
|
+
- Pipeline API is currently in Preview
|
|
478
|
+
|
|
479
|
+
### Standard Mode
|
|
480
|
+
|
|
481
|
+
Uses standard Firestore queries (`.where().get()`). Use only if you understand the limitations:
|
|
482
|
+
|
|
483
|
+
| Firestore Edition | With `data.*` Filters | Risk |
|
|
484
|
+
|---|---|---|
|
|
485
|
+
| Enterprise | Full collection scan (no index needed) | High billing on large collections |
|
|
486
|
+
| Standard | Fails without composite index | Query errors for unindexed fields |
|
|
487
|
+
|
|
488
|
+
Standard mode is appropriate for:
|
|
489
|
+
- **Emulator** — the emulator doesn't support pipelines, so firegraph auto-falls back to standard mode when `FIRESTORE_EMULATOR_HOST` is set
|
|
490
|
+
- **Small datasets** where full scans are acceptable
|
|
491
|
+
- Projects that manage their own composite indexes
|
|
492
|
+
|
|
493
|
+
### Emulator Auto-Fallback
|
|
494
|
+
|
|
495
|
+
When `FIRESTORE_EMULATOR_HOST` is detected, firegraph automatically uses standard mode regardless of the `queryMode` setting. No configuration needed.
|
|
496
|
+
|
|
497
|
+
### Transactions
|
|
498
|
+
|
|
499
|
+
Transactions always use standard Firestore queries, even when the client is in pipeline mode. This is because Pipeline queries are not transactionally bound — they see committed state, not the transaction's isolated view.
|
|
500
|
+
|
|
501
|
+
### Config File
|
|
502
|
+
|
|
503
|
+
Set the query mode in `firegraph.config.ts`:
|
|
504
|
+
|
|
505
|
+
```typescript
|
|
506
|
+
export default defineConfig({
|
|
507
|
+
entities: './entities',
|
|
508
|
+
queryMode: 'pipeline', // or 'standard'
|
|
509
|
+
});
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
Or via CLI flag: `npx firegraph editor --query-mode pipeline`
|
|
513
|
+
|
|
514
|
+
## Development
|
|
515
|
+
|
|
516
|
+
```bash
|
|
517
|
+
pnpm build # Build ESM + CJS + types
|
|
518
|
+
pnpm typecheck # Type check
|
|
519
|
+
pnpm test:unit # Unit tests (no emulator needed)
|
|
520
|
+
pnpm test:emulator # Full test suite against Firestore emulator
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
Requires Node.js 18+.
|
|
524
|
+
|
|
525
|
+
## License
|
|
526
|
+
|
|
527
|
+
MIT
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const subcommand = process.argv[2];
|
|
8
|
+
|
|
9
|
+
function parseArgs(argv) {
|
|
10
|
+
const args = {};
|
|
11
|
+
for (let i = 0; i < argv.length; i++) {
|
|
12
|
+
if (argv[i].startsWith('--')) {
|
|
13
|
+
const key = argv[i].slice(2);
|
|
14
|
+
const next = argv[i + 1];
|
|
15
|
+
if (next && !next.startsWith('--')) {
|
|
16
|
+
args[key] = next;
|
|
17
|
+
i++;
|
|
18
|
+
} else {
|
|
19
|
+
args[key] = true;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return args;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (subcommand === 'editor') {
|
|
27
|
+
process.env.NODE_ENV = 'production';
|
|
28
|
+
// Pass remaining args through (strip 'editor' subcommand)
|
|
29
|
+
process.argv = [process.argv[0], process.argv[1], ...process.argv.slice(3)];
|
|
30
|
+
|
|
31
|
+
const editorEntry = path.join(__dirname, '..', 'dist', 'editor', 'server', 'index.mjs');
|
|
32
|
+
if (!fs.existsSync(editorEntry)) {
|
|
33
|
+
const { execSync } = await import('child_process');
|
|
34
|
+
const pkgDir = path.join(__dirname, '..');
|
|
35
|
+
console.log('Editor not built yet — building...');
|
|
36
|
+
try {
|
|
37
|
+
execSync('npm run build:editor', { cwd: pkgDir, stdio: 'inherit' });
|
|
38
|
+
} catch {
|
|
39
|
+
console.error('Failed to build editor. Run "npm run build:editor" manually in the firegraph package directory.');
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await import(editorEntry);
|
|
45
|
+
} else if (subcommand === 'query') {
|
|
46
|
+
const queryEntry = path.join(__dirname, '..', 'dist', 'query-client', 'index.js');
|
|
47
|
+
if (!fs.existsSync(queryEntry)) {
|
|
48
|
+
console.error('Query client not built. Run "npm run build" first.');
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
const { runQueryCli } = await import(queryEntry);
|
|
52
|
+
await runQueryCli(process.argv.slice(3));
|
|
53
|
+
} else if (subcommand === 'codegen') {
|
|
54
|
+
const args = parseArgs(process.argv.slice(3));
|
|
55
|
+
const entitiesDir = path.resolve(args.entities || './entities');
|
|
56
|
+
const outPath = args.out || null;
|
|
57
|
+
|
|
58
|
+
const { discoverEntities } = await import(path.join(__dirname, '..', 'dist', 'index.js'));
|
|
59
|
+
const { generateTypes } = await import(path.join(__dirname, '..', 'dist', 'codegen', 'index.js'));
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const { result, warnings } = discoverEntities(entitiesDir);
|
|
63
|
+
for (const w of warnings) {
|
|
64
|
+
console.warn(` warning: ${w.message}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const nodeCount = result.nodes.size;
|
|
68
|
+
const edgeCount = result.edges.size;
|
|
69
|
+
|
|
70
|
+
if (nodeCount === 0 && edgeCount === 0) {
|
|
71
|
+
console.error(`No entities found in ${entitiesDir}`);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const output = await generateTypes(result);
|
|
76
|
+
|
|
77
|
+
if (outPath) {
|
|
78
|
+
const resolved = path.resolve(outPath);
|
|
79
|
+
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
80
|
+
fs.writeFileSync(resolved, output, 'utf-8');
|
|
81
|
+
console.log(`Generated ${nodeCount} node type(s) + ${edgeCount} edge type(s) → ${resolved}`);
|
|
82
|
+
} else {
|
|
83
|
+
process.stdout.write(output);
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
console.error(`Error: ${err.message}`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
} else if (subcommand === '--help' || subcommand === '-h' || !subcommand) {
|
|
90
|
+
console.log('');
|
|
91
|
+
console.log(' Usage: firegraph <command> [options]');
|
|
92
|
+
console.log('');
|
|
93
|
+
console.log(' Commands:');
|
|
94
|
+
console.log(' editor Launch the Firegraph Editor UI');
|
|
95
|
+
console.log(' query Query the graph via the editor API');
|
|
96
|
+
console.log(' codegen Generate TypeScript types from entity schemas');
|
|
97
|
+
console.log('');
|
|
98
|
+
console.log(' Editor options:');
|
|
99
|
+
console.log(' --config <path> Path to firegraph.config.ts (default: auto-discover in cwd)');
|
|
100
|
+
console.log(' --entities <path> Path to entities directory');
|
|
101
|
+
console.log(' --project <id> GCP project ID (default: auto-detect via ADC)');
|
|
102
|
+
console.log(' --collection <path> Firestore collection path (default: graph)');
|
|
103
|
+
console.log(' --port <number> Server port (default: 3883)');
|
|
104
|
+
console.log(' --emulator [host:port] Use Firestore emulator');
|
|
105
|
+
console.log(' --readonly Force read-only mode');
|
|
106
|
+
console.log('');
|
|
107
|
+
console.log(' Query options:');
|
|
108
|
+
console.log(' Run "firegraph query --help" for query-specific help');
|
|
109
|
+
console.log('');
|
|
110
|
+
console.log(' Codegen options:');
|
|
111
|
+
console.log(' --entities <path> Path to entities directory (default: ./entities)');
|
|
112
|
+
console.log(' --out <path> Output file path (default: stdout)');
|
|
113
|
+
console.log('');
|
|
114
|
+
console.log(' Config file:');
|
|
115
|
+
console.log(' Create a firegraph.config.ts in your project root to avoid passing');
|
|
116
|
+
console.log(' flags every time. CLI flags override config file values.');
|
|
117
|
+
console.log('');
|
|
118
|
+
console.log(' Examples:');
|
|
119
|
+
console.log(' npx firegraph editor # uses firegraph.config.ts');
|
|
120
|
+
console.log(' npx firegraph editor --config ./custom-config.ts # explicit config file');
|
|
121
|
+
console.log(' npx firegraph editor --entities ./entities # per-entity convention');
|
|
122
|
+
console.log(' npx firegraph codegen --entities ./entities # types to stdout');
|
|
123
|
+
console.log(' npx firegraph codegen --entities ./entities --out src/generated/types.ts');
|
|
124
|
+
console.log('');
|
|
125
|
+
} else {
|
|
126
|
+
console.error(`Unknown command: ${subcommand}`);
|
|
127
|
+
console.error('Run "firegraph --help" for usage information.');
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|