@typicalday/firegraph 0.11.2 → 0.13.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 (78) hide show
  1. package/README.md +355 -78
  2. package/dist/backend-DuvHGgK1.d.cts +1897 -0
  3. package/dist/backend-DuvHGgK1.d.ts +1897 -0
  4. package/dist/backend.cjs +365 -5
  5. package/dist/backend.cjs.map +1 -1
  6. package/dist/backend.d.cts +25 -5
  7. package/dist/backend.d.ts +25 -5
  8. package/dist/backend.js +209 -7
  9. package/dist/backend.js.map +1 -1
  10. package/dist/chunk-2DHMNTV6.js +16 -0
  11. package/dist/chunk-2DHMNTV6.js.map +1 -0
  12. package/dist/chunk-4MMQ5W74.js +288 -0
  13. package/dist/chunk-4MMQ5W74.js.map +1 -0
  14. package/dist/{chunk-5753Y42M.js → chunk-C2QMD7RY.js} +6 -10
  15. package/dist/chunk-C2QMD7RY.js.map +1 -0
  16. package/dist/chunk-D4J7Z4FE.js +67 -0
  17. package/dist/chunk-D4J7Z4FE.js.map +1 -0
  18. package/dist/chunk-EQJUUVFG.js +14 -0
  19. package/dist/chunk-EQJUUVFG.js.map +1 -0
  20. package/dist/chunk-N5HFDWQX.js +23 -0
  21. package/dist/chunk-N5HFDWQX.js.map +1 -0
  22. package/dist/chunk-PAD7WFFU.js +573 -0
  23. package/dist/chunk-PAD7WFFU.js.map +1 -0
  24. package/dist/chunk-TK64DNVK.js +256 -0
  25. package/dist/chunk-TK64DNVK.js.map +1 -0
  26. package/dist/{chunk-NJSOD64C.js → chunk-WRTFC5NG.js} +438 -30
  27. package/dist/chunk-WRTFC5NG.js.map +1 -0
  28. package/dist/client-BKi3vk0Q.d.ts +34 -0
  29. package/dist/client-BrsaXtDV.d.cts +34 -0
  30. package/dist/cloudflare/index.cjs +1386 -74
  31. package/dist/cloudflare/index.cjs.map +1 -1
  32. package/dist/cloudflare/index.d.cts +217 -13
  33. package/dist/cloudflare/index.d.ts +217 -13
  34. package/dist/cloudflare/index.js +639 -180
  35. package/dist/cloudflare/index.js.map +1 -1
  36. package/dist/codegen/index.d.cts +1 -1
  37. package/dist/codegen/index.d.ts +1 -1
  38. package/dist/errors-BRc3I_eH.d.cts +73 -0
  39. package/dist/errors-BRc3I_eH.d.ts +73 -0
  40. package/dist/firestore-enterprise/index.cjs +3877 -0
  41. package/dist/firestore-enterprise/index.cjs.map +1 -0
  42. package/dist/firestore-enterprise/index.d.cts +141 -0
  43. package/dist/firestore-enterprise/index.d.ts +141 -0
  44. package/dist/firestore-enterprise/index.js +985 -0
  45. package/dist/firestore-enterprise/index.js.map +1 -0
  46. package/dist/firestore-standard/index.cjs +3117 -0
  47. package/dist/firestore-standard/index.cjs.map +1 -0
  48. package/dist/firestore-standard/index.d.cts +49 -0
  49. package/dist/firestore-standard/index.d.ts +49 -0
  50. package/dist/firestore-standard/index.js +283 -0
  51. package/dist/firestore-standard/index.js.map +1 -0
  52. package/dist/index.cjs +809 -534
  53. package/dist/index.cjs.map +1 -1
  54. package/dist/index.d.cts +24 -100
  55. package/dist/index.d.ts +24 -100
  56. package/dist/index.js +184 -531
  57. package/dist/index.js.map +1 -1
  58. package/dist/registry-Bc7h6WTM.d.cts +64 -0
  59. package/dist/registry-C2KUPVZj.d.ts +64 -0
  60. package/dist/{scope-path-B1G3YiA7.d.ts → scope-path-CROFZGr9.d.cts} +1 -56
  61. package/dist/{scope-path-B1G3YiA7.d.cts → scope-path-CROFZGr9.d.ts} +1 -56
  62. package/dist/{serialization-ZZ7RSDRX.js → serialization-OE2PFZMY.js} +6 -4
  63. package/dist/sqlite/index.cjs +3631 -0
  64. package/dist/sqlite/index.cjs.map +1 -0
  65. package/dist/sqlite/index.d.cts +111 -0
  66. package/dist/sqlite/index.d.ts +111 -0
  67. package/dist/sqlite/index.js +1164 -0
  68. package/dist/sqlite/index.js.map +1 -0
  69. package/package.json +33 -3
  70. package/dist/backend-U-MLShlg.d.ts +0 -97
  71. package/dist/backend-np4gEVhB.d.cts +0 -97
  72. package/dist/chunk-5753Y42M.js.map +0 -1
  73. package/dist/chunk-NJSOD64C.js.map +0 -1
  74. package/dist/chunk-R7CRGYY4.js +0 -94
  75. package/dist/chunk-R7CRGYY4.js.map +0 -1
  76. package/dist/types-BGWxcpI_.d.cts +0 -736
  77. package/dist/types-BGWxcpI_.d.ts +0 -736
  78. /package/dist/{serialization-ZZ7RSDRX.js.map → serialization-OE2PFZMY.js.map} +0 -0
