@drakkar.software/starfish-client 3.0.0-alpha.4 → 3.0.0-alpha.40
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 +59 -0
- package/dist/append-log.d.ts +228 -0
- package/dist/append-log.js +267 -0
- package/dist/background-sync.js +29 -0
- package/dist/bindings/legend.d.ts +23 -0
- package/dist/bindings/legend.js +32 -0
- package/dist/bindings/legend.js.map +2 -2
- package/dist/bindings/suspense.js +49 -0
- package/dist/bindings/zustand.d.ts +167 -2
- package/dist/bindings/zustand.js +963 -87
- package/dist/bindings/zustand.js.map +4 -4
- package/dist/client.d.ts +283 -5
- package/dist/client.js +391 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +18 -0
- package/dist/debounced-sync.js +120 -0
- package/dist/dedup.js +35 -0
- package/dist/events.d.ts +150 -0
- package/dist/events.js +116 -0
- package/dist/events.js.map +7 -0
- package/dist/export.js +115 -0
- package/dist/fetch.d.ts +40 -0
- package/dist/fetch.js +51 -14
- package/dist/fetch.js.map +2 -2
- package/dist/history.js +61 -0
- package/dist/index.d.ts +14 -7
- package/dist/index.js +1025 -99
- package/dist/index.js.map +4 -4
- package/dist/kv-cache.d.ts +63 -0
- package/dist/logger.d.ts +3 -0
- package/dist/logger.js +80 -0
- package/dist/migrate.js +38 -0
- package/dist/mobile-lifecycle.d.ts +28 -1
- package/dist/mobile-lifecycle.js +94 -0
- package/dist/multi-store.js +92 -0
- package/dist/mutate.d.ts +39 -0
- package/dist/polling.js +52 -0
- package/dist/resolvers.js +223 -0
- package/dist/service-worker.js +55 -0
- package/dist/storage/indexeddb.js +59 -0
- package/dist/sync.d.ts +83 -0
- package/dist/sync.js +181 -0
- package/dist/types.d.ts +115 -9
- package/dist/types.js +18 -0
- package/dist/validate.js +28 -0
- package/package.json +12 -3
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/** Shallow structural comparison of two values. Handles objects, arrays, and primitives. */
|
|
2
|
+
function shallowEqual(a, b) {
|
|
3
|
+
if (a === b)
|
|
4
|
+
return true;
|
|
5
|
+
if (a == null || b == null)
|
|
6
|
+
return a === b;
|
|
7
|
+
if (typeof a !== typeof b)
|
|
8
|
+
return false;
|
|
9
|
+
if (typeof a !== "object")
|
|
10
|
+
return false;
|
|
11
|
+
if (Array.isArray(a) !== Array.isArray(b))
|
|
12
|
+
return false;
|
|
13
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
14
|
+
if (a.length !== b.length)
|
|
15
|
+
return false;
|
|
16
|
+
return a.every((v, i) => shallowEqual(v, b[i]));
|
|
17
|
+
}
|
|
18
|
+
const aObj = a;
|
|
19
|
+
const bObj = b;
|
|
20
|
+
const aKeys = Object.keys(aObj);
|
|
21
|
+
const bKeys = Object.keys(bObj);
|
|
22
|
+
if (aKeys.length !== bKeys.length)
|
|
23
|
+
return false;
|
|
24
|
+
return aKeys.every((k) => shallowEqual(aObj[k], bObj[k]));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Wrap a standard ConflictResolver to also return metadata about which fields conflicted.
|
|
28
|
+
* Compares local and remote keys to detect differing fields.
|
|
29
|
+
*/
|
|
30
|
+
export function withConflictMeta(resolver) {
|
|
31
|
+
return (local, remote) => {
|
|
32
|
+
const conflictedFields = [];
|
|
33
|
+
const allKeys = new Set([...Object.keys(local), ...Object.keys(remote)]);
|
|
34
|
+
for (const key of allKeys) {
|
|
35
|
+
const lv = local[key];
|
|
36
|
+
const rv = remote[key];
|
|
37
|
+
if (!shallowEqual(lv, rv)) {
|
|
38
|
+
conflictedFields.push(key);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const data = resolver(local, remote);
|
|
42
|
+
// Determine how it was resolved using structural comparison
|
|
43
|
+
let resolvedBy = "merged";
|
|
44
|
+
if (shallowEqual(data, local))
|
|
45
|
+
resolvedBy = "local";
|
|
46
|
+
else if (shallowEqual(data, remote))
|
|
47
|
+
resolvedBy = "remote";
|
|
48
|
+
return {
|
|
49
|
+
data,
|
|
50
|
+
meta: {
|
|
51
|
+
conflictedFields,
|
|
52
|
+
resolvedBy,
|
|
53
|
+
timestamp: Date.now(),
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Compare two timestamp values. Handles both numeric (epoch) and string (ISO-8601) timestamps. */
|
|
59
|
+
function compareTimestamps(a, b) {
|
|
60
|
+
if (typeof a === "number" && typeof b === "number")
|
|
61
|
+
return a >= b;
|
|
62
|
+
return String(a ?? "") >= String(b ?? "");
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Creates a conflict resolver that merges arrays by ID with per-item
|
|
66
|
+
* timestamp comparison, and uses document-level timestamp for scalars.
|
|
67
|
+
*
|
|
68
|
+
* For arrays: builds a union of both sets keyed by `idKey`. When both
|
|
69
|
+
* sides have the same item, the one with the newer `timestampKey` wins.
|
|
70
|
+
* For scalars: the document with the newer `documentTimestampKey` wins.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* ```ts
|
|
74
|
+
* const merge = createUnionMerge()
|
|
75
|
+
* const sync = new SyncManager({ ..., onConflict: merge })
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export function createUnionMerge(options) {
|
|
79
|
+
const idKey = options?.idKey ?? "id";
|
|
80
|
+
const tsKey = options?.timestampKey ?? "updatedAt";
|
|
81
|
+
const docTsKey = options?.documentTimestampKey ?? "timestamp";
|
|
82
|
+
return (local, remote) => {
|
|
83
|
+
const result = {};
|
|
84
|
+
const localNewer = compareTimestamps(local[docTsKey], remote[docTsKey]);
|
|
85
|
+
const allKeys = new Set([...Object.keys(local), ...Object.keys(remote)]);
|
|
86
|
+
for (const key of allKeys) {
|
|
87
|
+
const lv = local[key];
|
|
88
|
+
const rv = remote[key];
|
|
89
|
+
// Both sides have arrays — attempt ID-based union
|
|
90
|
+
if (Array.isArray(lv) && Array.isArray(rv)) {
|
|
91
|
+
const map = new Map();
|
|
92
|
+
// Seed with remote items
|
|
93
|
+
for (const item of rv) {
|
|
94
|
+
if (item && typeof item === "object" && idKey in item) {
|
|
95
|
+
map.set(item[idKey], item);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
map.set(Symbol(), item);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Overlay local items (per-item timestamp wins)
|
|
102
|
+
for (const item of lv) {
|
|
103
|
+
if (item && typeof item === "object" && idKey in item) {
|
|
104
|
+
const localItem = item;
|
|
105
|
+
const id = localItem[idKey];
|
|
106
|
+
const remoteItem = map.get(id);
|
|
107
|
+
if (!remoteItem) {
|
|
108
|
+
map.set(id, localItem);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
if (compareTimestamps(localItem[tsKey], remoteItem[tsKey])) {
|
|
112
|
+
map.set(id, localItem);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
map.set(Symbol(), item);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
result[key] = [...map.values()];
|
|
121
|
+
}
|
|
122
|
+
else if (lv !== undefined && rv !== undefined) {
|
|
123
|
+
// Scalar: document-level timestamp wins
|
|
124
|
+
result[key] = localNewer ? lv : rv;
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
// Only one side has the key
|
|
128
|
+
result[key] = lv ?? rv;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Creates a conflict resolver that handles soft-deleted items (tombstones).
|
|
136
|
+
* Extends union merge with tombstone awareness: if an item exists on one side
|
|
137
|
+
* with a `deletedAtKey` set, that deletion is respected even if the other side
|
|
138
|
+
* still has the item alive — as long as the deletion timestamp is newer.
|
|
139
|
+
*/
|
|
140
|
+
export function createSoftDeleteResolver(options) {
|
|
141
|
+
const idKey = options?.idKey ?? "id";
|
|
142
|
+
const tsKey = options?.timestampKey ?? "updatedAt";
|
|
143
|
+
const deletedAtKey = options?.deletedAtKey ?? "_deletedAt";
|
|
144
|
+
const baseMerge = createUnionMerge(options);
|
|
145
|
+
return (local, remote) => {
|
|
146
|
+
const merged = baseMerge(local, remote);
|
|
147
|
+
// Build a tombstone map from both sides: id → deletedAt timestamp
|
|
148
|
+
const tombstones = new Map();
|
|
149
|
+
for (const source of [local, remote]) {
|
|
150
|
+
for (const key of Object.keys(source)) {
|
|
151
|
+
const arr = source[key];
|
|
152
|
+
if (!Array.isArray(arr))
|
|
153
|
+
continue;
|
|
154
|
+
for (const item of arr) {
|
|
155
|
+
if (item && typeof item === "object" && idKey in item && deletedAtKey in item) {
|
|
156
|
+
const rec = item;
|
|
157
|
+
const id = rec[idKey];
|
|
158
|
+
const deletedAt = rec[deletedAtKey];
|
|
159
|
+
if (typeof deletedAt === "number" || typeof deletedAt === "string") {
|
|
160
|
+
const existing = tombstones.get(id);
|
|
161
|
+
if (existing == null || compareTimestamps(deletedAt, existing))
|
|
162
|
+
tombstones.set(id, deletedAt);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// For merged arrays, ensure tombstoned items stay deleted
|
|
169
|
+
// (don't resurrect an item if its tombstone is newer than its updatedAt)
|
|
170
|
+
for (const key of Object.keys(merged)) {
|
|
171
|
+
const value = merged[key];
|
|
172
|
+
if (!Array.isArray(value))
|
|
173
|
+
continue;
|
|
174
|
+
merged[key] = value.filter((item) => {
|
|
175
|
+
if (!item || typeof item !== "object" || !(idKey in item))
|
|
176
|
+
return true;
|
|
177
|
+
const rec = item;
|
|
178
|
+
const id = rec[idKey];
|
|
179
|
+
const deletedAt = tombstones.get(id);
|
|
180
|
+
if (deletedAt == null)
|
|
181
|
+
return true;
|
|
182
|
+
// Keep the item if it has a deletedAt (it's the tombstone itself)
|
|
183
|
+
if (rec[deletedAtKey] != null)
|
|
184
|
+
return true;
|
|
185
|
+
// Filter out alive items that have a newer tombstone
|
|
186
|
+
return compareTimestamps(rec[tsKey], deletedAt) && rec[tsKey] !== deletedAt;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return merged;
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Simple resolver: the document with the newer timestamp wins entirely.
|
|
194
|
+
* No per-field or per-item merging.
|
|
195
|
+
*/
|
|
196
|
+
export function timestampWinner(timestampKey = "timestamp") {
|
|
197
|
+
return (local, remote) => {
|
|
198
|
+
return compareTimestamps(local[timestampKey], remote[timestampKey])
|
|
199
|
+
? local
|
|
200
|
+
: remote;
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Remove expired tombstones from an array of items.
|
|
205
|
+
* Items with a `deletedAtKey` older than `ttlMs` are pruned.
|
|
206
|
+
*
|
|
207
|
+
* @param items - Array of items, some with a deletedAt timestamp
|
|
208
|
+
* @param ttlMs - Time-to-live in ms for tombstones (default: 30 days)
|
|
209
|
+
* @param deletedAtKey - Key marking deletion timestamp (default: "_deletedAt")
|
|
210
|
+
*/
|
|
211
|
+
export function pruneTombstones(items, ttlMs = 30 * 24 * 60 * 60 * 1000, deletedAtKey = "_deletedAt") {
|
|
212
|
+
const cutoff = Date.now() - ttlMs;
|
|
213
|
+
return items.filter((item) => {
|
|
214
|
+
const deletedAt = item[deletedAtKey];
|
|
215
|
+
if (deletedAt == null)
|
|
216
|
+
return true;
|
|
217
|
+
if (typeof deletedAt === "number")
|
|
218
|
+
return deletedAt > cutoff;
|
|
219
|
+
if (typeof deletedAt === "string")
|
|
220
|
+
return new Date(deletedAt).getTime() > cutoff;
|
|
221
|
+
return false;
|
|
222
|
+
});
|
|
223
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service Worker utilities for offline support and PWA functionality.
|
|
3
|
+
*/
|
|
4
|
+
/** Check if service workers are supported in the current environment. */
|
|
5
|
+
export function isServiceWorkerSupported() {
|
|
6
|
+
return typeof navigator !== "undefined" && "serviceWorker" in navigator;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Register a service worker for offline support.
|
|
10
|
+
* Returns the registration, or null if not supported.
|
|
11
|
+
*/
|
|
12
|
+
export async function registerServiceWorker(scriptUrl, opts) {
|
|
13
|
+
if (!isServiceWorkerSupported())
|
|
14
|
+
return null;
|
|
15
|
+
try {
|
|
16
|
+
const registration = await navigator.serviceWorker.register(scriptUrl, {
|
|
17
|
+
scope: opts?.scope,
|
|
18
|
+
});
|
|
19
|
+
if (opts?.onUpdate) {
|
|
20
|
+
registration.onupdatefound = () => {
|
|
21
|
+
const installingWorker = registration.installing;
|
|
22
|
+
if (installingWorker) {
|
|
23
|
+
installingWorker.onstatechange = () => {
|
|
24
|
+
if (installingWorker.state === "installed" &&
|
|
25
|
+
navigator.serviceWorker.controller) {
|
|
26
|
+
opts.onUpdate(registration);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return registration;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Unregister all service worker registrations. Returns true if any were unregistered. */
|
|
39
|
+
export async function unregisterServiceWorkers() {
|
|
40
|
+
if (!isServiceWorkerSupported())
|
|
41
|
+
return false;
|
|
42
|
+
try {
|
|
43
|
+
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
44
|
+
let unregistered = false;
|
|
45
|
+
for (const registration of registrations) {
|
|
46
|
+
const result = await registration.unregister();
|
|
47
|
+
if (result)
|
|
48
|
+
unregistered = true;
|
|
49
|
+
}
|
|
50
|
+
return unregistered;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IndexedDB-based storage adapter for Zustand persistence.
|
|
3
|
+
* Implements the same interface as Zustand's StateStorage (getItem/setItem/removeItem).
|
|
4
|
+
* Supports larger data than localStorage (typically 50MB+).
|
|
5
|
+
*/
|
|
6
|
+
function openDB(dbName, storeName) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const request = indexedDB.open(dbName, 1);
|
|
9
|
+
request.onupgradeneeded = () => {
|
|
10
|
+
const db = request.result;
|
|
11
|
+
if (!db.objectStoreNames.contains(storeName)) {
|
|
12
|
+
db.createObjectStore(storeName);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
request.onsuccess = () => resolve(request.result);
|
|
16
|
+
request.onerror = () => reject(request.error);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
function idbRequest(request) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
request.onsuccess = () => resolve(request.result);
|
|
22
|
+
request.onerror = () => reject(request.error);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
export function createIndexedDBStorage(opts) {
|
|
26
|
+
const dbName = opts?.dbName ?? "starfish";
|
|
27
|
+
const storeName = opts?.storeName ?? "state";
|
|
28
|
+
let dbPromise = null;
|
|
29
|
+
function getDB() {
|
|
30
|
+
if (!dbPromise) {
|
|
31
|
+
dbPromise = openDB(dbName, storeName).catch((err) => {
|
|
32
|
+
dbPromise = null; // Reset so next call retries
|
|
33
|
+
throw err;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return dbPromise;
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
async getItem(name) {
|
|
40
|
+
const db = await getDB();
|
|
41
|
+
const tx = db.transaction(storeName, "readonly");
|
|
42
|
+
const store = tx.objectStore(storeName);
|
|
43
|
+
const result = await idbRequest(store.get(name));
|
|
44
|
+
return result ?? null;
|
|
45
|
+
},
|
|
46
|
+
async setItem(name, value) {
|
|
47
|
+
const db = await getDB();
|
|
48
|
+
const tx = db.transaction(storeName, "readwrite");
|
|
49
|
+
const store = tx.objectStore(storeName);
|
|
50
|
+
await idbRequest(store.put(value, name));
|
|
51
|
+
},
|
|
52
|
+
async removeItem(name) {
|
|
53
|
+
const db = await getDB();
|
|
54
|
+
const tx = db.transaction(storeName, "readwrite");
|
|
55
|
+
const store = tx.objectStore(storeName);
|
|
56
|
+
await idbRequest(store.delete(name));
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
package/dist/sync.d.ts
CHANGED
|
@@ -70,14 +70,97 @@ export declare class SyncManager {
|
|
|
70
70
|
private lastCheckpoint;
|
|
71
71
|
private localData;
|
|
72
72
|
private aborted;
|
|
73
|
+
private lastFromCache;
|
|
74
|
+
/** True once {@link seedFromCache} has successfully seeded localData from the cache. */
|
|
75
|
+
private seeded;
|
|
73
76
|
constructor(options: SyncManagerOptions);
|
|
74
77
|
abort(): void;
|
|
75
78
|
get isAborted(): boolean;
|
|
76
79
|
getData(): Record<string, unknown>;
|
|
80
|
+
/**
|
|
81
|
+
* Returns true when `pull()` / `ingest()` should merge against the current
|
|
82
|
+
* `localData` rather than replace it wholesale.
|
|
83
|
+
*
|
|
84
|
+
* Two situations establish a merge baseline:
|
|
85
|
+
* - A successful prior pull/ingest advanced `lastCheckpoint` beyond 0 (the
|
|
86
|
+
* normal steady-state case, unchanged since alpha.36).
|
|
87
|
+
* - A cache seed painted `localData` via {@link seedFromCache} AND the store
|
|
88
|
+
* uses a custom conflict resolver (i.e. NOT the default `deepMerge`). For a
|
|
89
|
+
* union/custom resolver the seeded snapshot is a real baseline that must not
|
|
90
|
+
* be clobbered by a short first live response (a cache-fallback on 429/5xx
|
|
91
|
+
* or a momentarily-short concurrent server snapshot). For the default
|
|
92
|
+
* `deepMerge` resolver we keep the pre-fix wholesale-replace behaviour so
|
|
93
|
+
* non-union stores are byte-identical to alpha.36.
|
|
94
|
+
*/
|
|
95
|
+
private hasMergeBaseline;
|
|
96
|
+
/**
|
|
97
|
+
* Merge a remote snapshot with local (optimistic) data using this manager's
|
|
98
|
+
* conflict resolver — the same resolver the push-conflict path uses. A plain
|
|
99
|
+
* {@link pull} overwrites the store's data with the server snapshot, which
|
|
100
|
+
* would drop un-pushed local writes (they live only in the store, never in
|
|
101
|
+
* `localData` until a push succeeds). The zustand binding calls this on pull
|
|
102
|
+
* while the store is dirty so those writes survive. `local` wins by the same
|
|
103
|
+
* rules as a push conflict.
|
|
104
|
+
*/
|
|
105
|
+
resolve(local: Record<string, unknown>, remote: Record<string, unknown>): Record<string, unknown>;
|
|
77
106
|
getHash(): string | null;
|
|
78
107
|
/** Set the last-known server hash. Used by persistence layers to restore state across restarts. */
|
|
79
108
|
setHash(hash: string | null): void;
|
|
109
|
+
/**
|
|
110
|
+
* Whether the most recent {@link pull} (or {@link seedFromCache}) was served
|
|
111
|
+
* from the client's offline read-through cache rather than a live server
|
|
112
|
+
* response. The binding surfaces this as a `stale` flag so the UI can show an
|
|
113
|
+
* offline indicator without treating a cache hit as "reachable". Reset to
|
|
114
|
+
* false by the next successful network pull.
|
|
115
|
+
*/
|
|
116
|
+
getLastPullFromCache(): boolean;
|
|
117
|
+
/**
|
|
118
|
+
* Cache-first paint: seed `localData` from the client's read-through cache
|
|
119
|
+
* WITHOUT touching the network, decrypting in memory for E2E collections.
|
|
120
|
+
* Returns whether anything was seeded (false on a miss, an expired entry, or
|
|
121
|
+
* a decrypt failure — e.g. keyring skew). Call once on store creation before
|
|
122
|
+
* the initial live {@link pull}.
|
|
123
|
+
*
|
|
124
|
+
* `lastCheckpoint` is intentionally left at 0 so the first live pull sends a
|
|
125
|
+
* full (re)sync request to the server, not a delta. However, for stores with
|
|
126
|
+
* a custom conflict resolver (e.g. `createUnionMerge`) the seeded snapshot is
|
|
127
|
+
* treated as a merge baseline: {@link hasMergeBaseline} returns true, so the
|
|
128
|
+
* first pull/ingest merges against the seed rather than replacing it wholesale.
|
|
129
|
+
* This closes the bootstrap window where a short first-pull response (a cache-
|
|
130
|
+
* fallback on 429/5xx or a momentarily-short concurrent snapshot) would
|
|
131
|
+
* silently drop items the resolver was configured to preserve. For the default
|
|
132
|
+
* `deepMerge` resolver the first pull still takes the snapshot wholesale —
|
|
133
|
+
* behaviour is byte-identical to alpha.36.
|
|
134
|
+
*
|
|
135
|
+
* Requires the client to have been built with a `cache`.
|
|
136
|
+
*/
|
|
137
|
+
seedFromCache(): Promise<boolean>;
|
|
80
138
|
getCheckpoint(): number;
|
|
139
|
+
/**
|
|
140
|
+
* Apply a freshly-fetched `PullResult` to this manager's state WITHOUT
|
|
141
|
+
* firing a network request. Used by the zustand binding's `mergeResult`
|
|
142
|
+
* action to absorb a background revalidation result (delivered via
|
|
143
|
+
* {@link StarfishClientOptions.onRevalidated}) into the store.
|
|
144
|
+
*
|
|
145
|
+
* Like {@link pull}, `ingest` conflict-merges the snapshot against the
|
|
146
|
+
* established baseline via `this.onConflict` when a merge baseline exists
|
|
147
|
+
* ({@link hasMergeBaseline}) — so a union-merge store does not lose array
|
|
148
|
+
* items when a revalidation result (e.g. a stale cache-fallback on 429/5xx)
|
|
149
|
+
* is a shorter snapshot. The baseline is established by either a prior
|
|
150
|
+
* pull/ingest that advanced `lastCheckpoint`, or by a successful
|
|
151
|
+
* {@link seedFromCache} for a store with a custom resolver. The first ingest
|
|
152
|
+
* without such a baseline takes the snapshot wholesale (default `deepMerge`
|
|
153
|
+
* stores are byte-identical to alpha.36). Sets `lastFromCache = false` (a
|
|
154
|
+
* revalidation is a live response) so the binding can clear its `stale` flag.
|
|
155
|
+
*
|
|
156
|
+
* **Staleness guard**: if a `push()` advanced `lastCheckpoint` between the
|
|
157
|
+
* time the revalidation request was sent and the time it resolves, the
|
|
158
|
+
* result is from an older document version. Ingesting it would clobber the
|
|
159
|
+
* user's just-saved edit and reset `lastHash` to a stale server hash
|
|
160
|
+
* (causing a spurious 409 on the next push). We silently drop the result in
|
|
161
|
+
* that case — the store's post-push state is already correct.
|
|
162
|
+
*/
|
|
163
|
+
ingest(result: PullResult): Promise<void>;
|
|
81
164
|
pull(): Promise<PullResult>;
|
|
82
165
|
push(data: Record<string, unknown>): Promise<{
|
|
83
166
|
hash: string;
|
package/dist/sync.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { AUTHOR_PUBKEY_FIELD, AUTHOR_SIGNATURE_FIELD, deepMerge, docAuthorCanonicalInput, getBase64, } from "@drakkar.software/starfish-protocol";
|
|
2
|
+
import { ConflictError } from "./types.js";
|
|
3
|
+
import { stripPushPrefix } from "./client.js";
|
|
4
|
+
import { ValidationError } from "./validate.js";
|
|
5
|
+
export class AbortError extends Error {
|
|
6
|
+
constructor() {
|
|
7
|
+
super("SyncManager was aborted");
|
|
8
|
+
this.name = "AbortError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class SyncManager {
|
|
12
|
+
client;
|
|
13
|
+
pullPath;
|
|
14
|
+
pushPath;
|
|
15
|
+
onConflict;
|
|
16
|
+
maxRetries;
|
|
17
|
+
encryptor;
|
|
18
|
+
signer;
|
|
19
|
+
logger;
|
|
20
|
+
loggerName;
|
|
21
|
+
validate;
|
|
22
|
+
lastHash = null;
|
|
23
|
+
lastCheckpoint = 0;
|
|
24
|
+
localData = {};
|
|
25
|
+
aborted = false;
|
|
26
|
+
constructor(options) {
|
|
27
|
+
this.client = options.client;
|
|
28
|
+
this.pullPath = options.pullPath;
|
|
29
|
+
this.pushPath = options.pushPath;
|
|
30
|
+
this.onConflict = options.onConflict ?? deepMerge;
|
|
31
|
+
this.maxRetries = options.maxRetries ?? 3;
|
|
32
|
+
this.signer = options.signer;
|
|
33
|
+
this.logger = options.logger;
|
|
34
|
+
this.loggerName = options.loggerName ?? options.pullPath.split("/").filter(Boolean).pop() ?? options.pullPath;
|
|
35
|
+
this.validate = options.validate;
|
|
36
|
+
this.encryptor = options.encryptor ?? null;
|
|
37
|
+
}
|
|
38
|
+
abort() {
|
|
39
|
+
this.aborted = true;
|
|
40
|
+
}
|
|
41
|
+
get isAborted() {
|
|
42
|
+
return this.aborted;
|
|
43
|
+
}
|
|
44
|
+
getData() {
|
|
45
|
+
return { ...this.localData };
|
|
46
|
+
}
|
|
47
|
+
getHash() {
|
|
48
|
+
return this.lastHash;
|
|
49
|
+
}
|
|
50
|
+
/** Set the last-known server hash. Used by persistence layers to restore state across restarts. */
|
|
51
|
+
setHash(hash) {
|
|
52
|
+
this.lastHash = hash;
|
|
53
|
+
}
|
|
54
|
+
getCheckpoint() {
|
|
55
|
+
return this.lastCheckpoint;
|
|
56
|
+
}
|
|
57
|
+
async pull() {
|
|
58
|
+
if (this.aborted)
|
|
59
|
+
throw new AbortError();
|
|
60
|
+
this.logger?.pullStart(this.loggerName);
|
|
61
|
+
const start = performance.now();
|
|
62
|
+
try {
|
|
63
|
+
// NOTE: `SyncManager.pull` does NOT auto-enable `withKeyring`. Clients
|
|
64
|
+
// that drive the keyring helpers from `recipients.ts` and want to save
|
|
65
|
+
// the cold-start round-trip should call `client.pull(path, {withKeyring: true})`
|
|
66
|
+
// directly. We keep `SyncManager` keyring-agnostic so it stays usable
|
|
67
|
+
// for collections that don't use delegated encryption.
|
|
68
|
+
const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
|
|
69
|
+
if (this.aborted)
|
|
70
|
+
throw new AbortError();
|
|
71
|
+
if (this.encryptor) {
|
|
72
|
+
const decrypted = await this.encryptor.decrypt(result.data);
|
|
73
|
+
if (this.aborted)
|
|
74
|
+
throw new AbortError();
|
|
75
|
+
this.localData = decrypted;
|
|
76
|
+
result.data = decrypted;
|
|
77
|
+
}
|
|
78
|
+
else if (this.lastCheckpoint > 0) {
|
|
79
|
+
this.localData = deepMerge(this.localData, result.data);
|
|
80
|
+
result.data = this.localData;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
this.localData = result.data;
|
|
84
|
+
}
|
|
85
|
+
this.lastHash = result.hash;
|
|
86
|
+
this.lastCheckpoint = result.timestamp;
|
|
87
|
+
this.logger?.pullSuccess(this.loggerName, Math.round(performance.now() - start));
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
this.logger?.pullError(this.loggerName, err instanceof Error ? err.message : String(err));
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async push(data) {
|
|
96
|
+
if (this.aborted)
|
|
97
|
+
throw new AbortError();
|
|
98
|
+
if (this.validate) {
|
|
99
|
+
const result = this.validate(data);
|
|
100
|
+
if (result !== true)
|
|
101
|
+
throw new ValidationError(result);
|
|
102
|
+
}
|
|
103
|
+
this.logger?.pushStart(this.loggerName);
|
|
104
|
+
const start = performance.now();
|
|
105
|
+
let attempt = 0;
|
|
106
|
+
let pendingData = data;
|
|
107
|
+
while (attempt <= this.maxRetries) {
|
|
108
|
+
try {
|
|
109
|
+
const sealed = this.encryptor
|
|
110
|
+
? await this.encryptor.encrypt(pendingData)
|
|
111
|
+
: pendingData;
|
|
112
|
+
if (this.aborted)
|
|
113
|
+
throw new AbortError();
|
|
114
|
+
// v3.0 signer path: sign the document author proof over the doc-author
|
|
115
|
+
// canonical input (domain-tagged, bound to documentKey) and pass it as
|
|
116
|
+
// top-level body siblings of `data` (NOT inside `data`), where the server
|
|
117
|
+
// verifies it and stores the raw author pubkey.
|
|
118
|
+
let author;
|
|
119
|
+
if (this.signer) {
|
|
120
|
+
const { devEdPubHex, sign } = await this.signer.getSigner();
|
|
121
|
+
if (this.aborted)
|
|
122
|
+
throw new AbortError();
|
|
123
|
+
const documentKey = stripPushPrefix(this.pushPath);
|
|
124
|
+
const canonical = docAuthorCanonicalInput(documentKey, sealed);
|
|
125
|
+
const sigBytes = await sign(new TextEncoder().encode(canonical));
|
|
126
|
+
if (this.aborted)
|
|
127
|
+
throw new AbortError();
|
|
128
|
+
author = {
|
|
129
|
+
[AUTHOR_PUBKEY_FIELD]: devEdPubHex,
|
|
130
|
+
[AUTHOR_SIGNATURE_FIELD]: getBase64().encode(sigBytes),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const result = await this.client.push(this.pushPath, sealed, this.lastHash, author);
|
|
134
|
+
if (this.aborted)
|
|
135
|
+
throw new AbortError();
|
|
136
|
+
this.lastHash = result.hash;
|
|
137
|
+
this.lastCheckpoint = result.timestamp;
|
|
138
|
+
this.localData = pendingData;
|
|
139
|
+
this.logger?.pushSuccess(this.loggerName, Math.round(performance.now() - start));
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
if (err instanceof AbortError)
|
|
144
|
+
throw err;
|
|
145
|
+
if (!(err instanceof ConflictError) || attempt >= this.maxRetries) {
|
|
146
|
+
this.logger?.pushError(this.loggerName, err instanceof Error ? err.message : String(err));
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
this.logger?.conflict(this.loggerName, attempt + 1);
|
|
150
|
+
try {
|
|
151
|
+
const remote = await this.client.pull(this.pullPath);
|
|
152
|
+
if (this.aborted)
|
|
153
|
+
throw new AbortError();
|
|
154
|
+
const remoteData = this.encryptor
|
|
155
|
+
? await this.encryptor.decrypt(remote.data)
|
|
156
|
+
: remote.data;
|
|
157
|
+
if (this.aborted)
|
|
158
|
+
throw new AbortError();
|
|
159
|
+
this.lastHash = remote.hash;
|
|
160
|
+
this.lastCheckpoint = remote.timestamp;
|
|
161
|
+
pendingData = this.onConflict(pendingData, remoteData);
|
|
162
|
+
}
|
|
163
|
+
catch (resolveErr) {
|
|
164
|
+
if (resolveErr instanceof AbortError)
|
|
165
|
+
throw resolveErr;
|
|
166
|
+
const msg = resolveErr instanceof Error ? resolveErr.message : String(resolveErr);
|
|
167
|
+
this.logger?.pushError(this.loggerName, `Conflict resolution failed (attempt ${attempt + 1}): ${msg}`);
|
|
168
|
+
throw resolveErr;
|
|
169
|
+
}
|
|
170
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(100 * Math.pow(2, attempt), 2000) + Math.random() * 100));
|
|
171
|
+
attempt++;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
throw new ConflictError();
|
|
175
|
+
}
|
|
176
|
+
async update(modifier) {
|
|
177
|
+
await this.pull();
|
|
178
|
+
const updated = modifier(this.localData);
|
|
179
|
+
return this.push(updated);
|
|
180
|
+
}
|
|
181
|
+
}
|