@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/dist/index.js CHANGED
@@ -1,20 +1,9 @@
1
- //#region lib/core/hex.ts
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)}${toHex(seq, SEQ_LENGTH)}${nonce(NONCE_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/core/tombstone.ts
46
- function isDeleted(id, tombstones) {
47
- return tombstones[id] !== void 0;
48
- }
49
- function mergeTombstones(target, source) {
50
- const result = {};
51
- const keys = new Set([...Object.keys(target), ...Object.keys(source)]);
52
- for (const key of keys) {
53
- const targetStamp = target[key];
54
- const sourceStamp = source[key];
55
- if (targetStamp && sourceStamp) result[key] = targetStamp > sourceStamp ? targetStamp : sourceStamp;
56
- else if (targetStamp) result[key] = targetStamp;
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/core/atomizer.ts
64
- function pack(value, timestamp) {
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
- [KEYS.VAL]: value,
67
- [KEYS.TS]: timestamp
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/core/types.ts
84
- const KEYS = {
85
- VAL: "~val",
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
- * Type guard to check if a value is a Collection.
102
- * A Collection is a Record where all values are AtomizedDocuments.
103
- */
104
- function isCollection(value) {
105
- if (value === null || typeof value !== "object") return false;
106
- const obj = value;
107
- for (const key in obj) if (Object.prototype.hasOwnProperty.call(obj, key)) {
108
- if (!isAtomizedDocument(obj[key])) return false;
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
- return true;
92
+ recurse(obj, "");
93
+ return result;
111
94
  }
112
- /**
113
- * Type guard to check if a value is a CollectionState.
114
- * A CollectionState has documents (Collection) and tombstones (Record<string, string>).
115
- */
116
- function isCollectionState(value) {
117
- if (value === null || typeof value !== "object") return false;
118
- const obj = value;
119
- if (!("documents" in obj) || !("tombstones" in obj)) return false;
120
- return isCollection(obj["documents"]) && obj["tombstones"] !== null && typeof obj["tombstones"] === "object" && !Array.isArray(obj["tombstones"]);
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/core/lens.ts
125
- function createReadLens(doc) {
126
- return new Proxy(doc, {
127
- get(target, prop, receiver) {
128
- if (typeof prop === "symbol" || !Object.prototype.hasOwnProperty.call(target, prop)) return;
129
- const value = Reflect.get(target, prop, receiver);
130
- if (value === void 0) return void 0;
131
- if (isAtom(value)) return unpack(value);
132
- throw new Error(`createReadLens: field "${String(prop)}" is not an atom. Expected AtomizedDocument<T> with atomized fields only.`);
133
- },
134
- set() {
135
- console.warn("Mutations must use the update API.");
136
- return false;
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
- const merged = { ...local };
146
- let hasChanges = false;
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 === void 0) continue;
151
- if (!localAtom) {
152
- if (isAtom(incomingAtom)) {
153
- merged[key] = incomingAtom;
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 hasChanges ? merged : local;
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
- const mergedCollection = {};
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]) continue;
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) mergedCollection[id] = mergeDocs(localDoc, incomingDoc);
181
- else if (localDoc) mergedCollection[id] = localDoc;
182
- else if (incomingDoc) mergedCollection[id] = incomingDoc;
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
- documents: mergedCollection,
186
- tombstones: mergedTombstones
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/emitter/emitter.ts
192
- var Emitter = class {
193
- #listeners = /* @__PURE__ */ new Set();
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(JSON.stringify(result.issues, null, 2));
205
+ if (result.issues) throw new Error(result.issues.map((i) => i.message).join("; "));
215
206
  return result.value;
216
207
  }
217
- /**
218
- * Helper function to create a collection definition with proper type inference.
219
- * This captures the schema output type directly as the document type.
220
- *
221
- * @example
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
- "~docType": void 0,
231
- "~idType": void 0,
232
- "~schemaType": void 0,
233
- schema,
234
- getId
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/operations.ts
240
- function doGet(docs, tombstones, id) {
241
- if (isDeleted(id, tombstones)) return void 0;
242
- const current = docs[id];
243
- if (!current) return void 0;
244
- return createReadLens(current);
245
- }
246
- function doList(docs, tombstones) {
247
- const results = [];
248
- for (const [id, document] of Object.entries(docs)) if (!isDeleted(id, tombstones)) results.push(createReadLens(document));
249
- return results;
250
- }
251
- function doPut(docs, tombstones, data, stamp, validateFn, getId) {
252
- const validated = validateFn(data);
253
- const id = getId(validated);
254
- if (tombstones[id]) delete tombstones[id];
255
- docs[id] = atomize(validated, stamp);
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(this.#state.clock.ms, this.#state.clock.seq);
312
- }
313
- #getCollection(collection) {
314
- if (!(collection in this.#config)) throw new Error(`Collection "${collection}" not found`);
315
- return this.#state.collections[collection];
316
- }
317
- get(collection, id) {
318
- const col = this.#getCollection(collection);
319
- return doGet(col.documents, col.tombstones, id);
320
- }
321
- list(collection) {
322
- const col = this.#getCollection(collection);
323
- return doList(col.documents, col.tombstones);
324
- }
325
- put(collection, data) {
326
- const col = this.#getCollection(collection);
327
- const collectionConfig = this.#config[collection];
328
- const result = doPut(col.documents, col.tombstones, data, this.#getNextStamp(), (d) => validate(collectionConfig.schema, d), (d) => collectionConfig.getId(d));
329
- this.#emitter.emit({ [collection]: true });
330
- return result;
331
- }
332
- patch(collection, id, data) {
333
- const col = this.#getCollection(collection);
334
- const collectionConfig = this.#config[collection];
335
- const result = doPatch(col.documents, id, data, this.#getNextStamp(), (d) => validate(collectionConfig.schema, d));
336
- this.#emitter.emit({ [collection]: true });
337
- return result;
338
- }
339
- remove(collection, id) {
340
- const col = this.#getCollection(collection);
341
- doRemove(col.documents, col.tombstones, id, this.#getNextStamp());
342
- this.#emitter.emit({ [collection]: true });
343
- }
344
- subscribe(callback) {
345
- return this.#emitter.subscribe(callback);
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: (collection) => {
375
- const cloned = ensureCloned(collection);
376
- return doList(cloned.documents, cloned.tombstones);
337
+ list(options) {
338
+ return col.list(options?.where);
377
339
  },
378
- put: (collection, data) => {
379
- const cloned = ensureCloned(collection);
380
- const collectionConfig = this.#config[collection];
381
- event[collection] = true;
382
- return doPut(cloned.documents, cloned.tombstones, data, this.#getNextStamp(), (d) => validate(collectionConfig.schema, d), (d) => collectionConfig.getId(d));
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: (collection, id, data) => {
385
- const cloned = ensureCloned(collection);
386
- const collectionConfig = this.#config[collection];
387
- event[collection] = true;
388
- return doPatch(cloned.documents, id, data, this.#getNextStamp(), (d) => validate(collectionConfig.schema, d));
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: (collection, id) => {
391
- const cloned = ensureCloned(collection);
392
- event[collection] = true;
393
- doRemove(cloned.documents, cloned.tombstones, id, this.#getNextStamp());
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
- async #load() {
492
- const db = this.#db;
493
- if (!db) return null;
494
- try {
495
- return new Promise((resolve, reject) => {
496
- const request = db.transaction([this.#config.storeName], "readonly").objectStore(this.#config.storeName).get(STORE_KEY);
497
- request.onerror = () => {
498
- reject(/* @__PURE__ */ new Error(`Failed to load state: ${request.error?.message ?? "Unknown error"}`));
499
- };
500
- request.onsuccess = () => {
501
- const result = request.result;
502
- if (result) try {
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
- } catch {
512
- return null;
513
- }
514
- }
515
- async #save(serialized) {
516
- const db = this.#db;
517
- if (!db) throw new Error("IndexedDB not initialized. Call init() first.");
518
- return new Promise((resolve, reject) => {
519
- const request = db.transaction([this.#config.storeName], "readwrite").objectStore(this.#config.storeName).put(serialized, STORE_KEY);
520
- request.onerror = () => {
521
- reject(/* @__PURE__ */ new Error(`Failed to save state: ${request.error?.message ?? "Unknown error"}`));
522
- };
523
- request.onsuccess = () => {
524
- resolve();
525
- };
526
- });
527
- }
528
- async #persist() {
529
- try {
530
- const state = this.#store.getState();
531
- const serialized = this.#config.serialize(state);
532
- await this.#save(serialized);
533
- this.#sync.broadcast(state);
534
- this.#emitter.emit(serialized);
535
- } catch (error) {
536
- console.warn("[Starling Persistence] Failed to save state:", error);
537
- }
538
- }
539
- #openDB() {
540
- return new Promise((resolve, reject) => {
541
- const request = indexedDB.open(this.#config.key, DB_VERSION);
542
- request.onerror = () => {
543
- reject(/* @__PURE__ */ new Error(`Failed to open IndexedDB: ${request.error?.message ?? "Unknown error"}`));
544
- };
545
- request.onsuccess = () => {
546
- resolve(request.result);
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
- this.#sync.close();
589
- }
590
- subscribe(listener) {
591
- return this.#emitter.subscribe(listener);
592
- }
593
- };
410
+ });
411
+ }
594
412
 
595
413
  //#endregion
596
- export { IdbPersister, KEYS, Store, advanceClock, asStamp, atomize, createReadLens, createStore, define, doGet, doList, doPatch, doPut, doRemove, isAtom, isAtomizedDocument, isCollection, isCollectionState, isDeleted, makeStamp, mergeCollections, mergeDocs, mergeState, mergeTombstones, pack, unpack, validate };
414
+ export { advanceClock, createQuery, createStore, makeStamp };
597
415
  //# sourceMappingURL=index.js.map