package/README.md CHANGED
@@ -35,9 +35,11 @@ npm install -D tsup typescript
35
35
  ```typescript
36
36
  import { Firestore } from '@google-cloud/firestore';
37
37
  import { createGraphClient, generateId } from 'firegraph';
38
+ import { createFirestoreStandardBackend } from 'firegraph/firestore-standard';
38
39
 
39
40
  const db = new Firestore();
40
- const g = createGraphClient(db, 'graph');
41
+ const backend = createFirestoreStandardBackend(db, 'graph');
42
+ const g = createGraphClient(backend);
41
43
 
42
44
  // Create nodes
43
45
  const tourId = generateId();
@@ -83,34 +85,168 @@ UIDs **must** be generated via `generateId()` (21-char nanoid). Short sequential
83
85
 
84
86
  ```typescript
85
87
  import { createGraphClient } from 'firegraph';
88
+ import { createFirestoreStandardBackend } from 'firegraph/firestore-standard';
89
+ // or for Enterprise Firestore (Pipelines, DML, server-side traversal, FTS, geo):
90
+ import { createFirestoreEnterpriseBackend } from 'firegraph/firestore-enterprise';
86
91
 
87
- const g = createGraphClient(db, 'graph');
92
+ const backend = createFirestoreStandardBackend(db, 'graph');
93
+ const g = createGraphClient(backend);
88
94
  // or with options:
89
- const g = createGraphClient(db, 'graph', { registry });
95
+ const g = createGraphClient(backend, { registry });
90
96
  ```
91
97
 
98
+ For non-Firestore backends (SQLite, Cloudflare DO, routing backend) use `createGraphClientFromBackend`, which accepts any raw `StorageBackend<C>` without requiring a named factory:
99
+
100
+ ```typescript
101
+ import { createGraphClientFromBackend } from 'firegraph';
102
+ const g = createGraphClientFromBackend(backend, opts, metaBackend);
103
+ ```
104
+
105
+ `createGraphClientFromBackend` is a deprecated alias for `createGraphClient` — prefer `createGraphClient` directly. Both accept the same `opts` and `metaBackend` arguments.
106
+
92
107
  **Parameters:**
93
108
 
94
- - `db` — A `Firestore` instance from `@google-cloud/firestore`
95
- - `collectionPath` — Firestore collection path for all graph data
96
- - `options.registry` — Optional `GraphRegistry` for schema validation
97
- - `options.queryMode` — Query backend: `'pipeline'` (default) or `'standard'`
109
+ - `backend` — A `StorageBackend<C>` from `createFirestoreStandardBackend`, `createFirestoreEnterpriseBackend`, or another backend factory
110
+ - `opts.registry` — Optional `GraphRegistry` for schema validation
111
+ - `opts.registryMode` — Optional dynamic registry config (`{ mode: 'dynamic', collection? }`). Pass alongside `opts.registry` for merged mode (static + dynamic).
112
+ - `opts.migrationWriteBack` — Optional global write-back mode (`'off'` | `'eager'` | `'background'`)
113
+ - `opts.migrationSandbox` — Optional custom migration evaluator (overrides the default SES executor)
114
+ - `opts.queryMode` — Optional Firestore query backend (`'pipeline'` | `'standard'`; default `'pipeline'`). Ignored by non-Firestore backends.
115
+ - `opts.scanProtection` — Optional full-collection-scan gate (`'off'` | `'warn'` | `'error'`; default `'error'`)
116
+ - `metaBackend` — Optional separate backend for meta-type storage (dynamic registry)
117
+
118
+ ### Capability System
119
+
120
+ Every client exposes a `capabilities` property (a `BackendCapabilities` set) that reflects what the underlying backend supports. Use it for portable feature checks at runtime:
121
+
122
+ ```typescript
123
+ if (client.capabilities.has('query.join')) {
124
+ const result = await (client as JoinExtension).expand({ ... });
125
+ }
126
+ ```
127
+
128
+ `GraphClient<C>` is a generic type — the type parameter `C` is a union of the backend's declared `Capability` strings and controls which extension methods are present on the type. `CoreGraphClient` is the unconditional base (read + write + transactions + batch + subgraph + `capabilities`). Helper functions that should accept any client should be typed to `CoreGraphClient` or `GraphReader`/`GraphWriter`.
129
+
130
+ **Capability values:**
131
+
132
+ | Capability | Methods unlocked | Backends |
133
+ | ----------------------------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
134
+ | `core.read` / `core.write` / `core.batch` / `core.subgraph` | `getNode`, `putNode`, `findEdges`, `batch()`, `subgraph()`, etc. | All |
135
+ | `core.transactions` | `runTransaction(fn)` | Firestore (both), SQLite (`better-sqlite3` only; absent on D1); **absent on Cloudflare DO** |
136
+ | `query.aggregate` | `aggregate(spec)` | All; `min`/`max` only on SQLite + DO (both Firestore editions reject `min`/`max` — classic `Query.aggregate` exposes only count/sum/avg) |
137
+ | `query.select` | `findEdgesProjected(params)` | All |
138
+ | `query.join` | `expand(params)` | All |
139
+ | `query.dml` | `bulkDelete(params)`, `bulkUpdate(params)` | Enterprise (requires `previewDml: true`), SQLite, DO |
140
+ | `traversal.serverSide` | `runEngineTraversal(params)` | Enterprise |
141
+ | `search.vector` | `findNearest(params)` | Firestore (both) |
142
+ | `search.fullText` | `fullTextSearch(params)` | Enterprise. **Note:** the `fields` option is not yet supported — passing a non-empty `fields` array throws `INVALID_QUERY`. |
143
+ | `search.geo` | `geoSearch(params)` | Enterprise |
144
+ | `raw.firestore` | _(reserved — no methods yet)_ | Firestore (both) |
145
+ | `raw.sql` | _(reserved — no methods yet)_ | SQLite |
146
+ | `realtime.listen` | _(reserved — no methods yet)_ | _(none currently)_ |
147
+
148
+ ### Extension Methods
149
+
150
+ Methods unlocked by optional capabilities. Cast the client to the extension interface or check `client.capabilities` at runtime.
151
+
152
+ ```typescript
153
+ // query.aggregate — count / sum / avg (all backends); min / max (SQLite + DO only)
154
+ const stats = await (client as AggregateExtension).aggregate({
155
+ aType: 'tour',
156
+ axbType: 'is',
157
+ bType: 'tour',
158
+ ops: [
159
+ { op: 'count', alias: 'total' },
160
+ { op: 'avg', field: 'data.price', alias: 'avgPrice' },
161
+ ],
162
+ });
163
+
164
+ // query.select — projected edge scan
165
+ const names = await (client as SelectExtension).findEdgesProjected({
166
+ aType: 'tour',
167
+ axbType: 'is',
168
+ bType: 'tour',
169
+ fields: ['data.name', 'aUid'],
170
+ });
171
+
172
+ // query.join — server-side expand (fan-out from a set of sources)
173
+ const legs = await (client as JoinExtension).expand({
174
+ aType: 'tour',
175
+ axbType: 'hasDeparture',
176
+ bType: 'departure',
177
+ sources: [{ aUid: tourId }],
178
+ });
179
+
180
+ // query.dml — bulk delete / update (Enterprise opt-in; SQLite + DO always on)
181
+ await (client as DmlExtension).bulkDelete({
182
+ aType: 'tour',
183
+ axbType: 'hasDeparture',
184
+ bType: 'departure',
185
+ aUid: tourId,
186
+ });
187
+ await (client as DmlExtension).bulkUpdate(
188
+ {
189
+ aType: 'tour',
190
+ axbType: 'is',
191
+ bType: 'tour',
192
+ filters: [{ field: 'data.status', op: '==', value: 'draft' }],
193
+ },
194
+ { 'data.status': 'archived' },
195
+ );
196
+
197
+ // traversal.serverSide — multi-hop traversal in one Pipeline round-trip (Enterprise)
198
+ const tree = await (client as TraversalExtension).runEngineTraversal({
199
+ sources: [{ aType: 'tour', aUid: tourId }],
200
+ hops: [{ axbType: 'hasDeparture', bType: 'departure', limitPerSource: 10 }],
201
+ });
202
+
203
+ // search.vector — approximate nearest-neighbour (Firestore both editions)
204
+ const similar = await (client as VectorExtension).findNearest({
205
+ aType: 'tour',
206
+ axbType: 'is',
207
+ bType: 'tour',
208
+ queryVector: [0.1, 0.2, 0.3],
209
+ vectorField: 'data.embedding',
210
+ limit: 5,
211
+ });
212
+
213
+ // search.fullText — full-text search (Enterprise; `fields` throws INVALID_QUERY if non-empty)
214
+ const results = await (client as FullTextExtension).fullTextSearch({
215
+ aType: 'tour',
216
+ axbType: 'is',
217
+ bType: 'tour',
218
+ query: 'dolomites',
219
+ });
220
+
221
+ // search.geo — geospatial radius search (Enterprise)
222
+ const nearby = await (client as GeoExtension).geoSearch({
223
+ aType: 'tour',
224
+ axbType: 'is',
225
+ bType: 'tour',
226
+ geoField: 'data.location',
227
+ center: { latitude: 46.4, longitude: 11.9 },
228
+ radiusMeters: 50000,
229
+ });
230
+ ```
98
231
 
99
232
  ### Nodes
100
233
 
101
234
  ```typescript
