@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.
Files changed (48) hide show
  1. package/LICENSE +27 -0
  2. package/README.md +527 -0
  3. package/bin/firegraph.mjs +129 -0
  4. package/dist/chunk-KFA7G37W.js +443 -0
  5. package/dist/chunk-KFA7G37W.js.map +1 -0
  6. package/dist/chunk-YLGXLEUE.js +47 -0
  7. package/dist/chunk-YLGXLEUE.js.map +1 -0
  8. package/dist/client-Bk2Cm6xv.d.cts +131 -0
  9. package/dist/client-Bk2Cm6xv.d.ts +131 -0
  10. package/dist/codegen/index.cjs +81 -0
  11. package/dist/codegen/index.cjs.map +1 -0
  12. package/dist/codegen/index.d.cts +2 -0
  13. package/dist/codegen/index.d.ts +2 -0
  14. package/dist/codegen/index.js +7 -0
  15. package/dist/codegen/index.js.map +1 -0
  16. package/dist/editor/client/assets/index-DJJ_b0jI.js +411 -0
  17. package/dist/editor/client/assets/index-Q0QBYrMV.css +1 -0
  18. package/dist/editor/client/index.html +16 -0
  19. package/dist/editor/server/index.mjs +49597 -0
  20. package/dist/index-CG3R68Hu.d.cts +414 -0
  21. package/dist/index-CG3R68Hu.d.ts +414 -0
  22. package/dist/index.cjs +1953 -0
  23. package/dist/index.cjs.map +1 -0
  24. package/dist/index.d.cts +186 -0
  25. package/dist/index.d.ts +186 -0
  26. package/dist/index.js +1569 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/query-client/index.cjs +484 -0
  29. package/dist/query-client/index.cjs.map +1 -0
  30. package/dist/query-client/index.d.cts +15 -0
  31. package/dist/query-client/index.d.ts +15 -0
  32. package/dist/query-client/index.js +17 -0
  33. package/dist/query-client/index.js.map +1 -0
  34. package/dist/react.cjs +85 -0
  35. package/dist/react.cjs.map +1 -0
  36. package/dist/react.d.cts +44 -0
  37. package/dist/react.d.ts +44 -0
  38. package/dist/react.js +60 -0
  39. package/dist/react.js.map +1 -0
  40. package/dist/svelte.cjs +90 -0
  41. package/dist/svelte.cjs.map +1 -0
  42. package/dist/svelte.d.cts +46 -0
  43. package/dist/svelte.d.ts +46 -0
  44. package/dist/svelte.js +65 -0
  45. package/dist/svelte.js.map +1 -0
  46. package/dist/views-DL60k0cf.d.cts +91 -0
  47. package/dist/views-DL60k0cf.d.ts +91 -0
  48. 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
+ }