@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 +5 -0
- package/LICENSE +21 -0
- package/README.md +50 -2
- package/package.json +30 -4
- package/src/index.d.mts +66 -0
- package/src/index.mjs +264 -0
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
|
-
#
|
|
1
|
+
# @supersuit/firestore-double
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|
package/src/index.d.mts
ADDED
|
@@ -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
|
+
}
|