@supersuit/firestore-double 0.1.1 → 0.2.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 +15 -0
- package/README.md +20 -3
- package/package.json +2 -2
- package/src/index.d.mts +54 -3
- package/src/index.mjs +255 -37
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
What two living templates (cofounders-template, first-years-template) had in a hand-copied double, brought in at this package's strictness so they can drop the copy (2026-10-17):
|
|
6
|
+
|
|
7
|
+
- Cursors: `startAt`, `startAfter`, `endAt`, `endBefore`, by field values or by a snapshot. Values count against the explicit `orderBy`s only, as the SDK counts them ("Too many cursor values specified"); a document-id value with a slash is refused.
|
|
8
|
+
- `FieldPath.documentId()` (any FieldPath) in `orderBy` and `where`.
|
|
9
|
+
- Implicit order: an inequality filter with no `orderBy` answers by that field ascending, and ties break by document id in the last `orderBy`'s direction (0.1.x always broke ties ascending, which Firestore does not).
|
|
10
|
+
- `select(...fields)`: projections, dotted paths keep their branch, `select()` keeps no fields.
|
|
11
|
+
- `db.batch()`: writes land together at commit or not at all; a committed batch refuses more.
|
|
12
|
+
- `db.getAll(...refs)`.
|
|
13
|
+
- Transactions run again when a document or query they read changed before commit, up to `maxAttempts` (default 5), then fail `10 ABORTED`. A callback that does not return a Promise is refused, as the SDK refuses it.
|
|
14
|
+
- `db.hooks.afterRead(path)`: runs after each document read, so a test can race a write against a read-modify-write.
|
|
15
|
+
- `createBucket()`: a Cloud Storage bucket double (save, download with a range, getMetadata, exists, delete, copy, getFiles, deleteFiles, getSignedUrl) that answers a missing object with 404 and refuses a signed URL with no action, a bad or past expiry, or a v4 expiry over seven days. Records `signed` and `downloads`.
|
|
16
|
+
- `createFirebase()`: the database and the bucket together as `{ db, bucket, docs, files, signed, downloads, hooks }`.
|
|
17
|
+
|
|
3
18
|
## 0.1.1
|
|
4
19
|
|
|
5
20
|
The first version on npm. Identical to 0.1.0, which npm refuses to publish: the registry holds a staged 0.1.0 that `npm stage list` does not show, and answers 409 "Cannot publish over previously staged version" (2026-10-17).
|
package/README.md
CHANGED
|
@@ -36,15 +36,32 @@ db.peek("accounts/a"); // test helper: the stored data
|
|
|
36
36
|
| Read-only transactions | `runTransaction(fn, { readOnly: true })` refuses writes |
|
|
37
37
|
| Path parity | `doc()` needs an even path, `collection()` an odd one |
|
|
38
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
|
+
| Cursors | `startAt startAfter endAt endBefore` take field values (no more than the explicit `orderBy`s, or `Too many cursor values specified`) or a snapshot |
|
|
40
|
+
| Implicit order | An inequality filter orders by its field; ties break by document id in the last `orderBy`'s direction |
|
|
41
|
+
| Contention | A transaction whose reads changed before commit runs again, up to `maxAttempts` (default 5), then fails `10 ABORTED` |
|
|
42
|
+
| Batches | `batch()` lands whole or not at all; a committed batch refuses more |
|
|
39
43
|
| Copies | Everything read is a copy; mutating it never edits the store |
|
|
40
44
|
|
|
41
|
-
Supported surface: `collection(path)`, `doc(path)`, `runTransaction(fn,
|
|
45
|
+
Supported surface: `collection(path)`, `doc(path)`, `getAll(...refs)`, `batch()`, `runTransaction(fn, { readOnly, maxAttempts })`; on a reference `get set update create delete collection parent`; on a query `where orderBy limit startAt startAfter endAt endBefore select get count` (fields as strings or `FieldPath`, including `FieldPath.documentId()`); on a collection `doc() add()`; in a transaction `get(ref | query) getAll set update create delete`.
|
|
42
46
|
|
|
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)
|
|
47
|
+
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)`, and `db.hooks.afterRead = (path) => ...`, which runs after every document read so a test can slip a write in between a transaction's read and its commit.
|
|
48
|
+
|
|
49
|
+
## The bucket
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
import { createBucket, createFirebase } from "@supersuit/firestore-double";
|
|
53
|
+
|
|
54
|
+
const { bucket, files, signed, downloads } = createBucket();
|
|
55
|
+
// or both at once, in the shape a mocked firebase-admin module hands out:
|
|
56
|
+
const fb = createFirebase(); // { db, bucket, docs, files, signed, downloads, hooks }
|
|
57
|
+
vi.mock("@/lib/firebase-admin", () => ({ getAdminDb: () => fb.db, getAdminBucket: () => fb.bucket }));
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`bucket.file(name)` has `exists save download getMetadata delete copy getSignedUrl`; the bucket has `getFiles deleteFiles` by prefix. A missing object answers `code: 404`, `delete()` of one throws unless `{ ignoreNotFound: true }`, and `getSignedUrl` refuses no action, a bad or past expiry, and a v4 expiry over seven days, as `@google-cloud/storage` does. Signed URLs are `https://signed.example/<name>`; `signed` records every granted request and `downloads` every download.
|
|
44
61
|
|
|
45
62
|
## Not modelled
|
|
46
63
|
|
|
47
|
-
Composite-index requirements, `Timestamp` conversion (a `Date` comes back a `Date`), `FieldValue` sentinels,
|
|
64
|
+
Composite-index requirements, `Timestamp` conversion (a `Date` comes back a `Date`), `FieldValue` sentinels, listeners, and pessimistic locking (a conflicting write is modelled as a retry, never a wait). Each would be a welcome addition, with a test that fails without it.
|
|
48
65
|
|
|
49
66
|
## TypeScript
|
|
50
67
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/firestore-double",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "An in-memory Firestore for tests that
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "An in-memory Firestore and Cloud Storage bucket for tests that refuse what production refuses: reads after writes in a transaction, updates of missing documents, undefined values, missing objects.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
package/src/index.d.mts
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
// production code typed against `Firestore` accepts it with a cast (`db as unknown as Firestore`).
|
|
3
3
|
export declare const READ_AFTER_WRITE: string
|
|
4
4
|
export declare const READ_ONLY_WRITE: string
|
|
5
|
+
export declare const CONTENTION: string
|
|
5
6
|
|
|
6
7
|
type Data = Record<string, any>
|
|
8
|
+
/** A field path: "a.b", or a FieldPath (FieldPath.documentId() orders and filters by id). */
|
|
9
|
+
type Field = string | { segments: string[] }
|
|
7
10
|
|
|
8
11
|
export interface DocumentSnapshot {
|
|
9
12
|
id: string
|
|
@@ -19,9 +22,14 @@ export interface QuerySnapshot {
|
|
|
19
22
|
forEach(fn: (d: DocumentSnapshot) => void): void
|
|
20
23
|
}
|
|
21
24
|
export interface Query {
|
|
22
|
-
where(field:
|
|
23
|
-
orderBy(field:
|
|
25
|
+
where(field: Field, op: string, value: unknown): Query
|
|
26
|
+
orderBy(field: Field, dir?: 'asc' | 'desc'): Query
|
|
24
27
|
limit(n: number): Query
|
|
28
|
+
startAt(...values: unknown[]): Query
|
|
29
|
+
startAfter(...values: unknown[]): Query
|
|
30
|
+
endAt(...values: unknown[]): Query
|
|
31
|
+
endBefore(...values: unknown[]): Query
|
|
32
|
+
select(...fields: Field[]): Query
|
|
25
33
|
get(): Promise<QuerySnapshot>
|
|
26
34
|
count(): { get(): Promise<{ data(): { count: number } }> }
|
|
27
35
|
}
|
|
@@ -52,10 +60,21 @@ export interface Transaction {
|
|
|
52
60
|
create(ref: DocumentReference, data: Data): Transaction
|
|
53
61
|
delete(ref: DocumentReference): Transaction
|
|
54
62
|
}
|
|
63
|
+
export interface WriteBatch {
|
|
64
|
+
set(ref: DocumentReference, data: Data, opts?: { merge?: boolean }): WriteBatch
|
|
65
|
+
update(ref: DocumentReference, data: Data): WriteBatch
|
|
66
|
+
create(ref: DocumentReference, data: Data): WriteBatch
|
|
67
|
+
delete(ref: DocumentReference): WriteBatch
|
|
68
|
+
commit(): Promise<unknown[]>
|
|
69
|
+
}
|
|
55
70
|
export interface FirestoreDouble {
|
|
56
71
|
collection(path: string): CollectionReference
|
|
57
72
|
doc(path: string): DocumentReference
|
|
58
|
-
|
|
73
|
+
getAll(...refs: DocumentReference[]): Promise<DocumentSnapshot[]>
|
|
74
|
+
batch(): WriteBatch
|
|
75
|
+
runTransaction<T>(fn: (tx: Transaction) => Promise<T>, opts?: { readOnly?: boolean; maxAttempts?: number }): Promise<T>
|
|
76
|
+
/** Test hooks: `afterRead(path)` runs after every document read, so a test can race a write against a read-modify-write. */
|
|
77
|
+
hooks: { afterRead?: (path: string) => unknown }
|
|
59
78
|
/** Test helper: every stored document, keyed by full path. */
|
|
60
79
|
docs: Map<string, Data>
|
|
61
80
|
/** Test helper: the stored data at a path ("a/b", or "a", "b"). */
|
|
@@ -64,3 +83,35 @@ export interface FirestoreDouble {
|
|
|
64
83
|
seed(path: string, data: Data): void
|
|
65
84
|
}
|
|
66
85
|
export declare function createFirestore(settings?: { ignoreUndefinedProperties?: boolean }): FirestoreDouble
|
|
86
|
+
|
|
87
|
+
export interface StoredFile { bytes: Buffer; contentType?: string; metadata?: Record<string, string> }
|
|
88
|
+
export interface FileDouble {
|
|
89
|
+
name: string
|
|
90
|
+
exists(): Promise<[boolean]>
|
|
91
|
+
save(bytes: Uint8Array | string, opts?: { contentType?: string; metadata?: { contentType?: string; metadata?: Record<string, string> } }): Promise<void>
|
|
92
|
+
download(range?: { start?: number; end?: number }): Promise<[Buffer]>
|
|
93
|
+
getMetadata(): Promise<[{ name: string; size: string; contentType?: string; metadata?: Record<string, string> }]>
|
|
94
|
+
delete(opts?: { ignoreNotFound?: boolean }): Promise<unknown>
|
|
95
|
+
copy(dest: string | { name: string }): Promise<void>
|
|
96
|
+
getSignedUrl(opts: { version?: string; action: 'read' | 'write' | 'delete' | 'resumable'; expires: number | string | Date; [k: string]: unknown }): Promise<[string]>
|
|
97
|
+
}
|
|
98
|
+
export interface BucketDouble {
|
|
99
|
+
file(name: string): FileDouble
|
|
100
|
+
getFiles(query?: { prefix?: string }): Promise<[FileDouble[]]>
|
|
101
|
+
deleteFiles(query?: { prefix?: string }): Promise<void>
|
|
102
|
+
}
|
|
103
|
+
export interface BucketHandles {
|
|
104
|
+
bucket: BucketDouble
|
|
105
|
+
/** Test helper: every stored object by name. */
|
|
106
|
+
files: Map<string, StoredFile>
|
|
107
|
+
/** Test helper: every granted signing request. */
|
|
108
|
+
signed: { path: string; opts: Record<string, unknown> }[]
|
|
109
|
+
/** Test helper: every download by name. */
|
|
110
|
+
downloads: string[]
|
|
111
|
+
}
|
|
112
|
+
export declare function createBucket(): BucketHandles
|
|
113
|
+
export declare function createFirebase(settings?: { ignoreUndefinedProperties?: boolean }): BucketHandles & {
|
|
114
|
+
db: FirestoreDouble
|
|
115
|
+
docs: Map<string, Data>
|
|
116
|
+
hooks: FirestoreDouble['hooks']
|
|
117
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -13,15 +13,25 @@
|
|
|
13
13
|
// into ignoreUndefinedProperties, as Firestore does), writes inside a readOnly transaction,
|
|
14
14
|
// document and collection paths of the right parity, and query filters and orderBy dropping
|
|
15
15
|
// documents that lack the field. Results are copies, so mutating one never edits the store.
|
|
16
|
+
// Since 0.2.0: a transaction whose reads changed before it committed runs again, up to maxAttempts
|
|
17
|
+
// (default 5, as the SDK), then fails ABORTED (code 10); an inequality filter orders by its field and
|
|
18
|
+
// ties break by id in the last orderBy's direction; cursors count against orderBy as the SDK counts
|
|
19
|
+
// them; a batch lands whole or not at all and cannot be committed twice; and the bucket double
|
|
20
|
+
// answers a missing object with 404 and refuses a signed URL the SDK would refuse.
|
|
16
21
|
//
|
|
17
22
|
// NOT modelled: composite-index requirements, Timestamp conversion (a Date comes back a Date,
|
|
18
|
-
// where Firestore returns a Timestamp), FieldValue sentinels,
|
|
23
|
+
// where Firestore returns a Timestamp), FieldValue sentinels, listeners, pessimistic locking (a
|
|
24
|
+
// conflicting write is modelled as a retry of the transaction, never as a wait).
|
|
19
25
|
import { isDeepStrictEqual } from "node:util";
|
|
20
26
|
|
|
21
27
|
/** firebase-admin's text, copied from @google-cloud/firestore build/src/transaction.js. */
|
|
22
28
|
export const READ_AFTER_WRITE = "Firestore transactions require all reads to be executed before all writes.";
|
|
23
29
|
export const READ_ONLY_WRITE = "Firestore read-only transactions cannot execute writes.";
|
|
24
30
|
|
|
31
|
+
export const CONTENTION = "Too much contention on these documents. Please try again.";
|
|
32
|
+
const NAME = "__name__"; // FieldPath.documentId()
|
|
33
|
+
const INEQUALITY = new Set(["<", "<=", ">", ">=", "!=", "not-in"]);
|
|
34
|
+
|
|
25
35
|
const AUTO_ID = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
26
36
|
const autoId = () => Array.from({ length: 20 }, () => AUTO_ID[Math.floor(Math.random() * AUTO_ID.length)]).join("");
|
|
27
37
|
|
|
@@ -159,7 +169,7 @@ export function createFirestore(settings = {}) {
|
|
|
159
169
|
id: parts.at(-1), path, firestore: db,
|
|
160
170
|
get parent() { return collectionRef(parts.slice(0, -1).join("/")); },
|
|
161
171
|
collection: (sub) => collectionRef(`${path}/${sub}`),
|
|
162
|
-
get:
|
|
172
|
+
get: () => readThrough(ref, docs.get(path)),
|
|
163
173
|
set: async (data, opts) => { ops.set(docs, path, prepare(data), opts); return {}; },
|
|
164
174
|
update: async (data) => { ops.update(docs, path, prepareUpdate(data)); return {}; },
|
|
165
175
|
create: async (data) => { ops.create(docs, path, prepare(data)); return {}; },
|
|
@@ -169,7 +179,54 @@ export function createFirestore(settings = {}) {
|
|
|
169
179
|
return ref;
|
|
170
180
|
}
|
|
171
181
|
|
|
172
|
-
|
|
182
|
+
// A field path is a string ("a.b") or a FieldPath; FieldPath.documentId() is "__name__".
|
|
183
|
+
function fieldName(f) {
|
|
184
|
+
if (typeof f === "string" && f !== "") return f;
|
|
185
|
+
if (f && Array.isArray(f.segments) && f.segments.length) return f.segments.join(".");
|
|
186
|
+
throw new Error(`Value for argument "fieldPath" is not a valid field path. Paths must be non-empty strings or FieldPath objects.`);
|
|
187
|
+
}
|
|
188
|
+
const idOf = (path) => path.slice(path.lastIndexOf("/") + 1);
|
|
189
|
+
const valueAt = (path, data, f) => (f === NAME ? { has: true, value: idOf(path) } : fieldValue(data, f));
|
|
190
|
+
function nameValue(v) {
|
|
191
|
+
if (v && typeof v === "object" && typeof v.path === "string") return idOf(v.path);
|
|
192
|
+
if (typeof v !== "string") throw new Error(`The corresponding value for FieldPath.documentId() must be a string or a DocumentReference, but was "${v}".`);
|
|
193
|
+
if (v.includes("/")) throw new Error(`When querying a collection and ordering by FieldPath.documentId(), the corresponding value must be a plain document ID, but '${v}' contains a slash.`);
|
|
194
|
+
return v;
|
|
195
|
+
}
|
|
196
|
+
// Firestore orders by the explicit orderBys, then every inequality field not already ordered
|
|
197
|
+
// (lexicographically), then the document id, the implicit ones in the last explicit direction.
|
|
198
|
+
function effectiveOrders(filters, orders) {
|
|
199
|
+
const out = orders.slice();
|
|
200
|
+
const have = new Set(out.map(([f]) => f));
|
|
201
|
+
const dir = out.length ? out.at(-1)[1] : "asc";
|
|
202
|
+
for (const f of [...new Set(filters.filter(([f, op]) => INEQUALITY.has(op) && f !== NAME).map(([f]) => f))].sort()) {
|
|
203
|
+
if (!have.has(f)) { out.push([f, dir]); have.add(f); }
|
|
204
|
+
}
|
|
205
|
+
if (!have.has(NAME)) out.push([NAME, dir]);
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
// Position of a row against a cursor, over the cursor's (prefix of the) orders.
|
|
209
|
+
function position(path, data, cursor) {
|
|
210
|
+
for (let i = 0; i < cursor.values.length; i++) {
|
|
211
|
+
const [f, dir] = cursor.orders[i];
|
|
212
|
+
const c = compare(valueAt(path, data, f).value, cursor.values[i]);
|
|
213
|
+
if (c) return dir === "desc" ? -c : c;
|
|
214
|
+
}
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
function project(data, fields) {
|
|
218
|
+
const out = {};
|
|
219
|
+
for (const f of fields) {
|
|
220
|
+
if (f === NAME) continue;
|
|
221
|
+
const { has, value } = fieldValue(data, f);
|
|
222
|
+
if (has) setPath(out, f, clone(value));
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function query(collPath, state = { filters: [], orders: [], lim: null, start: null, end: null, fields: null }) {
|
|
228
|
+
const { filters, orders, lim, start, end, fields } = state;
|
|
229
|
+
const next = (patch) => query(collPath, { ...state, ...patch });
|
|
173
230
|
const depth = collPath.split("/").length + 1;
|
|
174
231
|
const run = () => {
|
|
175
232
|
let rows = [];
|
|
@@ -177,31 +234,70 @@ export function createFirestore(settings = {}) {
|
|
|
177
234
|
if (!path.startsWith(collPath + "/") || path.split("/").length !== depth) continue;
|
|
178
235
|
rows.push([path, data]);
|
|
179
236
|
}
|
|
180
|
-
|
|
181
|
-
rows = rows.filter(([, d]) =>
|
|
237
|
+
const all = effectiveOrders(filters, orders);
|
|
238
|
+
rows = rows.filter(([p, d]) => filters.every(([f, op, x]) => { const { has, value } = valueAt(p, d, f); return has && MATCH[op](value, x); }));
|
|
239
|
+
rows = rows.filter(([p, d]) => all.every(([f]) => valueAt(p, d, f).has));
|
|
182
240
|
rows.sort(([pa, a], [pb, b]) => {
|
|
183
|
-
for (const [f, dir] of
|
|
184
|
-
const c = compare(
|
|
241
|
+
for (const [f, dir] of all) {
|
|
242
|
+
const c = compare(valueAt(pa, a, f).value, valueAt(pb, b, f).value);
|
|
185
243
|
if (c) return dir === "desc" ? -c : c;
|
|
186
244
|
}
|
|
187
|
-
return
|
|
245
|
+
return 0;
|
|
188
246
|
});
|
|
247
|
+
if (start) rows = rows.filter(([p, d]) => { const c = position(p, d, start); return start.before ? c >= 0 : c > 0; });
|
|
248
|
+
if (end) rows = rows.filter(([p, d]) => { const c = position(p, d, end); return end.before ? c < 0 : c <= 0; });
|
|
189
249
|
if (lim !== null) rows = rows.slice(0, lim);
|
|
190
|
-
const out = rows.map(([p, d]) => snapshot(docRef(p), d));
|
|
250
|
+
const out = rows.map(([p, d]) => snapshot(docRef(p), fields ? project(d, fields) : d));
|
|
191
251
|
return { docs: out, size: out.length, empty: out.length === 0, forEach: (fn) => out.forEach(fn) };
|
|
192
252
|
};
|
|
253
|
+
// As the SDK: field values count against the explicit orderBys only; a lone snapshot is read
|
|
254
|
+
// through the implicit order too (inequality fields, then the document id).
|
|
255
|
+
const cursor = (args, before) => {
|
|
256
|
+
const snap = args.length === 1 && args[0] && typeof args[0] === "object" && "exists" in args[0] && typeof args[0].data === "function" && args[0].ref ? args[0] : null;
|
|
257
|
+
let used, values;
|
|
258
|
+
if (snap) {
|
|
259
|
+
used = effectiveOrders(filters, orders);
|
|
260
|
+
const data = snap.data();
|
|
261
|
+
values = used.map(([f]) => {
|
|
262
|
+
if (f === NAME) return snap.id;
|
|
263
|
+
const got = fieldValue(data, f);
|
|
264
|
+
if (!got.has) throw new Error(`Field "${f}" is missing in the provided DocumentSnapshot. Please provide a document that contains values for all specified orderBy() and where() constraints.`);
|
|
265
|
+
return got.value;
|
|
266
|
+
});
|
|
267
|
+
} else {
|
|
268
|
+
if (args.length > orders.length) throw new Error("Too many cursor values specified. The specified values must match the orderBy() constraints of the query.");
|
|
269
|
+
used = orders;
|
|
270
|
+
values = args.map((v, i) => {
|
|
271
|
+
if (v === undefined) throw new Error(`Cannot use "undefined" as a Firestore value.`);
|
|
272
|
+
return orders[i][0] === NAME ? nameValue(v) : v;
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return { orders: used, values, before };
|
|
276
|
+
};
|
|
193
277
|
return {
|
|
194
278
|
where(field, op, value) {
|
|
279
|
+
const f = fieldName(field);
|
|
195
280
|
if (!MATCH[op]) throw new Error(`Invalid value for argument "opStr". Acceptable values are: ${Object.keys(MATCH).join(", ")}`);
|
|
196
281
|
if (value === undefined) throw new Error(`Value for argument "value" is not a valid query constraint. Cannot use "undefined" as a Firestore value.`);
|
|
197
282
|
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
|
-
|
|
283
|
+
if (f === NAME) value = Array.isArray(value) ? value.map(nameValue) : nameValue(value);
|
|
284
|
+
return next({ filters: [...filters, [f, op, value]] });
|
|
199
285
|
},
|
|
200
286
|
orderBy(field, dir = "asc") {
|
|
287
|
+
const f = fieldName(field);
|
|
201
288
|
if (dir !== "asc" && dir !== "desc") throw new Error(`Invalid value for argument "directionStr". Acceptable values are: asc, desc`);
|
|
202
|
-
|
|
289
|
+
if (start || end) throw new Error("Cannot specify an orderBy() constraint after calling startAt(), startAfter(), endBefore() or endAt().");
|
|
290
|
+
return next({ orders: [...orders, [f, dir]] });
|
|
291
|
+
},
|
|
292
|
+
limit(n) {
|
|
293
|
+
if (!Number.isInteger(n) || n < 0) throw new Error(`Value for argument "limit" is not a valid integer.`);
|
|
294
|
+
return next({ lim: n });
|
|
203
295
|
},
|
|
204
|
-
|
|
296
|
+
startAt: (...v) => next({ start: cursor(v, true) }),
|
|
297
|
+
startAfter: (...v) => next({ start: cursor(v, false) }),
|
|
298
|
+
endBefore: (...v) => next({ end: cursor(v, true) }),
|
|
299
|
+
endAt: (...v) => next({ end: cursor(v, false) }),
|
|
300
|
+
select: (...fs) => next({ fields: fs.map(fieldName) }),
|
|
205
301
|
get: async () => run(),
|
|
206
302
|
count: () => ({ get: async () => { const n = run().size; return { data: () => ({ count: n }) }; } }),
|
|
207
303
|
_run: run,
|
|
@@ -218,41 +314,90 @@ export function createFirestore(settings = {}) {
|
|
|
218
314
|
};
|
|
219
315
|
}
|
|
220
316
|
|
|
221
|
-
|
|
222
|
-
|
|
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.
|
|
317
|
+
// Every write lands, or (on a failed precondition) none does.
|
|
318
|
+
function commit(writes) {
|
|
245
319
|
const next = new Map(docs);
|
|
246
320
|
for (const [op, path, data, setOpts] of writes) ops[op](next, path, data, setOpts);
|
|
247
321
|
docs.clear();
|
|
248
322
|
for (const [k, v] of next) docs.set(k, v);
|
|
249
|
-
|
|
323
|
+
}
|
|
324
|
+
const prepareFor = (op, data) => (op === "delete" ? undefined : op === "update" ? prepareUpdate(data) : prepare(data));
|
|
325
|
+
|
|
326
|
+
function batch() {
|
|
327
|
+
const writes = [];
|
|
328
|
+
let committed = false;
|
|
329
|
+
const guard = () => { if (committed) throw new Error("Cannot modify a WriteBatch that has already been committed."); };
|
|
330
|
+
const write = (op) => (ref, data, setOpts) => { guard(); writes.push([op, ref.path, prepareFor(op, data), setOpts]); return b; };
|
|
331
|
+
const b = {
|
|
332
|
+
set: write("set"), update: write("update"), create: write("create"), delete: write("delete"),
|
|
333
|
+
commit: async () => { guard(); committed = true; commit(writes); return []; },
|
|
334
|
+
};
|
|
335
|
+
return b;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function runTransaction(fn, opts = {}) {
|
|
339
|
+
const readOnly = !!opts.readOnly;
|
|
340
|
+
const attempts = readOnly ? 1 : (opts.maxAttempts ?? 5);
|
|
341
|
+
for (let attempt = 1; ; attempt++) {
|
|
342
|
+
const writes = [];
|
|
343
|
+
/** What each read saw, re-checked at commit: a function that answers whether it still holds. */
|
|
344
|
+
const reads = [];
|
|
345
|
+
const write = (op) => (ref, data, setOpts) => {
|
|
346
|
+
if (readOnly) throw new Error(READ_ONLY_WRITE);
|
|
347
|
+
writes.push([op, ref.path, prepareFor(op, data), setOpts]);
|
|
348
|
+
return tx;
|
|
349
|
+
};
|
|
350
|
+
const readDoc = (ref) => {
|
|
351
|
+
const seen = clone(docs.get(ref.path));
|
|
352
|
+
reads.push(() => isDeepStrictEqual(docs.get(ref.path), seen));
|
|
353
|
+
return readThrough(ref, seen);
|
|
354
|
+
};
|
|
355
|
+
const tx = {
|
|
356
|
+
// Synchronous throw, as the SDK does: Transaction.get is not async.
|
|
357
|
+
get(refOrQuery) {
|
|
358
|
+
if (writes.length) throw new Error(READ_AFTER_WRITE);
|
|
359
|
+
if (typeof refOrQuery?._run === "function") {
|
|
360
|
+
const got = refOrQuery._run();
|
|
361
|
+
const seen = got.docs.map((d) => [d.ref.path, d.data()]);
|
|
362
|
+
reads.push(() => isDeepStrictEqual(refOrQuery._run().docs.map((d) => [d.ref.path, d.data()]), seen));
|
|
363
|
+
return Promise.resolve(got);
|
|
364
|
+
}
|
|
365
|
+
return readDoc(refOrQuery);
|
|
366
|
+
},
|
|
367
|
+
getAll(...refs) {
|
|
368
|
+
if (writes.length) throw new Error(READ_AFTER_WRITE);
|
|
369
|
+
return Promise.all(refs.map(readDoc));
|
|
370
|
+
},
|
|
371
|
+
set: write("set"), update: write("update"), create: write("create"), delete: write("delete"),
|
|
372
|
+
};
|
|
373
|
+
const promise = fn(tx);
|
|
374
|
+
if (!(promise instanceof Promise)) throw new Error("You must return a Promise in your transaction()-callback.");
|
|
375
|
+
const result = await promise;
|
|
376
|
+
if (!readOnly && !reads.every((holds) => holds())) {
|
|
377
|
+
if (attempt < attempts) continue;
|
|
378
|
+
throw grpcError(10, "ABORTED", CONTENTION);
|
|
379
|
+
}
|
|
380
|
+
commit(writes);
|
|
381
|
+
return result;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Test hooks. `afterRead(path)` runs after each document read (a get, a getAll, or one inside a transaction). */
|
|
386
|
+
const hooks = {};
|
|
387
|
+
// The snapshot is taken first, so a write made by the hook lands after the read, as a racing writer's would.
|
|
388
|
+
async function readThrough(ref, data) {
|
|
389
|
+
const s = snapshot(ref, clone(data));
|
|
390
|
+
await hooks.afterRead?.(ref.path);
|
|
391
|
+
return s;
|
|
250
392
|
}
|
|
251
393
|
|
|
252
394
|
const db = {
|
|
253
395
|
collection: (path) => collectionRef(path),
|
|
254
396
|
doc: (path) => docRef(path),
|
|
397
|
+
getAll: (...refs) => Promise.all(refs.map((r) => r.get())),
|
|
398
|
+
batch,
|
|
255
399
|
runTransaction,
|
|
400
|
+
hooks,
|
|
256
401
|
/** Test helper: everything stored, keyed by full document path. */
|
|
257
402
|
docs,
|
|
258
403
|
/** Test helper: the stored data at a path, given whole ("a/b") or in segments ("a", "b"). */
|
|
@@ -262,3 +407,76 @@ export function createFirestore(settings = {}) {
|
|
|
262
407
|
};
|
|
263
408
|
return db;
|
|
264
409
|
}
|
|
410
|
+
|
|
411
|
+
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
412
|
+
const ACTIONS = new Set(["read", "write", "delete", "resumable"]);
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* An in-memory stand-in for the slice of a Cloud Storage bucket (firebase-admin's getStorage().bucket())
|
|
416
|
+
* that apps touch. A missing object answers 404 as the service does, a delete of one throws unless
|
|
417
|
+
* `ignoreNotFound`, and getSignedUrl refuses what @google-cloud/storage refuses (no action, a bad or past
|
|
418
|
+
* expiry, a v4 expiry beyond seven days). Signed URLs are `https://signed.example/<path>`.
|
|
419
|
+
*/
|
|
420
|
+
export function createBucket() {
|
|
421
|
+
/** Test helper: every stored object by name. */
|
|
422
|
+
const files = new Map();
|
|
423
|
+
/** Test helper: every signing request that was granted, so a test can assert what a link carries. */
|
|
424
|
+
const signed = [];
|
|
425
|
+
/** Test helper: every download by name, so a test can assert bytes were (or were not) pulled. */
|
|
426
|
+
const downloads = [];
|
|
427
|
+
const missing = (name) => Object.assign(new Error(`No such object: ${name}`), { code: 404 });
|
|
428
|
+
|
|
429
|
+
const file = (name) => ({
|
|
430
|
+
name,
|
|
431
|
+
exists: async () => [files.has(name)],
|
|
432
|
+
save: async (bytes, o) => {
|
|
433
|
+
files.set(name, { bytes: Buffer.from(bytes), contentType: o?.metadata?.contentType ?? o?.contentType, metadata: o?.metadata?.metadata ? { ...o.metadata.metadata } : undefined });
|
|
434
|
+
},
|
|
435
|
+
download: async (r) => {
|
|
436
|
+
downloads.push(name);
|
|
437
|
+
const f = files.get(name);
|
|
438
|
+
if (!f) throw missing(name);
|
|
439
|
+
if (r && (r.start !== undefined || r.end !== undefined)) return [Buffer.from(f.bytes.subarray(r.start ?? 0, (r.end ?? f.bytes.length - 1) + 1))];
|
|
440
|
+
return [Buffer.from(f.bytes)];
|
|
441
|
+
},
|
|
442
|
+
getMetadata: async () => {
|
|
443
|
+
const f = files.get(name);
|
|
444
|
+
if (!f) throw missing(name);
|
|
445
|
+
return [{ name, size: String(f.bytes.length), contentType: f.contentType, metadata: f.metadata ? { ...f.metadata } : undefined }];
|
|
446
|
+
},
|
|
447
|
+
delete: async (o) => { if (!files.delete(name) && !o?.ignoreNotFound) throw missing(name); return [{}]; },
|
|
448
|
+
copy: async (dest) => {
|
|
449
|
+
const f = files.get(name);
|
|
450
|
+
if (!f) throw missing(name);
|
|
451
|
+
const to = typeof dest === "string" ? dest : dest.name;
|
|
452
|
+
files.set(to, { bytes: Buffer.from(f.bytes), contentType: f.contentType, metadata: f.metadata ? { ...f.metadata } : undefined });
|
|
453
|
+
},
|
|
454
|
+
getSignedUrl: async (opts = {}) => {
|
|
455
|
+
if (!ACTIONS.has(opts.action)) throw new Error("The action is not provided or invalid.");
|
|
456
|
+
const at = new Date(opts.expires).valueOf();
|
|
457
|
+
if (opts.expires === undefined || Number.isNaN(at)) throw new Error("The expiration date provided was invalid.");
|
|
458
|
+
if (at < Date.now()) throw new Error("The expiration date provided was in the past.");
|
|
459
|
+
if (opts.version === "v4" && at - Date.now() > SEVEN_DAYS_MS) throw new Error("Max allowed expiration is seven days (604800 seconds).");
|
|
460
|
+
signed.push({ path: name, opts });
|
|
461
|
+
return [`https://signed.example/${name}`];
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
const bucket = {
|
|
466
|
+
file,
|
|
467
|
+
getFiles: async (q = {}) => [[...files.keys()].filter((n) => n.startsWith(q.prefix ?? "")).map(file)],
|
|
468
|
+
deleteFiles: async (q = {}) => { for (const n of [...files.keys()]) if (n.startsWith(q.prefix ?? "")) files.delete(n); },
|
|
469
|
+
};
|
|
470
|
+
return { bucket, files, signed, downloads };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* A database and a bucket together, in the shape app tests pass to a mocked firebase-admin module:
|
|
475
|
+
* `{ db, bucket, docs, files, signed, downloads, hooks }`.
|
|
476
|
+
* @param {{ ignoreUndefinedProperties?: boolean }} [settings]
|
|
477
|
+
*/
|
|
478
|
+
export function createFirebase(settings = {}) {
|
|
479
|
+
const db = createFirestore(settings);
|
|
480
|
+
const { bucket, files, signed, downloads } = createBucket();
|
|
481
|
+
return { db, bucket, docs: db.docs, files, signed, downloads, hooks: db.hooks };
|
|
482
|
+
}
|