@hvakr/firestate 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/dist/index.mjs ADDED
@@ -0,0 +1,1779 @@
1
+ import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
2
+ import { Timestamp, collection, deleteDoc, deleteField, doc as doc$1, onSnapshot, query, serverTimestamp, setDoc, updateDoc, writeBatch } from "firebase/firestore";
3
+ import { jsx } from "react/jsx-runtime";
4
+
5
+ //#region src/schema.ts
6
+ function defineDocument(definition) {
7
+ return definition;
8
+ }
9
+ function defineCollection(definition) {
10
+ return definition;
11
+ }
12
+
13
+ //#endregion
14
+ //#region src/diff.ts
15
+ /**
16
+ * Check if a value is a plain object (not array, null, or special Firestore type)
17
+ */
18
+ const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Timestamp) && Object.getPrototypeOf(value) === Object.prototype;
19
+ /**
20
+ * Check if a value is a Firestore opaque type: a FieldValue sentinel
21
+ * (`serverTimestamp`, `deleteField`, `increment`, `arrayUnion`,
22
+ * `arrayRemove`, …) or a value type the SDK ships with its own identity
23
+ * semantics (`Timestamp`, `DocumentReference`, `GeoPoint`, `Bytes`,
24
+ * `VectorValue`). They all expose `.isEqual()` and have a non-plain
25
+ * prototype.
26
+ *
27
+ * The diff / clone pipeline must treat these as **opaque**: never iterate
28
+ * their keys, never substitute their values, never compare them by `===`.
29
+ * Doing any of those silently breaks the write path — see the C1
30
+ * regression where `serverTimestamp()` was replaced with `Timestamp.now()`
31
+ * before reaching Firestore.
32
+ */
33
+ const isFirestoreOpaque = (value) => {
34
+ if (value === null || typeof value !== "object") return false;
35
+ if (Object.getPrototypeOf(value) === Object.prototype) return false;
36
+ return "isEqual" in value && typeof value.isEqual === "function";
37
+ };
38
+ const SERVER_TIMESTAMP_REF = serverTimestamp();
39
+ const DELETE_FIELD_REF = deleteField();
40
+ const isDeleteField = (value) => isFirestoreOpaque(value) && value.isEqual(DELETE_FIELD_REF);
41
+ const isServerTimestamp = (value) => isFirestoreOpaque(value) && value.isEqual(SERVER_TIMESTAMP_REF);
42
+ /**
43
+ * Check if two values are deeply equal
44
+ */
45
+ const isDeepEqual = (a, b) => {
46
+ if (a === b) return true;
47
+ if (a === null || b === null) return false;
48
+ if (typeof a !== typeof b) return false;
49
+ if (Array.isArray(a) && Array.isArray(b)) {
50
+ if (a.length !== b.length) return false;
51
+ return a.every((item, i) => isDeepEqual(item, b[i]));
52
+ }
53
+ if (isFirestoreOpaque(a) && isFirestoreOpaque(b)) return a.isEqual(b);
54
+ if (isPlainObject(a) && isPlainObject(b)) {
55
+ const keysA = Object.keys(a);
56
+ const keysB = Object.keys(b);
57
+ if (keysA.length !== keysB.length) return false;
58
+ return keysA.every((key) => isDeepEqual(a[key], b[key]));
59
+ }
60
+ return false;
61
+ };
62
+ /**
63
+ * Compute the minimal diff between two objects for Firestore updates.
64
+ * Returns only the fields that changed, using deleteField() for removed fields.
65
+ *
66
+ * @param from - The original object (sync state)
67
+ * @param to - The target object (local state)
68
+ * @returns A partial object containing only changed fields
69
+ */
70
+ const computeDiff = (from, to) => {
71
+ if (to === void 0) return deleteField();
72
+ const diff = {};
73
+ for (const key of Object.keys(to)) {
74
+ const fromValue = from[key];
75
+ const toValue = to[key];
76
+ if (Array.isArray(toValue)) {
77
+ if (!isDeepEqual(fromValue, toValue)) diff[key] = toValue;
78
+ continue;
79
+ }
80
+ if (isPlainObject(toValue)) {
81
+ if (!isDeepEqual(fromValue, toValue)) {
82
+ const nestedDiff = computeDiff(fromValue ?? {}, toValue);
83
+ if (Object.keys(nestedDiff).length > 0) diff[key] = nestedDiff;
84
+ }
85
+ continue;
86
+ }
87
+ if (isFirestoreOpaque(toValue)) {
88
+ if (!isFirestoreOpaque(fromValue) || !toValue.isEqual(fromValue)) diff[key] = toValue;
89
+ continue;
90
+ }
91
+ if (toValue !== void 0 && fromValue !== toValue) diff[key] = toValue;
92
+ }
93
+ for (const key of Object.keys(from)) if (to[key] === void 0) diff[key] = deleteField();
94
+ return diff;
95
+ };
96
+ /**
97
+ * Apply a Firestore diff to a target object in place (mutating).
98
+ * Handles deleteField(), serverTimestamp(), and nested objects.
99
+ *
100
+ * Most code should use `applyDiff` (immutable) instead.
101
+ * This mutable version is useful for performance-critical paths
102
+ * where you're already working with a cloned object.
103
+ *
104
+ * @param target - The object to mutate
105
+ * @param diff - The diff to apply
106
+ */
107
+ const applyDiffMutable = (target, diff) => {
108
+ for (const key of Object.keys(diff)) {
109
+ const value = diff[key];
110
+ if (isFirestoreOpaque(value)) {
111
+ if (isDeleteField(value)) {
112
+ delete target[key];
113
+ continue;
114
+ }
115
+ target[key] = value;
116
+ continue;
117
+ }
118
+ if (isPlainObject(value)) {
119
+ const existingValue = target[key];
120
+ if (!isPlainObject(existingValue)) target[key] = {};
121
+ applyDiffMutable(target[key], value);
122
+ continue;
123
+ }
124
+ target[key] = value;
125
+ }
126
+ };
127
+ /**
128
+ * Create a deep clone of an object that's safe for Firestore operations.
129
+ *
130
+ * Firestore opaque values (FieldValue sentinels, Timestamp,
131
+ * DocumentReference, GeoPoint, Bytes, VectorValue) are returned **by
132
+ * reference**. They are immutable from the user's perspective; cloning
133
+ * them by walking keys would either lose their prototype — turning a
134
+ * `DocumentReference` into a plain object Firestore can't recognize —
135
+ * or destroy a sentinel that needed to reach the server intact.
136
+ */
137
+ const deepClone = (value) => {
138
+ if (value === null || typeof value !== "object") return value;
139
+ if (isFirestoreOpaque(value)) return value;
140
+ if (Array.isArray(value)) return value.map(deepClone);
141
+ const result = {};
142
+ for (const key of Object.keys(value)) result[key] = deepClone(value[key]);
143
+ return result;
144
+ };
145
+ /**
146
+ * Check if a diff is empty (no changes)
147
+ */
148
+ const isDiffEmpty = (diff) => Object.keys(diff).length === 0;
149
+ /**
150
+ * Flatten a nested diff object to dot notation for use with Firestore's updateDoc.
151
+ *
152
+ * This converts:
153
+ * ```
154
+ * { building: { floors: 5, height: 100 }, name: 'Test' }
155
+ * ```
156
+ * To:
157
+ * ```
158
+ * { 'building.floors': 5, 'building.height': 100, 'name': 'Test' }
159
+ * ```
160
+ *
161
+ * Arrays, FieldValue sentinels (deleteField, serverTimestamp, …) and
162
+ * Firestore value types (Timestamp, DocumentReference, GeoPoint, Bytes,
163
+ * VectorValue) are NOT flattened — they're preserved at their path so
164
+ * Firestore receives them in their original form.
165
+ *
166
+ * @param diff - The nested diff object
167
+ * @param prefix - Internal: current path prefix for recursion
168
+ * @returns Flattened object with dotted keys
169
+ */
170
+ const flattenDiff = (diff, prefix = "") => {
171
+ const result = {};
172
+ for (const key of Object.keys(diff)) {
173
+ const value = diff[key];
174
+ const path = prefix ? `${prefix}.${key}` : key;
175
+ if (Array.isArray(value) || isFirestoreOpaque(value)) {
176
+ result[path] = value;
177
+ continue;
178
+ }
179
+ if (isPlainObject(value)) {
180
+ const nested = flattenDiff(value, path);
181
+ Object.assign(result, nested);
182
+ continue;
183
+ }
184
+ result[path] = value;
185
+ }
186
+ return result;
187
+ };
188
+ /**
189
+ * Merge two diffs together, with the second taking precedence
190
+ */
191
+ const mergeDiffs = (first, second) => {
192
+ const result = deepClone(first);
193
+ for (const key of Object.keys(second)) {
194
+ const firstValue = result[key];
195
+ const secondValue = second[key];
196
+ if (isPlainObject(firstValue) && isPlainObject(secondValue)) result[key] = mergeDiffs(firstValue, secondValue);
197
+ else result[key] = secondValue;
198
+ }
199
+ return result;
200
+ };
201
+ /**
202
+ * Apply a diff to an object, returning a new object.
203
+ * The original object is not modified.
204
+ *
205
+ * @example
206
+ * ```ts
207
+ * const original = { name: 'Project', count: 5 }
208
+ * const diff = { name: 'Updated', count: deleteField() }
209
+ * const result = applyDiff(original, diff)
210
+ * // result = { name: 'Updated' }
211
+ * // original is unchanged
212
+ * ```
213
+ */
214
+ const applyDiff = (state, diff) => {
215
+ const result = deepClone(state);
216
+ applyDiffMutable(result, diff);
217
+ return result;
218
+ };
219
+ /**
220
+ * Compute the undo diff that would reverse the effect of applying a diff to a state.
221
+ *
222
+ * Given a starting state and a diff that was (or will be) applied to it,
223
+ * returns a new diff that when applied to the result would restore the original state.
224
+ *
225
+ * @example
226
+ * ```ts
227
+ * const startState = { name: 'Foo', count: 5 }
228
+ * const diff = { name: 'Bar', count: deleteField() }
229
+ *
230
+ * // Apply the diff
231
+ * const endState = applyDiff(startState, diff)
232
+ * // endState = { name: 'Bar' }
233
+ *
234
+ * // Compute the undo
235
+ * const undoDiff = computeUndoDiff(startState, diff)
236
+ * // undoDiff = { name: 'Foo', count: 5 }
237
+ *
238
+ * // Applying undoDiff to endState restores startState
239
+ * const restored = applyDiff(endState, undoDiff)
240
+ * // restored = { name: 'Foo', count: 5 }
241
+ * ```
242
+ */
243
+ const computeUndoDiff = (startState, diff) => {
244
+ return computeDiff(applyDiff(startState, diff), startState);
245
+ };
246
+ /**
247
+ * Check if a diff affects a specific path (supports dot notation).
248
+ *
249
+ * @example
250
+ * ```ts
251
+ * const diff = { building: { floors: 5 }, name: 'Test' }
252
+ *
253
+ * diffContainsPath(diff, 'name') // true
254
+ * diffContainsPath(diff, 'building') // true
255
+ * diffContainsPath(diff, 'building.floors') // true
256
+ * diffContainsPath(diff, 'building.height') // false
257
+ * diffContainsPath(diff, 'other') // false
258
+ * ```
259
+ */
260
+ const diffContainsPath = (diff, path) => {
261
+ const parts = path.split(".");
262
+ let current = diff;
263
+ for (const part of parts) {
264
+ if (current === null || typeof current !== "object") return false;
265
+ if (!(part in current)) return false;
266
+ current = current[part];
267
+ }
268
+ return true;
269
+ };
270
+ /**
271
+ * Extract the value at a specific path from a diff (supports dot notation).
272
+ * Returns undefined if the path doesn't exist in the diff.
273
+ *
274
+ * @example
275
+ * ```ts
276
+ * const diff = { building: { floors: 5, height: 100 }, name: 'Test' }
277
+ *
278
+ * extractDiffValue(diff, 'name') // 'Test'
279
+ * extractDiffValue(diff, 'building') // { floors: 5, height: 100 }
280
+ * extractDiffValue(diff, 'building.floors') // 5
281
+ * extractDiffValue(diff, 'building.missing') // undefined
282
+ * ```
283
+ */
284
+ const extractDiffValue = (diff, path) => {
285
+ const parts = path.split(".");
286
+ let current = diff;
287
+ for (const part of parts) {
288
+ if (current === null || typeof current !== "object") return;
289
+ if (!(part in current)) return;
290
+ current = current[part];
291
+ }
292
+ return current;
293
+ };
294
+ /**
295
+ * Create a diff that sets a value at a specific path (supports dot notation).
296
+ *
297
+ * @example
298
+ * ```ts
299
+ * createDiffAtPath('name', 'New Name')
300
+ * // { name: 'New Name' }
301
+ *
302
+ * createDiffAtPath('building.floors', 5)
303
+ * // { building: { floors: 5 } }
304
+ *
305
+ * createDiffAtPath('building.config.enabled', true)
306
+ * // { building: { config: { enabled: true } } }
307
+ * ```
308
+ */
309
+ const createDiffAtPath = (path, value) => {
310
+ const parts = path.split(".");
311
+ const result = {};
312
+ let current = result;
313
+ for (let i = 0; i < parts.length - 1; i++) {
314
+ const part = parts[i];
315
+ if (part === void 0) continue;
316
+ current[part] = {};
317
+ current = current[part];
318
+ }
319
+ const lastPart = parts[parts.length - 1];
320
+ if (lastPart !== void 0) current[lastPart] = value;
321
+ return result;
322
+ };
323
+ /**
324
+ * Invert a flattened diff back to nested object structure.
325
+ * Opposite of flattenDiff.
326
+ *
327
+ * @example
328
+ * ```ts
329
+ * const flat = { 'building.floors': 5, 'building.height': 100, 'name': 'Test' }
330
+ * const nested = unflattenDiff(flat)
331
+ * // { building: { floors: 5, height: 100 }, name: 'Test' }
332
+ * ```
333
+ */
334
+ const unflattenDiff = (flatDiff) => {
335
+ const result = {};
336
+ for (const [path, value] of Object.entries(flatDiff)) {
337
+ const parts = path.split(".");
338
+ let current = result;
339
+ for (let i = 0; i < parts.length - 1; i++) {
340
+ const part = parts[i];
341
+ if (part === void 0) continue;
342
+ if (!(part in current) || typeof current[part] !== "object") current[part] = {};
343
+ current = current[part];
344
+ }
345
+ const lastPart = parts[parts.length - 1];
346
+ if (lastPart !== void 0) current[lastPart] = value;
347
+ }
348
+ return result;
349
+ };
350
+ /**
351
+ * Walk a state object collecting every dotted path that currently holds
352
+ * a `serverTimestamp()` sentinel. Arrays are not traversed — Firestore
353
+ * doesn't allow sentinels inside arrays. Non-plain objects (Timestamps,
354
+ * DocumentReferences, …) are leaves.
355
+ *
356
+ * @internal
357
+ */
358
+ const collectServerTimestampPaths = (state, prefix = "", out = /* @__PURE__ */ new Set()) => {
359
+ if (!state) return out;
360
+ for (const key of Object.keys(state)) {
361
+ const value = state[key];
362
+ const path = prefix ? `${prefix}.${key}` : key;
363
+ if (isServerTimestamp(value)) {
364
+ out.add(path);
365
+ continue;
366
+ }
367
+ if (isPlainObject(value)) collectServerTimestampPaths(value, path, out);
368
+ }
369
+ return out;
370
+ };
371
+ /**
372
+ * Reconcile a `displayOverrides` map against the current `localState`:
373
+ *
374
+ * - For each path that holds a `serverTimestamp()` sentinel but has no
375
+ * override yet, capture `Timestamp.now()` and store it (frozen-at-
376
+ * first-sighting).
377
+ * - For each existing override whose path no longer holds a sentinel
378
+ * (sentinel was overwritten, or `localState` cleared on snapshot ack),
379
+ * drop it.
380
+ *
381
+ * The map is mutated in place. Pass a custom `now` for deterministic
382
+ * tests; defaults to `Timestamp.now()`.
383
+ *
384
+ * @internal
385
+ */
386
+ const reconcileDisplayOverrides = (localState, overrides, now = () => Timestamp.now()) => {
387
+ const currentPaths = collectServerTimestampPaths(localState);
388
+ for (const path of currentPaths) if (!overrides.has(path)) overrides.set(path, now());
389
+ for (const path of [...overrides.keys()]) if (!currentPaths.has(path)) overrides.delete(path);
390
+ };
391
+ const setAtPath = (obj, path, value) => {
392
+ const parts = path.split(".");
393
+ let cur = obj;
394
+ for (let i = 0; i < parts.length - 1; i++) {
395
+ const part = parts[i];
396
+ if (!isPlainObject(cur[part])) cur[part] = {};
397
+ cur = cur[part];
398
+ }
399
+ cur[parts[parts.length - 1]] = value;
400
+ };
401
+ /**
402
+ * Apply a path → value override map to a merged view, returning a new
403
+ * object. Used by document.ts / collection.ts to substitute display
404
+ * values for sentinels still present in `localState`.
405
+ *
406
+ * @internal
407
+ */
408
+ const applyOverridesAtPaths = (merged, overrides) => {
409
+ if (overrides.size === 0) return merged;
410
+ const result = deepClone(merged);
411
+ for (const [path, value] of overrides) setAtPath(result, path, value);
412
+ return result;
413
+ };
414
+
415
+ //#endregion
416
+ //#region src/document.ts
417
+ let syncKeyCounter$1 = 0;
418
+ /**
419
+ * Create a document subscription.
420
+ * This is a low-level API - prefer using useDocument hook in React.
421
+ *
422
+ * @example
423
+ * ```ts
424
+ * const subscription = createDocumentSubscription({
425
+ * store,
426
+ * definition: projectDoc,
427
+ * docId: '123',
428
+ * })
429
+ *
430
+ * const unsubscribe = subscription.subscribe((state) => {
431
+ * console.log('Document state:', state)
432
+ * })
433
+ *
434
+ * subscription.load()
435
+ * ```
436
+ */
437
+ const createDocumentSubscription = (options) => {
438
+ const { store, definition, docId, collectionPath: resolvedCollectionPath, readOnly, onPushUndo } = options;
439
+ const { firestore, autosave: defaultAutosave, minLoadTime: defaultMinLoadTime } = store;
440
+ const { collection: collectionConfig, id, autosave = defaultAutosave, minLoadTime = defaultMinLoadTime, readOnly: definitionReadOnly, retryOnError = false, retryInterval = 5e3, schema } = definition;
441
+ const isReadOnly = readOnly ?? definitionReadOnly ?? false;
442
+ const documentId = docId ?? (typeof id === "string" ? id : void 0);
443
+ if (documentId === void 0) throw new Error(`createDocumentSubscription: definition.id is a function; pass a resolved docId in options.`);
444
+ const collectionPath = resolvedCollectionPath ?? (typeof collectionConfig === "string" ? collectionConfig : void 0);
445
+ if (collectionPath === void 0) throw new Error(`createDocumentSubscription: definition.collection is a function; pass a resolved collectionPath in options.`);
446
+ const docRef = doc$1(collection(firestore, collectionPath), documentId);
447
+ const state = {
448
+ syncState: void 0,
449
+ localState: void 0,
450
+ isLoading: true,
451
+ error: void 0,
452
+ waitingForUpdate: false,
453
+ inflightLocalState: void 0,
454
+ isSetOperation: false,
455
+ displayOverrides: /* @__PURE__ */ new Map()
456
+ };
457
+ const subscribers = /* @__PURE__ */ new Set();
458
+ let unsubscribeListener = null;
459
+ let autosaveTimeout = null;
460
+ let retryTimeout = null;
461
+ let minLoadTimeout = null;
462
+ let minLoadTimeElapsed = false;
463
+ let loaded = false;
464
+ let cachedHandle = null;
465
+ const syncKey = `doc:${collectionPath}/${documentId}#${++syncKeyCounter$1}`;
466
+ const getMergedData = () => {
467
+ if (state.localState === null) return void 0;
468
+ const base = state.localState ?? state.syncState;
469
+ if (base === void 0) return void 0;
470
+ return applyOverridesAtPaths(base, state.displayOverrides);
471
+ };
472
+ const getPublicState = () => ({
473
+ data: getMergedData(),
474
+ isLoading: state.isLoading,
475
+ isSynced: state.localState === void 0,
476
+ error: state.error
477
+ });
478
+ const notify = () => {
479
+ reconcileDisplayOverrides(state.localState && typeof state.localState === "object" ? state.localState : void 0, state.displayOverrides);
480
+ cachedHandle = null;
481
+ const publicState = getPublicState();
482
+ subscribers.forEach((fn) => fn(publicState));
483
+ store.reportSyncState(syncKey, publicState.isSynced);
484
+ };
485
+ const updateState = (diff, undoOptions = {}) => {
486
+ if (isReadOnly) return;
487
+ const currentData = getMergedData();
488
+ if (!currentData) {
489
+ if (process.env.NODE_ENV !== "production") console.warn(`[firestate] update() on ${collectionPath}/${documentId} was ignored: there is no current data to diff against. This happens when the document is still loading, has been deleted, or doesn't exist yet. Use set() to create the document, or gate update calls on a non-undefined data value.`);
490
+ return;
491
+ }
492
+ const newLocalState = deepClone(currentData);
493
+ applyDiffMutable(newLocalState, diff);
494
+ if (undoOptions?.undoable !== false && onPushUndo) {
495
+ const undoDiff = computeDiff(newLocalState, currentData);
496
+ const redoDiff = computeDiff(currentData, newLocalState);
497
+ onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
498
+ }
499
+ state.localState = newLocalState;
500
+ state.isSetOperation = false;
501
+ notify();
502
+ scheduleAutosave();
503
+ };
504
+ const setData = (data, undoOptions = {}) => {
505
+ if (isReadOnly) return;
506
+ if (schema) schema.parse(data);
507
+ const currentData = getMergedData();
508
+ if (undoOptions?.undoable !== false && onPushUndo) {
509
+ const dataForRedo = deepClone(data);
510
+ if (currentData === void 0) onPushUndo(() => deleteDocument({ undoable: false }), () => setData(dataForRedo, { undoable: false }), undoOptions);
511
+ else {
512
+ const dataToRestore = deepClone(currentData);
513
+ onPushUndo(() => setData(dataToRestore, { undoable: false }), () => setData(dataForRedo, { undoable: false }), undoOptions);
514
+ }
515
+ }
516
+ state.localState = deepClone(data);
517
+ state.isSetOperation = true;
518
+ notify();
519
+ scheduleAutosave();
520
+ };
521
+ const deleteDocument = (undoOptions = {}) => {
522
+ if (isReadOnly) return;
523
+ const currentData = getMergedData();
524
+ if (currentData === void 0) return;
525
+ if (undoOptions?.undoable !== false && onPushUndo) {
526
+ const dataToRestore = deepClone(currentData);
527
+ onPushUndo(() => setData(dataToRestore, { undoable: false }), () => deleteDocument({ undoable: false }), undoOptions);
528
+ }
529
+ state.localState = null;
530
+ state.isSetOperation = false;
531
+ notify();
532
+ scheduleAutosave();
533
+ };
534
+ const scheduleAutosave = () => {
535
+ if (autosaveTimeout) clearTimeout(autosaveTimeout);
536
+ if (autosave > 0) autosaveTimeout = setTimeout(() => {
537
+ sync();
538
+ }, autosave);
539
+ };
540
+ const sync = async () => {
541
+ if (state.localState === void 0) return;
542
+ if (state.localState === null) {
543
+ state.inflightLocalState = null;
544
+ state.waitingForUpdate = true;
545
+ try {
546
+ await deleteDoc(docRef);
547
+ } catch (error) {
548
+ console.error("Sync failed:", error);
549
+ state.waitingForUpdate = false;
550
+ state.inflightLocalState = void 0;
551
+ state.error = error;
552
+ store.reportError(error, {
553
+ type: "document",
554
+ path: `${collectionPath}/${documentId}`,
555
+ operation: "write"
556
+ });
557
+ notify();
558
+ }
559
+ return;
560
+ }
561
+ if (state.syncState && isDeepEqual(state.localState, state.syncState)) {
562
+ state.localState = void 0;
563
+ state.inflightLocalState = void 0;
564
+ notify();
565
+ return;
566
+ }
567
+ const wasSetOperation = state.isSetOperation;
568
+ state.isSetOperation = false;
569
+ const isCreation = !state.syncState;
570
+ const useSetDoc = wasSetOperation || isCreation;
571
+ const diff = state.syncState ? computeDiff(state.syncState, state.localState) : void 0;
572
+ state.inflightLocalState = deepClone(state.localState);
573
+ state.waitingForUpdate = true;
574
+ try {
575
+ if (useSetDoc) await setDoc(docRef, state.localState);
576
+ else await updateDoc(docRef, flattenDiff(diff));
577
+ } catch (error) {
578
+ console.error("Sync failed:", error);
579
+ state.waitingForUpdate = false;
580
+ state.inflightLocalState = void 0;
581
+ state.error = error;
582
+ store.reportError(error, {
583
+ type: "document",
584
+ path: `${collectionPath}/${documentId}`,
585
+ operation: "write"
586
+ });
587
+ notify();
588
+ }
589
+ };
590
+ const handleSnapshot = (newSyncData) => {
591
+ state.syncState = newSyncData;
592
+ state.error = void 0;
593
+ if (state.waitingForUpdate) {
594
+ state.waitingForUpdate = false;
595
+ const inflightState = state.inflightLocalState;
596
+ state.inflightLocalState = void 0;
597
+ const currentLocal = state.localState;
598
+ if (inflightState === null) {} else if (currentLocal === null) {} else if (inflightState && currentLocal && !isDeepEqual(currentLocal, inflightState)) {
599
+ const changesSinceInflight = computeDiff(inflightState, currentLocal);
600
+ const rebasedLocalState = deepClone(newSyncData);
601
+ applyDiffMutable(rebasedLocalState, changesSinceInflight);
602
+ state.localState = rebasedLocalState;
603
+ } else state.localState = void 0;
604
+ }
605
+ if (minLoadTimeElapsed) state.isLoading = false;
606
+ loaded = true;
607
+ if (state.localState !== void 0) scheduleAutosave();
608
+ notify();
609
+ };
610
+ const handleMissingDocument = () => {
611
+ state.syncState = void 0;
612
+ state.error = void 0;
613
+ if (state.localState === null) {
614
+ state.localState = void 0;
615
+ state.isSetOperation = false;
616
+ if (autosaveTimeout) {
617
+ clearTimeout(autosaveTimeout);
618
+ autosaveTimeout = null;
619
+ }
620
+ }
621
+ if (state.waitingForUpdate) {
622
+ state.waitingForUpdate = false;
623
+ state.inflightLocalState = void 0;
624
+ }
625
+ if (minLoadTimeElapsed) state.isLoading = false;
626
+ loaded = true;
627
+ notify();
628
+ };
629
+ const handleError = (error) => {
630
+ if (retryOnError) {
631
+ console.warn("Document listener error, retrying:", error);
632
+ retryTimeout = setTimeout(() => {
633
+ stop();
634
+ load();
635
+ }, retryInterval);
636
+ } else {
637
+ state.error = error;
638
+ state.isLoading = false;
639
+ loaded = true;
640
+ store.reportError(error, {
641
+ type: "document",
642
+ path: `${collectionPath}/${documentId}`,
643
+ operation: "read"
644
+ });
645
+ notify();
646
+ }
647
+ };
648
+ const load = () => {
649
+ if (unsubscribeListener) return;
650
+ loaded = false;
651
+ minLoadTimeElapsed = false;
652
+ unsubscribeListener = onSnapshot(docRef, (snapshot) => {
653
+ if (snapshot.exists()) handleSnapshot(snapshot.data());
654
+ else if (!snapshot.metadata.fromCache) handleMissingDocument();
655
+ }, handleError);
656
+ minLoadTimeout = setTimeout(() => {
657
+ minLoadTimeout = null;
658
+ if (loaded) {
659
+ state.isLoading = false;
660
+ notify();
661
+ }
662
+ minLoadTimeElapsed = true;
663
+ }, minLoadTime);
664
+ };
665
+ const stop = () => {
666
+ if (unsubscribeListener) {
667
+ unsubscribeListener();
668
+ unsubscribeListener = null;
669
+ }
670
+ if (autosaveTimeout) {
671
+ clearTimeout(autosaveTimeout);
672
+ autosaveTimeout = null;
673
+ }
674
+ if (retryTimeout) {
675
+ clearTimeout(retryTimeout);
676
+ retryTimeout = null;
677
+ }
678
+ if (minLoadTimeout) {
679
+ clearTimeout(minLoadTimeout);
680
+ minLoadTimeout = null;
681
+ }
682
+ store.unregisterSyncState(syncKey);
683
+ };
684
+ const subscribe = (fn) => {
685
+ subscribers.add(fn);
686
+ return () => subscribers.delete(fn);
687
+ };
688
+ const buildHandle = () => ({
689
+ data: getMergedData(),
690
+ update: updateState,
691
+ set: setData,
692
+ delete: deleteDocument,
693
+ isLoading: state.isLoading,
694
+ isSynced: state.localState === void 0,
695
+ sync,
696
+ error: state.error,
697
+ ref: docRef
698
+ });
699
+ const getHandle = () => {
700
+ if (cachedHandle === null) cachedHandle = buildHandle();
701
+ return cachedHandle;
702
+ };
703
+ return {
704
+ load,
705
+ stop,
706
+ subscribe,
707
+ getState: getPublicState,
708
+ getHandle,
709
+ sync
710
+ };
711
+ };
712
+
713
+ //#endregion
714
+ //#region src/collection.ts
715
+ let syncKeyCounter = 0;
716
+ /**
717
+ * Create a collection subscription.
718
+ * This is a low-level API - prefer using useCollection hook in React.
719
+ *
720
+ * @example
721
+ * ```ts
722
+ * const subscription = createCollectionSubscription({
723
+ * store,
724
+ * definition: spacesCollection,
725
+ * collectionPath: 'projects/123/spaces',
726
+ * })
727
+ *
728
+ * const unsubscribe = subscription.subscribe((state) => {
729
+ * console.log('Collection state:', state)
730
+ * })
731
+ *
732
+ * subscription.load() // For lazy collections
733
+ * ```
734
+ */
735
+ const createCollectionSubscription = (options) => {
736
+ const { store, definition, collectionPath: resolvedPath, readOnly, queryConstraints: extraConstraints, onPushUndo } = options;
737
+ const { firestore, autosave: defaultAutosave, minLoadTime: defaultMinLoadTime } = store;
738
+ const { path, autosave = defaultAutosave, minLoadTime = defaultMinLoadTime, readOnly: definitionReadOnly, lazy = false, queryConstraints: definitionConstraints, retryOnError = false, retryInterval = 5e3, schema } = definition;
739
+ const isReadOnly = readOnly ?? definitionReadOnly ?? false;
740
+ const collectionPath = resolvedPath ?? (typeof path === "string" ? path : void 0);
741
+ if (collectionPath === void 0) throw new Error(`createCollectionSubscription: definition.path is a function; pass a resolved collectionPath in options.`);
742
+ const allConstraints = [...definitionConstraints ?? [], ...extraConstraints ?? []];
743
+ const collectionRef = collection(firestore, collectionPath);
744
+ const state = {
745
+ syncState: void 0,
746
+ localState: void 0,
747
+ isLoading: !lazy,
748
+ isActive: !lazy,
749
+ error: void 0,
750
+ waitingForUpdate: false,
751
+ inflightLocalState: void 0,
752
+ displayOverrides: /* @__PURE__ */ new Map()
753
+ };
754
+ const subscribers = /* @__PURE__ */ new Set();
755
+ let unsubscribeListener = null;
756
+ let autosaveTimeout = null;
757
+ let minLoadTimeout = null;
758
+ let retryTimeout = null;
759
+ let minLoadTimeElapsed = false;
760
+ let loaded = false;
761
+ let cachedHandle = null;
762
+ const syncKey = `col:${collectionPath}#${++syncKeyCounter}`;
763
+ const getMergedData = () => {
764
+ return applyOverridesAtPaths(state.localState ?? state.syncState ?? {}, state.displayOverrides);
765
+ };
766
+ const getPublicState = () => ({
767
+ data: getMergedData(),
768
+ isLoading: state.isLoading,
769
+ isSynced: state.localState === void 0,
770
+ isActive: state.isActive,
771
+ error: state.error
772
+ });
773
+ const notify = () => {
774
+ reconcileDisplayOverrides(state.localState, state.displayOverrides);
775
+ cachedHandle = null;
776
+ const publicState = getPublicState();
777
+ subscribers.forEach((fn) => fn(publicState));
778
+ store.reportSyncState(syncKey, publicState.isSynced);
779
+ };
780
+ const warnNoSnapshot = (method) => {
781
+ if (process.env.NODE_ENV !== "production") console.warn(`[firestate] ${method}() on ${collectionPath} was ignored: the first snapshot has not arrived yet. Gate calls on the collection's isLoading/isActive state, or await the first data before mutating.`);
782
+ };
783
+ const updateState = (diff, undoOptions = {}) => {
784
+ if (isReadOnly) return;
785
+ if (state.syncState === void 0) {
786
+ warnNoSnapshot("update");
787
+ return;
788
+ }
789
+ const currentData = getMergedData();
790
+ const newLocalState = deepClone(currentData);
791
+ applyDiffMutable(newLocalState, diff);
792
+ for (const [docId, docData] of Object.entries(newLocalState)) if (docData && typeof docData === "object") docData.id = docId;
793
+ if (undoOptions?.undoable !== false && onPushUndo) {
794
+ const undoDiff = computeDiff(newLocalState, currentData);
795
+ const redoDiff = computeDiff(currentData, newLocalState);
796
+ onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
797
+ }
798
+ state.localState = newLocalState;
799
+ notify();
800
+ scheduleAutosave();
801
+ };
802
+ function addDocument(idOrData, dataOrOptions, maybeUndoOptions) {
803
+ const hasExplicitId = typeof idOrData === "string";
804
+ const data = hasExplicitId ? dataOrOptions : idOrData;
805
+ const undoOptions = (hasExplicitId ? maybeUndoOptions : dataOrOptions) ?? {};
806
+ if (isReadOnly) return void 0;
807
+ if (state.syncState === void 0) {
808
+ warnNoSnapshot("add");
809
+ return;
810
+ }
811
+ const id = hasExplicitId ? idOrData : doc$1(collectionRef).id;
812
+ const newDoc = {
813
+ ...data,
814
+ id
815
+ };
816
+ if (schema) schema.parse(newDoc);
817
+ const currentData = getMergedData();
818
+ const newLocalState = deepClone(currentData);
819
+ newLocalState[id] = newDoc;
820
+ if (undoOptions?.undoable !== false && onPushUndo) {
821
+ const undoDiff = computeDiff(newLocalState, currentData);
822
+ const redoDiff = computeDiff(currentData, newLocalState);
823
+ onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
824
+ }
825
+ state.localState = newLocalState;
826
+ notify();
827
+ scheduleAutosave();
828
+ return id;
829
+ }
830
+ const removeDocument = (id, undoOptions = {}) => {
831
+ if (isReadOnly) return;
832
+ if (state.syncState === void 0) {
833
+ warnNoSnapshot("remove");
834
+ return;
835
+ }
836
+ const currentData = getMergedData();
837
+ if (!(id in currentData)) return;
838
+ const newLocalState = deepClone(currentData);
839
+ delete newLocalState[id];
840
+ if (undoOptions?.undoable !== false && onPushUndo) {
841
+ const undoDiff = computeDiff(newLocalState, currentData);
842
+ const redoDiff = computeDiff(currentData, newLocalState);
843
+ onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
844
+ }
845
+ state.localState = newLocalState;
846
+ notify();
847
+ scheduleAutosave();
848
+ };
849
+ const scheduleAutosave = () => {
850
+ if (autosaveTimeout) clearTimeout(autosaveTimeout);
851
+ if (autosave > 0) autosaveTimeout = setTimeout(() => {
852
+ sync();
853
+ }, autosave);
854
+ };
855
+ const sync = async () => {
856
+ if (!state.localState) return;
857
+ if (state.syncState === void 0) return;
858
+ const syncState = state.syncState;
859
+ if (isDeepEqual(state.localState, syncState)) {
860
+ state.localState = void 0;
861
+ state.inflightLocalState = void 0;
862
+ notify();
863
+ return;
864
+ }
865
+ const diff = computeDiff(syncState, state.localState);
866
+ state.inflightLocalState = deepClone(state.localState);
867
+ state.waitingForUpdate = true;
868
+ try {
869
+ const batch = writeBatch(firestore);
870
+ const deleteFieldSentinel = deleteField();
871
+ for (const [docId, docDiff] of Object.entries(diff)) {
872
+ const docRef = doc$1(collectionRef, docId);
873
+ if (docDiff !== null && typeof docDiff === "object" && "isEqual" in docDiff && typeof docDiff.isEqual === "function" && docDiff.isEqual(deleteFieldSentinel)) batch.delete(docRef);
874
+ else if (!(docId in syncState)) batch.set(docRef, docDiff);
875
+ else {
876
+ const flatDiff = flattenDiff(docDiff);
877
+ batch.update(docRef, flatDiff);
878
+ }
879
+ }
880
+ await batch.commit();
881
+ } catch (error) {
882
+ console.error("Collection sync failed:", error);
883
+ state.waitingForUpdate = false;
884
+ state.inflightLocalState = void 0;
885
+ state.error = error;
886
+ store.reportError(error, {
887
+ type: "collection",
888
+ path: collectionPath,
889
+ operation: "write"
890
+ });
891
+ notify();
892
+ }
893
+ };
894
+ const handleSnapshot = (docs) => {
895
+ const newSyncState = {};
896
+ for (const { id, data } of docs) newSyncState[id] = {
897
+ ...data,
898
+ id
899
+ };
900
+ state.syncState = newSyncState;
901
+ state.error = void 0;
902
+ if (state.waitingForUpdate) {
903
+ state.waitingForUpdate = false;
904
+ const inflightState = state.inflightLocalState;
905
+ state.inflightLocalState = void 0;
906
+ const currentLocal = state.localState;
907
+ if (inflightState && currentLocal && !isDeepEqual(currentLocal, inflightState)) {
908
+ const changesSinceInflight = computeDiff(inflightState, currentLocal);
909
+ const rebasedLocalState = deepClone(newSyncState);
910
+ applyDiffMutable(rebasedLocalState, changesSinceInflight);
911
+ for (const [docId, docData] of Object.entries(rebasedLocalState)) if (docData && typeof docData === "object") docData.id = docId;
912
+ state.localState = rebasedLocalState;
913
+ } else state.localState = void 0;
914
+ }
915
+ if (minLoadTimeElapsed) state.isLoading = false;
916
+ loaded = true;
917
+ if (state.localState !== void 0) scheduleAutosave();
918
+ notify();
919
+ };
920
+ const handleError = (error) => {
921
+ if (retryOnError) {
922
+ console.warn("Collection listener error, retrying:", error);
923
+ retryTimeout = setTimeout(() => {
924
+ stop();
925
+ startListener();
926
+ }, retryInterval);
927
+ } else {
928
+ state.error = error;
929
+ state.isLoading = false;
930
+ loaded = true;
931
+ store.reportError(error, {
932
+ type: "collection",
933
+ path: collectionPath,
934
+ operation: "read"
935
+ });
936
+ notify();
937
+ }
938
+ };
939
+ const startListener = () => {
940
+ if (unsubscribeListener) return;
941
+ loaded = false;
942
+ minLoadTimeElapsed = false;
943
+ unsubscribeListener = onSnapshot(allConstraints.length > 0 ? query(collectionRef, ...allConstraints) : collectionRef, (snapshot) => {
944
+ handleSnapshot(snapshot.docs.map((docSnap) => ({
945
+ id: docSnap.id,
946
+ data: docSnap.data()
947
+ })));
948
+ }, handleError);
949
+ minLoadTimeout = setTimeout(() => {
950
+ minLoadTimeout = null;
951
+ if (loaded) {
952
+ state.isLoading = false;
953
+ notify();
954
+ }
955
+ minLoadTimeElapsed = true;
956
+ }, minLoadTime);
957
+ };
958
+ const load = () => {
959
+ if (unsubscribeListener) return;
960
+ if (!state.isActive) {
961
+ state.isActive = true;
962
+ state.isLoading = true;
963
+ notify();
964
+ }
965
+ startListener();
966
+ };
967
+ const stop = () => {
968
+ if (retryTimeout) {
969
+ clearTimeout(retryTimeout);
970
+ retryTimeout = null;
971
+ }
972
+ if (unsubscribeListener) {
973
+ unsubscribeListener();
974
+ unsubscribeListener = null;
975
+ }
976
+ if (autosaveTimeout) {
977
+ clearTimeout(autosaveTimeout);
978
+ autosaveTimeout = null;
979
+ }
980
+ if (minLoadTimeout) {
981
+ clearTimeout(minLoadTimeout);
982
+ minLoadTimeout = null;
983
+ }
984
+ store.unregisterSyncState(syncKey);
985
+ };
986
+ const subscribe = (fn) => {
987
+ subscribers.add(fn);
988
+ return () => subscribers.delete(fn);
989
+ };
990
+ const buildHandle = () => ({
991
+ data: getMergedData(),
992
+ update: updateState,
993
+ add: addDocument,
994
+ remove: removeDocument,
995
+ isLoading: state.isLoading,
996
+ isSynced: state.localState === void 0,
997
+ isActive: state.isActive,
998
+ load,
999
+ sync,
1000
+ error: state.error,
1001
+ ref: collectionRef
1002
+ });
1003
+ const getHandle = () => {
1004
+ if (cachedHandle === null) cachedHandle = buildHandle();
1005
+ return cachedHandle;
1006
+ };
1007
+ return {
1008
+ load,
1009
+ stop,
1010
+ subscribe,
1011
+ getState: getPublicState,
1012
+ getHandle,
1013
+ sync
1014
+ };
1015
+ };
1016
+
1017
+ //#endregion
1018
+ //#region src/hooks.ts
1019
+ /**
1020
+ * Returned when a hook is called with `enabled: false`. Module-level constants
1021
+ * so getSnapshot returns a stable reference and useSyncExternalStore doesn't
1022
+ * re-render. Cast at the call site to the generic handle type — every method
1023
+ * is a no-op so the cast is sound.
1024
+ */
1025
+ const NOOP = () => {};
1026
+ const ASYNC_NOOP = async () => {};
1027
+ const EMPTY_RECORD = {};
1028
+ const DISABLED_DOCUMENT_HANDLE = {
1029
+ data: void 0,
1030
+ update: NOOP,
1031
+ set: NOOP,
1032
+ delete: NOOP,
1033
+ isLoading: false,
1034
+ isSynced: true,
1035
+ sync: ASYNC_NOOP,
1036
+ error: void 0,
1037
+ ref: void 0
1038
+ };
1039
+ const DISABLED_ADD = () => void 0;
1040
+ const DISABLED_COLLECTION_HANDLE = {
1041
+ data: EMPTY_RECORD,
1042
+ update: NOOP,
1043
+ add: DISABLED_ADD,
1044
+ remove: NOOP,
1045
+ isLoading: false,
1046
+ isSynced: true,
1047
+ isActive: false,
1048
+ load: NOOP,
1049
+ sync: ASYNC_NOOP,
1050
+ error: void 0,
1051
+ ref: void 0
1052
+ };
1053
+ /**
1054
+ * Context for providing the Firestate store
1055
+ */
1056
+ const FirestateContext = createContext(null);
1057
+ /**
1058
+ * Hook to access the Firestate store
1059
+ */
1060
+ const useStore = () => {
1061
+ const store = useContext(FirestateContext);
1062
+ if (!store) throw new Error("useStore must be used within a FirestateProvider");
1063
+ return store;
1064
+ };
1065
+ /**
1066
+ * Hook to access the undo manager
1067
+ */
1068
+ const useUndoManager = () => {
1069
+ const { undoManager } = useStore();
1070
+ const subscribe = useCallback((onStoreChange) => undoManager.subscribe(onStoreChange), [undoManager]);
1071
+ const getSnapshot = useCallback(() => undoManager.getState(), [undoManager]);
1072
+ const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
1073
+ return useMemo(() => ({
1074
+ ...state,
1075
+ push: undoManager.push,
1076
+ undo: undoManager.undo,
1077
+ redo: undoManager.redo,
1078
+ clear: undoManager.clear
1079
+ }), [state, undoManager]);
1080
+ };
1081
+ /**
1082
+ * Hook to check if all tracked resources are synced
1083
+ */
1084
+ const useIsSynced = () => {
1085
+ const store = useStore();
1086
+ const subscribe = useCallback((onChange) => store.subscribeToSyncState(() => onChange()), [store]);
1087
+ const getSnapshot = useCallback(() => store.isSynced, [store]);
1088
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
1089
+ };
1090
+ /**
1091
+ * Hook to subscribe to a Firestore document with real-time updates.
1092
+ *
1093
+ * The subscription is keyed on the resolved document path (`definition` +
1094
+ * computed id) and `readOnly`. When that key changes — typically because
1095
+ * `params` produces a different id — the hook tears down the old Firestore
1096
+ * listener and attaches a new one. Toggling `undoable` does not rebuild the
1097
+ * subscription.
1098
+ *
1099
+ * Use `enabled: false` to suppress the subscription entirely (e.g., when
1100
+ * route params aren't ready yet).
1101
+ *
1102
+ * **SSR.** On the server there is no Firestore listener, so this hook returns
1103
+ * the initial handle (`{ data: undefined, isLoading: true }`). Mutations like
1104
+ * `update`/`set` will mutate orphaned local state with no effect — avoid
1105
+ * calling them server-side.
1106
+ *
1107
+ * @example
1108
+ * ```tsx
1109
+ * const projectDoc = defineDocument<Project>({
1110
+ * collection: 'projects',
1111
+ * id: (params) => params.projectId,
1112
+ * })
1113
+ *
1114
+ * function ProjectEditor({ projectId }: { projectId: string }) {
1115
+ * const { data, update, isLoading, isSynced } = useDocument({
1116
+ * definition: projectDoc,
1117
+ * params: { projectId },
1118
+ * })
1119
+ *
1120
+ * if (isLoading) return <Spinner />
1121
+ *
1122
+ * return (
1123
+ * <input
1124
+ * value={data?.name ?? ''}
1125
+ * onChange={(e) => update({ name: e.target.value })}
1126
+ * />
1127
+ * )
1128
+ * }
1129
+ * ```
1130
+ */
1131
+ const useDocument = (options) => {
1132
+ const { definition, params = {}, readOnly, undoable = true, enabled = true } = options;
1133
+ const store = useStore();
1134
+ const undoManager = store.undoManager;
1135
+ const undoableRef = useRef(undoable);
1136
+ undoableRef.current = undoable;
1137
+ const onPushUndo = useCallback((undoAction, redoAction, opts) => {
1138
+ if (!undoableRef.current) return;
1139
+ undoManager.push({
1140
+ undo: undoAction,
1141
+ redo: redoAction,
1142
+ groupId: opts?.undoGroupId
1143
+ });
1144
+ }, [undoManager]);
1145
+ const docId = enabled ? typeof definition.id === "function" ? definition.id(params) : definition.id : void 0;
1146
+ const collectionPath = enabled ? typeof definition.collection === "function" ? definition.collection(params) : definition.collection : void 0;
1147
+ const subscription = useMemo(() => enabled && docId !== void 0 && collectionPath !== void 0 ? createDocumentSubscription({
1148
+ store,
1149
+ definition,
1150
+ docId,
1151
+ collectionPath,
1152
+ readOnly,
1153
+ onPushUndo
1154
+ }) : null, [
1155
+ enabled,
1156
+ store,
1157
+ definition,
1158
+ docId,
1159
+ collectionPath,
1160
+ readOnly,
1161
+ onPushUndo
1162
+ ]);
1163
+ const subscribe = useCallback((onChange) => {
1164
+ if (!subscription) return NOOP;
1165
+ const unsub = subscription.subscribe(() => onChange());
1166
+ subscription.load();
1167
+ return () => {
1168
+ unsub();
1169
+ subscription.stop();
1170
+ };
1171
+ }, [subscription]);
1172
+ const getSnapshot = useCallback(() => subscription ? subscription.getHandle() : DISABLED_DOCUMENT_HANDLE, [subscription]);
1173
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
1174
+ };
1175
+ /**
1176
+ * Hook to subscribe to a Firestore collection with real-time updates.
1177
+ *
1178
+ * The subscription is keyed on the resolved collection path, `readOnly`, and
1179
+ * the `queryConstraints` reference. When any of these change, the listener
1180
+ * is torn down and re-attached with the new query. Toggling `undoable` does
1181
+ * not rebuild the subscription.
1182
+ *
1183
+ * **Memoize `queryConstraints`.** An inline array (`queryConstraints={[where(...)]}`)
1184
+ * creates a new reference every render, which will thrash the listener.
1185
+ * Wrap in `useMemo` with the underlying filter values as deps.
1186
+ *
1187
+ * Use `enabled: false` to suppress the subscription entirely (e.g., when
1188
+ * route params aren't ready yet).
1189
+ *
1190
+ * **SSR.** On the server there is no Firestore listener, so this hook returns
1191
+ * the initial handle (`{ data: {}, isLoading: true }` for non-lazy, or
1192
+ * `isActive: false` for lazy). Avoid calling mutations server-side.
1193
+ *
1194
+ * @example
1195
+ * ```tsx
1196
+ * const spacesCollection = defineCollection<Space>({
1197
+ * path: (params) => `projects/${params.projectId}/spaces`,
1198
+ * lazy: true,
1199
+ * })
1200
+ *
1201
+ * function SpacesList({ projectId }: { projectId: string }) {
1202
+ * const { data, update, load, isActive, isLoading } = useCollection({
1203
+ * definition: spacesCollection,
1204
+ * params: { projectId },
1205
+ * })
1206
+ *
1207
+ * // Lazy load on mount
1208
+ * useEffect(() => { load() }, [load])
1209
+ *
1210
+ * if (!isActive) return <Button onClick={load}>Load Spaces</Button>
1211
+ * if (isLoading) return <Spinner />
1212
+ *
1213
+ * return (
1214
+ * <ul>
1215
+ * {Object.values(data).map((space) => (
1216
+ * <li key={space.id}>{space.name}</li>
1217
+ * ))}
1218
+ * </ul>
1219
+ * )
1220
+ * }
1221
+ * ```
1222
+ */
1223
+ const useCollection = (options) => {
1224
+ const { definition, params = {}, readOnly, queryConstraints, undoable = true, enabled = true } = options;
1225
+ const store = useStore();
1226
+ const undoManager = store.undoManager;
1227
+ const undoableRef = useRef(undoable);
1228
+ undoableRef.current = undoable;
1229
+ const onPushUndo = useCallback((undoAction, redoAction, opts) => {
1230
+ if (!undoableRef.current) return;
1231
+ undoManager.push({
1232
+ undo: undoAction,
1233
+ redo: redoAction,
1234
+ groupId: opts?.undoGroupId
1235
+ });
1236
+ }, [undoManager]);
1237
+ const collectionPath = enabled ? typeof definition.path === "function" ? definition.path(params) : definition.path : void 0;
1238
+ const subscription = useMemo(() => enabled && collectionPath !== void 0 ? createCollectionSubscription({
1239
+ store,
1240
+ definition,
1241
+ collectionPath,
1242
+ readOnly,
1243
+ queryConstraints,
1244
+ onPushUndo
1245
+ }) : null, [
1246
+ enabled,
1247
+ store,
1248
+ definition,
1249
+ collectionPath,
1250
+ readOnly,
1251
+ queryConstraints,
1252
+ onPushUndo
1253
+ ]);
1254
+ const isLazy = definition.lazy ?? false;
1255
+ const subscribe = useCallback((onChange) => {
1256
+ if (!subscription) return NOOP;
1257
+ const unsub = subscription.subscribe(() => onChange());
1258
+ if (!isLazy) subscription.load();
1259
+ return () => {
1260
+ unsub();
1261
+ subscription.stop();
1262
+ };
1263
+ }, [subscription, isLazy]);
1264
+ const getSnapshot = useCallback(() => subscription ? subscription.getHandle() : DISABLED_COLLECTION_HANDLE, [subscription]);
1265
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
1266
+ };
1267
+ /**
1268
+ * Keyboard shortcut hook for undo/redo
1269
+ *
1270
+ * @example
1271
+ * ```tsx
1272
+ * function App() {
1273
+ * useUndoKeyboardShortcuts()
1274
+ * return <YourApp />
1275
+ * }
1276
+ * ```
1277
+ */
1278
+ const useUndoKeyboardShortcuts = () => {
1279
+ const undoManager = useStore().undoManager;
1280
+ useEffect(() => {
1281
+ const handleKeyDown = (e) => {
1282
+ if (!((navigator.userAgentData?.platform ?? navigator.platform).toUpperCase().includes("MAC") ? e.metaKey : e.ctrlKey)) return;
1283
+ if (e.key === "z" && !e.shiftKey) {
1284
+ e.preventDefault();
1285
+ undoManager.undo();
1286
+ } else if (e.key === "z" && e.shiftKey || e.key === "y") {
1287
+ e.preventDefault();
1288
+ undoManager.redo();
1289
+ }
1290
+ };
1291
+ window.addEventListener("keydown", handleKeyDown);
1292
+ return () => window.removeEventListener("keydown", handleKeyDown);
1293
+ }, [undoManager]);
1294
+ };
1295
+
1296
+ //#endregion
1297
+ //#region src/firestate.ts
1298
+ /**
1299
+ * Registry-driven Firestate API.
1300
+ *
1301
+ * Declare every document and collection in a single object and let the
1302
+ * library generate the typed read/write hooks. Replaces hand-writing a
1303
+ * `useSpaces`, `useWallTypes`, ... hook per Firestore collection.
1304
+ *
1305
+ * ```ts
1306
+ * interface TaskList { name: string; createdAt: number }
1307
+ * interface Task { title: string; completed: boolean }
1308
+ *
1309
+ * export const { useTaskList, useTasks } = createFirestate({
1310
+ * taskList: doc<TaskList>('taskLists/{listId}'),
1311
+ * tasks: col<Task>('taskLists/{listId}/tasks'),
1312
+ * })
1313
+ *
1314
+ * // At the call site:
1315
+ * const taskList = useTaskList({ listId })
1316
+ * const tasks = useTasks({ listId })
1317
+ * ```
1318
+ */
1319
+ /**
1320
+ * Declare a single-document entry for a Firestate registry.
1321
+ *
1322
+ * **A Zod `schema` field is required.** Both the data type (`T`) and the
1323
+ * path's literal type (`P`) are inferred from the call — `T` via
1324
+ * `z.infer<S>`, `P` from `path` — so the generated hook can statically
1325
+ * type-check the params object the caller passes. The schema also runs
1326
+ * at runtime on full-payload writes (`set`/`add`).
1327
+ *
1328
+ * If you'd rather not provide a schema at all, use {@link defineDocument}
1329
+ * directly — that escape hatch keeps the plain-TypeScript form, at the
1330
+ * cost of looser param typing on the hook and no runtime validation.
1331
+ *
1332
+ * ```ts
1333
+ * import { z } from 'zod'
1334
+ *
1335
+ * const TaskListSchema = z.object({ name: z.string(), createdAt: z.number() })
1336
+ * doc({ path: 'taskLists/{listId}', schema: TaskListSchema })
1337
+ * // → DocEntry<{ name: string; createdAt: number }, 'taskLists/{listId}'>
1338
+ * ```
1339
+ */
1340
+ function doc(opts) {
1341
+ const { path, ...rest } = opts;
1342
+ validateTemplate(path);
1343
+ splitDocPath(path);
1344
+ return {
1345
+ __kind: "document",
1346
+ path,
1347
+ ...rest
1348
+ };
1349
+ }
1350
+ /**
1351
+ * Declare a collection entry for a Firestate registry. See {@link doc}
1352
+ * for the schema/typing contract.
1353
+ */
1354
+ function col(opts) {
1355
+ const { path, ...rest } = opts;
1356
+ validateTemplate(path);
1357
+ return {
1358
+ __kind: "collection",
1359
+ path,
1360
+ ...rest
1361
+ };
1362
+ }
1363
+ /**
1364
+ * Turn a Firestate registry into a map of typed React hooks. Each entry
1365
+ * `K` produces a hook named `use{Capitalize<K>}`.
1366
+ *
1367
+ * ```ts
1368
+ * export const { useTaskList, useTasks } = createFirestate({
1369
+ * taskList: doc<TaskList>('taskLists/{listId}'),
1370
+ * tasks: col<Task>('taskLists/{listId}/tasks'),
1371
+ * })
1372
+ * ```
1373
+ */
1374
+ function createFirestate(registry) {
1375
+ const api = {};
1376
+ for (const key of Object.keys(registry)) {
1377
+ if (!isValidKey(key)) throw new Error(`[firestate] registry key "${key}" must start with a letter and contain only letters, digits, _ or $`);
1378
+ const entry = registry[key];
1379
+ const hookName = toHookName(key);
1380
+ if (entry.__kind === "document") {
1381
+ const definition = buildDocumentDefinition(entry);
1382
+ api[hookName] = (params = {}, options = {}) => useDocument({
1383
+ ...options,
1384
+ definition,
1385
+ params
1386
+ });
1387
+ } else {
1388
+ const definition = buildCollectionDefinition(entry);
1389
+ api[hookName] = (params = {}, options = {}) => useCollection({
1390
+ ...options,
1391
+ definition,
1392
+ params
1393
+ });
1394
+ }
1395
+ }
1396
+ return api;
1397
+ }
1398
+ /**
1399
+ * Build the underlying {@link DocumentDefinition} for a registry doc entry.
1400
+ * Exported for unit testing — registry consumers should call
1401
+ * {@link createFirestate} instead.
1402
+ *
1403
+ * @internal
1404
+ */
1405
+ function buildDocumentDefinition(entry) {
1406
+ const { collectionPath, idTemplate } = splitDocPath(entry.path);
1407
+ return defineDocument({
1408
+ schema: entry.schema,
1409
+ collection: (params) => interpolate(collectionPath, params),
1410
+ id: (params) => interpolate(idTemplate, params),
1411
+ autosave: entry.autosave,
1412
+ minLoadTime: entry.minLoadTime,
1413
+ readOnly: entry.readOnly,
1414
+ retryOnError: entry.retryOnError,
1415
+ retryInterval: entry.retryInterval
1416
+ });
1417
+ }
1418
+ /**
1419
+ * Build the underlying {@link CollectionDefinition} for a registry col entry.
1420
+ *
1421
+ * @internal
1422
+ */
1423
+ function buildCollectionDefinition(entry) {
1424
+ return defineCollection({
1425
+ schema: entry.schema,
1426
+ path: (params) => interpolate(entry.path, params),
1427
+ autosave: entry.autosave,
1428
+ minLoadTime: entry.minLoadTime,
1429
+ readOnly: entry.readOnly,
1430
+ lazy: entry.lazy,
1431
+ queryConstraints: entry.queryConstraints,
1432
+ retryOnError: entry.retryOnError,
1433
+ retryInterval: entry.retryInterval
1434
+ });
1435
+ }
1436
+ const VALID_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
1437
+ function isValidKey(key) {
1438
+ return VALID_KEY.test(key);
1439
+ }
1440
+ function toHookName(key) {
1441
+ return `use${key[0].toUpperCase()}${key.slice(1)}`;
1442
+ }
1443
+ const PLACEHOLDER = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
1444
+ /**
1445
+ * Validate that a path template uses only well-formed `{name}` placeholders
1446
+ * — no unclosed braces, no hyphens/dots inside placeholders, no `{1}` style
1447
+ * digit-leading names. Throws at definition time so a typo in the template
1448
+ * fails loud at `doc()` / `col()`, not three layers deep when a component
1449
+ * mounts.
1450
+ */
1451
+ function validateTemplate(template) {
1452
+ const stripped = template.replace(PLACEHOLDER, "");
1453
+ if (stripped.includes("{") || stripped.includes("}")) throw new Error(`[firestate] path "${template}" contains a malformed placeholder. Placeholders must look like "{name}" where name starts with a letter or underscore.`);
1454
+ }
1455
+ function interpolate(template, params) {
1456
+ return template.replace(PLACEHOLDER, (_, key) => {
1457
+ const v = params[key];
1458
+ if (v === void 0) throw new Error(`[firestate] missing param "${key}" for path "${template}"`);
1459
+ if (v === "") throw new Error(`[firestate] param "${key}" for path "${template}" must not be an empty string`);
1460
+ return v;
1461
+ });
1462
+ }
1463
+ /**
1464
+ * Split a document path template into a collection path and an id template.
1465
+ * `'taskLists/{listId}'` → `{ collectionPath: 'taskLists', idTemplate: '{listId}' }`.
1466
+ *
1467
+ * @internal
1468
+ */
1469
+ function splitDocPath(path) {
1470
+ const lastSlash = path.lastIndexOf("/");
1471
+ if (lastSlash === -1) throw new Error(`[firestate] document path "${path}" must contain at least one '/' separating the collection from the document id`);
1472
+ const collectionPath = path.slice(0, lastSlash);
1473
+ const idTemplate = path.slice(lastSlash + 1);
1474
+ if (collectionPath === "" || idTemplate === "") throw new Error(`[firestate] document path "${path}" must have non-empty collection and id segments`);
1475
+ return {
1476
+ collectionPath,
1477
+ idTemplate
1478
+ };
1479
+ }
1480
+
1481
+ //#endregion
1482
+ //#region src/undo.ts
1483
+ /**
1484
+ * Create an undo manager instance.
1485
+ * This is a standalone, framework-agnostic implementation.
1486
+ *
1487
+ * @example
1488
+ * ```ts
1489
+ * const undoManager = createUndoManager({ maxLength: 10 })
1490
+ *
1491
+ * undoManager.push({
1492
+ * undo: () => restoreOldValue(),
1493
+ * redo: () => applyNewValue(),
1494
+ * description: 'Update project name',
1495
+ * })
1496
+ *
1497
+ * await undoManager.undo() // Calls restoreOldValue()
1498
+ * await undoManager.redo() // Calls applyNewValue()
1499
+ * ```
1500
+ */
1501
+ const createUndoManager = (config = {}) => {
1502
+ const { maxLength = 20, onNavigate } = config;
1503
+ let undoStack = [];
1504
+ let redoStack = [];
1505
+ const subscribers = /* @__PURE__ */ new Set();
1506
+ let cachedState = null;
1507
+ const getState = () => {
1508
+ if (cachedState === null) cachedState = {
1509
+ undoStack,
1510
+ redoStack,
1511
+ canUndo: undoStack.length > 0,
1512
+ canRedo: redoStack.length > 0
1513
+ };
1514
+ return cachedState;
1515
+ };
1516
+ const notify = () => {
1517
+ cachedState = null;
1518
+ const state = getState();
1519
+ subscribers.forEach((fn) => fn(state));
1520
+ };
1521
+ const push = (action) => {
1522
+ if (action.groupId && undoStack.length > 0) {
1523
+ const last = undoStack[undoStack.length - 1];
1524
+ if (last?.groupId === action.groupId) {
1525
+ undoStack.pop();
1526
+ undoStack.push({
1527
+ undo: async () => {
1528
+ await action.undo();
1529
+ await last.undo();
1530
+ },
1531
+ redo: async () => {
1532
+ await last.redo();
1533
+ await action.redo();
1534
+ },
1535
+ groupId: action.groupId,
1536
+ path: action.path ?? last.path,
1537
+ description: action.description ?? last.description
1538
+ });
1539
+ redoStack = [];
1540
+ notify();
1541
+ return;
1542
+ }
1543
+ }
1544
+ undoStack.push(action);
1545
+ if (undoStack.length > maxLength) undoStack.shift();
1546
+ redoStack = [];
1547
+ notify();
1548
+ };
1549
+ const undo = async () => {
1550
+ const action = undoStack.pop();
1551
+ if (!action) return;
1552
+ if (action.path && onNavigate) onNavigate(action.path);
1553
+ try {
1554
+ await action.undo();
1555
+ redoStack.push(action);
1556
+ } catch (error) {
1557
+ undoStack.push(action);
1558
+ console.error("Undo failed:", error);
1559
+ throw error;
1560
+ }
1561
+ notify();
1562
+ };
1563
+ const redo = async () => {
1564
+ const action = redoStack.pop();
1565
+ if (!action) return;
1566
+ if (action.path && onNavigate) onNavigate(action.path);
1567
+ try {
1568
+ await action.redo();
1569
+ undoStack.push(action);
1570
+ if (undoStack.length > maxLength) undoStack.shift();
1571
+ } catch (error) {
1572
+ redoStack.push(action);
1573
+ console.error("Redo failed:", error);
1574
+ throw error;
1575
+ }
1576
+ notify();
1577
+ };
1578
+ const clear = () => {
1579
+ undoStack = [];
1580
+ redoStack = [];
1581
+ notify();
1582
+ };
1583
+ const subscribe = (fn) => {
1584
+ subscribers.add(fn);
1585
+ return () => subscribers.delete(fn);
1586
+ };
1587
+ return {
1588
+ get undoStack() {
1589
+ return undoStack;
1590
+ },
1591
+ get redoStack() {
1592
+ return redoStack;
1593
+ },
1594
+ get canUndo() {
1595
+ return undoStack.length > 0;
1596
+ },
1597
+ get canRedo() {
1598
+ return redoStack.length > 0;
1599
+ },
1600
+ push,
1601
+ undo,
1602
+ redo,
1603
+ clear,
1604
+ subscribe,
1605
+ getState
1606
+ };
1607
+ };
1608
+
1609
+ //#endregion
1610
+ //#region src/store.ts
1611
+ /**
1612
+ * Create a Firestate store.
1613
+ * This is the central configuration point for your Firestore state management.
1614
+ *
1615
+ * @example
1616
+ * ```ts
1617
+ * import { createStore } from 'firestate'
1618
+ * import { db } from './firebase'
1619
+ *
1620
+ * export const store = createStore({
1621
+ * firestore: db,
1622
+ * autosave: 1000,
1623
+ * maxUndoLength: 20,
1624
+ * onError: (error, context) => {
1625
+ * console.error(`Error in ${context.type} ${context.path}:`, error)
1626
+ * },
1627
+ * })
1628
+ * ```
1629
+ */
1630
+ const createStore = (config) => {
1631
+ const { firestore, autosave = 1e3, minLoadTime = 0, maxUndoLength = 20 } = config;
1632
+ let onError = config.onError;
1633
+ const undoManager = createUndoManager({ maxLength: maxUndoLength });
1634
+ const syncStates = /* @__PURE__ */ new Map();
1635
+ const syncSubscribers = /* @__PURE__ */ new Set();
1636
+ const computeIsSynced = () => {
1637
+ for (const synced of syncStates.values()) if (!synced) return false;
1638
+ return true;
1639
+ };
1640
+ const notifySyncSubscribers = () => {
1641
+ const isSynced = computeIsSynced();
1642
+ syncSubscribers.forEach((fn) => fn(isSynced));
1643
+ };
1644
+ return {
1645
+ firestore,
1646
+ undoManager,
1647
+ autosave,
1648
+ minLoadTime,
1649
+ reportError: (error, context) => {
1650
+ if (onError) onError(error, context);
1651
+ else console.error(`Firestate error in ${context.type} ${context.path} during ${context.operation}:`, error);
1652
+ },
1653
+ setOnError: (handler) => {
1654
+ onError = handler;
1655
+ },
1656
+ subscribeToSyncState: (fn) => {
1657
+ syncSubscribers.add(fn);
1658
+ return () => syncSubscribers.delete(fn);
1659
+ },
1660
+ reportSyncState: (key, isSynced) => {
1661
+ if (syncStates.get(key) !== isSynced) {
1662
+ syncStates.set(key, isSynced);
1663
+ notifySyncSubscribers();
1664
+ }
1665
+ },
1666
+ unregisterSyncState: (key) => {
1667
+ const prev = syncStates.get(key);
1668
+ if (prev === void 0) return;
1669
+ syncStates.delete(key);
1670
+ if (prev === false) notifySyncSubscribers();
1671
+ },
1672
+ get isSynced() {
1673
+ return computeIsSynced();
1674
+ }
1675
+ };
1676
+ };
1677
+
1678
+ //#endregion
1679
+ //#region src/provider.tsx
1680
+ /**
1681
+ * Provider component that sets up Firestate for your application.
1682
+ *
1683
+ * @example
1684
+ * ```tsx
1685
+ * import { FirestateProvider } from 'firestate'
1686
+ * import { db } from './firebase'
1687
+ *
1688
+ * function App() {
1689
+ * return (
1690
+ * <FirestateProvider
1691
+ * firestore={db}
1692
+ * autosave={1000}
1693
+ * maxUndoLength={20}
1694
+ * onError={(error, ctx) => console.error(ctx.path, error)}
1695
+ * >
1696
+ * <YourApp />
1697
+ * </FirestateProvider>
1698
+ * )
1699
+ * }
1700
+ * ```
1701
+ */
1702
+ const FirestateProvider = ({ firestore, autosave = 1e3, minLoadTime = 0, maxUndoLength = 20, onError, children }) => {
1703
+ const store = useMemo(() => createStore({
1704
+ firestore,
1705
+ autosave,
1706
+ minLoadTime,
1707
+ maxUndoLength,
1708
+ onError
1709
+ }), [
1710
+ firestore,
1711
+ autosave,
1712
+ minLoadTime,
1713
+ maxUndoLength
1714
+ ]);
1715
+ useEffect(() => {
1716
+ store.setOnError(onError);
1717
+ }, [store, onError]);
1718
+ return /* @__PURE__ */ jsx(FirestateContext.Provider, {
1719
+ value: store,
1720
+ children
1721
+ });
1722
+ };
1723
+ /**
1724
+ * Provider that uses an existing store instance.
1725
+ * Useful when you need to create the store outside of React.
1726
+ *
1727
+ * @example
1728
+ * ```tsx
1729
+ * const store = createStore({ firestore: db })
1730
+ *
1731
+ * function App() {
1732
+ * return (
1733
+ * <FirestateStoreProvider store={store}>
1734
+ * <YourApp />
1735
+ * </FirestateStoreProvider>
1736
+ * )
1737
+ * }
1738
+ * ```
1739
+ */
1740
+ const FirestateStoreProvider = ({ store, children }) => /* @__PURE__ */ jsx(FirestateContext.Provider, {
1741
+ value: store,
1742
+ children
1743
+ });
1744
+ /**
1745
+ * Hook to use navigation blocker when there are unsaved changes.
1746
+ * Works with react-router or similar routers.
1747
+ *
1748
+ * @example
1749
+ * ```tsx
1750
+ * function ProjectPage() {
1751
+ * const shouldBlock = useUnsavedChangesBlocker()
1752
+ *
1753
+ * // Use with react-router's useBlocker
1754
+ * const blocker = useBlocker(
1755
+ * ({ currentLocation, nextLocation }) =>
1756
+ * currentLocation.pathname !== nextLocation.pathname && shouldBlock
1757
+ * )
1758
+ *
1759
+ * return (
1760
+ * <>
1761
+ * <ProjectEditor />
1762
+ * {blocker.state === 'blocked' && (
1763
+ * <Dialog>Your changes may not be saved!</Dialog>
1764
+ * )}
1765
+ * </>
1766
+ * )
1767
+ * }
1768
+ * ```
1769
+ */
1770
+ const useUnsavedChangesBlocker = () => {
1771
+ const store = React.useContext(FirestateContext);
1772
+ const subscribe = useCallback((onChange) => store ? store.subscribeToSyncState(() => onChange()) : () => {}, [store]);
1773
+ const getSnapshot = useCallback(() => store ? !store.isSynced : false, [store]);
1774
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
1775
+ };
1776
+
1777
+ //#endregion
1778
+ export { FirestateContext, FirestateProvider, FirestateStoreProvider, applyDiff, applyDiffMutable, col, computeDiff, computeUndoDiff, createCollectionSubscription, createDiffAtPath, createDocumentSubscription, createFirestate, createStore, createUndoManager, deepClone, defineCollection, defineDocument, diffContainsPath, doc, extractDiffValue, flattenDiff, isDeepEqual, isDiffEmpty, mergeDiffs, unflattenDiff, useCollection, useDocument, useIsSynced, useStore, useUndoKeyboardShortcuts, useUndoManager, useUnsavedChangesBlocker };
1779
+ //# sourceMappingURL=index.mjs.map