@hvakr/firestate 0.1.2 → 0.1.3
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/README.md +189 -27
- package/dist/index.d.mts +373 -31
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +952 -465
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -1
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
|
|
3
|
+
import { FieldPath, Timestamp, collection, deleteDoc, deleteField, doc as doc$1, onSnapshot, query, queryEqual, serverTimestamp, setDoc, updateDoc, writeBatch } from "firebase/firestore";
|
|
3
4
|
import { jsx } from "react/jsx-runtime";
|
|
4
5
|
|
|
5
|
-
//#region src/schema.ts
|
|
6
|
+
//#region src/registry/schema.ts
|
|
6
7
|
function defineDocument(definition) {
|
|
7
8
|
return definition;
|
|
8
9
|
}
|
|
@@ -11,7 +12,7 @@ function defineCollection(definition) {
|
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
//#endregion
|
|
14
|
-
//#region src/diff.ts
|
|
15
|
+
//#region src/utils/diff.ts
|
|
15
16
|
/**
|
|
16
17
|
* Check if a value is a plain object (not array, null, or special Firestore type)
|
|
17
18
|
*/
|
|
@@ -60,6 +61,47 @@ const isDeepEqual = (a, b) => {
|
|
|
60
61
|
return false;
|
|
61
62
|
};
|
|
62
63
|
/**
|
|
64
|
+
* Equality used solely to decide whether a write is a no-op (§3 of the
|
|
65
|
+
* update-logic rewrite). Purpose-built to close the "Maximum update depth"
|
|
66
|
+
* render loop, where a write whose result equals the current view must produce
|
|
67
|
+
* NO new state identity. It differs from {@link isDeepEqual} in exactly the two
|
|
68
|
+
* places that let the loop survive a naive guard:
|
|
69
|
+
*
|
|
70
|
+
* - `NaN` ≡ `NaN` (via `Object.is`). `computeDiff` compares primitives with
|
|
71
|
+
* `!==`, so a field that is `NaN` on both sides emits a spurious diff.
|
|
72
|
+
* - An explicit-`undefined` key ≡ a missing key (`{x: undefined}` ≡ `{}`).
|
|
73
|
+
* `isDeepEqual` rejects these on a keys-length mismatch.
|
|
74
|
+
*
|
|
75
|
+
* Standalone ON PURPOSE — do NOT fold this into `isDeepEqual`. `computeDiff`
|
|
76
|
+
* and `computeUndoDiff` depend on `isDeepEqual`'s current `NaN !== NaN` and
|
|
77
|
+
* strict keys-length semantics; relaxing them there would change the wire
|
|
78
|
+
* payload. Firestore opaque values (Timestamp, sentinels, refs) delegate to
|
|
79
|
+
* their own `.isEqual`; arrays compare elementwise.
|
|
80
|
+
*/
|
|
81
|
+
const valuesEqualForNoOp = (a, b) => {
|
|
82
|
+
if (Object.is(a, b)) return true;
|
|
83
|
+
if (isFirestoreOpaque(a) || isFirestoreOpaque(b)) return isFirestoreOpaque(a) && isFirestoreOpaque(b) && a.isEqual(b);
|
|
84
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
85
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
|
86
|
+
if (a.length !== b.length) return false;
|
|
87
|
+
return a.every((item, i) => valuesEqualForNoOp(item, b[i]));
|
|
88
|
+
}
|
|
89
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
90
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
91
|
+
for (const key of keys) if (!valuesEqualForNoOp(a[key], b[key])) return false;
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Snapshot-side no-op collapse comparison over the observable fields shared by
|
|
98
|
+
* DocumentState and CollectionState (§3/§4): returns `true` when something a
|
|
99
|
+
* consumer can observe changed. `data` uses {@link valuesEqualForNoOp} so an
|
|
100
|
+
* explicit-`undefined` ≡ missing key does not count as a change. Collection
|
|
101
|
+
* state additionally checks `isActive` at the call site.
|
|
102
|
+
*/
|
|
103
|
+
const observableStateChanged = (prev, next) => prev.isLoading !== next.isLoading || prev.isSynced !== next.isSynced || prev.error !== next.error || !valuesEqualForNoOp(prev.data, next.data);
|
|
104
|
+
/**
|
|
63
105
|
* Compute the minimal diff between two objects for Firestore updates.
|
|
64
106
|
* Returns only the fields that changed, using deleteField() for removed fields.
|
|
65
107
|
*
|
|
@@ -94,6 +136,48 @@ const computeDiff = (from, to) => {
|
|
|
94
136
|
return diff;
|
|
95
137
|
};
|
|
96
138
|
/**
|
|
139
|
+
* Strip FieldValue sentinels from a freshly-computed `edits` object when they
|
|
140
|
+
* match a write the server has already durably accepted (`committed`).
|
|
141
|
+
*
|
|
142
|
+
* A sentinel (`serverTimestamp`, `increment`, `arrayUnion`, `arrayRemove`) is a
|
|
143
|
+
* fire-once write intent: the client ships the sentinel, the server resolves it
|
|
144
|
+
* to a concrete value, and the confirming snapshot carries that value. The
|
|
145
|
+
* snapshot rebase re-derives pending edits with `computeDiff` and re-applies
|
|
146
|
+
* them over the new server truth — but a sentinel can never compare equal to
|
|
147
|
+
* its own resolved value (`isDeepEqual` only delegates to `.isEqual` when both
|
|
148
|
+
* sides are opaque), so a committed sentinel would be re-derived and re-written
|
|
149
|
+
* on every snapshot forever (and a non-idempotent `increment` would re-apply
|
|
150
|
+
* each loop — data corruption).
|
|
151
|
+
*
|
|
152
|
+
* The fix: once a write's promise resolves, the caller records its payload as
|
|
153
|
+
* `committed`. On the next snapshot the rebase calls this to drop any sentinel
|
|
154
|
+
* that sits at the same path AND is `.isEqual` to the committed one — it has
|
|
155
|
+
* landed, so the server's value is now the truth. Everything else is preserved:
|
|
156
|
+
* a not-yet-sent sentinel (no `committed` entry), a re-edited sentinel (a
|
|
157
|
+
* different `.isEqual` result), and every plain value flow through untouched,
|
|
158
|
+
* so genuine pending edits and collaborator merges are unaffected.
|
|
159
|
+
*
|
|
160
|
+
* `edits` is mutated in place (it is always a fresh `computeDiff` result).
|
|
161
|
+
* Recurses into plain objects, so collection diffs keyed by docId converge too:
|
|
162
|
+
* when a doc's nested diff empties out, the doc entry itself is removed.
|
|
163
|
+
*
|
|
164
|
+
* @internal
|
|
165
|
+
*/
|
|
166
|
+
const dropCommittedSentinels = (edits, committed) => {
|
|
167
|
+
if (!committed) return edits;
|
|
168
|
+
for (const key of Object.keys(edits)) {
|
|
169
|
+
const value = edits[key];
|
|
170
|
+
const sent = committed[key];
|
|
171
|
+
if (isFirestoreOpaque(value)) {
|
|
172
|
+
if (isFirestoreOpaque(sent) && value.isEqual(sent)) delete edits[key];
|
|
173
|
+
} else if (isPlainObject(value) && isPlainObject(sent)) {
|
|
174
|
+
dropCommittedSentinels(value, sent);
|
|
175
|
+
if (Object.keys(value).length === 0) delete edits[key];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return edits;
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
97
181
|
* Apply a Firestore diff to a target object in place (mutating).
|
|
98
182
|
* Handles deleteField(), serverTimestamp(), and nested objects.
|
|
99
183
|
*
|
|
@@ -163,6 +247,13 @@ const isDiffEmpty = (diff) => Object.keys(diff).length === 0;
|
|
|
163
247
|
* VectorValue) are NOT flattened — they're preserved at their path so
|
|
164
248
|
* Firestore receives them in their original form.
|
|
165
249
|
*
|
|
250
|
+
* ⚠️ The dot-joined keys this produces are AMBIGUOUS to Firestore if any map
|
|
251
|
+
* key itself contains a "." (e.g. an email key `a@b.com`): `updateDoc` parses
|
|
252
|
+
* the "." as a path separator and writes to the wrong nested field. The
|
|
253
|
+
* write path uses {@link flattenDiffToFieldPaths} + `FieldPath` instead, which
|
|
254
|
+
* keeps every segment literal. Reach for this only when dotted-string keys are
|
|
255
|
+
* what you actually want.
|
|
256
|
+
*
|
|
166
257
|
* @param diff - The nested diff object
|
|
167
258
|
* @param prefix - Internal: current path prefix for recursion
|
|
168
259
|
* @returns Flattened object with dotted keys
|
|
@@ -186,6 +277,60 @@ const flattenDiff = (diff, prefix = "") => {
|
|
|
186
277
|
return result;
|
|
187
278
|
};
|
|
188
279
|
/**
|
|
280
|
+
* Flatten a nested diff into a list of `{ segments, value }` entries, where
|
|
281
|
+
* `segments` is the path expressed as an array of *literal* key segments
|
|
282
|
+
* (never joined into a dotted string).
|
|
283
|
+
*
|
|
284
|
+
* This is the segment-preserving sibling of {@link flattenDiff}. It exists
|
|
285
|
+
* because dot-joined string keys are ambiguous to Firestore: `updateDoc(ref,
|
|
286
|
+
* { "users.a@b.com.role": 4 })` parses the `.` inside the email key as a path
|
|
287
|
+
* separator and writes to `users → "a@b" → "com" → role` instead of the
|
|
288
|
+
* literal key `"a@b.com"`. Feeding these segments to `new FieldPath(...segments)`
|
|
289
|
+
* (with the variadic `updateDoc(ref, fieldPath, value, …)` form) keeps every
|
|
290
|
+
* segment literal, so a "." inside a map key is never re-interpreted.
|
|
291
|
+
*
|
|
292
|
+
* The opaque/plain-object rules mirror {@link flattenDiff} exactly: arrays,
|
|
293
|
+
* FieldValue sentinels (deleteField, serverTimestamp, …), and Firestore value
|
|
294
|
+
* types (Timestamp, DocumentReference, GeoPoint, Bytes, VectorValue) ride
|
|
295
|
+
* through verbatim at their path; only plain objects are recursed into.
|
|
296
|
+
*
|
|
297
|
+
* @param diff - The nested diff object
|
|
298
|
+
* @param prefix - Internal: accumulated path segments for recursion
|
|
299
|
+
* @returns Flat list of `{ segments, value }` entries
|
|
300
|
+
*/
|
|
301
|
+
const flattenDiffToFieldPaths = (diff, prefix = []) => {
|
|
302
|
+
const result = [];
|
|
303
|
+
for (const key of Object.keys(diff)) {
|
|
304
|
+
const value = diff[key];
|
|
305
|
+
const segments = [...prefix, key];
|
|
306
|
+
if (Array.isArray(value) || isFirestoreOpaque(value)) {
|
|
307
|
+
result.push({
|
|
308
|
+
segments,
|
|
309
|
+
value
|
|
310
|
+
});
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (isPlainObject(value)) {
|
|
314
|
+
result.push(...flattenDiffToFieldPaths(value, segments));
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
result.push({
|
|
318
|
+
segments,
|
|
319
|
+
value
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
return result;
|
|
323
|
+
};
|
|
324
|
+
/**
|
|
325
|
+
* Build the variadic `(fieldPath, value, …)` argument list for the
|
|
326
|
+
* `updateDoc(ref, …)` / `batch.update(ref, …)` form from a nested diff. Each
|
|
327
|
+
* segment array becomes a literal `FieldPath`, so a "." inside a map key (e.g.
|
|
328
|
+
* the email key `a@b.com`) stays a single segment instead of being re-parsed
|
|
329
|
+
* as a path separator. Returns `[]` for an empty diff — callers should skip
|
|
330
|
+
* the update entirely in that case.
|
|
331
|
+
*/
|
|
332
|
+
const diffToFieldPathArgs = (diff) => flattenDiffToFieldPaths(diff).flatMap((e) => [new FieldPath(...e.segments), e.value]);
|
|
333
|
+
/**
|
|
189
334
|
* Merge two diffs together, with the second taking precedence
|
|
190
335
|
*/
|
|
191
336
|
const mergeDiffs = (first, second) => {
|
|
@@ -413,120 +558,159 @@ const applyOverridesAtPaths = (merged, overrides) => {
|
|
|
413
558
|
};
|
|
414
559
|
|
|
415
560
|
//#endregion
|
|
416
|
-
//#region src/
|
|
561
|
+
//#region src/core/collection.ts
|
|
417
562
|
let syncKeyCounter$1 = 0;
|
|
418
563
|
/**
|
|
419
|
-
*
|
|
420
|
-
*
|
|
564
|
+
* Build the Firestore query a collection subscription runs: `definition`-level
|
|
565
|
+
* constraints first, then hook-level `extraConstraints`. With no constraints at
|
|
566
|
+
* all the bare collection reference is itself a valid `Query`.
|
|
567
|
+
*
|
|
568
|
+
* Single source of truth for query assembly. `useCollection` decides whether a
|
|
569
|
+
* fresh `queryConstraints` array is semantically the same query — and so
|
|
570
|
+
* whether to keep the existing listener instead of tearing it down — by
|
|
571
|
+
* building the prospective query with this exact function and comparing via
|
|
572
|
+
* Firestore's `queryEqual` (see hooks.ts). That comparison is only correct if
|
|
573
|
+
* it assembles the query the same way the subscription does, so both paths MUST
|
|
574
|
+
* go through here. Don't re-inline the merge order at either call site.
|
|
575
|
+
*/
|
|
576
|
+
const buildCollectionQuery = (ref, definitionConstraints, extraConstraints) => {
|
|
577
|
+
const all = [...definitionConstraints ?? [], ...extraConstraints ?? []];
|
|
578
|
+
return all.length > 0 ? query(ref, ...all) : ref;
|
|
579
|
+
};
|
|
580
|
+
/**
|
|
581
|
+
* Create a collection subscription.
|
|
582
|
+
* This is a low-level API - prefer using useCollection hook in React.
|
|
421
583
|
*
|
|
422
584
|
* @example
|
|
423
585
|
* ```ts
|
|
424
|
-
* const subscription =
|
|
586
|
+
* const subscription = createCollectionSubscription({
|
|
425
587
|
* store,
|
|
426
|
-
* definition:
|
|
427
|
-
*
|
|
588
|
+
* definition: spacesCollection,
|
|
589
|
+
* collectionPath: 'projects/123/spaces',
|
|
428
590
|
* })
|
|
429
591
|
*
|
|
430
592
|
* const unsubscribe = subscription.subscribe((state) => {
|
|
431
|
-
* console.log('
|
|
593
|
+
* console.log('Collection state:', state)
|
|
432
594
|
* })
|
|
433
595
|
*
|
|
434
|
-
* subscription.load()
|
|
596
|
+
* subscription.load() // For lazy collections
|
|
435
597
|
* ```
|
|
436
598
|
*/
|
|
437
|
-
const
|
|
438
|
-
const { store, definition,
|
|
599
|
+
const createCollectionSubscription = (options) => {
|
|
600
|
+
const { store, definition, collectionPath: resolvedPath, readOnly, queryConstraints: extraConstraints, onPushUndo } = options;
|
|
439
601
|
const { firestore, autosave: defaultAutosave, minLoadTime: defaultMinLoadTime } = store;
|
|
440
|
-
const {
|
|
602
|
+
const { path, autosave = defaultAutosave, minLoadTime = defaultMinLoadTime, readOnly: definitionReadOnly, lazy = false, queryConstraints: definitionConstraints, retryOnError = false, retryInterval = 5e3, schema } = definition;
|
|
441
603
|
const isReadOnly = readOnly ?? definitionReadOnly ?? false;
|
|
442
|
-
const
|
|
443
|
-
if (
|
|
444
|
-
const
|
|
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);
|
|
604
|
+
const collectionPath = resolvedPath ?? (typeof path === "string" ? path : void 0);
|
|
605
|
+
if (collectionPath === void 0) throw new Error(`createCollectionSubscription: definition.path is a function; pass a resolved collectionPath in options.`);
|
|
606
|
+
const collectionRef = collection(firestore, collectionPath);
|
|
447
607
|
const state = {
|
|
448
608
|
syncState: void 0,
|
|
449
609
|
localState: void 0,
|
|
450
|
-
isLoading:
|
|
610
|
+
isLoading: !lazy,
|
|
611
|
+
isActive: !lazy,
|
|
451
612
|
error: void 0,
|
|
452
|
-
|
|
453
|
-
inflightLocalState: void 0,
|
|
454
|
-
isSetOperation: false,
|
|
613
|
+
committedWrite: void 0,
|
|
455
614
|
displayOverrides: /* @__PURE__ */ new Map()
|
|
456
615
|
};
|
|
457
616
|
const subscribers = /* @__PURE__ */ new Set();
|
|
458
617
|
let unsubscribeListener = null;
|
|
459
618
|
let autosaveTimeout = null;
|
|
460
|
-
let retryTimeout = null;
|
|
461
619
|
let minLoadTimeout = null;
|
|
620
|
+
let retryTimeout = null;
|
|
462
621
|
let minLoadTimeElapsed = false;
|
|
463
622
|
let loaded = false;
|
|
464
623
|
let cachedHandle = null;
|
|
465
|
-
const syncKey = `
|
|
624
|
+
const syncKey = `col:${collectionPath}#${++syncKeyCounter$1}`;
|
|
466
625
|
const getMergedData = () => {
|
|
467
|
-
|
|
468
|
-
const base = state.localState ?? state.syncState;
|
|
469
|
-
if (base === void 0) return void 0;
|
|
470
|
-
return applyOverridesAtPaths(base, state.displayOverrides);
|
|
626
|
+
return applyOverridesAtPaths(state.localState ?? state.syncState ?? {}, state.displayOverrides);
|
|
471
627
|
};
|
|
472
628
|
const getPublicState = () => ({
|
|
473
629
|
data: getMergedData(),
|
|
474
630
|
isLoading: state.isLoading,
|
|
475
631
|
isSynced: state.localState === void 0,
|
|
632
|
+
isActive: state.isActive,
|
|
476
633
|
error: state.error
|
|
477
634
|
});
|
|
635
|
+
let lastPublished = null;
|
|
636
|
+
const publicStateChanged = (prev, next) => prev.isActive !== next.isActive || observableStateChanged(prev, next);
|
|
478
637
|
const notify = () => {
|
|
479
|
-
reconcileDisplayOverrides(state.localState
|
|
480
|
-
cachedHandle = null;
|
|
638
|
+
reconcileDisplayOverrides(state.localState, state.displayOverrides);
|
|
481
639
|
const publicState = getPublicState();
|
|
640
|
+
if (lastPublished !== null && !publicStateChanged(lastPublished, publicState)) return;
|
|
641
|
+
lastPublished = publicState;
|
|
642
|
+
cachedHandle = null;
|
|
482
643
|
subscribers.forEach((fn) => fn(publicState));
|
|
483
644
|
store.reportSyncState(syncKey, publicState.isSynced);
|
|
484
645
|
};
|
|
646
|
+
const warnNoSnapshot = (method) => {
|
|
647
|
+
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.`);
|
|
648
|
+
};
|
|
485
649
|
const updateState = (diff, undoOptions = {}) => {
|
|
486
650
|
if (isReadOnly) return;
|
|
487
|
-
if (
|
|
488
|
-
|
|
651
|
+
if (state.syncState === void 0) {
|
|
652
|
+
warnNoSnapshot("update");
|
|
489
653
|
return;
|
|
490
654
|
}
|
|
491
|
-
const rawBase = state.localState ?? state.syncState;
|
|
655
|
+
const rawBase = state.localState ?? state.syncState ?? {};
|
|
492
656
|
const newLocalState = deepClone(rawBase);
|
|
493
657
|
applyDiffMutable(newLocalState, diff);
|
|
658
|
+
for (const [docId, docData] of Object.entries(newLocalState)) if (docData && typeof docData === "object") docData.id = docId;
|
|
659
|
+
if (valuesEqualForNoOp(rawBase, newLocalState)) return;
|
|
494
660
|
if (undoOptions?.undoable !== false && onPushUndo) {
|
|
495
661
|
const undoDiff = computeDiff(newLocalState, rawBase);
|
|
496
662
|
const redoDiff = computeDiff(rawBase, newLocalState);
|
|
497
663
|
onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
|
|
498
664
|
}
|
|
499
665
|
state.localState = newLocalState;
|
|
500
|
-
state.isSetOperation = false;
|
|
501
666
|
notify();
|
|
502
667
|
scheduleAutosave();
|
|
503
668
|
};
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
669
|
+
function addDocument(idOrData, dataOrOptions, maybeUndoOptions) {
|
|
670
|
+
const hasExplicitId = typeof idOrData === "string";
|
|
671
|
+
const data = hasExplicitId ? dataOrOptions : idOrData;
|
|
672
|
+
const undoOptions = (hasExplicitId ? maybeUndoOptions : dataOrOptions) ?? {};
|
|
673
|
+
if (isReadOnly) return void 0;
|
|
674
|
+
if (state.syncState === void 0) {
|
|
675
|
+
warnNoSnapshot("add");
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const id = hasExplicitId ? idOrData : doc$1(collectionRef).id;
|
|
679
|
+
const newDoc = {
|
|
680
|
+
...data,
|
|
681
|
+
id
|
|
682
|
+
};
|
|
683
|
+
if (schema) schema.parse(newDoc);
|
|
507
684
|
const currentData = getMergedData();
|
|
685
|
+
const newLocalState = deepClone(currentData);
|
|
686
|
+
newLocalState[id] = newDoc;
|
|
687
|
+
if (valuesEqualForNoOp(currentData, newLocalState)) return id;
|
|
508
688
|
if (undoOptions?.undoable !== false && onPushUndo) {
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
const dataToRestore = deepClone(state.localState ?? state.syncState);
|
|
513
|
-
onPushUndo(() => setData(dataToRestore, { undoable: false }), () => setData(dataForRedo, { undoable: false }), undoOptions);
|
|
514
|
-
}
|
|
689
|
+
const undoDiff = computeDiff(newLocalState, currentData);
|
|
690
|
+
const redoDiff = computeDiff(currentData, newLocalState);
|
|
691
|
+
onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
|
|
515
692
|
}
|
|
516
|
-
state.localState =
|
|
517
|
-
state.isSetOperation = true;
|
|
693
|
+
state.localState = newLocalState;
|
|
518
694
|
notify();
|
|
519
695
|
scheduleAutosave();
|
|
520
|
-
|
|
521
|
-
|
|
696
|
+
return id;
|
|
697
|
+
}
|
|
698
|
+
const removeDocument = (id, undoOptions = {}) => {
|
|
522
699
|
if (isReadOnly) return;
|
|
523
|
-
if (
|
|
700
|
+
if (state.syncState === void 0) {
|
|
701
|
+
warnNoSnapshot("remove");
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
const currentData = getMergedData();
|
|
705
|
+
if (!(id in currentData)) return;
|
|
706
|
+
const newLocalState = deepClone(currentData);
|
|
707
|
+
delete newLocalState[id];
|
|
524
708
|
if (undoOptions?.undoable !== false && onPushUndo) {
|
|
525
|
-
const
|
|
526
|
-
|
|
709
|
+
const undoDiff = computeDiff(newLocalState, currentData);
|
|
710
|
+
const redoDiff = computeDiff(currentData, newLocalState);
|
|
711
|
+
onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
|
|
527
712
|
}
|
|
528
|
-
state.localState =
|
|
529
|
-
state.isSetOperation = false;
|
|
713
|
+
state.localState = newLocalState;
|
|
530
714
|
notify();
|
|
531
715
|
scheduleAutosave();
|
|
532
716
|
};
|
|
@@ -537,120 +721,94 @@ const createDocumentSubscription = (options) => {
|
|
|
537
721
|
}, autosave);
|
|
538
722
|
};
|
|
539
723
|
const sync = async () => {
|
|
540
|
-
if (state.localState
|
|
541
|
-
if (state.
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
try {
|
|
545
|
-
await deleteDoc(docRef);
|
|
546
|
-
} catch (error) {
|
|
547
|
-
console.error("Sync failed:", error);
|
|
548
|
-
state.waitingForUpdate = false;
|
|
549
|
-
state.inflightLocalState = void 0;
|
|
550
|
-
state.error = error;
|
|
551
|
-
store.reportError(error, {
|
|
552
|
-
type: "document",
|
|
553
|
-
path: `${collectionPath}/${documentId}`,
|
|
554
|
-
operation: "write"
|
|
555
|
-
});
|
|
556
|
-
notify();
|
|
557
|
-
}
|
|
558
|
-
return;
|
|
559
|
-
}
|
|
560
|
-
if (state.syncState && isDeepEqual(state.localState, state.syncState)) {
|
|
724
|
+
if (!state.localState) return;
|
|
725
|
+
if (state.syncState === void 0) return;
|
|
726
|
+
const syncState = state.syncState;
|
|
727
|
+
if (isDeepEqual(state.localState, syncState)) {
|
|
561
728
|
state.localState = void 0;
|
|
562
|
-
state.inflightLocalState = void 0;
|
|
563
729
|
notify();
|
|
564
730
|
return;
|
|
565
731
|
}
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
const isCreation = !state.syncState;
|
|
569
|
-
const useSetDoc = wasSetOperation || isCreation;
|
|
570
|
-
const diff = state.syncState ? computeDiff(state.syncState, state.localState) : void 0;
|
|
571
|
-
state.inflightLocalState = deepClone(state.localState);
|
|
572
|
-
state.waitingForUpdate = true;
|
|
732
|
+
const diff = computeDiff(syncState, state.localState);
|
|
733
|
+
const committing = deepClone(state.localState);
|
|
573
734
|
try {
|
|
574
|
-
|
|
575
|
-
|
|
735
|
+
const batch = writeBatch(firestore);
|
|
736
|
+
const deleteFieldSentinel = deleteField();
|
|
737
|
+
for (const [docId, docDiff] of Object.entries(diff)) {
|
|
738
|
+
const docRef = doc$1(collectionRef, docId);
|
|
739
|
+
if (docDiff !== null && typeof docDiff === "object" && "isEqual" in docDiff && typeof docDiff.isEqual === "function" && docDiff.isEqual(deleteFieldSentinel)) batch.delete(docRef);
|
|
740
|
+
else if (!(docId in syncState)) batch.set(docRef, docDiff);
|
|
741
|
+
else {
|
|
742
|
+
const args = diffToFieldPathArgs(docDiff);
|
|
743
|
+
if (args.length) batch.update(docRef, ...args);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
await batch.commit();
|
|
747
|
+
state.committedWrite = committing;
|
|
576
748
|
} catch (error) {
|
|
577
|
-
console.error("
|
|
578
|
-
state.waitingForUpdate = false;
|
|
579
|
-
state.inflightLocalState = void 0;
|
|
749
|
+
console.error("Collection sync failed:", error);
|
|
580
750
|
state.error = error;
|
|
581
751
|
store.reportError(error, {
|
|
582
|
-
type: "
|
|
583
|
-
path:
|
|
752
|
+
type: "collection",
|
|
753
|
+
path: collectionPath,
|
|
584
754
|
operation: "write"
|
|
585
755
|
});
|
|
586
756
|
notify();
|
|
587
757
|
}
|
|
588
758
|
};
|
|
589
|
-
const handleSnapshot = (
|
|
590
|
-
|
|
759
|
+
const handleSnapshot = (docs) => {
|
|
760
|
+
const newSyncState = {};
|
|
761
|
+
for (const { id, data } of docs) newSyncState[id] = {
|
|
762
|
+
...data,
|
|
763
|
+
id
|
|
764
|
+
};
|
|
765
|
+
const prevSync = state.syncState;
|
|
766
|
+
state.syncState = newSyncState;
|
|
591
767
|
state.error = void 0;
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
} else state.localState = void 0;
|
|
768
|
+
const committed = state.committedWrite;
|
|
769
|
+
state.committedWrite = void 0;
|
|
770
|
+
const currentLocal = state.localState;
|
|
771
|
+
if (currentLocal !== void 0 && prevSync !== void 0) {
|
|
772
|
+
const userEdits = computeDiff(prevSync, currentLocal);
|
|
773
|
+
dropCommittedSentinels(userEdits, committed);
|
|
774
|
+
const rebasedLocalState = applyDiff(newSyncState, userEdits);
|
|
775
|
+
for (const docId of Object.keys(prevSync)) if (!(docId in newSyncState)) delete rebasedLocalState[docId];
|
|
776
|
+
for (const [docId, docData] of Object.entries(rebasedLocalState)) if (docData && typeof docData === "object") docData.id = docId;
|
|
777
|
+
state.localState = isDeepEqual(rebasedLocalState, newSyncState) ? void 0 : rebasedLocalState;
|
|
603
778
|
}
|
|
604
779
|
if (minLoadTimeElapsed) state.isLoading = false;
|
|
605
780
|
loaded = true;
|
|
606
781
|
if (state.localState !== void 0) scheduleAutosave();
|
|
607
782
|
notify();
|
|
608
783
|
};
|
|
609
|
-
const handleMissingDocument = () => {
|
|
610
|
-
state.syncState = void 0;
|
|
611
|
-
state.error = void 0;
|
|
612
|
-
if (state.localState === null) {
|
|
613
|
-
state.localState = void 0;
|
|
614
|
-
state.isSetOperation = false;
|
|
615
|
-
if (autosaveTimeout) {
|
|
616
|
-
clearTimeout(autosaveTimeout);
|
|
617
|
-
autosaveTimeout = null;
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
if (state.waitingForUpdate) {
|
|
621
|
-
state.waitingForUpdate = false;
|
|
622
|
-
state.inflightLocalState = void 0;
|
|
623
|
-
}
|
|
624
|
-
if (minLoadTimeElapsed) state.isLoading = false;
|
|
625
|
-
loaded = true;
|
|
626
|
-
notify();
|
|
627
|
-
};
|
|
628
784
|
const handleError = (error) => {
|
|
629
785
|
if (retryOnError) {
|
|
630
|
-
console.warn("
|
|
786
|
+
console.warn("Collection listener error, retrying:", error);
|
|
631
787
|
retryTimeout = setTimeout(() => {
|
|
632
788
|
stop();
|
|
633
|
-
|
|
789
|
+
startListener();
|
|
634
790
|
}, retryInterval);
|
|
635
791
|
} else {
|
|
636
792
|
state.error = error;
|
|
637
793
|
state.isLoading = false;
|
|
638
794
|
loaded = true;
|
|
639
795
|
store.reportError(error, {
|
|
640
|
-
type: "
|
|
641
|
-
path:
|
|
796
|
+
type: "collection",
|
|
797
|
+
path: collectionPath,
|
|
642
798
|
operation: "read"
|
|
643
799
|
});
|
|
644
800
|
notify();
|
|
645
801
|
}
|
|
646
802
|
};
|
|
647
|
-
const
|
|
803
|
+
const startListener = () => {
|
|
648
804
|
if (unsubscribeListener) return;
|
|
649
805
|
loaded = false;
|
|
650
806
|
minLoadTimeElapsed = false;
|
|
651
|
-
unsubscribeListener = onSnapshot(
|
|
652
|
-
|
|
653
|
-
|
|
807
|
+
unsubscribeListener = onSnapshot(buildCollectionQuery(collectionRef, definitionConstraints, extraConstraints), (snapshot) => {
|
|
808
|
+
handleSnapshot(snapshot.docs.map((docSnap) => ({
|
|
809
|
+
id: docSnap.id,
|
|
810
|
+
data: docSnap.data()
|
|
811
|
+
})));
|
|
654
812
|
}, handleError);
|
|
655
813
|
minLoadTimeout = setTimeout(() => {
|
|
656
814
|
minLoadTimeout = null;
|
|
@@ -661,7 +819,20 @@ const createDocumentSubscription = (options) => {
|
|
|
661
819
|
minLoadTimeElapsed = true;
|
|
662
820
|
}, minLoadTime);
|
|
663
821
|
};
|
|
822
|
+
const load = () => {
|
|
823
|
+
if (unsubscribeListener) return;
|
|
824
|
+
if (!state.isActive) {
|
|
825
|
+
state.isActive = true;
|
|
826
|
+
state.isLoading = true;
|
|
827
|
+
notify();
|
|
828
|
+
}
|
|
829
|
+
startListener();
|
|
830
|
+
};
|
|
664
831
|
const stop = () => {
|
|
832
|
+
if (retryTimeout) {
|
|
833
|
+
clearTimeout(retryTimeout);
|
|
834
|
+
retryTimeout = null;
|
|
835
|
+
}
|
|
665
836
|
if (unsubscribeListener) {
|
|
666
837
|
unsubscribeListener();
|
|
667
838
|
unsubscribeListener = null;
|
|
@@ -670,10 +841,6 @@ const createDocumentSubscription = (options) => {
|
|
|
670
841
|
clearTimeout(autosaveTimeout);
|
|
671
842
|
autosaveTimeout = null;
|
|
672
843
|
}
|
|
673
|
-
if (retryTimeout) {
|
|
674
|
-
clearTimeout(retryTimeout);
|
|
675
|
-
retryTimeout = null;
|
|
676
|
-
}
|
|
677
844
|
if (minLoadTimeout) {
|
|
678
845
|
clearTimeout(minLoadTimeout);
|
|
679
846
|
minLoadTimeout = null;
|
|
@@ -687,13 +854,15 @@ const createDocumentSubscription = (options) => {
|
|
|
687
854
|
const buildHandle = () => ({
|
|
688
855
|
data: getMergedData(),
|
|
689
856
|
update: updateState,
|
|
690
|
-
|
|
691
|
-
|
|
857
|
+
add: addDocument,
|
|
858
|
+
remove: removeDocument,
|
|
692
859
|
isLoading: state.isLoading,
|
|
693
860
|
isSynced: state.localState === void 0,
|
|
861
|
+
isActive: state.isActive,
|
|
862
|
+
load,
|
|
694
863
|
sync,
|
|
695
864
|
error: state.error,
|
|
696
|
-
ref:
|
|
865
|
+
ref: collectionRef
|
|
697
866
|
});
|
|
698
867
|
const getHandle = () => {
|
|
699
868
|
if (cachedHandle === null) cachedHandle = buildHandle();
|
|
@@ -710,154 +879,127 @@ const createDocumentSubscription = (options) => {
|
|
|
710
879
|
};
|
|
711
880
|
|
|
712
881
|
//#endregion
|
|
713
|
-
//#region src/
|
|
882
|
+
//#region src/core/document.ts
|
|
714
883
|
let syncKeyCounter = 0;
|
|
715
884
|
/**
|
|
716
|
-
*
|
|
717
|
-
*
|
|
718
|
-
* all the bare collection reference is itself a valid `Query`.
|
|
719
|
-
*
|
|
720
|
-
* Single source of truth for query assembly. `useCollection` decides whether a
|
|
721
|
-
* fresh `queryConstraints` array is semantically the same query — and so
|
|
722
|
-
* whether to keep the existing listener instead of tearing it down — by
|
|
723
|
-
* building the prospective query with this exact function and comparing via
|
|
724
|
-
* Firestore's `queryEqual` (see hooks.ts). That comparison is only correct if
|
|
725
|
-
* it assembles the query the same way the subscription does, so both paths MUST
|
|
726
|
-
* go through here. Don't re-inline the merge order at either call site.
|
|
727
|
-
*/
|
|
728
|
-
const buildCollectionQuery = (ref, definitionConstraints, extraConstraints) => {
|
|
729
|
-
const all = [...definitionConstraints ?? [], ...extraConstraints ?? []];
|
|
730
|
-
return all.length > 0 ? query(ref, ...all) : ref;
|
|
731
|
-
};
|
|
732
|
-
/**
|
|
733
|
-
* Create a collection subscription.
|
|
734
|
-
* This is a low-level API - prefer using useCollection hook in React.
|
|
885
|
+
* Create a document subscription.
|
|
886
|
+
* This is a low-level API - prefer using useDocument hook in React.
|
|
735
887
|
*
|
|
736
888
|
* @example
|
|
737
889
|
* ```ts
|
|
738
|
-
* const subscription =
|
|
890
|
+
* const subscription = createDocumentSubscription({
|
|
739
891
|
* store,
|
|
740
|
-
* definition:
|
|
741
|
-
*
|
|
892
|
+
* definition: projectDoc,
|
|
893
|
+
* docId: '123',
|
|
742
894
|
* })
|
|
743
895
|
*
|
|
744
896
|
* const unsubscribe = subscription.subscribe((state) => {
|
|
745
|
-
* console.log('
|
|
897
|
+
* console.log('Document state:', state)
|
|
746
898
|
* })
|
|
747
899
|
*
|
|
748
|
-
* subscription.load()
|
|
900
|
+
* subscription.load()
|
|
749
901
|
* ```
|
|
750
902
|
*/
|
|
751
|
-
const
|
|
752
|
-
const { store, definition, collectionPath:
|
|
903
|
+
const createDocumentSubscription = (options) => {
|
|
904
|
+
const { store, definition, docId, collectionPath: resolvedCollectionPath, readOnly, onPushUndo } = options;
|
|
753
905
|
const { firestore, autosave: defaultAutosave, minLoadTime: defaultMinLoadTime } = store;
|
|
754
|
-
const {
|
|
906
|
+
const { collection: collectionConfig, id, autosave = defaultAutosave, minLoadTime = defaultMinLoadTime, readOnly: definitionReadOnly, retryOnError = false, retryInterval = 5e3, schema } = definition;
|
|
755
907
|
const isReadOnly = readOnly ?? definitionReadOnly ?? false;
|
|
756
|
-
const
|
|
757
|
-
if (
|
|
758
|
-
const
|
|
908
|
+
const documentId = docId ?? (typeof id === "string" ? id : void 0);
|
|
909
|
+
if (documentId === void 0) throw new Error(`createDocumentSubscription: definition.id is a function; pass a resolved docId in options.`);
|
|
910
|
+
const collectionPath = resolvedCollectionPath ?? (typeof collectionConfig === "string" ? collectionConfig : void 0);
|
|
911
|
+
if (collectionPath === void 0) throw new Error(`createDocumentSubscription: definition.collection is a function; pass a resolved collectionPath in options.`);
|
|
912
|
+
const docRef = doc$1(collection(firestore, collectionPath), documentId);
|
|
759
913
|
const state = {
|
|
760
914
|
syncState: void 0,
|
|
761
915
|
localState: void 0,
|
|
762
|
-
isLoading:
|
|
763
|
-
isActive: !lazy,
|
|
916
|
+
isLoading: true,
|
|
764
917
|
error: void 0,
|
|
765
918
|
waitingForUpdate: false,
|
|
766
919
|
inflightLocalState: void 0,
|
|
920
|
+
committedWrite: void 0,
|
|
921
|
+
isSetOperation: false,
|
|
767
922
|
displayOverrides: /* @__PURE__ */ new Map()
|
|
768
923
|
};
|
|
769
924
|
const subscribers = /* @__PURE__ */ new Set();
|
|
770
925
|
let unsubscribeListener = null;
|
|
771
926
|
let autosaveTimeout = null;
|
|
772
|
-
let minLoadTimeout = null;
|
|
773
927
|
let retryTimeout = null;
|
|
928
|
+
let minLoadTimeout = null;
|
|
774
929
|
let minLoadTimeElapsed = false;
|
|
775
930
|
let loaded = false;
|
|
776
931
|
let cachedHandle = null;
|
|
777
|
-
const syncKey = `
|
|
932
|
+
const syncKey = `doc:${collectionPath}/${documentId}#${++syncKeyCounter}`;
|
|
778
933
|
const getMergedData = () => {
|
|
779
|
-
|
|
934
|
+
if (state.localState === null) return void 0;
|
|
935
|
+
const base = state.localState ?? state.syncState;
|
|
936
|
+
if (base === void 0) return void 0;
|
|
937
|
+
return applyOverridesAtPaths(base, state.displayOverrides);
|
|
780
938
|
};
|
|
781
939
|
const getPublicState = () => ({
|
|
782
940
|
data: getMergedData(),
|
|
783
941
|
isLoading: state.isLoading,
|
|
784
942
|
isSynced: state.localState === void 0,
|
|
785
|
-
isActive: state.isActive,
|
|
786
943
|
error: state.error
|
|
787
944
|
});
|
|
945
|
+
let lastPublished = null;
|
|
946
|
+
const publicStateChanged = observableStateChanged;
|
|
788
947
|
const notify = () => {
|
|
789
|
-
reconcileDisplayOverrides(state.localState, state.displayOverrides);
|
|
790
|
-
cachedHandle = null;
|
|
948
|
+
reconcileDisplayOverrides(state.localState && typeof state.localState === "object" ? state.localState : void 0, state.displayOverrides);
|
|
791
949
|
const publicState = getPublicState();
|
|
950
|
+
if (lastPublished !== null && !publicStateChanged(lastPublished, publicState)) return;
|
|
951
|
+
lastPublished = publicState;
|
|
952
|
+
cachedHandle = null;
|
|
792
953
|
subscribers.forEach((fn) => fn(publicState));
|
|
793
954
|
store.reportSyncState(syncKey, publicState.isSynced);
|
|
794
955
|
};
|
|
795
|
-
const warnNoSnapshot = (method) => {
|
|
796
|
-
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.`);
|
|
797
|
-
};
|
|
798
956
|
const updateState = (diff, undoOptions = {}) => {
|
|
799
957
|
if (isReadOnly) return;
|
|
800
|
-
if (
|
|
801
|
-
|
|
958
|
+
if (!getMergedData()) {
|
|
959
|
+
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.`);
|
|
802
960
|
return;
|
|
803
961
|
}
|
|
804
|
-
const rawBase = state.localState ?? state.syncState
|
|
962
|
+
const rawBase = state.localState ?? state.syncState;
|
|
805
963
|
const newLocalState = deepClone(rawBase);
|
|
806
964
|
applyDiffMutable(newLocalState, diff);
|
|
807
|
-
|
|
965
|
+
if (valuesEqualForNoOp(rawBase, newLocalState)) return;
|
|
808
966
|
if (undoOptions?.undoable !== false && onPushUndo) {
|
|
809
967
|
const undoDiff = computeDiff(newLocalState, rawBase);
|
|
810
968
|
const redoDiff = computeDiff(rawBase, newLocalState);
|
|
811
969
|
onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
|
|
812
970
|
}
|
|
813
971
|
state.localState = newLocalState;
|
|
972
|
+
state.isSetOperation = false;
|
|
814
973
|
notify();
|
|
815
974
|
scheduleAutosave();
|
|
816
975
|
};
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
const undoOptions = (hasExplicitId ? maybeUndoOptions : dataOrOptions) ?? {};
|
|
821
|
-
if (isReadOnly) return void 0;
|
|
822
|
-
if (state.syncState === void 0) {
|
|
823
|
-
warnNoSnapshot("add");
|
|
824
|
-
return;
|
|
825
|
-
}
|
|
826
|
-
const id = hasExplicitId ? idOrData : doc$1(collectionRef).id;
|
|
827
|
-
const newDoc = {
|
|
828
|
-
...data,
|
|
829
|
-
id
|
|
830
|
-
};
|
|
831
|
-
if (schema) schema.parse(newDoc);
|
|
976
|
+
const setData = (data, undoOptions = {}) => {
|
|
977
|
+
if (isReadOnly) return;
|
|
978
|
+
if (schema) schema.parse(data);
|
|
832
979
|
const currentData = getMergedData();
|
|
833
|
-
|
|
834
|
-
newLocalState[id] = newDoc;
|
|
980
|
+
if (currentData !== void 0 && valuesEqualForNoOp(state.localState ?? state.syncState, data)) return;
|
|
835
981
|
if (undoOptions?.undoable !== false && onPushUndo) {
|
|
836
|
-
const
|
|
837
|
-
|
|
838
|
-
|
|
982
|
+
const dataForRedo = deepClone(data);
|
|
983
|
+
if (currentData === void 0) onPushUndo(() => deleteDocument({ undoable: false }), () => setData(dataForRedo, { undoable: false }), undoOptions);
|
|
984
|
+
else {
|
|
985
|
+
const dataToRestore = deepClone(state.localState ?? state.syncState);
|
|
986
|
+
onPushUndo(() => setData(dataToRestore, { undoable: false }), () => setData(dataForRedo, { undoable: false }), undoOptions);
|
|
987
|
+
}
|
|
839
988
|
}
|
|
840
|
-
state.localState =
|
|
989
|
+
state.localState = deepClone(data);
|
|
990
|
+
state.isSetOperation = true;
|
|
841
991
|
notify();
|
|
842
992
|
scheduleAutosave();
|
|
843
|
-
|
|
844
|
-
}
|
|
845
|
-
const removeDocument = (id, undoOptions = {}) => {
|
|
993
|
+
};
|
|
994
|
+
const deleteDocument = (undoOptions = {}) => {
|
|
846
995
|
if (isReadOnly) return;
|
|
847
|
-
if (
|
|
848
|
-
warnNoSnapshot("remove");
|
|
849
|
-
return;
|
|
850
|
-
}
|
|
851
|
-
const currentData = getMergedData();
|
|
852
|
-
if (!(id in currentData)) return;
|
|
853
|
-
const newLocalState = deepClone(currentData);
|
|
854
|
-
delete newLocalState[id];
|
|
996
|
+
if (getMergedData() === void 0) return;
|
|
855
997
|
if (undoOptions?.undoable !== false && onPushUndo) {
|
|
856
|
-
const
|
|
857
|
-
|
|
858
|
-
onPushUndo(() => updateState(undoDiff, { undoable: false }), () => updateState(redoDiff, { undoable: false }), undoOptions);
|
|
998
|
+
const dataToRestore = deepClone(state.localState ?? state.syncState);
|
|
999
|
+
onPushUndo(() => setData(dataToRestore, { undoable: false }), () => deleteDocument({ undoable: false }), undoOptions);
|
|
859
1000
|
}
|
|
860
|
-
state.localState =
|
|
1001
|
+
state.localState = null;
|
|
1002
|
+
state.isSetOperation = false;
|
|
861
1003
|
notify();
|
|
862
1004
|
scheduleAutosave();
|
|
863
1005
|
};
|
|
@@ -868,98 +1010,126 @@ const createCollectionSubscription = (options) => {
|
|
|
868
1010
|
}, autosave);
|
|
869
1011
|
};
|
|
870
1012
|
const sync = async () => {
|
|
871
|
-
if (
|
|
872
|
-
if (state.
|
|
873
|
-
|
|
874
|
-
|
|
1013
|
+
if (state.localState === void 0) return;
|
|
1014
|
+
if (state.localState === null) {
|
|
1015
|
+
state.inflightLocalState = null;
|
|
1016
|
+
state.waitingForUpdate = true;
|
|
1017
|
+
try {
|
|
1018
|
+
await deleteDoc(docRef);
|
|
1019
|
+
} catch (error) {
|
|
1020
|
+
console.error("Sync failed:", error);
|
|
1021
|
+
state.waitingForUpdate = false;
|
|
1022
|
+
state.inflightLocalState = void 0;
|
|
1023
|
+
state.error = error;
|
|
1024
|
+
store.reportError(error, {
|
|
1025
|
+
type: "document",
|
|
1026
|
+
path: `${collectionPath}/${documentId}`,
|
|
1027
|
+
operation: "write"
|
|
1028
|
+
});
|
|
1029
|
+
notify();
|
|
1030
|
+
}
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
if (state.syncState && isDeepEqual(state.localState, state.syncState)) {
|
|
875
1034
|
state.localState = void 0;
|
|
876
1035
|
state.inflightLocalState = void 0;
|
|
877
1036
|
notify();
|
|
878
1037
|
return;
|
|
879
1038
|
}
|
|
880
|
-
const
|
|
881
|
-
state.
|
|
1039
|
+
const wasSetOperation = state.isSetOperation;
|
|
1040
|
+
state.isSetOperation = false;
|
|
1041
|
+
const isCreation = !state.syncState;
|
|
1042
|
+
const useSetDoc = wasSetOperation || isCreation;
|
|
1043
|
+
const diff = state.syncState ? computeDiff(state.syncState, state.localState) : void 0;
|
|
1044
|
+
const committing = deepClone(state.localState);
|
|
1045
|
+
state.inflightLocalState = committing;
|
|
882
1046
|
state.waitingForUpdate = true;
|
|
883
1047
|
try {
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
if (docDiff !== null && typeof docDiff === "object" && "isEqual" in docDiff && typeof docDiff.isEqual === "function" && docDiff.isEqual(deleteFieldSentinel)) batch.delete(docRef);
|
|
889
|
-
else if (!(docId in syncState)) batch.set(docRef, docDiff);
|
|
890
|
-
else {
|
|
891
|
-
const flatDiff = flattenDiff(docDiff);
|
|
892
|
-
batch.update(docRef, flatDiff);
|
|
893
|
-
}
|
|
1048
|
+
if (useSetDoc) await setDoc(docRef, state.localState);
|
|
1049
|
+
else {
|
|
1050
|
+
const args = diffToFieldPathArgs(diff);
|
|
1051
|
+
if (args.length) await updateDoc(docRef, ...args);
|
|
894
1052
|
}
|
|
895
|
-
|
|
1053
|
+
state.committedWrite = committing;
|
|
896
1054
|
} catch (error) {
|
|
897
|
-
console.error("
|
|
1055
|
+
console.error("Sync failed:", error);
|
|
898
1056
|
state.waitingForUpdate = false;
|
|
899
1057
|
state.inflightLocalState = void 0;
|
|
900
1058
|
state.error = error;
|
|
901
1059
|
store.reportError(error, {
|
|
902
|
-
type: "
|
|
903
|
-
path: collectionPath
|
|
1060
|
+
type: "document",
|
|
1061
|
+
path: `${collectionPath}/${documentId}`,
|
|
904
1062
|
operation: "write"
|
|
905
1063
|
});
|
|
906
1064
|
notify();
|
|
907
1065
|
}
|
|
908
1066
|
};
|
|
909
|
-
const handleSnapshot = (
|
|
910
|
-
const
|
|
911
|
-
|
|
912
|
-
...data,
|
|
913
|
-
id
|
|
914
|
-
};
|
|
915
|
-
state.syncState = newSyncState;
|
|
1067
|
+
const handleSnapshot = (newSyncData) => {
|
|
1068
|
+
const prevSync = state.syncState;
|
|
1069
|
+
state.syncState = newSyncData;
|
|
916
1070
|
state.error = void 0;
|
|
1071
|
+
state.waitingForUpdate = false;
|
|
1072
|
+
state.inflightLocalState = void 0;
|
|
1073
|
+
const committed = state.committedWrite;
|
|
1074
|
+
state.committedWrite = void 0;
|
|
1075
|
+
const currentLocal = state.localState;
|
|
1076
|
+
if (currentLocal === null) {} else if (currentLocal !== void 0 && prevSync !== void 0) {
|
|
1077
|
+
const userEdits = computeDiff(prevSync, currentLocal);
|
|
1078
|
+
dropCommittedSentinels(userEdits, committed);
|
|
1079
|
+
const rebasedLocalState = applyDiff(newSyncData, userEdits);
|
|
1080
|
+
state.localState = isDeepEqual(rebasedLocalState, newSyncData) ? void 0 : rebasedLocalState;
|
|
1081
|
+
}
|
|
1082
|
+
if (minLoadTimeElapsed) state.isLoading = false;
|
|
1083
|
+
loaded = true;
|
|
1084
|
+
if (state.localState !== void 0) scheduleAutosave();
|
|
1085
|
+
notify();
|
|
1086
|
+
};
|
|
1087
|
+
const handleMissingDocument = () => {
|
|
1088
|
+
state.syncState = void 0;
|
|
1089
|
+
state.error = void 0;
|
|
1090
|
+
state.committedWrite = void 0;
|
|
1091
|
+
if (state.localState === null) {
|
|
1092
|
+
state.localState = void 0;
|
|
1093
|
+
state.isSetOperation = false;
|
|
1094
|
+
if (autosaveTimeout) {
|
|
1095
|
+
clearTimeout(autosaveTimeout);
|
|
1096
|
+
autosaveTimeout = null;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
917
1099
|
if (state.waitingForUpdate) {
|
|
918
1100
|
state.waitingForUpdate = false;
|
|
919
|
-
const inflightState = state.inflightLocalState;
|
|
920
1101
|
state.inflightLocalState = void 0;
|
|
921
|
-
const currentLocal = state.localState;
|
|
922
|
-
if (inflightState && currentLocal && !isDeepEqual(currentLocal, inflightState)) {
|
|
923
|
-
const changesSinceInflight = computeDiff(inflightState, currentLocal);
|
|
924
|
-
const rebasedLocalState = deepClone(newSyncState);
|
|
925
|
-
applyDiffMutable(rebasedLocalState, changesSinceInflight);
|
|
926
|
-
for (const [docId, docData] of Object.entries(rebasedLocalState)) if (docData && typeof docData === "object") docData.id = docId;
|
|
927
|
-
state.localState = rebasedLocalState;
|
|
928
|
-
} else state.localState = void 0;
|
|
929
1102
|
}
|
|
930
1103
|
if (minLoadTimeElapsed) state.isLoading = false;
|
|
931
1104
|
loaded = true;
|
|
932
|
-
if (state.localState !== void 0) scheduleAutosave();
|
|
933
1105
|
notify();
|
|
934
1106
|
};
|
|
935
1107
|
const handleError = (error) => {
|
|
936
1108
|
if (retryOnError) {
|
|
937
|
-
console.warn("
|
|
1109
|
+
console.warn("Document listener error, retrying:", error);
|
|
938
1110
|
retryTimeout = setTimeout(() => {
|
|
939
1111
|
stop();
|
|
940
|
-
|
|
1112
|
+
load();
|
|
941
1113
|
}, retryInterval);
|
|
942
1114
|
} else {
|
|
943
1115
|
state.error = error;
|
|
944
1116
|
state.isLoading = false;
|
|
945
1117
|
loaded = true;
|
|
946
1118
|
store.reportError(error, {
|
|
947
|
-
type: "
|
|
948
|
-
path: collectionPath
|
|
1119
|
+
type: "document",
|
|
1120
|
+
path: `${collectionPath}/${documentId}`,
|
|
949
1121
|
operation: "read"
|
|
950
1122
|
});
|
|
951
1123
|
notify();
|
|
952
1124
|
}
|
|
953
1125
|
};
|
|
954
|
-
const
|
|
1126
|
+
const load = () => {
|
|
955
1127
|
if (unsubscribeListener) return;
|
|
956
1128
|
loaded = false;
|
|
957
1129
|
minLoadTimeElapsed = false;
|
|
958
|
-
unsubscribeListener = onSnapshot(
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
data: docSnap.data()
|
|
962
|
-
})));
|
|
1130
|
+
unsubscribeListener = onSnapshot(docRef, (snapshot) => {
|
|
1131
|
+
if (snapshot.exists()) handleSnapshot(snapshot.data());
|
|
1132
|
+
else if (!snapshot.metadata.fromCache) handleMissingDocument();
|
|
963
1133
|
}, handleError);
|
|
964
1134
|
minLoadTimeout = setTimeout(() => {
|
|
965
1135
|
minLoadTimeout = null;
|
|
@@ -970,20 +1140,7 @@ const createCollectionSubscription = (options) => {
|
|
|
970
1140
|
minLoadTimeElapsed = true;
|
|
971
1141
|
}, minLoadTime);
|
|
972
1142
|
};
|
|
973
|
-
const load = () => {
|
|
974
|
-
if (unsubscribeListener) return;
|
|
975
|
-
if (!state.isActive) {
|
|
976
|
-
state.isActive = true;
|
|
977
|
-
state.isLoading = true;
|
|
978
|
-
notify();
|
|
979
|
-
}
|
|
980
|
-
startListener();
|
|
981
|
-
};
|
|
982
1143
|
const stop = () => {
|
|
983
|
-
if (retryTimeout) {
|
|
984
|
-
clearTimeout(retryTimeout);
|
|
985
|
-
retryTimeout = null;
|
|
986
|
-
}
|
|
987
1144
|
if (unsubscribeListener) {
|
|
988
1145
|
unsubscribeListener();
|
|
989
1146
|
unsubscribeListener = null;
|
|
@@ -992,6 +1149,10 @@ const createCollectionSubscription = (options) => {
|
|
|
992
1149
|
clearTimeout(autosaveTimeout);
|
|
993
1150
|
autosaveTimeout = null;
|
|
994
1151
|
}
|
|
1152
|
+
if (retryTimeout) {
|
|
1153
|
+
clearTimeout(retryTimeout);
|
|
1154
|
+
retryTimeout = null;
|
|
1155
|
+
}
|
|
995
1156
|
if (minLoadTimeout) {
|
|
996
1157
|
clearTimeout(minLoadTimeout);
|
|
997
1158
|
minLoadTimeout = null;
|
|
@@ -1005,15 +1166,13 @@ const createCollectionSubscription = (options) => {
|
|
|
1005
1166
|
const buildHandle = () => ({
|
|
1006
1167
|
data: getMergedData(),
|
|
1007
1168
|
update: updateState,
|
|
1008
|
-
|
|
1009
|
-
|
|
1169
|
+
set: setData,
|
|
1170
|
+
delete: deleteDocument,
|
|
1010
1171
|
isLoading: state.isLoading,
|
|
1011
1172
|
isSynced: state.localState === void 0,
|
|
1012
|
-
isActive: state.isActive,
|
|
1013
|
-
load,
|
|
1014
1173
|
sync,
|
|
1015
1174
|
error: state.error,
|
|
1016
|
-
ref:
|
|
1175
|
+
ref: docRef
|
|
1017
1176
|
});
|
|
1018
1177
|
const getHandle = () => {
|
|
1019
1178
|
if (cachedHandle === null) cachedHandle = buildHandle();
|
|
@@ -1030,7 +1189,251 @@ const createCollectionSubscription = (options) => {
|
|
|
1030
1189
|
};
|
|
1031
1190
|
|
|
1032
1191
|
//#endregion
|
|
1033
|
-
//#region src/
|
|
1192
|
+
//#region src/core/shared-subscription.ts
|
|
1193
|
+
const registries = /* @__PURE__ */ new WeakMap();
|
|
1194
|
+
const getRegistry = (store) => {
|
|
1195
|
+
let reg = registries.get(store);
|
|
1196
|
+
if (!reg) {
|
|
1197
|
+
reg = {
|
|
1198
|
+
docs: /* @__PURE__ */ new WeakMap(),
|
|
1199
|
+
cols: /* @__PURE__ */ new WeakMap()
|
|
1200
|
+
};
|
|
1201
|
+
registries.set(store, reg);
|
|
1202
|
+
}
|
|
1203
|
+
return reg;
|
|
1204
|
+
};
|
|
1205
|
+
const docKey = (collectionPath, docId) => `${collectionPath}\0${docId}`;
|
|
1206
|
+
const colBucketKey = (collectionPath) => collectionPath;
|
|
1207
|
+
/**
|
|
1208
|
+
* Semantic query match, hardened for two cases the raw `queryEqual` does not
|
|
1209
|
+
* cover here: the same reference (the common case — a hook reuses its memoized
|
|
1210
|
+
* query) short-circuits true, and a `queryEqual` that throws (test harnesses
|
|
1211
|
+
* that mock the query builders pass non-`Query` placeholders) is treated as a
|
|
1212
|
+
* non-match rather than crashing the lookup.
|
|
1213
|
+
*/
|
|
1214
|
+
const sameQuery = (a, b) => {
|
|
1215
|
+
if (a === b) return true;
|
|
1216
|
+
try {
|
|
1217
|
+
return queryEqual(a, b);
|
|
1218
|
+
} catch {
|
|
1219
|
+
return false;
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
/** Push to the store-global undo manager, gated by the entry's shared flag. */
|
|
1223
|
+
const makeOnPushUndo = (store, entry) => (undoAction, redoAction, opts) => {
|
|
1224
|
+
if (!entry.undoableEnabled) return;
|
|
1225
|
+
store.undoManager.push({
|
|
1226
|
+
undo: undoAction,
|
|
1227
|
+
redo: redoAction,
|
|
1228
|
+
groupId: opts?.undoGroupId
|
|
1229
|
+
});
|
|
1230
|
+
};
|
|
1231
|
+
const noop = () => {};
|
|
1232
|
+
const asyncNoop = async () => {};
|
|
1233
|
+
const noopAdd = () => void 0;
|
|
1234
|
+
const readOnlyDocumentHandle = (handle) => ({
|
|
1235
|
+
...handle,
|
|
1236
|
+
update: noop,
|
|
1237
|
+
set: noop,
|
|
1238
|
+
delete: noop,
|
|
1239
|
+
sync: asyncNoop
|
|
1240
|
+
});
|
|
1241
|
+
const readOnlyCollectionHandle = (handle) => ({
|
|
1242
|
+
...handle,
|
|
1243
|
+
update: noop,
|
|
1244
|
+
add: noopAdd,
|
|
1245
|
+
remove: noop,
|
|
1246
|
+
sync: asyncNoop
|
|
1247
|
+
});
|
|
1248
|
+
/**
|
|
1249
|
+
* Find or create the shared subscription for a document resource and return a
|
|
1250
|
+
* facade bound to it. Creating the entry attaches no listener. Intended to be
|
|
1251
|
+
* called from a hook's render-phase `useMemo`.
|
|
1252
|
+
*/
|
|
1253
|
+
const getDocumentShared = ({ store, definition, collectionPath, docId, readOnly }) => {
|
|
1254
|
+
const map = getDocMap(store, definition);
|
|
1255
|
+
const key = docKey(collectionPath, docId);
|
|
1256
|
+
const facadeReadOnly = readOnly ?? definition.readOnly ?? false;
|
|
1257
|
+
const buildSub = (entry) => createDocumentSubscription({
|
|
1258
|
+
store,
|
|
1259
|
+
definition,
|
|
1260
|
+
docId,
|
|
1261
|
+
collectionPath,
|
|
1262
|
+
readOnly: false,
|
|
1263
|
+
onPushUndo: makeOnPushUndo(store, entry)
|
|
1264
|
+
});
|
|
1265
|
+
const buildEntry = () => {
|
|
1266
|
+
const entry = {
|
|
1267
|
+
sub: null,
|
|
1268
|
+
refCount: 0,
|
|
1269
|
+
undoableEnabled: true,
|
|
1270
|
+
live: true
|
|
1271
|
+
};
|
|
1272
|
+
entry.sub = buildSub(entry);
|
|
1273
|
+
return entry;
|
|
1274
|
+
};
|
|
1275
|
+
let ent = map.get(key) ?? buildEntry();
|
|
1276
|
+
let desiredUndoable = true;
|
|
1277
|
+
let readOnlySource = null;
|
|
1278
|
+
let readOnlyHandle = null;
|
|
1279
|
+
return {
|
|
1280
|
+
getHandle: () => {
|
|
1281
|
+
const handle = ent.sub.getHandle();
|
|
1282
|
+
if (!facadeReadOnly) return handle;
|
|
1283
|
+
if (handle !== readOnlySource) {
|
|
1284
|
+
readOnlySource = handle;
|
|
1285
|
+
readOnlyHandle = readOnlyDocumentHandle(handle);
|
|
1286
|
+
}
|
|
1287
|
+
return readOnlyHandle;
|
|
1288
|
+
},
|
|
1289
|
+
load: () => ent.sub.load(),
|
|
1290
|
+
setUndoable: (enabled) => {
|
|
1291
|
+
if (facadeReadOnly) return;
|
|
1292
|
+
desiredUndoable = enabled;
|
|
1293
|
+
ent.undoableEnabled = enabled;
|
|
1294
|
+
},
|
|
1295
|
+
acquire: (onChange) => {
|
|
1296
|
+
const committed = map.get(key);
|
|
1297
|
+
if (committed) ent = committed;
|
|
1298
|
+
else map.set(key, ent);
|
|
1299
|
+
if (!ent.live) {
|
|
1300
|
+
ent.sub = buildSub(ent);
|
|
1301
|
+
ent.live = true;
|
|
1302
|
+
}
|
|
1303
|
+
if (!facadeReadOnly) ent.undoableEnabled = desiredUndoable;
|
|
1304
|
+
ent.refCount++;
|
|
1305
|
+
const notifyUnsub = ent.sub.subscribe(onChange);
|
|
1306
|
+
let released = false;
|
|
1307
|
+
return () => {
|
|
1308
|
+
if (released) return;
|
|
1309
|
+
released = true;
|
|
1310
|
+
notifyUnsub();
|
|
1311
|
+
ent.refCount--;
|
|
1312
|
+
if (ent.refCount <= 0) {
|
|
1313
|
+
ent.sub.stop();
|
|
1314
|
+
ent.live = false;
|
|
1315
|
+
if (map.get(key) === ent) map.delete(key);
|
|
1316
|
+
}
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
};
|
|
1320
|
+
};
|
|
1321
|
+
const getDocMap = (store, definition) => {
|
|
1322
|
+
const reg = getRegistry(store);
|
|
1323
|
+
let map = reg.docs.get(definition);
|
|
1324
|
+
if (!map) {
|
|
1325
|
+
map = /* @__PURE__ */ new Map();
|
|
1326
|
+
reg.docs.set(definition, map);
|
|
1327
|
+
}
|
|
1328
|
+
return map;
|
|
1329
|
+
};
|
|
1330
|
+
/**
|
|
1331
|
+
* Find or create the shared subscription whose query is `queryEqual` to
|
|
1332
|
+
* `params.query` and return a facade bound to it. Entries on the same path but
|
|
1333
|
+
* with a different query coexist in the same bucket.
|
|
1334
|
+
*/
|
|
1335
|
+
const getCollectionShared = ({ store, definition, collectionPath, readOnly, queryConstraints, query: query$1 }) => {
|
|
1336
|
+
const bucket = getColBucket(store, definition, collectionPath);
|
|
1337
|
+
const facadeReadOnly = readOnly ?? definition.readOnly ?? false;
|
|
1338
|
+
const buildSub = (entry) => createCollectionSubscription({
|
|
1339
|
+
store,
|
|
1340
|
+
definition,
|
|
1341
|
+
collectionPath,
|
|
1342
|
+
readOnly: false,
|
|
1343
|
+
queryConstraints,
|
|
1344
|
+
onPushUndo: makeOnPushUndo(store, entry)
|
|
1345
|
+
});
|
|
1346
|
+
const buildEntry = () => {
|
|
1347
|
+
const entry = {
|
|
1348
|
+
sub: null,
|
|
1349
|
+
refCount: 0,
|
|
1350
|
+
undoableEnabled: true,
|
|
1351
|
+
live: true,
|
|
1352
|
+
query: query$1
|
|
1353
|
+
};
|
|
1354
|
+
entry.sub = buildSub(entry);
|
|
1355
|
+
return entry;
|
|
1356
|
+
};
|
|
1357
|
+
let ent = bucket.find((e) => sameQuery(e.query, query$1)) ?? buildEntry();
|
|
1358
|
+
let desiredUndoable = true;
|
|
1359
|
+
let readOnlySource = null;
|
|
1360
|
+
let readOnlyHandle = null;
|
|
1361
|
+
return {
|
|
1362
|
+
getHandle: () => {
|
|
1363
|
+
const handle = ent.sub.getHandle();
|
|
1364
|
+
if (!facadeReadOnly) return handle;
|
|
1365
|
+
if (handle !== readOnlySource) {
|
|
1366
|
+
readOnlySource = handle;
|
|
1367
|
+
readOnlyHandle = readOnlyCollectionHandle(handle);
|
|
1368
|
+
}
|
|
1369
|
+
return readOnlyHandle;
|
|
1370
|
+
},
|
|
1371
|
+
load: () => ent.sub.load(),
|
|
1372
|
+
setUndoable: (enabled) => {
|
|
1373
|
+
if (facadeReadOnly) return;
|
|
1374
|
+
desiredUndoable = enabled;
|
|
1375
|
+
ent.undoableEnabled = enabled;
|
|
1376
|
+
},
|
|
1377
|
+
acquire: (onChange) => {
|
|
1378
|
+
const committed = bucket.find((e) => sameQuery(e.query, query$1));
|
|
1379
|
+
if (committed) ent = committed;
|
|
1380
|
+
else bucket.push(ent);
|
|
1381
|
+
if (!ent.live) {
|
|
1382
|
+
ent.sub = buildSub(ent);
|
|
1383
|
+
ent.live = true;
|
|
1384
|
+
}
|
|
1385
|
+
if (!facadeReadOnly) ent.undoableEnabled = desiredUndoable;
|
|
1386
|
+
ent.refCount++;
|
|
1387
|
+
const notifyUnsub = ent.sub.subscribe(onChange);
|
|
1388
|
+
let released = false;
|
|
1389
|
+
return () => {
|
|
1390
|
+
if (released) return;
|
|
1391
|
+
released = true;
|
|
1392
|
+
notifyUnsub();
|
|
1393
|
+
ent.refCount--;
|
|
1394
|
+
if (ent.refCount <= 0) {
|
|
1395
|
+
ent.sub.stop();
|
|
1396
|
+
ent.live = false;
|
|
1397
|
+
const idx = bucket.indexOf(ent);
|
|
1398
|
+
if (idx !== -1) bucket.splice(idx, 1);
|
|
1399
|
+
}
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
};
|
|
1403
|
+
};
|
|
1404
|
+
const getColBucket = (store, definition, collectionPath) => {
|
|
1405
|
+
const reg = getRegistry(store);
|
|
1406
|
+
let byKey = reg.cols.get(definition);
|
|
1407
|
+
if (!byKey) {
|
|
1408
|
+
byKey = /* @__PURE__ */ new Map();
|
|
1409
|
+
reg.cols.set(definition, byKey);
|
|
1410
|
+
}
|
|
1411
|
+
const key = colBucketKey(collectionPath);
|
|
1412
|
+
let bucket = byKey.get(key);
|
|
1413
|
+
if (!bucket) {
|
|
1414
|
+
bucket = [];
|
|
1415
|
+
byKey.set(key, bucket);
|
|
1416
|
+
}
|
|
1417
|
+
return bucket;
|
|
1418
|
+
};
|
|
1419
|
+
/**
|
|
1420
|
+
* Build the query a collection hook would subscribe to, or `null` when the
|
|
1421
|
+
* constraints cannot form a valid query (e.g. a gated empty-`in` placeholder
|
|
1422
|
+
* Firestore refuses to construct). The hook only resolves a shared entry when
|
|
1423
|
+
* this is non-null — matching the lazy-before-load and `enabled`-gating
|
|
1424
|
+
* contracts where no listener should run yet.
|
|
1425
|
+
*/
|
|
1426
|
+
const buildSharedCollectionQuery = (store, collectionPath, definitionConstraints, extraConstraints) => {
|
|
1427
|
+
const ref = collection(store.firestore, collectionPath);
|
|
1428
|
+
try {
|
|
1429
|
+
return buildCollectionQuery(ref, definitionConstraints, extraConstraints);
|
|
1430
|
+
} catch {
|
|
1431
|
+
return null;
|
|
1432
|
+
}
|
|
1433
|
+
};
|
|
1434
|
+
|
|
1435
|
+
//#endregion
|
|
1436
|
+
//#region src/react/hooks.ts
|
|
1034
1437
|
/**
|
|
1035
1438
|
* Whether two hook-level `queryConstraints` arrays produce the same Firestore
|
|
1036
1439
|
* query for a collection. `QueryConstraint` objects are opaque, so instead of
|
|
@@ -1094,6 +1497,14 @@ const DISABLED_COLLECTION_HANDLE = {
|
|
|
1094
1497
|
ref: void 0
|
|
1095
1498
|
};
|
|
1096
1499
|
/**
|
|
1500
|
+
* Equality over an {@link ObservableSelection} (no-selector path): status fields
|
|
1501
|
+
* compared by identity, the `data` slice by `dataEqual` (the default value
|
|
1502
|
+
* comparison). This is what lets a change to a value-equal `data` be collapsed
|
|
1503
|
+
* while any status change still re-renders the plain handle.
|
|
1504
|
+
*/
|
|
1505
|
+
const selectionEqual = (a, b, dataEqual) => a.isLoading === b.isLoading && a.isSynced === b.isSynced && a.error === b.error && a.isActive === b.isActive && dataEqual(a.data, b.data);
|
|
1506
|
+
const defaultDataEqual = valuesEqualForNoOp;
|
|
1507
|
+
/**
|
|
1097
1508
|
* Context for providing the Firestate store
|
|
1098
1509
|
*/
|
|
1099
1510
|
const FirestateContext = createContext(null);
|
|
@@ -1130,169 +1541,82 @@ const useIsSynced = () => {
|
|
|
1130
1541
|
const getSnapshot = useCallback(() => store.isSynced, [store]);
|
|
1131
1542
|
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
1132
1543
|
};
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
*
|
|
1136
|
-
* The subscription is keyed on the resolved document path (`definition` +
|
|
1137
|
-
* computed id) and `readOnly`. When that key changes — typically because
|
|
1138
|
-
* `params` produces a different id — the hook tears down the old Firestore
|
|
1139
|
-
* listener and attaches a new one. Toggling `undoable` does not rebuild the
|
|
1140
|
-
* subscription.
|
|
1141
|
-
*
|
|
1142
|
-
* Use `enabled: false` to suppress the subscription entirely (e.g., when
|
|
1143
|
-
* route params aren't ready yet).
|
|
1144
|
-
*
|
|
1145
|
-
* **SSR.** On the server there is no Firestore listener, so this hook returns
|
|
1146
|
-
* the initial handle (`{ data: undefined, isLoading: true }`). Mutations like
|
|
1147
|
-
* `update`/`set` will mutate orphaned local state with no effect — avoid
|
|
1148
|
-
* calling them server-side.
|
|
1149
|
-
*
|
|
1150
|
-
* @example
|
|
1151
|
-
* ```tsx
|
|
1152
|
-
* const projectDoc = defineDocument<Project>({
|
|
1153
|
-
* collection: 'projects',
|
|
1154
|
-
* id: (params) => params.projectId,
|
|
1155
|
-
* })
|
|
1156
|
-
*
|
|
1157
|
-
* function ProjectEditor({ projectId }: { projectId: string }) {
|
|
1158
|
-
* const { data, update, isLoading, isSynced } = useDocument({
|
|
1159
|
-
* definition: projectDoc,
|
|
1160
|
-
* params: { projectId },
|
|
1161
|
-
* })
|
|
1162
|
-
*
|
|
1163
|
-
* if (isLoading) return <Spinner />
|
|
1164
|
-
*
|
|
1165
|
-
* return (
|
|
1166
|
-
* <input
|
|
1167
|
-
* value={data?.name ?? ''}
|
|
1168
|
-
* onChange={(e) => update({ name: e.target.value })}
|
|
1169
|
-
* />
|
|
1170
|
-
* )
|
|
1171
|
-
* }
|
|
1172
|
-
* ```
|
|
1173
|
-
*/
|
|
1174
|
-
const useDocument = (options) => {
|
|
1175
|
-
const { definition, params = {}, readOnly, undoable = true, enabled = true } = options;
|
|
1544
|
+
function useDocument(options) {
|
|
1545
|
+
const { definition, params = {}, readOnly, undoable = true, enabled = true, selector, isEqual } = options;
|
|
1176
1546
|
const store = useStore();
|
|
1177
|
-
const undoManager = store.undoManager;
|
|
1178
|
-
const undoableRef = useRef(undoable);
|
|
1179
|
-
undoableRef.current = undoable;
|
|
1180
|
-
const onPushUndo = useCallback((undoAction, redoAction, opts) => {
|
|
1181
|
-
if (!undoableRef.current) return;
|
|
1182
|
-
undoManager.push({
|
|
1183
|
-
undo: undoAction,
|
|
1184
|
-
redo: redoAction,
|
|
1185
|
-
groupId: opts?.undoGroupId
|
|
1186
|
-
});
|
|
1187
|
-
}, [undoManager]);
|
|
1188
1547
|
const docId = enabled ? typeof definition.id === "function" ? definition.id(params) : definition.id : void 0;
|
|
1189
1548
|
const collectionPath = enabled ? typeof definition.collection === "function" ? definition.collection(params) : definition.collection : void 0;
|
|
1190
|
-
const
|
|
1549
|
+
const shared = useMemo(() => enabled && docId !== void 0 && collectionPath !== void 0 ? getDocumentShared({
|
|
1191
1550
|
store,
|
|
1192
1551
|
definition,
|
|
1193
|
-
docId,
|
|
1194
1552
|
collectionPath,
|
|
1195
|
-
|
|
1196
|
-
|
|
1553
|
+
docId,
|
|
1554
|
+
readOnly
|
|
1197
1555
|
}) : null, [
|
|
1198
1556
|
enabled,
|
|
1199
1557
|
store,
|
|
1200
1558
|
definition,
|
|
1201
1559
|
docId,
|
|
1202
1560
|
collectionPath,
|
|
1203
|
-
readOnly
|
|
1204
|
-
onPushUndo
|
|
1561
|
+
readOnly
|
|
1205
1562
|
]);
|
|
1563
|
+
useEffect(() => {
|
|
1564
|
+
shared?.setUndoable(undoable);
|
|
1565
|
+
}, [shared, undoable]);
|
|
1206
1566
|
const subscribe = useCallback((onChange) => {
|
|
1207
|
-
if (!
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1567
|
+
if (!shared) return NOOP;
|
|
1568
|
+
shared.setUndoable(undoable);
|
|
1569
|
+
const release = shared.acquire(onChange);
|
|
1570
|
+
try {
|
|
1571
|
+
shared.load();
|
|
1572
|
+
} catch (e) {
|
|
1573
|
+
release();
|
|
1574
|
+
throw e;
|
|
1575
|
+
}
|
|
1576
|
+
return release;
|
|
1577
|
+
}, [shared]);
|
|
1578
|
+
const getSnapshot = useCallback(() => shared ? shared.getHandle() : DISABLED_DOCUMENT_HANDLE, [shared]);
|
|
1579
|
+
const selection = useSyncExternalStoreWithSelector(subscribe, getSnapshot, getSnapshot, useCallback((handle) => {
|
|
1580
|
+
const state = {
|
|
1581
|
+
data: handle.data,
|
|
1582
|
+
isLoading: handle.isLoading,
|
|
1583
|
+
isSynced: handle.isSynced,
|
|
1584
|
+
error: handle.error
|
|
1213
1585
|
};
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
* route params aren't ready yet).
|
|
1248
|
-
*
|
|
1249
|
-
* **SSR.** On the server there is no Firestore listener, so this hook returns
|
|
1250
|
-
* the initial handle (`{ data: {}, isLoading: true }` for non-lazy, or
|
|
1251
|
-
* `isActive: false` for lazy). Avoid calling mutations server-side.
|
|
1252
|
-
*
|
|
1253
|
-
* @example
|
|
1254
|
-
* ```tsx
|
|
1255
|
-
* const spacesCollection = defineCollection<Space>({
|
|
1256
|
-
* path: (params) => `projects/${params.projectId}/spaces`,
|
|
1257
|
-
* lazy: true,
|
|
1258
|
-
* })
|
|
1259
|
-
*
|
|
1260
|
-
* function SpacesList({ projectId }: { projectId: string }) {
|
|
1261
|
-
* const { data, update, load, isActive, isLoading } = useCollection({
|
|
1262
|
-
* definition: spacesCollection,
|
|
1263
|
-
* params: { projectId },
|
|
1264
|
-
* })
|
|
1265
|
-
*
|
|
1266
|
-
* // Lazy load on mount
|
|
1267
|
-
* useEffect(() => { load() }, [load])
|
|
1268
|
-
*
|
|
1269
|
-
* if (!isActive) return <Button onClick={load}>Load Spaces</Button>
|
|
1270
|
-
* if (isLoading) return <Spinner />
|
|
1271
|
-
*
|
|
1272
|
-
* return (
|
|
1273
|
-
* <ul>
|
|
1274
|
-
* {Object.values(data).map((space) => (
|
|
1275
|
-
* <li key={space.id}>{space.name}</li>
|
|
1276
|
-
* ))}
|
|
1277
|
-
* </ul>
|
|
1278
|
-
* )
|
|
1279
|
-
* }
|
|
1280
|
-
* ```
|
|
1281
|
-
*/
|
|
1282
|
-
const useCollection = (options) => {
|
|
1283
|
-
const { definition, params = {}, readOnly, queryConstraints, undoable = true, enabled = true } = options;
|
|
1586
|
+
return selector ? selector(state) : state;
|
|
1587
|
+
}, [selector]), useCallback((a, b) => selector ? (isEqual ?? defaultDataEqual)(a, b) : selectionEqual(a, b, defaultDataEqual), [selector, isEqual]));
|
|
1588
|
+
const hasSelector = selector != null;
|
|
1589
|
+
return useMemo(() => {
|
|
1590
|
+
const handle = getSnapshot();
|
|
1591
|
+
if (hasSelector) return {
|
|
1592
|
+
data: selection,
|
|
1593
|
+
update: handle.update,
|
|
1594
|
+
set: handle.set,
|
|
1595
|
+
delete: handle.delete,
|
|
1596
|
+
sync: handle.sync,
|
|
1597
|
+
ref: handle.ref
|
|
1598
|
+
};
|
|
1599
|
+
const s = selection;
|
|
1600
|
+
return {
|
|
1601
|
+
data: s.data,
|
|
1602
|
+
update: handle.update,
|
|
1603
|
+
set: handle.set,
|
|
1604
|
+
delete: handle.delete,
|
|
1605
|
+
isLoading: s.isLoading,
|
|
1606
|
+
isSynced: s.isSynced,
|
|
1607
|
+
sync: handle.sync,
|
|
1608
|
+
error: s.error,
|
|
1609
|
+
ref: handle.ref
|
|
1610
|
+
};
|
|
1611
|
+
}, [
|
|
1612
|
+
selection,
|
|
1613
|
+
getSnapshot,
|
|
1614
|
+
hasSelector
|
|
1615
|
+
]);
|
|
1616
|
+
}
|
|
1617
|
+
function useCollection(options) {
|
|
1618
|
+
const { definition, params = {}, readOnly, queryConstraints, undoable = true, enabled = true, selector, isEqual } = options;
|
|
1284
1619
|
const store = useStore();
|
|
1285
|
-
const undoManager = store.undoManager;
|
|
1286
|
-
const undoableRef = useRef(undoable);
|
|
1287
|
-
undoableRef.current = undoable;
|
|
1288
|
-
const onPushUndo = useCallback((undoAction, redoAction, opts) => {
|
|
1289
|
-
if (!undoableRef.current) return;
|
|
1290
|
-
undoManager.push({
|
|
1291
|
-
undo: undoAction,
|
|
1292
|
-
redo: redoAction,
|
|
1293
|
-
groupId: opts?.undoGroupId
|
|
1294
|
-
});
|
|
1295
|
-
}, [undoManager]);
|
|
1296
1620
|
const collectionPath = enabled ? typeof definition.path === "function" ? definition.path(params) : definition.path : void 0;
|
|
1297
1621
|
const stableConstraintsRef = useRef(queryConstraints);
|
|
1298
1622
|
const stableActiveRef = useRef(false);
|
|
@@ -1300,35 +1624,88 @@ const useCollection = (options) => {
|
|
|
1300
1624
|
if (collectionPath === void 0 || !stableActiveRef.current || !queryConstraintsEqual(store.firestore, collectionPath, definition.queryConstraints, stableConstraintsRef.current, queryConstraints)) stableConstraintsRef.current = queryConstraints;
|
|
1301
1625
|
stableActiveRef.current = active;
|
|
1302
1626
|
const stableConstraints = stableConstraintsRef.current;
|
|
1303
|
-
const
|
|
1627
|
+
const isLazy = definition.lazy ?? false;
|
|
1628
|
+
const builtQuery = useMemo(() => active ? buildSharedCollectionQuery(store, collectionPath, definition.queryConstraints, stableConstraints) : null, [
|
|
1629
|
+
active,
|
|
1630
|
+
store,
|
|
1631
|
+
collectionPath,
|
|
1632
|
+
definition,
|
|
1633
|
+
stableConstraints
|
|
1634
|
+
]);
|
|
1635
|
+
const shared = useMemo(() => active && builtQuery !== null ? getCollectionShared({
|
|
1304
1636
|
store,
|
|
1305
1637
|
definition,
|
|
1306
1638
|
collectionPath,
|
|
1307
1639
|
readOnly,
|
|
1308
1640
|
queryConstraints: stableConstraints,
|
|
1309
|
-
|
|
1641
|
+
query: builtQuery
|
|
1310
1642
|
}) : null, [
|
|
1311
|
-
|
|
1643
|
+
active,
|
|
1312
1644
|
store,
|
|
1313
1645
|
definition,
|
|
1314
1646
|
collectionPath,
|
|
1315
1647
|
readOnly,
|
|
1316
1648
|
stableConstraints,
|
|
1317
|
-
|
|
1649
|
+
builtQuery
|
|
1318
1650
|
]);
|
|
1319
|
-
|
|
1651
|
+
useEffect(() => {
|
|
1652
|
+
shared?.setUndoable(undoable);
|
|
1653
|
+
}, [shared, undoable]);
|
|
1320
1654
|
const subscribe = useCallback((onChange) => {
|
|
1321
|
-
if (!
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1655
|
+
if (!shared) return NOOP;
|
|
1656
|
+
shared.setUndoable(undoable);
|
|
1657
|
+
const release = shared.acquire(onChange);
|
|
1658
|
+
if (!isLazy) try {
|
|
1659
|
+
shared.load();
|
|
1660
|
+
} catch (e) {
|
|
1661
|
+
release();
|
|
1662
|
+
throw e;
|
|
1663
|
+
}
|
|
1664
|
+
return release;
|
|
1665
|
+
}, [shared, isLazy]);
|
|
1666
|
+
const getSnapshot = useCallback(() => shared ? shared.getHandle() : DISABLED_COLLECTION_HANDLE, [shared]);
|
|
1667
|
+
const selection = useSyncExternalStoreWithSelector(subscribe, getSnapshot, getSnapshot, useCallback((handle) => {
|
|
1668
|
+
const state = {
|
|
1669
|
+
data: handle.data,
|
|
1670
|
+
isLoading: handle.isLoading,
|
|
1671
|
+
isSynced: handle.isSynced,
|
|
1672
|
+
isActive: handle.isActive,
|
|
1673
|
+
error: handle.error
|
|
1327
1674
|
};
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1675
|
+
return selector ? selector(state) : state;
|
|
1676
|
+
}, [selector]), useCallback((a, b) => selector ? (isEqual ?? defaultDataEqual)(a, b) : selectionEqual(a, b, defaultDataEqual), [selector, isEqual]));
|
|
1677
|
+
const hasSelector = selector != null;
|
|
1678
|
+
return useMemo(() => {
|
|
1679
|
+
const handle = getSnapshot();
|
|
1680
|
+
if (hasSelector) return {
|
|
1681
|
+
data: selection,
|
|
1682
|
+
update: handle.update,
|
|
1683
|
+
add: handle.add,
|
|
1684
|
+
remove: handle.remove,
|
|
1685
|
+
load: handle.load,
|
|
1686
|
+
sync: handle.sync,
|
|
1687
|
+
ref: handle.ref
|
|
1688
|
+
};
|
|
1689
|
+
const s = selection;
|
|
1690
|
+
return {
|
|
1691
|
+
data: s.data,
|
|
1692
|
+
update: handle.update,
|
|
1693
|
+
add: handle.add,
|
|
1694
|
+
remove: handle.remove,
|
|
1695
|
+
isLoading: s.isLoading,
|
|
1696
|
+
isSynced: s.isSynced,
|
|
1697
|
+
isActive: s.isActive ?? false,
|
|
1698
|
+
load: handle.load,
|
|
1699
|
+
sync: handle.sync,
|
|
1700
|
+
error: s.error,
|
|
1701
|
+
ref: handle.ref
|
|
1702
|
+
};
|
|
1703
|
+
}, [
|
|
1704
|
+
selection,
|
|
1705
|
+
getSnapshot,
|
|
1706
|
+
hasSelector
|
|
1707
|
+
]);
|
|
1708
|
+
}
|
|
1332
1709
|
/**
|
|
1333
1710
|
* Keyboard shortcut hook for undo/redo
|
|
1334
1711
|
*
|
|
@@ -1359,7 +1736,7 @@ const useUndoKeyboardShortcuts = () => {
|
|
|
1359
1736
|
};
|
|
1360
1737
|
|
|
1361
1738
|
//#endregion
|
|
1362
|
-
//#region src/firestate.ts
|
|
1739
|
+
//#region src/registry/firestate.ts
|
|
1363
1740
|
/**
|
|
1364
1741
|
* Registry-driven Firestate API.
|
|
1365
1742
|
*
|
|
@@ -1394,6 +1771,10 @@ const useUndoKeyboardShortcuts = () => {
|
|
|
1394
1771
|
* directly — that escape hatch keeps the plain-TypeScript form, at the
|
|
1395
1772
|
* cost of looser param typing on the hook and no runtime validation.
|
|
1396
1773
|
*
|
|
1774
|
+
* `path` may also be a function returning the full document path at runtime —
|
|
1775
|
+
* for paths that branch on a param. Param keys can't be inferred from a
|
|
1776
|
+
* function, so they fall back to `Record<string, string>`. See {@link PathArg}.
|
|
1777
|
+
*
|
|
1397
1778
|
* ```ts
|
|
1398
1779
|
* import { z } from 'zod'
|
|
1399
1780
|
*
|
|
@@ -1404,26 +1785,43 @@ const useUndoKeyboardShortcuts = () => {
|
|
|
1404
1785
|
*/
|
|
1405
1786
|
function doc(opts) {
|
|
1406
1787
|
const { path, ...rest } = opts;
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1788
|
+
if (typeof path === "string") {
|
|
1789
|
+
validateTemplate(path);
|
|
1790
|
+
splitDocPath(path);
|
|
1791
|
+
}
|
|
1792
|
+
const entry = {
|
|
1410
1793
|
__kind: "document",
|
|
1411
1794
|
path,
|
|
1412
1795
|
...rest
|
|
1413
1796
|
};
|
|
1797
|
+
entry.select = (selector, options) => ({
|
|
1798
|
+
__kind: "document-selected",
|
|
1799
|
+
base: entry,
|
|
1800
|
+
selector,
|
|
1801
|
+
isEqual: options?.isEqual
|
|
1802
|
+
});
|
|
1803
|
+
return entry;
|
|
1414
1804
|
}
|
|
1415
1805
|
/**
|
|
1416
1806
|
* Declare a collection entry for a Firestate registry. See {@link doc}
|
|
1417
|
-
* for the schema/typing contract.
|
|
1807
|
+
* for the schema/typing contract. `path` may also be a function returning
|
|
1808
|
+
* the collection path at runtime — see {@link PathArg}.
|
|
1418
1809
|
*/
|
|
1419
1810
|
function col(opts) {
|
|
1420
1811
|
const { path, ...rest } = opts;
|
|
1421
|
-
validateTemplate(path);
|
|
1422
|
-
|
|
1812
|
+
if (typeof path === "string") validateTemplate(path);
|
|
1813
|
+
const entry = {
|
|
1423
1814
|
__kind: "collection",
|
|
1424
1815
|
path,
|
|
1425
1816
|
...rest
|
|
1426
1817
|
};
|
|
1818
|
+
entry.select = (selector, options) => ({
|
|
1819
|
+
__kind: "collection-selected",
|
|
1820
|
+
base: entry,
|
|
1821
|
+
selector,
|
|
1822
|
+
isEqual: options?.isEqual
|
|
1823
|
+
});
|
|
1824
|
+
return entry;
|
|
1427
1825
|
}
|
|
1428
1826
|
/**
|
|
1429
1827
|
* Turn a Firestate registry into a map of typed React hooks. Each entry
|
|
@@ -1438,24 +1836,62 @@ function col(opts) {
|
|
|
1438
1836
|
*/
|
|
1439
1837
|
function createFirestate(registry) {
|
|
1440
1838
|
const api = {};
|
|
1839
|
+
const docDefs = /* @__PURE__ */ new Map();
|
|
1840
|
+
const colDefs = /* @__PURE__ */ new Map();
|
|
1841
|
+
const docDefFor = (base) => {
|
|
1842
|
+
let def = docDefs.get(base);
|
|
1843
|
+
if (!def) {
|
|
1844
|
+
def = buildDocumentDefinition(base);
|
|
1845
|
+
docDefs.set(base, def);
|
|
1846
|
+
}
|
|
1847
|
+
return def;
|
|
1848
|
+
};
|
|
1849
|
+
const colDefFor = (base) => {
|
|
1850
|
+
let def = colDefs.get(base);
|
|
1851
|
+
if (!def) {
|
|
1852
|
+
def = buildCollectionDefinition(base);
|
|
1853
|
+
colDefs.set(base, def);
|
|
1854
|
+
}
|
|
1855
|
+
return def;
|
|
1856
|
+
};
|
|
1441
1857
|
for (const key of Object.keys(registry)) {
|
|
1442
1858
|
if (!isValidKey(key)) throw new Error(`[firestate] registry key "${key}" must start with a letter and contain only letters, digits, _ or $`);
|
|
1443
1859
|
const entry = registry[key];
|
|
1444
1860
|
const hookName = toHookName(key);
|
|
1445
1861
|
if (entry.__kind === "document") {
|
|
1446
|
-
const definition =
|
|
1862
|
+
const definition = docDefFor(entry);
|
|
1447
1863
|
api[hookName] = (params = {}, options = {}) => useDocument({
|
|
1448
1864
|
...options,
|
|
1449
1865
|
definition,
|
|
1450
1866
|
params
|
|
1451
1867
|
});
|
|
1452
|
-
} else {
|
|
1453
|
-
const definition =
|
|
1868
|
+
} else if (entry.__kind === "collection") {
|
|
1869
|
+
const definition = colDefFor(entry);
|
|
1454
1870
|
api[hookName] = (params = {}, options = {}) => useCollection({
|
|
1455
1871
|
...options,
|
|
1456
1872
|
definition,
|
|
1457
1873
|
params
|
|
1458
1874
|
});
|
|
1875
|
+
} else if (entry.__kind === "document-selected") {
|
|
1876
|
+
const definition = docDefFor(entry.base);
|
|
1877
|
+
const { selector, isEqual } = entry;
|
|
1878
|
+
api[hookName] = (params = {}, options = {}) => useDocument({
|
|
1879
|
+
...options,
|
|
1880
|
+
definition,
|
|
1881
|
+
params,
|
|
1882
|
+
selector: (state) => selector(state, params),
|
|
1883
|
+
isEqual
|
|
1884
|
+
});
|
|
1885
|
+
} else {
|
|
1886
|
+
const definition = colDefFor(entry.base);
|
|
1887
|
+
const { selector, isEqual } = entry;
|
|
1888
|
+
api[hookName] = (params = {}, options = {}) => useCollection({
|
|
1889
|
+
...options,
|
|
1890
|
+
definition,
|
|
1891
|
+
params,
|
|
1892
|
+
selector: (state) => selector(state, params),
|
|
1893
|
+
isEqual
|
|
1894
|
+
});
|
|
1459
1895
|
}
|
|
1460
1896
|
}
|
|
1461
1897
|
return api;
|
|
@@ -1468,16 +1904,25 @@ function createFirestate(registry) {
|
|
|
1468
1904
|
* @internal
|
|
1469
1905
|
*/
|
|
1470
1906
|
function buildDocumentDefinition(entry) {
|
|
1471
|
-
const {
|
|
1472
|
-
|
|
1907
|
+
const { path } = entry;
|
|
1908
|
+
const common = {
|
|
1473
1909
|
schema: entry.schema,
|
|
1474
|
-
collection: (params) => interpolate(collectionPath, params),
|
|
1475
|
-
id: (params) => interpolate(idTemplate, params),
|
|
1476
1910
|
autosave: entry.autosave,
|
|
1477
1911
|
minLoadTime: entry.minLoadTime,
|
|
1478
1912
|
readOnly: entry.readOnly,
|
|
1479
1913
|
retryOnError: entry.retryOnError,
|
|
1480
1914
|
retryInterval: entry.retryInterval
|
|
1915
|
+
};
|
|
1916
|
+
if (typeof path === "function") return defineDocument({
|
|
1917
|
+
...common,
|
|
1918
|
+
collection: (params) => splitDocPath(path(params)).collectionPath,
|
|
1919
|
+
id: (params) => splitDocPath(path(params)).idTemplate
|
|
1920
|
+
});
|
|
1921
|
+
const { collectionPath, idTemplate } = splitDocPath(path);
|
|
1922
|
+
return defineDocument({
|
|
1923
|
+
...common,
|
|
1924
|
+
collection: (params) => interpolate(collectionPath, params),
|
|
1925
|
+
id: (params) => interpolate(idTemplate, params)
|
|
1481
1926
|
});
|
|
1482
1927
|
}
|
|
1483
1928
|
/**
|
|
@@ -1486,9 +1931,10 @@ function buildDocumentDefinition(entry) {
|
|
|
1486
1931
|
* @internal
|
|
1487
1932
|
*/
|
|
1488
1933
|
function buildCollectionDefinition(entry) {
|
|
1934
|
+
const { path } = entry;
|
|
1489
1935
|
return defineCollection({
|
|
1490
1936
|
schema: entry.schema,
|
|
1491
|
-
path: (params) => interpolate(
|
|
1937
|
+
path: typeof path === "function" ? path : (params) => interpolate(path, params),
|
|
1492
1938
|
autosave: entry.autosave,
|
|
1493
1939
|
minLoadTime: entry.minLoadTime,
|
|
1494
1940
|
readOnly: entry.readOnly,
|
|
@@ -1544,7 +1990,48 @@ function splitDocPath(path) {
|
|
|
1544
1990
|
}
|
|
1545
1991
|
|
|
1546
1992
|
//#endregion
|
|
1547
|
-
//#region src/
|
|
1993
|
+
//#region src/utils/shallow.ts
|
|
1994
|
+
/**
|
|
1995
|
+
* Shallow structural equality.
|
|
1996
|
+
*
|
|
1997
|
+
* Returns `true` when `a` and `b` are identical by `Object.is`, or are two
|
|
1998
|
+
* arrays / two plain objects whose entries are pairwise `Object.is`-equal one
|
|
1999
|
+
* level deep. Anything else (different shapes, nested objects that aren't
|
|
2000
|
+
* reference-equal) is `false`.
|
|
2001
|
+
*
|
|
2002
|
+
* Intended as the `isEqual` for a hook `selector` that builds a fresh array or
|
|
2003
|
+
* object every render — e.g. `data => Object.values(data).map(d => d.id)` or
|
|
2004
|
+
* `data => ({ name: data?.name, done: data?.done })`. The default selector
|
|
2005
|
+
* comparison is a *deep* value compare, which is correct but does more work
|
|
2006
|
+
* than needed for a flat projection; `shallow` re-renders on a genuine change
|
|
2007
|
+
* to any entry while collapsing the fresh-reference-same-entries case.
|
|
2008
|
+
*
|
|
2009
|
+
* Not recursive on purpose: if a selected entry is itself an object you mutate
|
|
2010
|
+
* in place rather than replace, prefer the default deep comparison or a
|
|
2011
|
+
* bespoke `isEqual`.
|
|
2012
|
+
*/
|
|
2013
|
+
const shallow = (a, b) => {
|
|
2014
|
+
if (Object.is(a, b)) return true;
|
|
2015
|
+
if (typeof a !== "object" || a === null || typeof b !== "object" || b === null) return false;
|
|
2016
|
+
const aIsArray = Array.isArray(a);
|
|
2017
|
+
if (aIsArray !== Array.isArray(b)) return false;
|
|
2018
|
+
if (aIsArray) {
|
|
2019
|
+
const arrA = a;
|
|
2020
|
+
const arrB = b;
|
|
2021
|
+
if (arrA.length !== arrB.length) return false;
|
|
2022
|
+
for (let i = 0; i < arrA.length; i++) if (!Object.is(arrA[i], arrB[i])) return false;
|
|
2023
|
+
return true;
|
|
2024
|
+
}
|
|
2025
|
+
const objA = a;
|
|
2026
|
+
const objB = b;
|
|
2027
|
+
const keysA = Object.keys(objA);
|
|
2028
|
+
if (keysA.length !== Object.keys(objB).length) return false;
|
|
2029
|
+
for (const key of keysA) if (!Object.prototype.hasOwnProperty.call(objB, key) || !Object.is(objA[key], objB[key])) return false;
|
|
2030
|
+
return true;
|
|
2031
|
+
};
|
|
2032
|
+
|
|
2033
|
+
//#endregion
|
|
2034
|
+
//#region src/utils/undo.ts
|
|
1548
2035
|
/**
|
|
1549
2036
|
* Create an undo manager instance.
|
|
1550
2037
|
* This is a standalone, framework-agnostic implementation.
|
|
@@ -1672,7 +2159,7 @@ const createUndoManager = (config = {}) => {
|
|
|
1672
2159
|
};
|
|
1673
2160
|
|
|
1674
2161
|
//#endregion
|
|
1675
|
-
//#region src/store.ts
|
|
2162
|
+
//#region src/core/store.ts
|
|
1676
2163
|
/**
|
|
1677
2164
|
* Create a Firestate store.
|
|
1678
2165
|
* This is the central configuration point for your Firestore state management.
|
|
@@ -1748,7 +2235,7 @@ const createStore = (config) => {
|
|
|
1748
2235
|
};
|
|
1749
2236
|
|
|
1750
2237
|
//#endregion
|
|
1751
|
-
//#region src/provider.tsx
|
|
2238
|
+
//#region src/react/provider.tsx
|
|
1752
2239
|
/**
|
|
1753
2240
|
* Provider component that sets up Firestate for your application.
|
|
1754
2241
|
*
|
|
@@ -1851,5 +2338,5 @@ const useUnsavedChangesBlocker = () => {
|
|
|
1851
2338
|
};
|
|
1852
2339
|
|
|
1853
2340
|
//#endregion
|
|
1854
|
-
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 };
|
|
2341
|
+
export { FirestateContext, FirestateProvider, FirestateStoreProvider, applyDiff, applyDiffMutable, col, computeDiff, computeUndoDiff, createCollectionSubscription, createDiffAtPath, createDocumentSubscription, createFirestate, createStore, createUndoManager, deepClone, defineCollection, defineDocument, diffContainsPath, doc, extractDiffValue, flattenDiff, flattenDiffToFieldPaths, isDeepEqual, isDiffEmpty, mergeDiffs, shallow, unflattenDiff, useCollection, useDocument, useIsSynced, useStore, useUndoKeyboardShortcuts, useUndoManager, useUnsavedChangesBlocker };
|
|
1855
2342
|
//# sourceMappingURL=index.mjs.map
|