102
235
  const tourId = generateId();
103
236
 
104
- // Create or overwrite a node
237
+ // Create or deep-merge a node (sibling keys at any depth survive)
105
238
  await g.putNode('tour', tourId, { name: 'Dolomites Classic' });
106
239
 
107
240
  // Read a node
108
241
  const node = await g.getNode(tourId);
109
242
  // → StoredGraphRecord | null
110
243
 
111
- // Update fields (partial merge into data)
244
+ // Partial update (deep merge into data)
112
245
  await g.updateNode(tourId, { difficulty: 'extreme' });
113
246
 
247
+ // Full replace — discards every prior key not in the new payload
248
+ await g.replaceNode('tour', tourId, { name: 'Dolomites — 2026 Edition' });
249
+
114
250
  // Delete a node
115
251
  await g.removeNode(tourId);
116
252
 
@@ -118,12 +254,19 @@ await g.removeNode(tourId);
118
254
  const tours = await g.findNodes({ aType: 'tour' });
119
255
  ```
120
256
 
257
+ **Write semantics (0.12+):** `putNode`/`putEdge` and `updateNode`/`updateEdge`
258
+ **deep-merge** by default — sibling keys at every nesting depth survive. Use
259
+ `replaceNode`/`replaceEdge` when you want the old "wipe and rewrite" behaviour.
260
+ Arrays are terminal (replaced wholesale, not element-merged); `undefined`
261
+ values are skipped; `null` is preserved verbatim; and the
262
+ [`deleteField()`](#field-deletion) sentinel removes a field at any depth.
263
+
121
264
  ### Edges
122
265
 
123
266
  ```typescript
