@supersuit/firestore-double 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release, extracted from the hand-rolled doubles in continental-works-web (`src/lib/suits.test.mjs`, `src/lib/operator-number.test.mjs`) and supersuit-room-host (`src/lib/rooms/memory-store.ts`), after two of them let a read-after-write transaction pass tests and fail in production (2026-10-15, 2026-10-17).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SupersuitUp
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.
package/README.md CHANGED
@@ -1,3 +1,51 @@
1
- # Temporary Holding Version
1
+ # @supersuit/firestore-double
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An in-memory Firestore for tests that refuses what production refuses: reads after writes in a transaction, updates of missing documents, undefined values.
4
+
5
+ A test double is only worth having if it is at least as strict as the thing it stands in for. A hand-rolled one is usually laxer, and then the tests pass and production fails. This one is shaped like `firebase-admin`'s `Firestore`, so the code under test runs unchanged, and it throws Firestore's own errors where Firestore would.
6
+
7
+ ## 30 seconds
8
+
9
+ ```bash
10
+ npm i -D @supersuit/firestore-double
11
+ ```
12
+
13
+ ```js
14
+ import { createFirestore } from "@supersuit/firestore-double";
15
+
16
+ const db = createFirestore(); // hand it wherever your code takes a Firestore
17
+ await db.collection("accounts").doc("a").set({ email: "a@x" });
18
+
19
+ await db.runTransaction(async (tx) => {
20
+ tx.set(db.doc("accounts/b"), { email: "b@x" });
21
+ await tx.get(db.doc("accounts/a")); // throws, exactly as production does:
22
+ }); // "Firestore transactions require all reads to be executed before all writes."
23
+
24
+ db.peek("accounts/a"); // test helper: the stored data
25
+ ```
26
+
27
+ ## What it enforces
28
+
29
+ | Rule | What you get |
30
+ | --- | --- |
31
+ | Reads before writes in a transaction | `Firestore transactions require all reads to be executed before all writes.` (synchronous, like the SDK) |
32
+ | Transaction writes are atomic | Nothing lands if the function throws or a precondition fails at commit |
33
+ | `update()` needs the document | `5 NOT_FOUND: No document to update: <path>`, `err.code === 5` |
34
+ | `create()` needs it absent | `6 ALREADY_EXISTS: Document already exists: <path>`, `err.code === 6` |
35
+ | No `undefined` in data | Refused, unless `createFirestore({ ignoreUndefinedProperties: true })`, which drops it as Firestore does |
36
+ | Read-only transactions | `runTransaction(fn, { readOnly: true })` refuses writes |
37
+ | Path parity | `doc()` needs an even path, `collection()` an odd one |
38
+ | Queries | `where` (`== != < <= > >= in not-in array-contains array-contains-any`) and `orderBy` drop documents lacking the field; range filters match their own type only; default order is by document id |
39
+ | Copies | Everything read is a copy; mutating it never edits the store |
40
+
41
+ Supported surface: `collection(path)`, `doc(path)`, `runTransaction(fn, opts)`; on a reference `get set update create delete collection parent`; on a query `where orderBy limit get count`; on a collection `doc() add()`; in a transaction `get(ref | query) getAll set update create delete`.
42
+
43
+ Test helpers that real Firestore does not have: `db.docs` (a `Map` of full path to data), `db.peek(path)` or `db.peek("coll", "id")`, `db.seed(path, data)`.
44
+
45
+ ## Not modelled
46
+
47
+ Composite-index requirements, `Timestamp` conversion (a `Date` comes back a `Date`), `FieldValue` sentinels, contention and retries, listeners, batches. Each would be a welcome addition, with a test that fails without it.
48
+
49
+ ## TypeScript
50
+
51
+ Types ship in `src/index.d.mts`. Code typed against `Firestore` takes the double with a cast: `createFirestore() as unknown as Firestore`.
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@supersuit/firestore-double",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "An in-memory Firestore for tests that refuses what production refuses: reads after writes in a transaction, updates of missing documents, undefined values.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.d.mts",
9
+ "default": "./src/index.mjs"
10
+ },
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "src/",
15
+ "README.md",
16
+ "CHANGELOG.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "test": "node --test test/*.test.mjs"
21
+ },
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/SupersuitUp/firestore-double.git"
29
+ },
30
+ "dependencies": {},
31
+ "types": "./src/index.d.mts"
32
+ }
@@ -0,0 +1,66 @@
1
+ // Types for @supersuit/firestore-double. The double is shaped like firebase-admin's Firestore, so
2
+ // production code typed against `Firestore` accepts it with a cast (`db as unknown as Firestore`).
3
+ export declare const READ_AFTER_WRITE: string
4
+ export declare const READ_ONLY_WRITE: string
5
+
6
+ type Data = Record<string, any>
7
+
8
+ export interface DocumentSnapshot {
9
+ id: string
10
+ ref: DocumentReference
11
+ exists: boolean
12
+ data(): Data | undefined
13
+ get(field: string): any
14
+ }
15
+ export interface QuerySnapshot {
16
+ docs: DocumentSnapshot[]
17
+ size: number
18
+ empty: boolean
19
+ forEach(fn: (d: DocumentSnapshot) => void): void
20
+ }
21
+ export interface Query {
22
+ where(field: string, op: string, value: unknown): Query
23
+ orderBy(field: string, dir?: 'asc' | 'desc'): Query
24
+ limit(n: number): Query
25
+ get(): Promise<QuerySnapshot>
26
+ count(): { get(): Promise<{ data(): { count: number } }> }
27
+ }
28
+ export interface DocumentReference {
29
+ id: string
30
+ path: string
31
+ readonly parent: CollectionReference
32
+ collection(sub: string): CollectionReference
33
+ get(): Promise<DocumentSnapshot>
34
+ set(data: Data, opts?: { merge?: boolean }): Promise<unknown>
35
+ update(data: Data): Promise<unknown>
36
+ create(data: Data): Promise<unknown>
37
+ delete(): Promise<unknown>
38
+ isEqual(other: DocumentReference): boolean
39
+ }
40
+ export interface CollectionReference extends Query {
41
+ id: string
42
+ path: string
43
+ doc(id?: string): DocumentReference
44
+ add(data: Data): Promise<DocumentReference>
45
+ }
46
+ export interface Transaction {
47
+ get(ref: DocumentReference): Promise<DocumentSnapshot>
48
+ get(query: Query): Promise<QuerySnapshot>
49
+ getAll(...refs: DocumentReference[]): Promise<DocumentSnapshot[]>
50
+ set(ref: DocumentReference, data: Data, opts?: { merge?: boolean }): Transaction
51
+ update(ref: DocumentReference, data: Data): Transaction
52
+ create(ref: DocumentReference, data: Data): Transaction
53
+ delete(ref: DocumentReference): Transaction
54
+ }
55
+ export interface FirestoreDouble {
56
+ collection(path: string): CollectionReference
57
+ doc(path: string): DocumentReference
58
+ runTransaction<T>(fn: (tx: Transaction) => Promise<T>, opts?: { readOnly?: boolean }): Promise<T>
59
+ /** Test helper: every stored document, keyed by full path. */
60
+ docs: Map<string, Data>
61
+ /** Test helper: the stored data at a path ("a/b", or "a", "b"). */
62
+ peek(...parts: string[]): Data | undefined
63
+ /** Test helper: put a document in place without validation. */
64
+ seed(path: string, data: Data): void
65
+ }
66
+ export declare function createFirestore(settings?: { ignoreUndefinedProperties?: boolean }): FirestoreDouble
package/src/index.mjs ADDED
@@ -0,0 +1,264 @@
1
+ // @supersuit/firestore-double: an in-memory stand-in for firebase-admin's Firestore, for tests.
2
+ //
3
+ // WHY IT EXISTS. Twice in three days a hand-rolled double was laxer than Firestore, the tests
4
+ // passed, and production failed: Supersuit Rooms' retireMember (2026-10-15) and the account
5
+ // server's registerSuit (2026-10-17) both read inside a transaction after writing, which
6
+ // Firestore refuses. A double is only worth having if it refuses what production refuses, so
7
+ // every rule below is one Firestore enforces, with Firestore's own error text where a caller
8
+ // might match on it.
9
+ //
10
+ // Enforced: reads before writes in a transaction (the SDK's exact message), transaction writes
11
+ // landing together or not at all, update() of a missing document (5 NOT_FOUND), create() of an
12
+ // existing one (6 ALREADY_EXISTS), undefined anywhere in written data (unless the caller opts
13
+ // into ignoreUndefinedProperties, as Firestore does), writes inside a readOnly transaction,
14
+ // document and collection paths of the right parity, and query filters and orderBy dropping
15
+ // documents that lack the field. Results are copies, so mutating one never edits the store.
16
+ //
17
+ // NOT modelled: composite-index requirements, Timestamp conversion (a Date comes back a Date,
18
+ // where Firestore returns a Timestamp), FieldValue sentinels, contention and retries, listeners.
19
+ import { isDeepStrictEqual } from "node:util";
20
+
21
+ /** firebase-admin's text, copied from @google-cloud/firestore build/src/transaction.js. */
22
+ export const READ_AFTER_WRITE = "Firestore transactions require all reads to be executed before all writes.";
23
+ export const READ_ONLY_WRITE = "Firestore read-only transactions cannot execute writes.";
24
+
25
+ const AUTO_ID = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
26
+ const autoId = () => Array.from({ length: 20 }, () => AUTO_ID[Math.floor(Math.random() * AUTO_ID.length)]).join("");
27
+
28
+ const isPlain = (v) => v !== null && typeof v === "object" && Object.getPrototypeOf(v) === Object.prototype;
29
+ const clone = (v) => (v === undefined ? undefined : structuredClone(v));
30
+
31
+ function grpcError(code, status, message) {
32
+ const e = new Error(`${code} ${status}: ${message}`);
33
+ e.code = code;
34
+ e.details = message;
35
+ return e;
36
+ }
37
+
38
+ function segments(path, kind) {
39
+ if (typeof path !== "string" || path === "") throw new Error(`Value for argument "${kind}Path" is not a valid resource path. Path must be a non-empty string.`);
40
+ const parts = path.split("/");
41
+ if (parts.some((p) => p === "")) throw new Error(`Value for argument "${kind}Path" is not a valid resource path. Paths must not contain //.`);
42
+ const wantOdd = kind === "collection";
43
+ if ((parts.length % 2 === 1) !== wantOdd) {
44
+ throw new Error(`Value for argument "${kind}Path" must point to a ${kind}, but was "${path}". Your path does not contain an ${wantOdd ? "odd" : "even"} number of components.`);
45
+ }
46
+ return parts;
47
+ }
48
+
49
+ function fieldValue(data, field) {
50
+ let cur = data;
51
+ for (const k of field.split(".")) {
52
+ if (!isPlain(cur) || !(k in cur)) return { has: false };
53
+ cur = cur[k];
54
+ }
55
+ return { has: true, value: cur };
56
+ }
57
+
58
+ function validate(data, ignoreUndefined, where = "") {
59
+ if (!isPlain(data)) throw new Error(`Value for argument "data" is not a valid Firestore document. Input is not a plain JavaScript object.`);
60
+ const walk = (v, at) => {
61
+ if (v === undefined) {
62
+ if (ignoreUndefined) return;
63
+ throw new Error(`Value for argument "data" is not a valid Firestore document. Cannot use "undefined" as a Firestore value (found in field "${at}"). If you want to ignore undefined values, enable \`ignoreUndefinedProperties\`.`);
64
+ }
65
+ if (typeof v === "function" || typeof v === "symbol") throw new Error(`Value for argument "data" is not a valid Firestore document. Couldn't serialize object of type "${typeof v}" (found in field "${at}").`);
66
+ if (Array.isArray(v)) v.forEach((x, i) => { if (Array.isArray(x)) throw new Error(`Value for argument "data" is not a valid Firestore document. Cannot use nested arrays (found in field "${at}.${i}").`); walk(x, at); });
67
+ else if (isPlain(v)) for (const [k, x] of Object.entries(v)) walk(x, at ? `${at}.${k}` : k);
68
+ };
69
+ for (const [k, v] of Object.entries(data)) walk(v, where ? `${where}.${k}` : k);
70
+ }
71
+
72
+ function strip(v) {
73
+ // With ignoreUndefinedProperties, Firestore drops the field rather than storing it.
74
+ if (Array.isArray(v)) return v.filter((x) => x !== undefined).map(strip);
75
+ if (isPlain(v)) return Object.fromEntries(Object.entries(v).filter(([, x]) => x !== undefined).map(([k, x]) => [k, strip(x)]));
76
+ return v;
77
+ }
78
+
79
+ function deepMerge(base, patch) {
80
+ const out = isPlain(base) ? { ...base } : {};
81
+ for (const [k, v] of Object.entries(patch)) out[k] = isPlain(v) && isPlain(out[k]) ? deepMerge(out[k], v) : v;
82
+ return out;
83
+ }
84
+
85
+ function setPath(obj, field, value) {
86
+ const keys = field.split(".");
87
+ let cur = obj;
88
+ for (const k of keys.slice(0, -1)) { if (!isPlain(cur[k])) cur[k] = {}; cur = cur[k]; }
89
+ cur[keys.at(-1)] = value;
90
+ }
91
+
92
+ // Firestore orders values of different types by type first; within a type, by value.
93
+ const TYPE_ORDER = (v) => v === null ? 0 : typeof v === "boolean" ? 1 : typeof v === "number" ? 2 : v instanceof Date ? 3 : typeof v === "string" ? 4 : Array.isArray(v) ? 6 : 7;
94
+ function compare(a, b) {
95
+ const ta = TYPE_ORDER(a), tb = TYPE_ORDER(b);
96
+ if (ta !== tb) return ta - tb;
97
+ if (a instanceof Date) return a.getTime() - b.getTime();
98
+ if (typeof a === "number" || typeof a === "string" || typeof a === "boolean") return a < b ? -1 : a > b ? 1 : 0;
99
+ return 0;
100
+ }
101
+ const sameType = (a, b) => TYPE_ORDER(a) === TYPE_ORDER(b);
102
+
103
+ const MATCH = {
104
+ "==": (v, x) => isDeepStrictEqual(v, x),
105
+ "!=": (v, x) => v !== null && !isDeepStrictEqual(v, x),
106
+ "<": (v, x) => sameType(v, x) && compare(v, x) < 0,
107
+ "<=": (v, x) => sameType(v, x) && compare(v, x) <= 0,
108
+ ">": (v, x) => sameType(v, x) && compare(v, x) > 0,
109
+ ">=": (v, x) => sameType(v, x) && compare(v, x) >= 0,
110
+ "in": (v, xs) => xs.some((x) => isDeepStrictEqual(v, x)),
111
+ "not-in": (v, xs) => v !== null && !xs.some((x) => isDeepStrictEqual(v, x)),
112
+ "array-contains": (v, x) => Array.isArray(v) && v.some((e) => isDeepStrictEqual(e, x)),
113
+ "array-contains-any": (v, xs) => Array.isArray(v) && v.some((e) => xs.some((x) => isDeepStrictEqual(e, x))),
114
+ };
115
+
116
+ /**
117
+ * A fresh, empty in-memory Firestore shaped like firebase-admin's.
118
+ * @param {{ ignoreUndefinedProperties?: boolean }} [settings]
119
+ */
120
+ export function createFirestore(settings = {}) {
121
+ const ignoreUndefined = !!settings.ignoreUndefinedProperties;
122
+ /** Every document, keyed by its full path ("rooms/r1/members/m1"). Tests may read and seed it. */
123
+ const docs = new Map();
124
+
125
+ const prepare = (data) => { validate(data, ignoreUndefined); return clone(ignoreUndefined ? strip(data) : data); };
126
+
127
+ function snapshot(ref, data) {
128
+ return {
129
+ id: ref.id, ref, exists: data !== undefined,
130
+ data: () => clone(data),
131
+ get: (field) => (data === undefined ? undefined : clone(fieldValue(data, field).value)),
132
+ };
133
+ }
134
+
135
+ // Write operations as pure functions of a document map, so a transaction can run them on a copy.
136
+ const ops = {
137
+ set(map, path, data, opts) { map.set(path, opts?.merge ? deepMerge(map.get(path), data) : data); },
138
+ update(map, path, data) {
139
+ if (!map.has(path)) throw grpcError(5, "NOT_FOUND", `No document to update: ${path}`);
140
+ const next = structuredClone(map.get(path));
141
+ for (const [k, v] of Object.entries(data)) setPath(next, k, v);
142
+ map.set(path, next);
143
+ },
144
+ create(map, path, data) {
145
+ if (map.has(path)) throw grpcError(6, "ALREADY_EXISTS", `Document already exists: ${path}`);
146
+ map.set(path, data);
147
+ },
148
+ delete(map, path) { map.delete(path); },
149
+ };
150
+ function prepareUpdate(data) {
151
+ if (!isPlain(data) || Object.keys(data).length === 0) throw new Error("At least one field must be updated.");
152
+ validate(data, ignoreUndefined);
153
+ return clone(ignoreUndefined ? strip(data) : data);
154
+ }
155
+
156
+ function docRef(path) {
157
+ const parts = segments(path, "document");
158
+ const ref = {
159
+ id: parts.at(-1), path, firestore: db,
160
+ get parent() { return collectionRef(parts.slice(0, -1).join("/")); },
161
+ collection: (sub) => collectionRef(`${path}/${sub}`),
162
+ get: async () => snapshot(ref, docs.get(path)),
163
+ set: async (data, opts) => { ops.set(docs, path, prepare(data), opts); return {}; },
164
+ update: async (data) => { ops.update(docs, path, prepareUpdate(data)); return {}; },
165
+ create: async (data) => { ops.create(docs, path, prepare(data)); return {}; },
166
+ delete: async () => { ops.delete(docs, path); return {}; },
167
+ isEqual: (other) => other?.path === path,
168
+ };
169
+ return ref;
170
+ }
171
+
172
+ function query(collPath, filters = [], orders = [], lim = null) {
173
+ const depth = collPath.split("/").length + 1;
174
+ const run = () => {
175
+ let rows = [];
176
+ for (const [path, data] of docs) {
177
+ if (!path.startsWith(collPath + "/") || path.split("/").length !== depth) continue;
178
+ rows.push([path, data]);
179
+ }
180
+ rows = rows.filter(([, d]) => filters.every(([f, op, x]) => { const { has, value } = fieldValue(d, f); return has && MATCH[op](value, x); }));
181
+ rows = rows.filter(([, d]) => orders.every(([f]) => fieldValue(d, f).has));
182
+ rows.sort(([pa, a], [pb, b]) => {
183
+ for (const [f, dir] of orders) {
184
+ const c = compare(fieldValue(a, f).value, fieldValue(b, f).value);
185
+ if (c) return dir === "desc" ? -c : c;
186
+ }
187
+ return pa < pb ? -1 : pa > pb ? 1 : 0; // then by document id, as Firestore does
188
+ });
189
+ if (lim !== null) rows = rows.slice(0, lim);
190
+ const out = rows.map(([p, d]) => snapshot(docRef(p), d));
191
+ return { docs: out, size: out.length, empty: out.length === 0, forEach: (fn) => out.forEach(fn) };
192
+ };
193
+ return {
194
+ where(field, op, value) {
195
+ if (!MATCH[op]) throw new Error(`Invalid value for argument "opStr". Acceptable values are: ${Object.keys(MATCH).join(", ")}`);
196
+ if (value === undefined) throw new Error(`Value for argument "value" is not a valid query constraint. Cannot use "undefined" as a Firestore value.`);
197
+ if ((op === "in" || op === "not-in" || op === "array-contains-any") && !Array.isArray(value)) throw new Error(`Invalid Query. A non-empty array is required for '${op}' filters.`);
198
+ return query(collPath, [...filters, [field, op, value]], orders, lim);
199
+ },
200
+ orderBy(field, dir = "asc") {
201
+ if (dir !== "asc" && dir !== "desc") throw new Error(`Invalid value for argument "directionStr". Acceptable values are: asc, desc`);
202
+ return query(collPath, filters, [...orders, [field, dir]], lim);
203
+ },
204
+ limit(n) { return query(collPath, filters, orders, n); },
205
+ get: async () => run(),
206
+ count: () => ({ get: async () => { const n = run().size; return { data: () => ({ count: n }) }; } }),
207
+ _run: run,
208
+ };
209
+ }
210
+
211
+ function collectionRef(path) {
212
+ const parts = segments(path, "collection");
213
+ return {
214
+ ...query(path),
215
+ id: parts.at(-1), path, firestore: db,
216
+ doc: (id) => docRef(`${path}/${id === undefined ? autoId() : id}`),
217
+ add: async (data) => { const ref = docRef(`${path}/${autoId()}`); await ref.create(data); return ref; },
218
+ };
219
+ }
220
+
221
+ async function runTransaction(fn, opts = {}) {
222
+ const writes = [];
223
+ const readOnly = !!opts.readOnly;
224
+ const write = (op) => (ref, data, setOpts) => {
225
+ if (readOnly) throw new Error(READ_ONLY_WRITE);
226
+ const prepared = op === "delete" ? undefined : op === "update" ? prepareUpdate(data) : prepare(data);
227
+ writes.push([op, ref.path, prepared, setOpts]);
228
+ return tx;
229
+ };
230
+ const tx = {
231
+ // Synchronous throw, as the SDK does: Transaction.get is not async.
232
+ get(refOrQuery) {
233
+ if (writes.length) throw new Error(READ_AFTER_WRITE);
234
+ if (typeof refOrQuery?._run === "function") return Promise.resolve(refOrQuery._run());
235
+ return refOrQuery.get();
236
+ },
237
+ getAll(...refs) {
238
+ if (writes.length) throw new Error(READ_AFTER_WRITE);
239
+ return Promise.all(refs.map((r) => r.get()));
240
+ },
241
+ set: write("set"), update: write("update"), create: write("create"), delete: write("delete"),
242
+ };
243
+ const result = await fn(tx);
244
+ // Commit: every write lands, or (on a failed precondition) none does.
245
+ const next = new Map(docs);
246
+ for (const [op, path, data, setOpts] of writes) ops[op](next, path, data, setOpts);
247
+ docs.clear();
248
+ for (const [k, v] of next) docs.set(k, v);
249
+ return result;
250
+ }
251
+
252
+ const db = {
253
+ collection: (path) => collectionRef(path),
254
+ doc: (path) => docRef(path),
255
+ runTransaction,
256
+ /** Test helper: everything stored, keyed by full document path. */
257
+ docs,
258
+ /** Test helper: the stored data at a path, given whole ("a/b") or in segments ("a", "b"). */
259
+ peek: (...parts) => clone(docs.get(parts.join("/"))),
260
+ /** Test helper: put a document in place without going through validation. */
261
+ seed: (path, data) => { segments(path, "document"); docs.set(path, clone(data)); },
262
+ };
263
+ return db;
264
+ }