@byearlybird/starling 0.17.3 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -96
- package/dist/index.d.ts +63 -178
- package/dist/index.js +337 -519
- package/dist/index.js.map +1 -1
- package/package.json +20 -17
package/dist/index.js
CHANGED
|
@@ -1,20 +1,9 @@
|
|
|
1
|
-
//#region lib/
|
|
1
|
+
//#region lib/clock.ts
|
|
2
2
|
function toHex(value, padLength) {
|
|
3
3
|
return value.toString(16).padStart(padLength, "0");
|
|
4
4
|
}
|
|
5
|
-
function nonce(length) {
|
|
6
|
-
const bytes = new Uint8Array(length / 2);
|
|
7
|
-
crypto.getRandomValues(bytes);
|
|
8
|
-
return Array.from(bytes).map((b) => toHex(b, 2)).join("");
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
//#endregion
|
|
12
|
-
//#region lib/core/clock.ts
|
|
13
5
|
const MS_LENGTH = 12;
|
|
14
6
|
const SEQ_LENGTH = 6;
|
|
15
|
-
const NONCE_LENGTH = 6;
|
|
16
|
-
const STAMP_LENGTH = MS_LENGTH + SEQ_LENGTH + NONCE_LENGTH;
|
|
17
|
-
const HEX_PATTERN = /^[0-9a-f]+$/i;
|
|
18
7
|
function advanceClock(current, next) {
|
|
19
8
|
if (next.ms > current.ms) return {
|
|
20
9
|
ms: next.ms,
|
|
@@ -29,569 +18,398 @@ function advanceClock(current, next) {
|
|
|
29
18
|
seq: current.seq + 1
|
|
30
19
|
};
|
|
31
20
|
}
|
|
32
|
-
function makeStamp(ms, seq) {
|
|
33
|
-
return `${toHex(ms, MS_LENGTH)}
|
|
34
|
-
}
|
|
35
|
-
/**
|
|
36
|
-
* Parse and validate a string as a Stamp. Use when deserializing stored state.
|
|
37
|
-
* Throws if the value is not a valid 24-character hex string.
|
|
38
|
-
*/
|
|
39
|
-
function asStamp(value) {
|
|
40
|
-
if (value.length !== STAMP_LENGTH || !HEX_PATTERN.test(value)) throw new Error(`Invalid stamp: expected ${STAMP_LENGTH} hex characters, got "${value}"`);
|
|
41
|
-
return value;
|
|
21
|
+
function makeStamp(ms, seq, deviceId) {
|
|
22
|
+
return `${toHex(ms, MS_LENGTH)}@${toHex(seq, SEQ_LENGTH)}@${deviceId}`;
|
|
42
23
|
}
|
|
43
24
|
|
|
44
25
|
//#endregion
|
|
45
|
-
//#region lib/
|
|
46
|
-
function
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
else if (sourceStamp) result[key] = sourceStamp;
|
|
58
|
-
}
|
|
59
|
-
return result;
|
|
26
|
+
//#region lib/emitter.ts
|
|
27
|
+
function createEmitter() {
|
|
28
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
29
|
+
return {
|
|
30
|
+
subscribe(listener) {
|
|
31
|
+
listeners.add(listener);
|
|
32
|
+
return () => listeners.delete(listener);
|
|
33
|
+
},
|
|
34
|
+
emit(event) {
|
|
35
|
+
for (const listener of Array.from(listeners)) listener(event);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
60
38
|
}
|
|
61
39
|
|
|
62
40
|
//#endregion
|
|
63
|
-
//#region lib/
|
|
64
|
-
function
|
|
41
|
+
//#region lib/query.ts
|
|
42
|
+
function createQuery(store, predicate, dependencies) {
|
|
43
|
+
const emitter = createEmitter();
|
|
44
|
+
const disposers = /* @__PURE__ */ new Set();
|
|
45
|
+
let result = predicate(store);
|
|
46
|
+
const unsub = store.subscribe((e) => {
|
|
47
|
+
if (dependencies.some((dep) => !!e[dep])) {
|
|
48
|
+
result = predicate(store);
|
|
49
|
+
emitter.emit(result);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
65
52
|
return {
|
|
66
|
-
|
|
67
|
-
|
|
53
|
+
get result() {
|
|
54
|
+
return result;
|
|
55
|
+
},
|
|
56
|
+
subscribe(cb) {
|
|
57
|
+
const disposer = emitter.subscribe(cb);
|
|
58
|
+
disposers.add(disposer);
|
|
59
|
+
return () => {
|
|
60
|
+
disposer();
|
|
61
|
+
disposers.delete(disposer);
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
dispose() {
|
|
65
|
+
for (const disposer of disposers) disposer();
|
|
66
|
+
disposers.clear();
|
|
67
|
+
unsub();
|
|
68
|
+
}
|
|
68
69
|
};
|
|
69
70
|
}
|
|
70
|
-
function unpack(node) {
|
|
71
|
-
return isAtom(node) ? node[KEYS.VAL] : void 0;
|
|
72
|
-
}
|
|
73
|
-
function isAtom(node) {
|
|
74
|
-
return node !== null && typeof node === "object" && KEYS.VAL in node;
|
|
75
|
-
}
|
|
76
|
-
function atomize(data, timestamp) {
|
|
77
|
-
const document = {};
|
|
78
|
-
for (const key of Object.keys(data)) document[key] = pack(data[key], timestamp);
|
|
79
|
-
return document;
|
|
80
|
-
}
|
|
81
71
|
|
|
82
72
|
//#endregion
|
|
83
|
-
//#region lib/
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
TS: "~ts"
|
|
87
|
-
};
|
|
88
|
-
/**
|
|
89
|
-
* Type guard to check if a value is an AtomizedDocument.
|
|
90
|
-
* An AtomizedDocument is an object where all values are Atoms.
|
|
91
|
-
*/
|
|
92
|
-
function isAtomizedDocument(value) {
|
|
93
|
-
if (value === null || typeof value !== "object") return false;
|
|
94
|
-
const obj = value;
|
|
95
|
-
for (const key in obj) if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
96
|
-
if (!isAtom(obj[key])) return false;
|
|
97
|
-
}
|
|
98
|
-
return true;
|
|
73
|
+
//#region lib/flat.ts
|
|
74
|
+
function isPlainObject(value) {
|
|
75
|
+
return Object.prototype.toString.call(value) === "[object Object]";
|
|
99
76
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
77
|
+
function flatten(obj, transform) {
|
|
78
|
+
const result = {};
|
|
79
|
+
function recurse(current, prefix) {
|
|
80
|
+
const keys = Object.keys(current);
|
|
81
|
+
if (keys.length === 0) {
|
|
82
|
+
result[prefix] = transform ? transform(current) : current;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
for (const key of keys) {
|
|
86
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
87
|
+
const value = current[key];
|
|
88
|
+
if (isPlainObject(value) && !Array.isArray(value)) recurse(value, path);
|
|
89
|
+
else result[path] = transform ? transform(value) : value;
|
|
90
|
+
}
|
|
109
91
|
}
|
|
110
|
-
|
|
92
|
+
recurse(obj, "");
|
|
93
|
+
return result;
|
|
111
94
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
95
|
+
function unflatten(obj) {
|
|
96
|
+
const result = {};
|
|
97
|
+
for (const key of Object.keys(obj)) {
|
|
98
|
+
const parts = key.split(".");
|
|
99
|
+
let current = result;
|
|
100
|
+
for (let i = 0; i < parts.length; i++) {
|
|
101
|
+
const part = parts[i];
|
|
102
|
+
if (part === "__proto__") break;
|
|
103
|
+
if (i === parts.length - 1) current[part] = obj[key];
|
|
104
|
+
else {
|
|
105
|
+
const next = parts[i + 1];
|
|
106
|
+
const useArray = /^\d+$/.test(next);
|
|
107
|
+
if (current[part] === void 0) current[part] = useArray ? [] : {};
|
|
108
|
+
current = current[part];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
121
113
|
}
|
|
122
114
|
|
|
123
115
|
//#endregion
|
|
124
|
-
//#region lib/
|
|
125
|
-
function
|
|
126
|
-
return
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
116
|
+
//#region lib/crdt.ts
|
|
117
|
+
function atomize(data, timestamp) {
|
|
118
|
+
return flatten(data, (value) => ({
|
|
119
|
+
"~v": value,
|
|
120
|
+
"~ts": timestamp
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
function mergeTombstones(target, source) {
|
|
124
|
+
let result;
|
|
125
|
+
for (const [key, stamp] of Object.entries(source)) {
|
|
126
|
+
const existing = target[key];
|
|
127
|
+
if (!existing || stamp > existing) {
|
|
128
|
+
if (!result) result = { ...target };
|
|
129
|
+
result[key] = stamp;
|
|
137
130
|
}
|
|
138
|
-
}
|
|
131
|
+
}
|
|
132
|
+
return result ?? target;
|
|
139
133
|
}
|
|
140
|
-
|
|
141
|
-
//#endregion
|
|
142
|
-
//#region lib/core/merge.ts
|
|
143
|
-
/** Merges incoming doc fields into local. Adds new keys from incoming; LWW on conflicts. */
|
|
144
134
|
function mergeDocs(local, incoming) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
for (const key of Object.keys(incoming)) {
|
|
148
|
-
const localAtom = local[key];
|
|
135
|
+
let merged;
|
|
136
|
+
for (const key of Object.keys(local)) {
|
|
149
137
|
const incomingAtom = incoming[key];
|
|
150
|
-
if (incomingAtom
|
|
151
|
-
if (
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
hasChanges = true;
|
|
155
|
-
}
|
|
156
|
-
continue;
|
|
157
|
-
}
|
|
158
|
-
if (isAtom(localAtom) && isAtom(incomingAtom)) {
|
|
159
|
-
if (incomingAtom[KEYS.TS] > localAtom[KEYS.TS]) {
|
|
160
|
-
merged[key] = incomingAtom;
|
|
161
|
-
hasChanges = true;
|
|
162
|
-
}
|
|
138
|
+
if (!incomingAtom) continue;
|
|
139
|
+
if (incomingAtom["~ts"] > local[key]["~ts"]) {
|
|
140
|
+
if (!merged) merged = { ...local };
|
|
141
|
+
merged[key] = incomingAtom;
|
|
163
142
|
}
|
|
164
143
|
}
|
|
165
|
-
return
|
|
144
|
+
return merged ?? local;
|
|
166
145
|
}
|
|
167
|
-
/**
|
|
168
|
-
* Merges two collection states, respecting tombstones.
|
|
169
|
-
* Documents that are tombstoned are excluded from the result.
|
|
170
|
-
* For documents that exist in both collections, fields are merged using LWW semantics.
|
|
171
|
-
*/
|
|
172
146
|
function mergeCollections(local, incoming) {
|
|
173
147
|
const mergedTombstones = mergeTombstones(local.tombstones, incoming.tombstones);
|
|
174
|
-
|
|
148
|
+
let mergedDocuments;
|
|
149
|
+
let puts;
|
|
150
|
+
let patches;
|
|
151
|
+
let removes;
|
|
175
152
|
const allDocumentIds = new Set([...Object.keys(local.documents), ...Object.keys(incoming.documents)]);
|
|
176
153
|
for (const id of allDocumentIds) {
|
|
177
|
-
if (mergedTombstones[id])
|
|
154
|
+
if (mergedTombstones[id]) {
|
|
155
|
+
if (local.documents[id]) {
|
|
156
|
+
if (!mergedDocuments) mergedDocuments = { ...local.documents };
|
|
157
|
+
delete mergedDocuments[id];
|
|
158
|
+
(removes ??= []).push(id);
|
|
159
|
+
}
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
178
162
|
const localDoc = local.documents[id];
|
|
179
163
|
const incomingDoc = incoming.documents[id];
|
|
180
|
-
if (localDoc && incomingDoc)
|
|
181
|
-
|
|
182
|
-
|
|
164
|
+
if (localDoc && incomingDoc) {
|
|
165
|
+
const merged = mergeDocs(localDoc, incomingDoc);
|
|
166
|
+
if (merged !== localDoc) {
|
|
167
|
+
if (!mergedDocuments) mergedDocuments = { ...local.documents };
|
|
168
|
+
mergedDocuments[id] = merged;
|
|
169
|
+
(patches ??= []).push(id);
|
|
170
|
+
}
|
|
171
|
+
} else if (incomingDoc) {
|
|
172
|
+
if (!mergedDocuments) mergedDocuments = { ...local.documents };
|
|
173
|
+
mergedDocuments[id] = incomingDoc;
|
|
174
|
+
(puts ??= []).push(id);
|
|
175
|
+
}
|
|
183
176
|
}
|
|
177
|
+
if (mergedTombstones === local.tombstones && mergedDocuments === void 0) return {
|
|
178
|
+
state: local,
|
|
179
|
+
changes: void 0
|
|
180
|
+
};
|
|
181
|
+
const changes = {
|
|
182
|
+
put: puts ?? [],
|
|
183
|
+
patch: patches ?? [],
|
|
184
|
+
remove: removes ?? []
|
|
185
|
+
};
|
|
184
186
|
return {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
+
state: {
|
|
188
|
+
documents: mergedDocuments ?? local.documents,
|
|
189
|
+
tombstones: mergedTombstones
|
|
190
|
+
},
|
|
191
|
+
changes
|
|
187
192
|
};
|
|
188
193
|
}
|
|
194
|
+
function readDoc(doc) {
|
|
195
|
+
const flat = {};
|
|
196
|
+
for (const key of Object.keys(doc)) flat[key] = doc[key]["~v"];
|
|
197
|
+
return unflatten(flat);
|
|
198
|
+
}
|
|
189
199
|
|
|
190
200
|
//#endregion
|
|
191
|
-
//#region lib/
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
subscribe(listener) {
|
|
195
|
-
this.#listeners.add(listener);
|
|
196
|
-
return () => this.#listeners.delete(listener);
|
|
197
|
-
}
|
|
198
|
-
emit(event) {
|
|
199
|
-
const listeners = Array.from(this.#listeners);
|
|
200
|
-
for (const listener of listeners) listener(event);
|
|
201
|
-
}
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
//#endregion
|
|
205
|
-
//#region lib/store/schema.ts
|
|
206
|
-
/**
|
|
207
|
-
* Validates input data against a schema.
|
|
208
|
-
* Accepts `unknown` input to allow safe validation of untyped data.
|
|
209
|
-
* The schema's runtime validation will ensure type safety.
|
|
210
|
-
*/
|
|
211
|
-
function validate(schema, input) {
|
|
212
|
-
const result = schema["~standard"].validate(input);
|
|
201
|
+
//#region lib/collection.ts
|
|
202
|
+
function validateSchema(schema, data) {
|
|
203
|
+
const result = schema["~standard"].validate(data);
|
|
213
204
|
if (result instanceof Promise) throw new TypeError("Schema validation must be synchronous");
|
|
214
|
-
if (result.issues) throw new Error(
|
|
205
|
+
if (result.issues) throw new Error(result.issues.map((i) => i.message).join("; "));
|
|
215
206
|
return result.value;
|
|
216
207
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
* ```ts
|
|
223
|
-
* const store = createStore({
|
|
224
|
-
* users: define(userSchema, (data) => data.id),
|
|
225
|
-
* });
|
|
226
|
-
* ```
|
|
227
|
-
*/
|
|
228
|
-
function define(schema, getId) {
|
|
208
|
+
function createCollection(def, getStamp) {
|
|
209
|
+
let state = {
|
|
210
|
+
documents: {},
|
|
211
|
+
tombstones: {}
|
|
212
|
+
};
|
|
229
213
|
return {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
214
|
+
get(id) {
|
|
215
|
+
if (state.tombstones[id]) return void 0;
|
|
216
|
+
const current = state.documents[id];
|
|
217
|
+
if (!current) return void 0;
|
|
218
|
+
return readDoc(current);
|
|
219
|
+
},
|
|
220
|
+
list(where) {
|
|
221
|
+
const results = [];
|
|
222
|
+
for (const [id, document] of Object.entries(state.documents)) if (!state.tombstones[id]) {
|
|
223
|
+
const doc = readDoc(document);
|
|
224
|
+
if (!where || where(doc)) results.push(doc);
|
|
225
|
+
}
|
|
226
|
+
return results;
|
|
227
|
+
},
|
|
228
|
+
put(data) {
|
|
229
|
+
const toPut = def.schema ? validateSchema(def.schema, data) : data;
|
|
230
|
+
const id = toPut[def.keyPath];
|
|
231
|
+
if (state.tombstones[id]) delete state.tombstones[id];
|
|
232
|
+
const stamp = getStamp();
|
|
233
|
+
state.documents[id] = atomize(toPut, stamp);
|
|
234
|
+
return {
|
|
235
|
+
id,
|
|
236
|
+
doc: readDoc(state.documents[id])
|
|
237
|
+
};
|
|
238
|
+
},
|
|
239
|
+
patch(id, data) {
|
|
240
|
+
const prev = state.documents[id];
|
|
241
|
+
if (!prev) throw new Error(`Cannot patch non-existent document "${id}"`);
|
|
242
|
+
const changes = atomize(data, getStamp());
|
|
243
|
+
const merged = {
|
|
244
|
+
...prev,
|
|
245
|
+
...changes
|
|
246
|
+
};
|
|
247
|
+
state.documents[id] = merged;
|
|
248
|
+
const result = readDoc(merged);
|
|
249
|
+
if (def.schema) try {
|
|
250
|
+
validateSchema(def.schema, result);
|
|
251
|
+
} catch (e) {
|
|
252
|
+
state.documents[id] = prev;
|
|
253
|
+
throw e;
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
id,
|
|
257
|
+
doc: result
|
|
258
|
+
};
|
|
259
|
+
},
|
|
260
|
+
remove(id) {
|
|
261
|
+
state.tombstones[id] = getStamp();
|
|
262
|
+
delete state.documents[id];
|
|
263
|
+
},
|
|
264
|
+
getState() {
|
|
265
|
+
return state;
|
|
266
|
+
},
|
|
267
|
+
setState(newState) {
|
|
268
|
+
state = newState;
|
|
269
|
+
},
|
|
270
|
+
snapshot() {
|
|
271
|
+
return structuredClone(state);
|
|
272
|
+
},
|
|
273
|
+
restore(snap) {
|
|
274
|
+
state = snap;
|
|
275
|
+
}
|
|
235
276
|
};
|
|
236
277
|
}
|
|
237
278
|
|
|
238
279
|
//#endregion
|
|
239
|
-
//#region lib/store
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
function
|
|
247
|
-
const
|
|
248
|
-
for (const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
return createReadLens(docs[id]);
|
|
257
|
-
}
|
|
258
|
-
function doPatch(docs, id, data, stamp, validateFn) {
|
|
259
|
-
const current = docs[id];
|
|
260
|
-
if (!current) throw new Error(`Cannot patch non-existent document "${id}"`);
|
|
261
|
-
const merged = mergeDocs(current, atomize(data, stamp));
|
|
262
|
-
const plain = createReadLens(merged);
|
|
263
|
-
validateFn(plain);
|
|
264
|
-
docs[id] = merged;
|
|
265
|
-
return plain;
|
|
266
|
-
}
|
|
267
|
-
function doRemove(docs, tombstones, id, stamp) {
|
|
268
|
-
tombstones[id] = stamp;
|
|
269
|
-
delete docs[id];
|
|
270
|
-
}
|
|
271
|
-
function mergeState(currentState, snapshot, config) {
|
|
272
|
-
const diff = {};
|
|
273
|
-
currentState.clock = advanceClock(currentState.clock, snapshot.clock);
|
|
274
|
-
for (const [name, incomingCollectionState] of Object.entries(snapshot.collections)) {
|
|
275
|
-
const localCollectionState = currentState.collections[name] ?? {
|
|
276
|
-
documents: {},
|
|
277
|
-
tombstones: {}
|
|
278
|
-
};
|
|
279
|
-
currentState.collections[name] = mergeCollections(localCollectionState, incomingCollectionState);
|
|
280
|
-
if (name in config) diff[name] = true;
|
|
281
|
-
}
|
|
282
|
-
return diff;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
//#endregion
|
|
286
|
-
//#region lib/store/store.ts
|
|
287
|
-
var Store = class {
|
|
288
|
-
#config;
|
|
289
|
-
#emitter;
|
|
290
|
-
#state;
|
|
291
|
-
constructor(config) {
|
|
292
|
-
this.#config = config;
|
|
293
|
-
this.#emitter = new Emitter();
|
|
294
|
-
this.#state = {
|
|
295
|
-
clock: {
|
|
296
|
-
ms: Date.now(),
|
|
297
|
-
seq: 0
|
|
298
|
-
},
|
|
299
|
-
collections: {}
|
|
300
|
-
};
|
|
301
|
-
for (const collectionName of Object.keys(config)) this.#state.collections[collectionName] = {
|
|
302
|
-
documents: {},
|
|
303
|
-
tombstones: {}
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
|
-
#getNextStamp() {
|
|
307
|
-
this.#state.clock = advanceClock(this.#state.clock, {
|
|
280
|
+
//#region lib/store.ts
|
|
281
|
+
const RESERVED_NAMES = new Set([
|
|
282
|
+
"subscribe",
|
|
283
|
+
"getState",
|
|
284
|
+
"merge",
|
|
285
|
+
"transact"
|
|
286
|
+
]);
|
|
287
|
+
function createStore(config) {
|
|
288
|
+
const { deviceId, collections } = config;
|
|
289
|
+
for (const name of Object.keys(collections)) if (RESERVED_NAMES.has(name)) throw new Error(`Collection name "${name}" conflicts with a store method`);
|
|
290
|
+
const emitter = createEmitter();
|
|
291
|
+
let clock = {
|
|
292
|
+
ms: Date.now(),
|
|
293
|
+
seq: 0
|
|
294
|
+
};
|
|
295
|
+
function getNextStamp() {
|
|
296
|
+
clock = advanceClock(clock, {
|
|
308
297
|
ms: Date.now(),
|
|
309
298
|
seq: 0
|
|
310
299
|
});
|
|
311
|
-
return makeStamp(
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
getState() {
|
|
348
|
-
return { ...this.#state };
|
|
349
|
-
}
|
|
350
|
-
merge(snapshot) {
|
|
351
|
-
const diff = mergeState(this.#state, snapshot, this.#config);
|
|
352
|
-
this.#emitter.emit(diff);
|
|
353
|
-
return diff;
|
|
354
|
-
}
|
|
355
|
-
transact(callback) {
|
|
356
|
-
const event = {};
|
|
357
|
-
const clonedStates = {};
|
|
358
|
-
const ensureCloned = (name) => {
|
|
359
|
-
if (!(name in this.#config)) throw new Error(`Collection "${name}" not found`);
|
|
360
|
-
if (!clonedStates[name]) {
|
|
361
|
-
const original = this.#state.collections[name];
|
|
362
|
-
clonedStates[name] = {
|
|
363
|
-
documents: structuredClone(original.documents),
|
|
364
|
-
tombstones: structuredClone(original.tombstones)
|
|
365
|
-
};
|
|
366
|
-
}
|
|
367
|
-
return clonedStates[name];
|
|
368
|
-
};
|
|
369
|
-
const result = callback({
|
|
370
|
-
get: (collection, id) => {
|
|
371
|
-
const cloned = ensureCloned(collection);
|
|
372
|
-
return doGet(cloned.documents, cloned.tombstones, id);
|
|
300
|
+
return makeStamp(clock.ms, clock.seq, deviceId);
|
|
301
|
+
}
|
|
302
|
+
const cols = {};
|
|
303
|
+
for (const name of Object.keys(collections)) cols[name] = createCollection(collections[name], getNextStamp);
|
|
304
|
+
let transacting = false;
|
|
305
|
+
let pendingEvent = null;
|
|
306
|
+
let txSnapshots = null;
|
|
307
|
+
function getPendingChanges(name) {
|
|
308
|
+
let entry = pendingEvent[name];
|
|
309
|
+
if (!entry) {
|
|
310
|
+
entry = {
|
|
311
|
+
put: [],
|
|
312
|
+
patch: [],
|
|
313
|
+
remove: []
|
|
314
|
+
};
|
|
315
|
+
pendingEvent[name] = entry;
|
|
316
|
+
}
|
|
317
|
+
return entry;
|
|
318
|
+
}
|
|
319
|
+
function recordChange(name, type, id) {
|
|
320
|
+
if (transacting) getPendingChanges(name)[type].push(id);
|
|
321
|
+
else emitter.emit({ [name]: {
|
|
322
|
+
put: [],
|
|
323
|
+
patch: [],
|
|
324
|
+
remove: [],
|
|
325
|
+
[type]: [id]
|
|
326
|
+
} });
|
|
327
|
+
}
|
|
328
|
+
function ensureSnapshot(name) {
|
|
329
|
+
if (transacting && txSnapshots && !txSnapshots.has(name)) txSnapshots.set(name, cols[name].snapshot());
|
|
330
|
+
}
|
|
331
|
+
function buildCollectionAPI(name) {
|
|
332
|
+
const col = cols[name];
|
|
333
|
+
return {
|
|
334
|
+
get(id) {
|
|
335
|
+
return col.get(id);
|
|
373
336
|
},
|
|
374
|
-
list
|
|
375
|
-
|
|
376
|
-
return doList(cloned.documents, cloned.tombstones);
|
|
337
|
+
list(options) {
|
|
338
|
+
return col.list(options?.where);
|
|
377
339
|
},
|
|
378
|
-
put
|
|
379
|
-
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
return
|
|
340
|
+
put(data) {
|
|
341
|
+
ensureSnapshot(name);
|
|
342
|
+
const { id, doc } = col.put(data);
|
|
343
|
+
recordChange(name, "put", id);
|
|
344
|
+
return doc;
|
|
383
345
|
},
|
|
384
|
-
patch
|
|
385
|
-
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
return
|
|
346
|
+
patch(id, data) {
|
|
347
|
+
ensureSnapshot(name);
|
|
348
|
+
const { doc } = col.patch(id, data);
|
|
349
|
+
recordChange(name, "patch", id);
|
|
350
|
+
return doc;
|
|
389
351
|
},
|
|
390
|
-
remove
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
352
|
+
remove(id) {
|
|
353
|
+
ensureSnapshot(name);
|
|
354
|
+
col.remove(id);
|
|
355
|
+
recordChange(name, "remove", id);
|
|
394
356
|
}
|
|
395
|
-
});
|
|
396
|
-
for (const name of Object.keys(clonedStates)) this.#state.collections[name] = clonedStates[name];
|
|
397
|
-
if (Object.keys(event).length > 0) this.#emitter.emit(event);
|
|
398
|
-
return result;
|
|
399
|
-
}
|
|
400
|
-
};
|
|
401
|
-
function createStore(config) {
|
|
402
|
-
return new Store(config);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
//#endregion
|
|
406
|
-
//#region lib/persisters/broadcast-sync.ts
|
|
407
|
-
/**
|
|
408
|
-
* Cross-tab synchronization via BroadcastChannel.
|
|
409
|
-
* Handles environments where BroadcastChannel is unavailable.
|
|
410
|
-
*/
|
|
411
|
-
var BroadcastSync = class {
|
|
412
|
-
#channel = null;
|
|
413
|
-
#onMessage;
|
|
414
|
-
constructor(options) {
|
|
415
|
-
this.#onMessage = options.onMessage;
|
|
416
|
-
try {
|
|
417
|
-
this.#channel = new globalThis.BroadcastChannel(options.channelName);
|
|
418
|
-
this.#channel.onmessage = (event) => {
|
|
419
|
-
if (event.data?.type === "state-update" && event.data?.state) try {
|
|
420
|
-
const incomingState = event.data.state;
|
|
421
|
-
this.#onMessage(incomingState);
|
|
422
|
-
} catch (error) {
|
|
423
|
-
console.warn("[BroadcastSync] Failed to process message:", error);
|
|
424
|
-
}
|
|
425
|
-
};
|
|
426
|
-
} catch (error) {
|
|
427
|
-
console.warn("[BroadcastSync] BroadcastChannel not available:", error);
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
/**
|
|
431
|
-
* Broadcast state to other tabs. Does nothing if BroadcastChannel is unavailable.
|
|
432
|
-
*/
|
|
433
|
-
broadcast(state) {
|
|
434
|
-
if (!this.#channel) return;
|
|
435
|
-
try {
|
|
436
|
-
this.#channel.postMessage({
|
|
437
|
-
type: "state-update",
|
|
438
|
-
state
|
|
439
|
-
});
|
|
440
|
-
} catch (error) {
|
|
441
|
-
console.warn("[BroadcastSync] Failed to broadcast:", error);
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
/**
|
|
445
|
-
* Close the BroadcastChannel and clean up resources.
|
|
446
|
-
*/
|
|
447
|
-
close() {
|
|
448
|
-
if (this.#channel) {
|
|
449
|
-
this.#channel.close();
|
|
450
|
-
this.#channel = null;
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
/**
|
|
454
|
-
* Check if BroadcastChannel is available and connected.
|
|
455
|
-
*/
|
|
456
|
-
get available() {
|
|
457
|
-
return this.#channel !== null;
|
|
458
|
-
}
|
|
459
|
-
};
|
|
460
|
-
|
|
461
|
-
//#endregion
|
|
462
|
-
//#region lib/persisters/idb-persister.ts
|
|
463
|
-
const DEFAULT_DEBOUNCE_MS = 300;
|
|
464
|
-
const DB_VERSION = 1;
|
|
465
|
-
const DEFAULT_STORE_NAME = "state";
|
|
466
|
-
const STORE_KEY = "store";
|
|
467
|
-
var IdbPersister = class {
|
|
468
|
-
#store;
|
|
469
|
-
#config;
|
|
470
|
-
#debounceTimer = null;
|
|
471
|
-
#sync;
|
|
472
|
-
#emitter;
|
|
473
|
-
#unsubscribe = null;
|
|
474
|
-
#db = null;
|
|
475
|
-
constructor(store, options) {
|
|
476
|
-
this.#store = store;
|
|
477
|
-
this.#config = {
|
|
478
|
-
key: options.key,
|
|
479
|
-
debounceMs: options.debounceMs ?? DEFAULT_DEBOUNCE_MS,
|
|
480
|
-
storeName: options.storeName ?? DEFAULT_STORE_NAME,
|
|
481
|
-
serialize: options.serialize ?? ((s) => JSON.stringify(s)),
|
|
482
|
-
deserialize: options.deserialize ?? ((s) => JSON.parse(s)),
|
|
483
|
-
channelName: options.channelName ?? `starling:${options.key}`
|
|
484
357
|
};
|
|
485
|
-
this.#emitter = new Emitter();
|
|
486
|
-
this.#sync = new BroadcastSync({
|
|
487
|
-
channelName: this.#config.channelName,
|
|
488
|
-
onMessage: (state) => this.#store.merge(state)
|
|
489
|
-
});
|
|
490
358
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
return
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
resolve(this.#config.deserialize(result));
|
|
504
|
-
} catch (error) {
|
|
505
|
-
console.warn("[Starling Persistence] Failed to deserialize state:", error);
|
|
506
|
-
resolve(null);
|
|
507
|
-
}
|
|
508
|
-
else resolve(null);
|
|
509
|
-
};
|
|
359
|
+
const collectionAPIs = {};
|
|
360
|
+
for (const name of Object.keys(collections)) collectionAPIs[name] = buildCollectionAPI(name);
|
|
361
|
+
return Object.assign(collectionAPIs, {
|
|
362
|
+
subscribe(callback) {
|
|
363
|
+
return emitter.subscribe(callback);
|
|
364
|
+
},
|
|
365
|
+
getState() {
|
|
366
|
+
const colStates = {};
|
|
367
|
+
for (const name of Object.keys(cols)) colStates[name] = cols[name].getState();
|
|
368
|
+
return structuredClone({
|
|
369
|
+
clock,
|
|
370
|
+
collections: colStates
|
|
510
371
|
});
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
}
|
|
548
|
-
request.onupgradeneeded = (event) => {
|
|
549
|
-
const db = event.target.result;
|
|
550
|
-
if (!db.objectStoreNames.contains(this.#config.storeName)) db.createObjectStore(this.#config.storeName);
|
|
551
|
-
};
|
|
552
|
-
});
|
|
553
|
-
}
|
|
554
|
-
async init() {
|
|
555
|
-
try {
|
|
556
|
-
this.#db = await this.#openDB();
|
|
557
|
-
} catch (error) {
|
|
558
|
-
console.warn("[Starling Persistence] Failed to open IndexedDB:", error);
|
|
559
|
-
}
|
|
560
|
-
try {
|
|
561
|
-
const savedState = await this.#load();
|
|
562
|
-
if (savedState) this.#store.merge(savedState);
|
|
563
|
-
} catch (error) {
|
|
564
|
-
console.warn("[Starling Persistence] Failed to load state:", error);
|
|
565
|
-
}
|
|
566
|
-
this.#unsubscribe = this.#store.subscribe(() => {
|
|
567
|
-
if (this.#debounceTimer) clearTimeout(this.#debounceTimer);
|
|
568
|
-
this.#debounceTimer = setTimeout(() => {
|
|
569
|
-
this.#debounceTimer = null;
|
|
570
|
-
this.#persist();
|
|
571
|
-
}, this.#config.debounceMs);
|
|
572
|
-
});
|
|
573
|
-
}
|
|
574
|
-
async dispose() {
|
|
575
|
-
if (this.#unsubscribe) {
|
|
576
|
-
this.#unsubscribe();
|
|
577
|
-
this.#unsubscribe = null;
|
|
578
|
-
}
|
|
579
|
-
if (this.#debounceTimer) {
|
|
580
|
-
clearTimeout(this.#debounceTimer);
|
|
581
|
-
this.#debounceTimer = null;
|
|
582
|
-
}
|
|
583
|
-
if (this.#db) await this.#persist();
|
|
584
|
-
if (this.#db) {
|
|
585
|
-
this.#db.close();
|
|
586
|
-
this.#db = null;
|
|
372
|
+
},
|
|
373
|
+
merge(snapshot) {
|
|
374
|
+
const diff = {};
|
|
375
|
+
clock = advanceClock(clock, snapshot.clock);
|
|
376
|
+
for (const [name, incomingCollectionState] of Object.entries(snapshot.collections)) {
|
|
377
|
+
const col = cols[name];
|
|
378
|
+
const { state: mergedState, changes } = mergeCollections(col ? col.getState() : {
|
|
379
|
+
documents: {},
|
|
380
|
+
tombstones: {}
|
|
381
|
+
}, incomingCollectionState);
|
|
382
|
+
if (changes) {
|
|
383
|
+
if (col) col.setState(mergedState);
|
|
384
|
+
if (name in collections) diff[name] = changes;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (Object.keys(diff).length > 0) emitter.emit(diff);
|
|
388
|
+
return diff;
|
|
389
|
+
},
|
|
390
|
+
transact(callback) {
|
|
391
|
+
txSnapshots = /* @__PURE__ */ new Map();
|
|
392
|
+
transacting = true;
|
|
393
|
+
pendingEvent = {};
|
|
394
|
+
try {
|
|
395
|
+
const result = callback(collectionAPIs);
|
|
396
|
+
const event = pendingEvent;
|
|
397
|
+
transacting = false;
|
|
398
|
+
pendingEvent = null;
|
|
399
|
+
txSnapshots = null;
|
|
400
|
+
if (Object.keys(event).length > 0) emitter.emit(event);
|
|
401
|
+
return result;
|
|
402
|
+
} catch (e) {
|
|
403
|
+
for (const [name, snap] of txSnapshots) cols[name].restore(snap);
|
|
404
|
+
transacting = false;
|
|
405
|
+
pendingEvent = null;
|
|
406
|
+
txSnapshots = null;
|
|
407
|
+
throw e;
|
|
408
|
+
}
|
|
587
409
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
subscribe(listener) {
|
|
591
|
-
return this.#emitter.subscribe(listener);
|
|
592
|
-
}
|
|
593
|
-
};
|
|
410
|
+
});
|
|
411
|
+
}
|
|
594
412
|
|
|
595
413
|
//#endregion
|
|
596
|
-
export {
|
|
414
|
+
export { advanceClock, createQuery, createStore, makeStamp };
|
|
597
415
|
//# sourceMappingURL=index.js.map
|