124
267
  const depId = generateId();
125
268
 
126
- // Create or overwrite an edge
269
+ // Create or deep-merge an edge
127
270
  await g.putEdge('tour', tourId, 'hasDeparture', 'departure', depId, { order: 0 });
128
271
 
129
272
  // Read a specific edge
@@ -133,10 +276,37 @@ const edge = await g.getEdge(tourId, 'hasDeparture', depId);
133
276
  // Check existence
134
277
  const exists = await g.edgeExists(tourId, 'hasDeparture', depId);
135
278
 
279
+ // Partial update (deep merge)
280
+ await g.updateEdge(tourId, 'hasDeparture', depId, { order: 5 });
281
+
282
+ // Full replace — discards every prior key not in the new payload
283
+ await g.replaceEdge('tour', tourId, 'hasDeparture', 'departure', depId, { order: 5 });
284
+
136
285
  // Delete an edge
137
286
  await g.removeEdge(tourId, 'hasDeparture', depId);
287
+
288
+ // Bulk delete all edges matching a filter (available on all backends)
289
+ const result = await g.bulkRemoveEdges({ aUid: tourId, axbType: 'hasDeparture' });
290
+ // → BulkResult { deleted: number, errors: BulkBatchError[] }
291
+ ```
292
+
293
+ ### Field Deletion
294
+
295
+ The `deleteField()` sentinel removes a field from a stored document. It works
296
+ across every backend (Firestore, SQLite, Cloudflare Durable Objects), so
297
+ calling code stays portable:
298
+
299
+ ```typescript
300
+ import { deleteField } from 'firegraph';
301
+
302
+ await g.updateNode(tourId, {
303
+ meta: { deprecatedTag: deleteField() }, // removes meta.deprecatedTag
304
+ });
138
305
  ```
139
306
 
307
+ Equivalent to Firestore's `FieldValue.delete()`, but Workers-safe and
308
+ SQLite-aware.
309
+
140
310
  ### Querying Edges
141
311
 
142
312
  `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.
