@typicalday/firegraph 0.11.2 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -5
- package/dist/backend-BsR0lnFL.d.ts +200 -0
- package/dist/backend-Ct-fLlkG.d.cts +200 -0
- package/dist/backend.cjs +143 -2
- package/dist/backend.cjs.map +1 -1
- package/dist/backend.d.cts +3 -3
- package/dist/backend.d.ts +3 -3
- package/dist/backend.js +13 -4
- package/dist/backend.js.map +1 -1
- package/dist/chunk-AWW4MUJ5.js +245 -0
- package/dist/chunk-AWW4MUJ5.js.map +1 -0
- package/dist/{chunk-5753Y42M.js → chunk-C2QMD7RY.js} +6 -10
- package/dist/chunk-C2QMD7RY.js.map +1 -0
- package/dist/chunk-EQJUUVFG.js +14 -0
- package/dist/chunk-EQJUUVFG.js.map +1 -0
- package/dist/{chunk-NJSOD64C.js → chunk-HONQY4HF.js} +80 -17
- package/dist/chunk-HONQY4HF.js.map +1 -0
- package/dist/cloudflare/index.cjs +458 -73
- package/dist/cloudflare/index.cjs.map +1 -1
- package/dist/cloudflare/index.d.cts +8 -5
- package/dist/cloudflare/index.d.ts +8 -5
- package/dist/cloudflare/index.js +234 -56
- package/dist/cloudflare/index.js.map +1 -1
- package/dist/codegen/index.d.cts +1 -1
- package/dist/codegen/index.d.ts +1 -1
- package/dist/index.cjs +271 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +21 -69
- package/dist/index.d.ts +21 -69
- package/dist/index.js +58 -28
- package/dist/index.js.map +1 -1
- package/dist/registry-B1qsVL0E.d.cts +64 -0
- package/dist/registry-Fi074zVa.d.ts +64 -0
- package/dist/{serialization-ZZ7RSDRX.js → serialization-OE2PFZMY.js} +6 -4
- package/dist/{types-BGWxcpI_.d.cts → types-DxYLy8Ol.d.cts} +36 -2
- package/dist/{types-BGWxcpI_.d.ts → types-DxYLy8Ol.d.ts} +36 -2
- package/package.json +1 -1
- package/dist/backend-U-MLShlg.d.ts +0 -97
- package/dist/backend-np4gEVhB.d.cts +0 -97
- package/dist/chunk-5753Y42M.js.map +0 -1
- package/dist/chunk-NJSOD64C.js.map +0 -1
- package/dist/chunk-R7CRGYY4.js +0 -94
- package/dist/chunk-R7CRGYY4.js.map +0 -1
- /package/dist/{serialization-ZZ7RSDRX.js.map → serialization-OE2PFZMY.js.map} +0 -0
package/README.md
CHANGED
|
@@ -101,16 +101,19 @@ const g = createGraphClient(db, 'graph', { registry });
|
|
|
101
101
|
```typescript
|
|
102
102
|
const tourId = generateId();
|
|
103
103
|
|
|
104
|
-
// Create or
|
|
104
|
+
// Create or deep-merge a node (sibling keys at any depth survive)
|
|
105
105
|
await g.putNode('tour', tourId, { name: 'Dolomites Classic' });
|
|
106
106
|
|
|
107
107
|
// Read a node
|
|
108
108
|
const node = await g.getNode(tourId);
|
|
109
109
|
// → StoredGraphRecord | null
|
|
110
110
|
|
|
111
|
-
//
|
|
111
|
+
// Partial update (deep merge into data)
|
|
112
112
|
await g.updateNode(tourId, { difficulty: 'extreme' });
|
|
113
113
|
|
|
114
|
+
// Full replace — discards every prior key not in the new payload
|
|
115
|
+
await g.replaceNode('tour', tourId, { name: 'Dolomites — 2026 Edition' });
|
|
116
|
+
|
|
114
117
|
// Delete a node
|
|
115
118
|
await g.removeNode(tourId);
|
|
116
119
|
|
|
@@ -118,12 +121,19 @@ await g.removeNode(tourId);
|
|
|
118
121
|
const tours = await g.findNodes({ aType: 'tour' });
|
|
119
122
|
```
|
|
120
123
|
|
|
124
|
+
**Write semantics (0.12+):** `putNode`/`putEdge` and `updateNode`/`updateEdge`
|
|
125
|
+
**deep-merge** by default — sibling keys at every nesting depth survive. Use
|
|
126
|
+
`replaceNode`/`replaceEdge` when you want the old "wipe and rewrite" behaviour.
|
|
127
|
+
Arrays are terminal (replaced wholesale, not element-merged); `undefined`
|
|
128
|
+
values are skipped; `null` is preserved verbatim; and the
|
|
129
|
+
[`deleteField()`](#field-deletion) sentinel removes a field at any depth.
|
|
130
|
+
|
|
121
131
|
### Edges
|
|
122
132
|
|
|
123
133
|
```typescript
|
|
124
134
|
const depId = generateId();
|
|
125
135
|
|
|
126
|
-
// Create or
|
|
136
|
+
// Create or deep-merge an edge
|
|
127
137
|
await g.putEdge('tour', tourId, 'hasDeparture', 'departure', depId, { order: 0 });
|
|
128
138
|
|
|
129
139
|
// Read a specific edge
|
|
@@ -133,10 +143,33 @@ const edge = await g.getEdge(tourId, 'hasDeparture', depId);
|
|
|
133
143
|
// Check existence
|
|
134
144
|
const exists = await g.edgeExists(tourId, 'hasDeparture', depId);
|
|
135
145
|
|
|
146
|
+
// Partial update (deep merge)
|
|
147
|
+
await g.updateEdge(tourId, 'hasDeparture', depId, { order: 5 });
|
|
148
|
+
|
|
149
|
+
// Full replace — discards every prior key not in the new payload
|
|
150
|
+
await g.replaceEdge('tour', tourId, 'hasDeparture', 'departure', depId, { order: 5 });
|
|
151
|
+
|
|
136
152
|
// Delete an edge
|
|
137
153
|
await g.removeEdge(tourId, 'hasDeparture', depId);
|
|
138
154
|
```
|
|
139
155
|
|
|
156
|
+
### Field Deletion
|
|
157
|
+
|
|
158
|
+
The `deleteField()` sentinel removes a field from a stored document. It works
|
|
159
|
+
across every backend (Firestore, SQLite, Cloudflare Durable Objects), so
|
|
160
|
+
calling code stays portable:
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
import { deleteField } from 'firegraph';
|
|
164
|
+
|
|
165
|
+
await g.updateNode(tourId, {
|
|
166
|
+
meta: { deprecatedTag: deleteField() }, // removes meta.deprecatedTag
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Equivalent to Firestore's `FieldValue.delete()`, but Workers-safe and
|
|
171
|
+
SQLite-aware.
|
|
172
|
+
|
|
140
173
|
### Querying Edges
|
|
141
174
|
|
|
142
175
|
`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.
|
|
@@ -377,8 +410,8 @@ const tour = await g.getNode(tourId);
|
|
|
377
410
|
|
|
378
411
|
- **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
412
|
- **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
|
|
381
|
-
- **`updateNode`**:
|
|
413
|
+
- **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.
|
|
414
|
+
- **`updateNode` / `updateEdge`**: Do not stamp `v` — they are raw partial patches without schema context. The next read re-triggers migration (which is idempotent).
|
|
382
415
|
|
|
383
416
|
#### Write-Back
|
|
384
417
|
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { S as StoredGraphRecord, g as QueryFilter, z as QueryOptions, l as GraphReader, m as BulkOptions, C as CascadeResult, F as FindEdgesParams, o as BulkResult } from './types-DxYLy8Ol.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Write-plan helper — flattens partial-update payloads into a list of
|
|
5
|
+
* deep-path operations every backend can execute identically.
|
|
6
|
+
*
|
|
7
|
+
* Background: firegraph used to ship two write semantics that quietly
|
|
8
|
+
* disagreed about depth.
|
|
9
|
+
* - `putNode`/`putEdge` did a full document replace.
|
|
10
|
+
* - `updateNode`/`updateEdge` did a one-level shallow merge: top-level
|
|
11
|
+
* keys were preserved, but nested objects were replaced wholesale.
|
|
12
|
+
*
|
|
13
|
+
* Both behaviours dropped sibling keys silently. The 0.12 contract is that
|
|
14
|
+
* `put*` and `update*` deep-merge by default (sibling keys at any depth
|
|
15
|
+
* survive); `replace*` is the explicit escape hatch.
|
|
16
|
+
*
|
|
17
|
+
* `flattenPatch` walks a partial-update payload and emits one
|
|
18
|
+
* {@link DataPathOp} per terminal value. Plain objects recurse; arrays,
|
|
19
|
+
* primitives, Firestore special types, and tagged firegraph-serialization
|
|
20
|
+
* objects are terminal (replaced as a unit). `undefined` values are
|
|
21
|
+
* skipped; `null` is preserved as a real `null` write; the
|
|
22
|
+
* {@link DELETE_FIELD} sentinel marks a field for removal.
|
|
23
|
+
*
|
|
24
|
+
* The output is deliberately backend-agnostic. Each backend translates ops
|
|
25
|
+
* into its native dialect:
|
|
26
|
+
* - Firestore: dotted field path → `data.a.b.c` for `update()`.
|
|
27
|
+
* - SQLite / DO SQLite: `json_set(data, '$.a.b.c', ?)` /
|
|
28
|
+
* `json_remove(data, '$.a.b.c')`.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* Sentinel returned by {@link deleteField}. Treated by all backends as
|
|
32
|
+
* "remove this field from the stored document".
|
|
33
|
+
*
|
|
34
|
+
* Equivalent to Firestore's `FieldValue.delete()`, but works for SQLite
|
|
35
|
+
* backends too. Use inside `updateNode`/`updateEdge` payloads.
|
|
36
|
+
*/
|
|
37
|
+
declare const DELETE_FIELD: unique symbol;
|
|
38
|
+
type DeleteSentinel = typeof DELETE_FIELD;
|
|
39
|
+
/**
|
|
40
|
+
* Returns the firegraph delete sentinel. Place this anywhere in an
|
|
41
|
+
* `updateNode`/`updateEdge` payload to remove the corresponding field.
|
|
42
|
+
*
|
|
43
|
+
* ```ts
|
|
44
|
+
* await client.updateNode('tour', uid, {
|
|
45
|
+
* attrs: { obsoleteFlag: deleteField() },
|
|
46
|
+
* });
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
declare function deleteField(): DeleteSentinel;
|
|
50
|
+
/** Type guard for the delete sentinel. */
|
|
51
|
+
declare function isDeleteSentinel(value: unknown): value is DeleteSentinel;
|
|
52
|
+
/**
|
|
53
|
+
* Single terminal write operation produced by {@link flattenPatch}.
|
|
54
|
+
*
|
|
55
|
+
* `path` is a non-empty array of plain object keys. `value` is the value to
|
|
56
|
+
* write; ignored when `delete` is `true`. Arrays / primitives / Firestore
|
|
57
|
+
* special types appear here as whole terminal values.
|
|
58
|
+
*/
|
|
59
|
+
interface DataPathOp {
|
|
60
|
+
path: readonly string[];
|
|
61
|
+
value: unknown;
|
|
62
|
+
delete: boolean;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Flatten a partial-update payload into a list of terminal {@link DataPathOp}s.
|
|
66
|
+
*
|
|
67
|
+
* Rules:
|
|
68
|
+
* - Plain objects (no prototype or `Object.prototype`) recurse — each
|
|
69
|
+
* key becomes another path segment.
|
|
70
|
+
* - Arrays are terminal: writing `{tags: ['a']}` overwrites the whole
|
|
71
|
+
* `tags` array. Element-wise array merging is intentionally NOT
|
|
72
|
+
* supported — it's almost never what callers actually want, and
|
|
73
|
+
* Firestore `arrayUnion`/`arrayRemove` give precise semantics when
|
|
74
|
+
* they are.
|
|
75
|
+
* - `undefined` values are skipped (no op generated). Use
|
|
76
|
+
* {@link deleteField} if you actually want to remove a field.
|
|
77
|
+
* - `null` is preserved verbatim — emits a terminal op with `value: null`.
|
|
78
|
+
* - {@link DELETE_FIELD} produces an op with `delete: true`.
|
|
79
|
+
* - Firestore special types and tagged serialization payloads are terminal.
|
|
80
|
+
* - Class instances are terminal.
|
|
81
|
+
*
|
|
82
|
+
* Throws if any object key on the recursion path is unsafe (see
|
|
83
|
+
* {@link assertSafePath}).
|
|
84
|
+
*/
|
|
85
|
+
declare function flattenPatch(data: Record<string, unknown>): DataPathOp[];
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Backend abstraction for firegraph.
|
|
89
|
+
*
|
|
90
|
+
* `StorageBackend` is the single interface every storage driver implements.
|
|
91
|
+
* The Firestore backend wraps `@google-cloud/firestore`; the SQLite backend
|
|
92
|
+
* (shared by D1 and Durable Object SQLite) uses a parameterized SQL executor.
|
|
93
|
+
*
|
|
94
|
+
* `GraphClientImpl` and friends depend only on this interface — they have
|
|
95
|
+
* no direct knowledge of Firestore or SQLite.
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Per-record write payload — backend-agnostic. Timestamps are not present;
|
|
100
|
+
* the backend supplies them via `serverTimestamp()` placeholders that it
|
|
101
|
+
* itself resolves at commit time.
|
|
102
|
+
*/
|
|
103
|
+
interface WritableRecord {
|
|
104
|
+
aType: string;
|
|
105
|
+
aUid: string;
|
|
106
|
+
axbType: string;
|
|
107
|
+
bType: string;
|
|
108
|
+
bUid: string;
|
|
109
|
+
data: Record<string, unknown>;
|
|
110
|
+
/** Schema version (set by the writer when registry has migrations). */
|
|
111
|
+
v?: number;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Write semantics for `setDoc`.
|
|
115
|
+
*
|
|
116
|
+
* - `'merge'` — the new contract (0.12+). Existing fields not mentioned
|
|
117
|
+
* in the new data survive; nested objects are recursively merged;
|
|
118
|
+
* arrays are replaced as a unit. This is the default for
|
|
119
|
+
* `putNode` / `putEdge`.
|
|
120
|
+
* - `'replace'` — the document is replaced wholesale, dropping any
|
|
121
|
+
* fields not present in the payload. This is the explicit escape
|
|
122
|
+
* hatch surfaced as `replaceNode` / `replaceEdge` and used by
|
|
123
|
+
* migration write-back.
|
|
124
|
+
*/
|
|
125
|
+
type WriteMode = 'merge' | 'replace';
|
|
126
|
+
/**
|
|
127
|
+
* Patch shape for `updateDoc`.
|
|
128
|
+
*
|
|
129
|
+
* - `dataOps`: list of deep-path terminal ops produced by
|
|
130
|
+
* `flattenPatch()` (one op per leaf — arrays / primitives / Firestore
|
|
131
|
+
* special types are terminal). Used by `updateNode` / `updateEdge`.
|
|
132
|
+
* Sibling keys at every depth are preserved.
|
|
133
|
+
* - `replaceData`: full `data` replacement. Used only by the migration
|
|
134
|
+
* write-back path, which has already produced a complete migrated
|
|
135
|
+
* document.
|
|
136
|
+
* - `v`: optional schema-version stamp.
|
|
137
|
+
*
|
|
138
|
+
* `updatedAt` is always set by the backend.
|
|
139
|
+
*/
|
|
140
|
+
interface UpdatePayload {
|
|
141
|
+
dataOps?: DataPathOp[];
|
|
142
|
+
replaceData?: Record<string, unknown>;
|
|
143
|
+
v?: number;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Read/write transaction adapter. Mirrors Firestore's transaction semantics:
|
|
147
|
+
* reads are snapshot-consistent; writes are issued inside the transaction
|
|
148
|
+
* and a rejection from any write aborts the surrounding `runTransaction`.
|
|
149
|
+
*
|
|
150
|
+
* Writes return `Promise<void>` so SQL drivers can surface row-level errors
|
|
151
|
+
* (constraint violations, malformed JSON paths) rather than swallowing them.
|
|
152
|
+
* Firestore implementations can resolve synchronously since the underlying
|
|
153
|
+
* `Transaction.set/update/delete` calls are themselves synchronous buffers.
|
|
154
|
+
*/
|
|
155
|
+
interface TransactionBackend {
|
|
156
|
+
getDoc(docId: string): Promise<StoredGraphRecord | null>;
|
|
157
|
+
query(filters: QueryFilter[], options?: QueryOptions): Promise<StoredGraphRecord[]>;
|
|
158
|
+
setDoc(docId: string, record: WritableRecord, mode: WriteMode): Promise<void>;
|
|
159
|
+
updateDoc(docId: string, update: UpdatePayload): Promise<void>;
|
|
160
|
+
deleteDoc(docId: string): Promise<void>;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Atomic multi-write batch.
|
|
164
|
+
*/
|
|
165
|
+
interface BatchBackend {
|
|
166
|
+
setDoc(docId: string, record: WritableRecord, mode: WriteMode): void;
|
|
167
|
+
updateDoc(docId: string, update: UpdatePayload): void;
|
|
168
|
+
deleteDoc(docId: string): void;
|
|
169
|
+
commit(): Promise<void>;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The single storage abstraction.
|
|
173
|
+
*
|
|
174
|
+
* Each backend instance is scoped to a "graph location" — for Firestore
|
|
175
|
+
* that's a collection path; for SQLite it's a (table, scopePath) pair.
|
|
176
|
+
* `subgraph()` returns a child backend bound to a nested location.
|
|
177
|
+
*/
|
|
178
|
+
interface StorageBackend {
|
|
179
|
+
/** Backend-internal location identifier (collection path or table name). */
|
|
180
|
+
readonly collectionPath: string;
|
|
181
|
+
/** Subgraph scope (empty string for root). */
|
|
182
|
+
readonly scopePath: string;
|
|
183
|
+
getDoc(docId: string): Promise<StoredGraphRecord | null>;
|
|
184
|
+
query(filters: QueryFilter[], options?: QueryOptions): Promise<StoredGraphRecord[]>;
|
|
185
|
+
setDoc(docId: string, record: WritableRecord, mode: WriteMode): Promise<void>;
|
|
186
|
+
updateDoc(docId: string, update: UpdatePayload): Promise<void>;
|
|
187
|
+
deleteDoc(docId: string): Promise<void>;
|
|
188
|
+
runTransaction<T>(fn: (tx: TransactionBackend) => Promise<T>): Promise<T>;
|
|
189
|
+
createBatch(): BatchBackend;
|
|
190
|
+
subgraph(parentNodeUid: string, name: string): StorageBackend;
|
|
191
|
+
removeNodeCascade(uid: string, reader: GraphReader, options?: BulkOptions): Promise<CascadeResult>;
|
|
192
|
+
bulkRemoveEdges(params: FindEdgesParams, reader: GraphReader, options?: BulkOptions): Promise<BulkResult>;
|
|
193
|
+
/**
|
|
194
|
+
* Find edges across all subgraphs sharing a given collection name.
|
|
195
|
+
* Optional — backends that can't support this should throw a clear error.
|
|
196
|
+
*/
|
|
197
|
+
findEdgesGlobal?(params: FindEdgesParams, collectionName?: string): Promise<StoredGraphRecord[]>;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export { type BatchBackend as B, DELETE_FIELD as D, type StorageBackend as S, type TransactionBackend as T, type UpdatePayload as U, type WritableRecord as W, type DataPathOp as a, type WriteMode as b, deleteField as d, flattenPatch as f, isDeleteSentinel as i };
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { S as StoredGraphRecord, g as QueryFilter, z as QueryOptions, l as GraphReader, m as BulkOptions, C as CascadeResult, F as FindEdgesParams, o as BulkResult } from './types-DxYLy8Ol.cjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Write-plan helper — flattens partial-update payloads into a list of
|
|
5
|
+
* deep-path operations every backend can execute identically.
|
|
6
|
+
*
|
|
7
|
+
* Background: firegraph used to ship two write semantics that quietly
|
|
8
|
+
* disagreed about depth.
|
|
9
|
+
* - `putNode`/`putEdge` did a full document replace.
|
|
10
|
+
* - `updateNode`/`updateEdge` did a one-level shallow merge: top-level
|
|
11
|
+
* keys were preserved, but nested objects were replaced wholesale.
|
|
12
|
+
*
|
|
13
|
+
* Both behaviours dropped sibling keys silently. The 0.12 contract is that
|
|
14
|
+
* `put*` and `update*` deep-merge by default (sibling keys at any depth
|
|
15
|
+
* survive); `replace*` is the explicit escape hatch.
|
|
16
|
+
*
|
|
17
|
+
* `flattenPatch` walks a partial-update payload and emits one
|
|
18
|
+
* {@link DataPathOp} per terminal value. Plain objects recurse; arrays,
|
|
19
|
+
* primitives, Firestore special types, and tagged firegraph-serialization
|
|
20
|
+
* objects are terminal (replaced as a unit). `undefined` values are
|
|
21
|
+
* skipped; `null` is preserved as a real `null` write; the
|
|
22
|
+
* {@link DELETE_FIELD} sentinel marks a field for removal.
|
|
23
|
+
*
|
|
24
|
+
* The output is deliberately backend-agnostic. Each backend translates ops
|
|
25
|
+
* into its native dialect:
|
|
26
|
+
* - Firestore: dotted field path → `data.a.b.c` for `update()`.
|
|
27
|
+
* - SQLite / DO SQLite: `json_set(data, '$.a.b.c', ?)` /
|
|
28
|
+
* `json_remove(data, '$.a.b.c')`.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* Sentinel returned by {@link deleteField}. Treated by all backends as
|
|
32
|
+
* "remove this field from the stored document".
|
|
33
|
+
*
|
|
34
|
+
* Equivalent to Firestore's `FieldValue.delete()`, but works for SQLite
|
|
35
|
+
* backends too. Use inside `updateNode`/`updateEdge` payloads.
|
|
36
|
+
*/
|
|
37
|
+
declare const DELETE_FIELD: unique symbol;
|
|
38
|
+
type DeleteSentinel = typeof DELETE_FIELD;
|
|
39
|
+
/**
|
|
40
|
+
* Returns the firegraph delete sentinel. Place this anywhere in an
|
|
41
|
+
* `updateNode`/`updateEdge` payload to remove the corresponding field.
|
|
42
|
+
*
|
|
43
|
+
* ```ts
|
|
44
|
+
* await client.updateNode('tour', uid, {
|
|
45
|
+
* attrs: { obsoleteFlag: deleteField() },
|
|
46
|
+
* });
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
declare function deleteField(): DeleteSentinel;
|
|
50
|
+
/** Type guard for the delete sentinel. */
|
|
51
|
+
declare function isDeleteSentinel(value: unknown): value is DeleteSentinel;
|
|
52
|
+
/**
|
|
53
|
+
* Single terminal write operation produced by {@link flattenPatch}.
|
|
54
|
+
*
|
|
55
|
+
* `path` is a non-empty array of plain object keys. `value` is the value to
|
|
56
|
+
* write; ignored when `delete` is `true`. Arrays / primitives / Firestore
|
|
57
|
+
* special types appear here as whole terminal values.
|
|
58
|
+
*/
|
|
59
|
+
interface DataPathOp {
|
|
60
|
+
path: readonly string[];
|
|
61
|
+
value: unknown;
|
|
62
|
+
delete: boolean;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Flatten a partial-update payload into a list of terminal {@link DataPathOp}s.
|
|
66
|
+
*
|
|
67
|
+
* Rules:
|
|
68
|
+
* - Plain objects (no prototype or `Object.prototype`) recurse — each
|
|
69
|
+
* key becomes another path segment.
|
|
70
|
+
* - Arrays are terminal: writing `{tags: ['a']}` overwrites the whole
|
|
71
|
+
* `tags` array. Element-wise array merging is intentionally NOT
|
|
72
|
+
* supported — it's almost never what callers actually want, and
|
|
73
|
+
* Firestore `arrayUnion`/`arrayRemove` give precise semantics when
|
|
74
|
+
* they are.
|
|
75
|
+
* - `undefined` values are skipped (no op generated). Use
|
|
76
|
+
* {@link deleteField} if you actually want to remove a field.
|
|
77
|
+
* - `null` is preserved verbatim — emits a terminal op with `value: null`.
|
|
78
|
+
* - {@link DELETE_FIELD} produces an op with `delete: true`.
|
|
79
|
+
* - Firestore special types and tagged serialization payloads are terminal.
|
|
80
|
+
* - Class instances are terminal.
|
|
81
|
+
*
|
|
82
|
+
* Throws if any object key on the recursion path is unsafe (see
|
|
83
|
+
* {@link assertSafePath}).
|
|
84
|
+
*/
|
|
85
|
+
declare function flattenPatch(data: Record<string, unknown>): DataPathOp[];
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Backend abstraction for firegraph.
|
|
89
|
+
*
|
|
90
|
+
* `StorageBackend` is the single interface every storage driver implements.
|
|
91
|
+
* The Firestore backend wraps `@google-cloud/firestore`; the SQLite backend
|
|
92
|
+
* (shared by D1 and Durable Object SQLite) uses a parameterized SQL executor.
|
|
93
|
+
*
|
|
94
|
+
* `GraphClientImpl` and friends depend only on this interface — they have
|
|
95
|
+
* no direct knowledge of Firestore or SQLite.
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Per-record write payload — backend-agnostic. Timestamps are not present;
|
|
100
|
+
* the backend supplies them via `serverTimestamp()` placeholders that it
|
|
101
|
+
* itself resolves at commit time.
|
|
102
|
+
*/
|
|
103
|
+
interface WritableRecord {
|
|
104
|
+
aType: string;
|
|
105
|
+
aUid: string;
|
|
106
|
+
axbType: string;
|
|
107
|
+
bType: string;
|
|
108
|
+
bUid: string;
|
|
109
|
+
data: Record<string, unknown>;
|
|
110
|
+
/** Schema version (set by the writer when registry has migrations). */
|
|
111
|
+
v?: number;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Write semantics for `setDoc`.
|
|
115
|
+
*
|
|
116
|
+
* - `'merge'` — the new contract (0.12+). Existing fields not mentioned
|
|
117
|
+
* in the new data survive; nested objects are recursively merged;
|
|
118
|
+
* arrays are replaced as a unit. This is the default for
|
|
119
|
+
* `putNode` / `putEdge`.
|
|
120
|
+
* - `'replace'` — the document is replaced wholesale, dropping any
|
|
121
|
+
* fields not present in the payload. This is the explicit escape
|
|
122
|
+
* hatch surfaced as `replaceNode` / `replaceEdge` and used by
|
|
123
|
+
* migration write-back.
|
|
124
|
+
*/
|
|
125
|
+
type WriteMode = 'merge' | 'replace';
|
|
126
|
+
/**
|
|
127
|
+
* Patch shape for `updateDoc`.
|
|
128
|
+
*
|
|
129
|
+
* - `dataOps`: list of deep-path terminal ops produced by
|
|
130
|
+
* `flattenPatch()` (one op per leaf — arrays / primitives / Firestore
|
|
131
|
+
* special types are terminal). Used by `updateNode` / `updateEdge`.
|
|
132
|
+
* Sibling keys at every depth are preserved.
|
|
133
|
+
* - `replaceData`: full `data` replacement. Used only by the migration
|
|
134
|
+
* write-back path, which has already produced a complete migrated
|
|
135
|
+
* document.
|
|
136
|
+
* - `v`: optional schema-version stamp.
|
|
137
|
+
*
|
|
138
|
+
* `updatedAt` is always set by the backend.
|
|
139
|
+
*/
|
|
140
|
+
interface UpdatePayload {
|
|
141
|
+
dataOps?: DataPathOp[];
|
|
142
|
+
replaceData?: Record<string, unknown>;
|
|
143
|
+
v?: number;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Read/write transaction adapter. Mirrors Firestore's transaction semantics:
|
|
147
|
+
* reads are snapshot-consistent; writes are issued inside the transaction
|
|
148
|
+
* and a rejection from any write aborts the surrounding `runTransaction`.
|
|
149
|
+
*
|
|
150
|
+
* Writes return `Promise<void>` so SQL drivers can surface row-level errors
|
|
151
|
+
* (constraint violations, malformed JSON paths) rather than swallowing them.
|
|
152
|
+
* Firestore implementations can resolve synchronously since the underlying
|
|
153
|
+
* `Transaction.set/update/delete` calls are themselves synchronous buffers.
|
|
154
|
+
*/
|
|
155
|
+
interface TransactionBackend {
|
|
156
|
+
getDoc(docId: string): Promise<StoredGraphRecord | null>;
|
|
157
|
+
query(filters: QueryFilter[], options?: QueryOptions): Promise<StoredGraphRecord[]>;
|
|
158
|
+
setDoc(docId: string, record: WritableRecord, mode: WriteMode): Promise<void>;
|
|
159
|
+
updateDoc(docId: string, update: UpdatePayload): Promise<void>;
|
|
160
|
+
deleteDoc(docId: string): Promise<void>;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Atomic multi-write batch.
|
|
164
|
+
*/
|
|
165
|
+
interface BatchBackend {
|
|
166
|
+
setDoc(docId: string, record: WritableRecord, mode: WriteMode): void;
|
|
167
|
+
updateDoc(docId: string, update: UpdatePayload): void;
|
|
168
|
+
deleteDoc(docId: string): void;
|
|
169
|
+
commit(): Promise<void>;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The single storage abstraction.
|
|
173
|
+
*
|
|
174
|
+
* Each backend instance is scoped to a "graph location" — for Firestore
|
|
175
|
+
* that's a collection path; for SQLite it's a (table, scopePath) pair.
|
|
176
|
+
* `subgraph()` returns a child backend bound to a nested location.
|
|
177
|
+
*/
|
|
178
|
+
interface StorageBackend {
|
|
179
|
+
/** Backend-internal location identifier (collection path or table name). */
|
|
180
|
+
readonly collectionPath: string;
|
|
181
|
+
/** Subgraph scope (empty string for root). */
|
|
182
|
+
readonly scopePath: string;
|
|
183
|
+
getDoc(docId: string): Promise<StoredGraphRecord | null>;
|
|
184
|
+
query(filters: QueryFilter[], options?: QueryOptions): Promise<StoredGraphRecord[]>;
|
|
185
|
+
setDoc(docId: string, record: WritableRecord, mode: WriteMode): Promise<void>;
|
|
186
|
+
updateDoc(docId: string, update: UpdatePayload): Promise<void>;
|
|
187
|
+
deleteDoc(docId: string): Promise<void>;
|
|
188
|
+
runTransaction<T>(fn: (tx: TransactionBackend) => Promise<T>): Promise<T>;
|
|
189
|
+
createBatch(): BatchBackend;
|
|
190
|
+
subgraph(parentNodeUid: string, name: string): StorageBackend;
|
|
191
|
+
removeNodeCascade(uid: string, reader: GraphReader, options?: BulkOptions): Promise<CascadeResult>;
|
|
192
|
+
bulkRemoveEdges(params: FindEdgesParams, reader: GraphReader, options?: BulkOptions): Promise<BulkResult>;
|
|
193
|
+
/**
|
|
194
|
+
* Find edges across all subgraphs sharing a given collection name.
|
|
195
|
+
* Optional — backends that can't support this should throw a clear error.
|
|
196
|
+
*/
|
|
197
|
+
findEdgesGlobal?(params: FindEdgesParams, collectionName?: string): Promise<StoredGraphRecord[]>;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export { type BatchBackend as B, DELETE_FIELD as D, type StorageBackend as S, type TransactionBackend as T, type UpdatePayload as U, type WritableRecord as W, type DataPathOp as a, type WriteMode as b, deleteField as d, flattenPatch as f, isDeleteSentinel as i };
|
package/dist/backend.cjs
CHANGED
|
@@ -21,9 +21,13 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var backend_exports = {};
|
|
22
22
|
__export(backend_exports, {
|
|
23
23
|
CrossBackendTransactionError: () => CrossBackendTransactionError,
|
|
24
|
+
DELETE_FIELD: () => DELETE_FIELD,
|
|
24
25
|
appendStorageScope: () => appendStorageScope,
|
|
25
26
|
createRoutingBackend: () => createRoutingBackend,
|
|
27
|
+
deleteField: () => deleteField,
|
|
28
|
+
flattenPatch: () => flattenPatch,
|
|
26
29
|
isAncestorScopeUid: () => isAncestorScopeUid,
|
|
30
|
+
isDeleteSentinel: () => isDeleteSentinel,
|
|
27
31
|
parseStorageScope: () => parseStorageScope,
|
|
28
32
|
resolveAncestorScope: () => resolveAncestorScope
|
|
29
33
|
});
|
|
@@ -104,8 +108,8 @@ var RoutingStorageBackend = class _RoutingStorageBackend {
|
|
|
104
108
|
return this.base.query(filters, options);
|
|
105
109
|
}
|
|
106
110
|
// --- Pass-through writes ---
|
|
107
|
-
setDoc(docId, record) {
|
|
108
|
-
return this.base.setDoc(docId, record);
|
|
111
|
+
setDoc(docId, record, mode) {
|
|
112
|
+
return this.base.setDoc(docId, record, mode);
|
|
109
113
|
}
|
|
110
114
|
updateDoc(docId, update) {
|
|
111
115
|
return this.base.updateDoc(docId, update);
|
|
@@ -161,6 +165,139 @@ function createRoutingBackend(base, options) {
|
|
|
161
165
|
return new RoutingStorageBackend(base, options, "", base.scopePath);
|
|
162
166
|
}
|
|
163
167
|
|
|
168
|
+
// src/internal/serialization-tag.ts
|
|
169
|
+
var SERIALIZATION_TAG = "__firegraph_ser__";
|
|
170
|
+
var KNOWN_TYPES = /* @__PURE__ */ new Set(["Timestamp", "GeoPoint", "VectorValue", "DocumentReference"]);
|
|
171
|
+
function isTaggedValue(value) {
|
|
172
|
+
if (value === null || typeof value !== "object") return false;
|
|
173
|
+
const tag = value[SERIALIZATION_TAG];
|
|
174
|
+
return typeof tag === "string" && KNOWN_TYPES.has(tag);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// src/internal/write-plan.ts
|
|
178
|
+
var DELETE_FIELD = /* @__PURE__ */ Symbol.for("firegraph.deleteField");
|
|
179
|
+
function deleteField() {
|
|
180
|
+
return DELETE_FIELD;
|
|
181
|
+
}
|
|
182
|
+
function isDeleteSentinel(value) {
|
|
183
|
+
return value === DELETE_FIELD;
|
|
184
|
+
}
|
|
185
|
+
var FIRESTORE_TERMINAL_CTOR = /* @__PURE__ */ new Set([
|
|
186
|
+
"Timestamp",
|
|
187
|
+
"GeoPoint",
|
|
188
|
+
"VectorValue",
|
|
189
|
+
"DocumentReference",
|
|
190
|
+
"FieldValue",
|
|
191
|
+
"NumericIncrementTransform",
|
|
192
|
+
"ArrayUnionTransform",
|
|
193
|
+
"ArrayRemoveTransform",
|
|
194
|
+
"ServerTimestampTransform",
|
|
195
|
+
"DeleteTransform"
|
|
196
|
+
]);
|
|
197
|
+
function isTerminalValue(value) {
|
|
198
|
+
if (value === null) return true;
|
|
199
|
+
const t = typeof value;
|
|
200
|
+
if (t !== "object") return true;
|
|
201
|
+
if (Array.isArray(value)) return true;
|
|
202
|
+
if (isTaggedValue(value)) return true;
|
|
203
|
+
const proto = Object.getPrototypeOf(value);
|
|
204
|
+
if (proto === null || proto === Object.prototype) return false;
|
|
205
|
+
const ctor = value.constructor;
|
|
206
|
+
if (ctor && typeof ctor.name === "string" && FIRESTORE_TERMINAL_CTOR.has(ctor.name)) return true;
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
var SAFE_KEY_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
210
|
+
function walkForDeleteSentinels(node, path, parent, visit) {
|
|
211
|
+
if (node === null || node === void 0) return;
|
|
212
|
+
if (isDeleteSentinel(node)) {
|
|
213
|
+
visit({ path, parent });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (typeof node !== "object") return;
|
|
217
|
+
if (isTaggedValue(node)) return;
|
|
218
|
+
if (Array.isArray(node)) {
|
|
219
|
+
for (let i = 0; i < node.length; i++) {
|
|
220
|
+
walkForDeleteSentinels(node[i], [...path, String(i)], { kind: "array", index: i }, visit);
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const proto = Object.getPrototypeOf(node);
|
|
225
|
+
if (proto !== null && proto !== Object.prototype) return;
|
|
226
|
+
const obj = node;
|
|
227
|
+
for (const key of Object.keys(obj)) {
|
|
228
|
+
walkForDeleteSentinels(obj[key], [...path, key], { kind: "object" }, visit);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function assertSafePath(path) {
|
|
232
|
+
for (const seg of path) {
|
|
233
|
+
if (!SAFE_KEY_RE.test(seg)) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`firegraph: unsafe object key ${JSON.stringify(seg)} at path ${path.map((p) => JSON.stringify(p)).join(" > ")}. Keys used inside update payloads must match /^[A-Za-z_][A-Za-z0-9_-]*$/ so they can be embedded safely in SQLite JSON paths.`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function flattenPatch(data) {
|
|
241
|
+
const ops = [];
|
|
242
|
+
walk(data, [], ops);
|
|
243
|
+
return ops;
|
|
244
|
+
}
|
|
245
|
+
function assertNoDeleteSentinelsInArrayValue(arr, arrayPath) {
|
|
246
|
+
walkForDeleteSentinels(arr, arrayPath, { kind: "root" }, ({ parent }) => {
|
|
247
|
+
const arrayPathStr = arrayPath.length === 0 ? "<root>" : arrayPath.map((p) => JSON.stringify(p)).join(" > ");
|
|
248
|
+
if (parent.kind === "array") {
|
|
249
|
+
throw new Error(
|
|
250
|
+
`firegraph: deleteField() sentinel at index ${parent.index} inside an array at path ${arrayPathStr}. Arrays are terminal in update payloads (replaced as a unit), so the sentinel would be silently dropped by JSON serialization. To remove the field entirely, pass deleteField() in place of the whole array.`
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
throw new Error(
|
|
254
|
+
`firegraph: deleteField() sentinel inside an array element at path ${arrayPathStr}. Arrays are terminal in update payloads \u2014 the sentinel would be silently dropped by JSON serialization.`
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
function walk(node, path, out) {
|
|
259
|
+
if (node === void 0) return;
|
|
260
|
+
if (isDeleteSentinel(node)) {
|
|
261
|
+
if (path.length === 0) {
|
|
262
|
+
throw new Error("firegraph: deleteField() cannot be the entire update payload.");
|
|
263
|
+
}
|
|
264
|
+
assertSafePath(path);
|
|
265
|
+
out.push({ path: [...path], value: void 0, delete: true });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (isTerminalValue(node)) {
|
|
269
|
+
if (path.length === 0) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
"firegraph: update payload must be a plain object. Got " + (node === null ? "null" : Array.isArray(node) ? "array" : typeof node) + "."
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
if (Array.isArray(node)) {
|
|
275
|
+
assertNoDeleteSentinelsInArrayValue(node, path);
|
|
276
|
+
}
|
|
277
|
+
assertSafePath(path);
|
|
278
|
+
out.push({ path: [...path], value: node, delete: false });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const obj = node;
|
|
282
|
+
const keys = Object.keys(obj);
|
|
283
|
+
if (keys.length === 0) {
|
|
284
|
+
if (path.length > 0) {
|
|
285
|
+
assertSafePath(path);
|
|
286
|
+
out.push({ path: [...path], value: {}, delete: false });
|
|
287
|
+
}
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
for (const key of keys) {
|
|
291
|
+
if (key === SERIALIZATION_TAG) {
|
|
292
|
+
const where = path.length === 0 ? "<root>" : path.map((p) => JSON.stringify(p)).join(" > ");
|
|
293
|
+
throw new Error(
|
|
294
|
+
`firegraph: update payload contains a literal \`${SERIALIZATION_TAG}\` key at ${where}. That key is reserved for firegraph's serialization envelope and cannot appear on a plain object in user data. Use a different field name, or pass a recognized tagged value through replaceNode/replaceEdge instead.`
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
walk(obj[key], [...path, key], out);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
164
301
|
// src/scope-path.ts
|
|
165
302
|
function parseStorageScope(scope) {
|
|
166
303
|
if (scope === "") return [];
|
|
@@ -213,9 +350,13 @@ function appendStorageScope(parentScope, uid, name) {
|
|
|
213
350
|
// Annotate the CommonJS export names for ESM import in node:
|
|
214
351
|
0 && (module.exports = {
|
|
215
352
|
CrossBackendTransactionError,
|
|
353
|
+
DELETE_FIELD,
|
|
216
354
|
appendStorageScope,
|
|
217
355
|
createRoutingBackend,
|
|
356
|
+
deleteField,
|
|
357
|
+
flattenPatch,
|
|
218
358
|
isAncestorScopeUid,
|
|
359
|
+
isDeleteSentinel,
|
|
219
360
|
parseStorageScope,
|
|
220
361
|
resolveAncestorScope
|
|
221
362
|
});
|