@supersuit/firestore-double 0.1.0 → 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 CHANGED
@@ -1,5 +1,24 @@
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
+
18
+ ## 0.1.1
19
+
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).
21
+
3
22
  ## 0.1.0
4
23
 
5
24
  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/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, 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`.
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, contention and retries, listeners, batches. Each would be a welcome addition, with a test that fails without it.
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.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.",
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: string, op: string, value: unknown): Query
23
- orderBy(field: string, dir?: 'asc' | 'desc'): Query
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
- runTransaction<T>(fn: (tx: Transaction) => Promise<T>, opts?: { readOnly?: boolean }): Promise<T>
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, contention and retries, listeners.
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: async () => snapshot(ref, docs.get(path)),
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
- function query(collPath, filters = [], orders = [], lim = null) {
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
- 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));
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 orders) {
184
- const c = compare(fieldValue(a, f).value, fieldValue(b, f).value);
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 pa < pb ? -1 : pa > pb ? 1 : 0; // then by document id, as Firestore does
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
- return query(collPath, [...filters, [field, op, value]], orders, lim);
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
- return query(collPath, filters, [...orders, [field, dir]], lim);
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
- limit(n) { return query(collPath, filters, orders, n); },
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
- 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.
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
- return result;
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
+ }