@@ -253,11 +423,12 @@ await g.runTransaction(async (tx) => {
253
423
 
254
424
  #### Run Options
255
425
 
256
- | Option | Type | Default | Description |
257
- | --------------------- | --------- | ------- | ---------------------------- |
258
- | `maxReads` | `number` | `100` | Total Firestore read budget |
259
- | `concurrency` | `number` | `5` | Max parallel queries per hop |
260
- | `returnIntermediates` | `boolean` | `false` | Include edges from all hops |
426
+ | Option | Type | Default | Description |
427
+ | --------------------- | ---------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
428
+ | `maxReads` | `number` | `100` | Total read budget |
429
+ | `concurrency` | `number` | `5` | Max parallel queries per hop |
430
+ | `returnIntermediates` | `boolean` | `false` | Include edges from all hops |
431
+ | `engineTraversal` | `'auto' \| 'force' \| 'off'` | `'auto'` | Engine-level traversal on Enterprise backends. `'auto'` silently falls back if ineligible; `'force'` throws if unavailable; `'off'` disables |
261
432
 
262
433
  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.
263
434
 
@@ -267,6 +438,7 @@ Optional type validation using Zod (or any object with a `.parse()` method):
267
438
 
268
439
  ```typescript
269
440
  import { createRegistry, createGraphClient } from 'firegraph';
441
+ import { createFirestoreStandardBackend } from 'firegraph/firestore-standard';
270
442
  import { z } from 'zod';
271
443
 
272
444
  const registry = createRegistry([
@@ -287,7 +459,8 @@ const registry = createRegistry([
287
459
  },
288
460
  ]);
289
461
 
290
- const g = createGraphClient(db, 'graph', { registry });
462
+ const backend = createFirestoreStandardBackend(db, 'graph');
463
+ const g = createGraphClient(backend, { registry });
291
464
 
292
465
  // This validates against the registry before writing:
293
466
  const id = generateId();
@@ -304,8 +477,10 @@ For agent-driven or runtime-extensible schemas, firegraph supports a **dynamic r
304
477
 
305
478
  ```typescript
306
479
  import { createGraphClient } from 'firegraph';
480
+ import { createFirestoreStandardBackend } from 'firegraph/firestore-standard';
307
481
 
308
- const g = createGraphClient(db, 'graph', {
482
+ const backend = createFirestoreStandardBackend(db, 'graph');
483
+ const g = createGraphClient(backend, {
309
484
  registryMode: { mode: 'dynamic' },
310
485
  });
311
486
 
@@ -338,7 +513,7 @@ Key behaviors:
338
513
  - **After `reloadRegistry()`**: Domain writes are validated against the compiled registry. Unknown types are always rejected.
339
514
  - **Upsert semantics**: Calling `defineNodeType('tour', ...)` twice overwrites the previous definition. After reloading, the latest schema is used.
340
515
  - **Separate collection**: Meta-nodes can be stored in a different collection via `registryMode: { mode: 'dynamic', collection: 'meta' }`.
341
- - **Merged mode**: Provide both `registry` (static) and `registryMode` (dynamic) to get a merged registry where static entries take priority and dynamic definitions can only add new types not override existing ones.
516
+ - **Merged mode**: Pass both `registry` (the static side, typically built via `createRegistry` or `createMergedRegistry`) and `registryMode: { mode: 'dynamic' }`. Firegraph then merges them static entries take priority and dynamic definitions can only add new types, never override existing ones. There is no separate `mode: 'merged'` value; merged behavior is implied by supplying both options together.
342
517
 
343
518
  Dynamic registry returns a `DynamicGraphClient` which extends `GraphClient` with `defineNodeType()`, `defineEdgeType()`, and `reloadRegistry()`. Transactions and batches also validate against the compiled dynamic registry.
344
519
 
@@ -348,6 +523,7 @@ Firegraph supports schema versioning with automatic migration of records on read
348
523
 
349
524
  ```typescript
350
525
  import { createRegistry, createGraphClient } from 'firegraph';
526
+ import { createFirestoreStandardBackend } from 'firegraph/firestore-standard';
351
527
  import type { MigrationStep } from 'firegraph';
352
528
 
353
529
  const migrations: MigrationStep[] = [
@@ -366,7 +542,8 @@ const registry = createRegistry([
366
542
  },
367
543
  ]);
368
544
 
369
- const g = createGraphClient(db, 'graph', { registry });
545
+ const backend = createFirestoreStandardBackend(db, 'graph');
546
+ const g = createGraphClient(backend, { registry });
370
547
 
371
548
  // Reading a v0 record automatically migrates it to v2 in memory
372
549
  const tour = await g.getNode(tourId);
@@ -377,8 +554,8 @@ const tour = await g.getNode(tourId);
377
554
 
378
555
  - **Version storage**: The `v` field lives on the record envelope (top-level, alongside `aType`, `data`, etc.), not inside `data`. Records without `v` are treated as version 0 (legacy data).
379
556
  - **Read path**: When a record is read and its `v` is behind the derived version (`max(toVersion)` from migrations), migrations run sequentially to bring data up to the current version.
380
- - **Write path**: When writing via `putNode`/`putEdge`, the record is stamped with `v` equal to the derived version automatically.
381
- - **`updateNode`**: Does not stamp `v` — it is a raw partial update without schema context. The next read re-triggers migration (which is idempotent).
557
+ - **Write path**: When writing via `putNode`/`putEdge` (deep-merge) or `replaceNode`/`replaceEdge` (full overwrite), the record is stamped with `v` equal to the derived version automatically.
558
+ - **`updateNode` / `updateEdge`**: Do not stamp `v` — they are raw partial patches without schema context. The next read re-triggers migration (which is idempotent).
382
559
 
383
560
  #### Write-Back
384
561
 
@@ -394,7 +571,8 @@ Resolution order: `entry.migrationWriteBack > client.migrationWriteBack > 'off'`
394
571
 
395
572
  ```typescript
396
573
  // Global default
397
- const g = createGraphClient(db, 'graph', {
574
+ const backend = createFirestoreStandardBackend(db, 'graph');
575
+ const g = createGraphClient(backend, {
398
576
  registry,
399
577
  migrationWriteBack: 'background',
400
578
  });
@@ -428,7 +606,8 @@ Stored migration strings must be self-contained — no `import`, `require`, or e
428
606
  For custom sandboxing, pass `migrationSandbox` to `createGraphClient()`:
429
607
 
430
608
  ```typescript
431
- const g = createGraphClient(db, 'graph', {
609
+ const backend = createFirestoreStandardBackend(db, 'graph');
610
+ const g = createGraphClient(backend, {
432
611
  registryMode: { mode: 'dynamic' },
433
612
  migrationSandbox: (source) => {
434
613
  const compartment = new Compartment({
@@ -496,7 +675,8 @@ const registry = createRegistry([
496
675
  { aType: 'task', axbType: 'is', bType: 'task', allowedIn: ['workspace', '**/workspace'] },
497
676
  ]);
498
677
 
499
- const g = createGraphClient(db, 'graph', { registry });
678
+ const backend = createFirestoreStandardBackend(db, 'graph');
679
+ const g = createGraphClient(backend, { registry });
500
680
 
501
681
  // Agent only at root
502
682
  await g.putNode('agent', agentId, {}); // OK
@@ -570,6 +750,7 @@ Edges that connect nodes across different subgraphs. The key rule: **edges live
570
750
 
571
751
  ```typescript
572
752
  import { createGraphClient, createRegistry, createTraversal, generateId } from 'firegraph';
753
+ import { createFirestoreStandardBackend } from 'firegraph/firestore-standard';
573
754
 
574
755
  // Registry declares that 'assignedTo' edges live in the 'workflow' subgraph
575
756
  const registry = createRegistry([
@@ -578,7 +759,8 @@ const registry = createRegistry([
578
759
  { aType: 'task', axbType: 'assignedTo', bType: 'agent', targetGraph: 'workflow' },
579
760
  ]);
580
761
 
581
- const g = createGraphClient(db, 'graph', { registry });
762
+ const backend = createFirestoreStandardBackend(db, 'graph');
763
+ const g = createGraphClient(backend, { registry });
582
764
 
583
765
  // Create a task in the root graph
584
766
  const taskId = generateId();
@@ -653,7 +835,7 @@ This uses Firestore collection group queries and requires collection group index
653
835
 
654
836
  #### Multi-Hop Limitation
655
837
 
656
- 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:
838
+ Each hop carries its reader context forward if hop 1 crosses into a subgraph, hop 2 stays in that subgraph. To return to the root or traverse a different subgraph, create a separate traversal from the desired client:
657
839
 
658
840
  ```typescript
659
841
  // This traversal finds agents in the workflow subgraph
@@ -689,19 +871,22 @@ const id = generateId(); // 21-char URL-safe nanoid
689
871
 
690
872
  All errors extend `FiregraphError` with a `code` property:
691
873
 
692
- | Error Class | Code | When |
693
- | ------------------------ | ------------------------ | --------------------------------------------------------------- |
694
- | `FiregraphError` | varies | Base class |
695
- | `NodeNotFoundError` | `NODE_NOT_FOUND` | Node lookup fails (not thrown by `getNode` — it returns `null`) |
696
- | `EdgeNotFoundError` | `EDGE_NOT_FOUND` | Edge lookup fails |
697
- | `ValidationError` | `VALIDATION_ERROR` | Schema validation fails (registry JSON Schema validation) |
698
- | `RegistryViolationError` | `REGISTRY_VIOLATION` | Triple not registered |
699
- | `RegistryScopeError` | `REGISTRY_SCOPE` | Type not allowed at this subgraph scope |
700
- | `MigrationError` | `MIGRATION_ERROR` | Migration function fails or chain is incomplete |
701
- | `DynamicRegistryError` | `DYNAMIC_REGISTRY_ERROR` | Dynamic registry misconfiguration or misuse |
702
- | `InvalidQueryError` | `INVALID_QUERY` | `findEdges` called with no filters |
703
- | `QuerySafetyError` | `QUERY_SAFETY` | Query would cause a full collection scan |
704
- | `TraversalError` | `TRAVERSAL_ERROR` | `run()` called with zero hops |
874
+ | Error Class | Code | When |
875
+ | ------------------------------ | --------------------------- | ----------------------------------------------------------------------- |
876
+ | `FiregraphError` | varies | Base class |
877
+ | `NodeNotFoundError` | `NODE_NOT_FOUND` | Node lookup fails (not thrown by `getNode` — it returns `null`) |
878
+ | `EdgeNotFoundError` | `EDGE_NOT_FOUND` | Edge lookup fails (not thrown by `getEdge` — it returns `null`) |
879
+ | `ValidationError` | `VALIDATION_ERROR` | Schema validation fails (registry JSON Schema validation) |
880
+ | `RegistryViolationError` | `REGISTRY_VIOLATION` | Triple not registered |
881
+ | `RegistryScopeError` | `REGISTRY_SCOPE` | Type not allowed at this subgraph scope |
882
+ | `MigrationError` | `MIGRATION_ERROR` | Migration function fails or chain is incomplete |
883
+ | `DynamicRegistryError` | `DYNAMIC_REGISTRY_ERROR` | Dynamic registry misconfiguration or misuse |
884
+ | `InvalidQueryError` | `INVALID_QUERY` | `findEdges` called with no filters |
885
+ | `QuerySafetyError` | `QUERY_SAFETY` | Query would cause a full collection scan |
886
+ | `TraversalError` | `TRAVERSAL_ERROR` | `run()` called with zero hops |
887
+ | `CapabilityNotSupportedError` | `CAPABILITY_NOT_SUPPORTED` | Capability-gated method called on a backend that doesn't declare it |
888
+ | `CrossBackendTransactionError` | `CROSS_BACKEND_TRANSACTION` | `runTransaction()` attempted across backends with different storage |
889
+ | `DiscoveryError` | `DISCOVERY_ERROR` | Entity discovery fails (missing required files, malformed schema, etc.) |
705
890
 
706
891
  ```typescript
707
892
  import { FiregraphError, ValidationError } from 'firegraph';
@@ -732,15 +917,49 @@ import type {
732
917
  QueryPlan,
733
918
  QueryFilter,
734
919
  QueryOptions,
735
-
736
- // Client interfaces
920
+ QueryMode,
921
+ ScanProtection,
922
+ WhereClause,
923
+ IndexFieldSpec,
924
+ IndexSpec,
925
+
926
+ // Client interfaces — CoreGraphClient is the unconditional base
927
+ Capability,
928
+ CoreGraphClient,
737
929
  GraphReader,
738
930
  GraphWriter,
739
- GraphClient,
931
+ GraphClient, // generic GraphClient<C extends Capability>
740
932
  GraphTransaction,
741
933
  GraphBatch,
742
934
  GraphClientOptions,
743
935
 
936
+ // Capability-gated extensions
937
+ AggregateExtension,
938
+ AggregateField,
939
+ AggregateOp,
940
+ AggregateResult,
941
+ AggregateSpec,
942
+ SelectExtension,
943
+ FindEdgesProjectedParams,
944
+ ProjectedRow,
945
+ JoinExtension,
946
+ ExpandParams,
947
+ ExpandResult,
948
+ DmlExtension,
949
+ BulkUpdatePatch,
950
+ BulkOptions,
951
+ BulkResult,
952
+ BulkBatchError,
953
+ BulkProgress,
954
+ VectorExtension,
955
+ FindNearestParams,
956
+ DistanceMeasure,
957
+ FullTextSearchExtension,
958
+ GeoExtension,
959
+ RawFirestoreExtension,
960
+ RawSqlExtension,
961
+ RealtimeListenExtension,
962
+
744
963
  // Registry
745
964
  RegistryEntry, // includes targetGraph, allowedIn
746
965
  GraphRegistry, // includes lookupByAxbType
@@ -748,9 +967,12 @@ import type {
748
967
 
749
968
  // Dynamic Registry
750
969
  DynamicGraphClient,
970
+ DynamicGraphMethods,
751
971
  DynamicRegistryConfig,
752
972
  NodeTypeData,
753
973
  EdgeTypeData,
974
+ DefineTypeOptions,
975
+ CascadeResult,
754
976
 
755
977
  // Migration
756
978
  MigrationFn,
@@ -758,6 +980,7 @@ import type {
758
980
  StoredMigrationStep,
759
981
  MigrationExecutor,
760
982
  MigrationWriteBack,
983
+ MigrationResult,
761
984
 
762
985
  // Traversal
763
986
  HopDefinition, // includes targetGraph
@@ -768,10 +991,14 @@ import type {
768
991
 
769
992
  // Entity Discovery
770
993
  DiscoveredEntity,
771
- DiscoveryResult,
994
+ DiscoverResult, // return type of discoverEntities()
995
+ DiscoveryResult, // { nodes: Map<...>, edges: Map<...> } — the .result field of DiscoverResult
996
+ DiscoveryWarning,
772
997
  } from 'firegraph';
773
998
  ```
774
999
 
1000
+ > **Note:** Several types are defined in the library but not yet exported from the `'firegraph'` entry point: the parameter and result types for `fullTextSearch()`, `geoSearch()`, and `runEngineTraversal()` (`FullTextSearchParams`, `GeoSearchParams`, `GeoPointLiteral`, `EngineHopSpec`, `EngineTraversalParams`, `EngineTraversalResult`), and the extension interface `EngineTraversalExtension`. Rely on type inference or declare local `Parameters<typeof client.fullTextSearch>[0]`-style helpers until these types are promoted to the public export.
1001
+
775
1002
  ## How It Works
776
1003
 
777
1004
  ### Storage Layout
@@ -799,68 +1026,118 @@ When you call `findEdges`, the query planner decides the strategy:
799
1026
 
800
1027
  ### Traversal Execution
801
1028
 
802
- 1. Start with `sourceUids = [startUid]`
803
- 2. For each hop in sequence:
804
- - Resolve `targetGraph`: check hop override, then registry, then none
805
- - If cross-graph (forward + `targetGraph` + `GraphClient` reader): create a subgraph reader via `reader.subgraph(sourceUid, targetGraph)` for each source
806
- - Fan out: query edges for each source UID (parallel, bounded by semaphore)
807
- - Each `findEdges` call counts as 1 read against the budget
808
- - Apply in-memory `filter` if specified, then apply `limit`
809
- - Collect edges, extract next source UIDs (deduplicated)
810
- - If budget exceeded, mark `truncated` and stop
811
- 3. Return final hop edges as `nodes`, all hop data in `hops`
1029
+ Traversal dispatches through three tiers in order:
1030
+
1031
+ 1. **Engine-level** (Firestore Enterprise, `traversal.serverSide`): collapses the entire hop chain into one nested-Pipeline server-side round trip. Requires every hop to have a positive `limitPerSource`, no JS `filter` predicates, no cross-graph hops, and depth ≤ 5. Counts as `totalReads: 1`. Controlled by `engineTraversal` option (`'auto'` by default). Sets `truncated: true` on any hop whose returned edge count reaches `limitPerSource`; `result.truncated` is `true` when any hop is truncated.
1032
+
1033
+ 2. **Expand fast-path** (`query.join`): one `expand()` call per hop instead of one `findEdges` per source. Counts as 1 read per hop regardless of source-set size.
1034
+
1035
+ 3. **Per-source loop** (all backends): fan-out over source UIDs in parallel (bounded by semaphore). Each `findEdges` call counts as 1 read against the budget.
1036
+
1037
+ For each hop the traversal also: resolves `targetGraph` (hop override → registry → none), creates subgraph readers for cross-graph hops, applies in-memory `filter` + `limit`, deduplicates next source UIDs, and stops with `truncated = true` if the budget is exceeded.
812
1038
 
813
1039
  ## Query Modes
814
1040
 
815
- Firegraph supports two query backends. The mode is set when creating a client:
1041
+ Firegraph ships two Firestore backends that you choose at construction time:
816
1042
 
817
1043
  ```typescript
818
- // Pipeline mode (default) requires Enterprise Firestore
819
- const g = createGraphClient(db, 'graph');
1044
+ import { createGraphClient } from 'firegraph';
1045
+ import { createFirestoreStandardBackend } from 'firegraph/firestore-standard';
1046
+ import { createFirestoreEnterpriseBackend } from 'firegraph/firestore-enterprise';
1047
+
1048
+ // Standard — works on any Firestore project, uses classic .where().get() queries
1049
+ const backend = createFirestoreStandardBackend(db, 'graph');
1050
+ const g = createGraphClient(backend, { registry });
1051
+
1052
+ // Enterprise — uses Firestore Pipelines by default; requires Enterprise Firestore
1053
+ const backend = createFirestoreEnterpriseBackend(db, 'graph');
1054
+ const g = createGraphClient(backend, { registry });
820
1055
 
821
- // Standard mode (opt-in) for emulator or small datasets
822
- const g = createGraphClient(db, 'graph', { queryMode: 'standard' });
1056
+ // Enterprise with classic query path (e.g. to avoid full-collection scans)
1057
+ const backend = createFirestoreEnterpriseBackend(db, 'graph', { defaultQueryMode: 'classic' });
823
1058
  ```
824
1059
 
825
- ### Pipeline Mode (Default)
1060
+ ### Standard Backend (`firegraph/firestore-standard`)
1061
+
1062
+ Uses classic Firestore queries (`.where().get()`). Works on any Firestore project (no Enterprise edition required). Limitations:
1063
+
1064
+ | `data.*` Filters | Risk |
1065
+ | ----------------------------- | --------------------------------- |
1066
+ | Fails without composite index | Query errors for unindexed fields |
826
1067
 
827
- Uses the Firestore Pipeline API (`db.pipeline()`). This is the recommended mode for production.
1068
+ Appropriate for:
1069
+
1070
+ - Any Firestore project (Standard or Enterprise edition)
1071
+ - **Emulator** testing — classic queries work out of the box
1072
+ - Projects that manage their own composite indexes
1073
+
1074
+ ### Enterprise Backend (`firegraph/firestore-enterprise`)
1075
+
1076
+ Uses the Firestore Pipeline API (`db.pipeline()`) by default. Requires **Firestore Enterprise** edition.
828
1077
 
829
1078
  - Enables queries on `data.*` fields without composite indexes
830
- - Requires **Firestore Enterprise** edition
831
- - Pipeline API is currently in Preview
1079
+ - Unlocks additional capabilities: `query.dml`, `traversal.serverSide`, `search.fullText`, `search.geo`
832
1080
 
833
- ### Standard Mode
1081
+ **Emulator auto-fallback:** when `FIRESTORE_EMULATOR_HOST` is detected, the Enterprise backend automatically switches to the classic query path (pipelines aren't supported in the emulator). No configuration needed.
834
1082
 
835
- Uses standard Firestore queries (`.where().get()`). Use only if you understand the limitations:
1083
+ **Transactions** always use the classic query path regardless of `defaultQueryMode`, because Pipeline queries are not transactionally bound.
836
1084
 
837
- | Firestore Edition | With `data.*` Filters | Risk |
838
- | ----------------- | -------------------------------------- | --------------------------------- |
839
- | Enterprise | Full collection scan (no index needed) | High billing on large collections |
840
- | Standard | Fails without composite index | Query errors for unindexed fields |
1085
+ ### SQLite Backend (`firegraph/sqlite`)
841
1086
 
842
- Standard mode is appropriate for:
1087
+ Shared-table SQLite backend for Node.js (`better-sqlite3`) and Cloudflare D1. Supports all four core capabilities plus `query.aggregate`, `query.select`, `query.join`, and `query.dml`. Does not support `search.*`.
843
1088
 
844
- - **Emulator** — the emulator doesn't support pipelines, so firegraph auto-falls back to standard mode when `FIRESTORE_EMULATOR_HOST` is set
845
- - **Small datasets** where full scans are acceptable
846
- - Projects that manage their own composite indexes
1089
+ ```typescript
1090
+ import { createSqliteBackend } from 'firegraph/sqlite';
1091
+ import { createGraphClientFromBackend } from 'firegraph';
847
1092
 
848
- ### Emulator Auto-Fallback
1093
+ const backend = createSqliteBackend(executor, 'graph');
1094
+ const g = createGraphClientFromBackend(backend, { registry });
1095
+ ```
849
1096
 
850
- When `FIRESTORE_EMULATOR_HOST` is detected, firegraph automatically uses standard mode regardless of the `queryMode` setting. No configuration needed.
1097
+ Note: `core.transactions` is only declared when `executor.transaction` is defined `better-sqlite3` provides this, but Cloudflare D1 does not.
851
1098
 
852
- ### Transactions
1099
+ ### Cloudflare Durable Object Backend (`firegraph/cloudflare`)
1100
+
1101
+ Runs inside a Durable Object via `state.storage.sql`. Same capability set as SQLite minus `core.transactions` (the DO's single-threaded executor cannot block on transaction callbacks) and `raw.sql` (the DO SQL surface is hidden behind RPC).
1102
+
1103
+ ```typescript
1104
+ // In your DO class file (workerd bundle):
1105
+ import { FiregraphDO } from 'firegraph/cloudflare';
1106
+ export class MyGraphDO extends FiregraphDO {}
1107
+
1108
+ // In your backend code (Node):
1109
+ import { DORPCBackend, createDOClient } from 'firegraph/cloudflare';
1110
+ const g = createDOClient(env.MY_GRAPH, 'graph', { registry });
1111
+ ```
1112
+
1113
+ `firegraph/cloudflare` also re-exports `createRegistry`, `createMergedRegistry`, `generateId`, `META_NODE_TYPE`, `META_EDGE_TYPE`, and `deleteField()` so workerd-bundled code can build registries without statically importing `@google-cloud/firestore`.
1114
+
1115
+ `createSiblingClient(client, siblingRootKey)` creates a peer root-level `DOGraphClient` for a sibling collection within the same Durable Object — useful when a DO hosts multiple logical graph roots.
1116
+
1117
+ ### Routing Backend (`firegraph/backend`)
1118
+
1119
+ Assembles a single capability-typed backend that routes operations to the appropriate per-subgraph backend. Use when different subgraphs live in different storage systems.
1120
+
1121
+ ```typescript
1122
+ import { createRoutingBackend } from 'firegraph/backend';
1123
+ import { createGraphClientFromBackend } from 'firegraph';
1124
+
1125
+ const backend = createRoutingBackend(defaultBackend, {
1126
+ 'users/*': userBackend,
1127
+ });
1128
+ const g = createGraphClientFromBackend(backend, { registry });
1129
+ ```
853
1130
 
854
- 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.
1131
+ `firegraph/backend` also exports `StorageBackend`, `BackendCapabilities`, `createCapabilities`, and `intersectCapabilities` for authors implementing custom backends.
855
1132
 
856
1133
  ### Config File
857
1134
 
858
- Set the query mode in `firegraph.config.ts`:
1135
+ Set the default backend in `firegraph.config.ts`:
859
1136
 
860
1137
  ```typescript
861
1138
  export default defineConfig({
862
1139
  entities: './entities',
863
- queryMode: 'pipeline', // or 'standard'
1140
+ queryMode: 'pipeline', // 'pipeline' selects Enterprise, 'standard' selects Standard
864
1141
  });
865
1142
  ```
866
1143