@byearlybird/starling 0.9.2 → 0.10.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,10 +1,20 @@
1
- //#region src/crdt/eventstamp.ts
1
+ //#region src/clock/errors.ts
2
+ var InvalidEventstampError = class extends Error {
3
+ constructor(eventstamp) {
4
+ super(`Invalid eventstamp: "${eventstamp}"`);
5
+ this.name = "InvalidEventstampError";
6
+ }
7
+ };
8
+
9
+ //#endregion
10
+ //#region src/clock/eventstamp.ts
2
11
  function generateNonce() {
3
12
  return Math.random().toString(16).slice(2, 6).padStart(4, "0");
4
13
  }
5
14
  function encodeEventstamp(timestampMs, counter, nonce) {
6
15
  return `${new Date(timestampMs).toISOString()}|${counter.toString(16).padStart(4, "0")}|${nonce}`;
7
16
  }
17
+ const EVENTSTAMP_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\|[0-9a-f]{4,}\|[0-9a-f]{4}$/;
8
18
  /**
9
19
  * Validates whether a string is a properly formatted eventstamp.
10
20
  * Expected format: YYYY-MM-DDTHH:mm:ss.SSSZ|HHHH+|HHHH
@@ -12,10 +22,10 @@ function encodeEventstamp(timestampMs, counter, nonce) {
12
22
  * and HHHH represents exactly 4 hex characters for the nonce.
13
23
  */
14
24
  function isValidEventstamp(stamp) {
15
- return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\|[0-9a-f]{4,}\|[0-9a-f]{4}$/.test(stamp);
25
+ return EVENTSTAMP_REGEX.test(stamp);
16
26
  }
17
27
  function decodeEventstamp(eventstamp) {
18
- if (!isValidEventstamp(eventstamp)) throw new Error(`Invalid eventstamp format: "${eventstamp}". Expected format: YYYY-MM-DDTHH:mm:ss.SSSZ|HHHH+|HHHH`);
28
+ if (!isValidEventstamp(eventstamp)) throw new InvalidEventstampError(eventstamp);
19
29
  const parts = eventstamp.split("|");
20
30
  const isoString = parts[0];
21
31
  const hexCounter = parts[1];
@@ -27,248 +37,261 @@ function decodeEventstamp(eventstamp) {
27
37
  };
28
38
  }
29
39
  const MIN_EVENTSTAMP = encodeEventstamp(0, 0, "0000");
30
-
31
- //#endregion
32
- //#region src/crdt/utils.ts
33
- function isObject(value) {
34
- return !!(value != null && typeof value === "object" && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype);
35
- }
36
- function isEncodedValue(value) {
37
- return !!(typeof value === "object" && value !== null && "~value" in value && "~eventstamp" in value);
40
+ /**
41
+ * Find the maximum eventstamp from an array of eventstamps.
42
+ * Returns MIN_EVENTSTAMP if the array is empty.
43
+ * @param eventstamps - Array of eventstamp strings
44
+ * @returns The maximum eventstamp
45
+ */
46
+ function maxEventstamp(eventstamps) {
47
+ if (eventstamps.length === 0) return MIN_EVENTSTAMP;
48
+ return eventstamps.reduce((max, stamp) => stamp > max ? stamp : max);
38
49
  }
39
50
 
40
51
  //#endregion
41
- //#region src/crdt/value.ts
42
- function encodeValue(value, eventstamp) {
52
+ //#region src/clock/clock.ts
53
+ /**
54
+ * Create a new Clock instance.
55
+ * @param initialState - Optional initial state for the clock
56
+ */
57
+ function createClock(initialState) {
58
+ let counter = initialState?.counter ?? 0;
59
+ let lastMs = initialState?.lastMs ?? Date.now();
60
+ let lastNonce = initialState?.lastNonce ?? generateNonce();
61
+ const now = () => {
62
+ const wallMs = Date.now();
63
+ if (wallMs > lastMs) {
64
+ lastMs = wallMs;
65
+ counter = 0;
66
+ lastNonce = generateNonce();
67
+ } else {
68
+ counter++;
69
+ lastNonce = generateNonce();
70
+ }
71
+ return encodeEventstamp(lastMs, counter, lastNonce);
72
+ };
73
+ const latest = () => encodeEventstamp(lastMs, counter, lastNonce);
74
+ const forward = (eventstamp) => {
75
+ if (!isValidEventstamp(eventstamp)) throw new InvalidEventstampError(eventstamp);
76
+ if (eventstamp > latest()) {
77
+ const newer = decodeEventstamp(eventstamp);
78
+ lastMs = newer.timestampMs;
79
+ counter = newer.counter;
80
+ lastNonce = newer.nonce;
81
+ }
82
+ };
43
83
  return {
44
- "~value": value,
45
- "~eventstamp": eventstamp
84
+ now,
85
+ latest,
86
+ forward
46
87
  };
47
88
  }
48
- function decodeValue(value) {
49
- return value["~value"];
50
- }
51
- function mergeValues(into, from) {
52
- return into["~eventstamp"] > from["~eventstamp"] ? [into, into["~eventstamp"]] : [from, from["~eventstamp"]];
89
+ /**
90
+ * Create a Clock from an eventstamp string.
91
+ * @param eventstamp - Eventstamp string to decode and initialize clock from
92
+ * @throws Error if eventstamp is invalid
93
+ */
94
+ function createClockFromEventstamp(eventstamp) {
95
+ if (!isValidEventstamp(eventstamp)) throw new Error(`Invalid eventstamp: "${eventstamp}"`);
96
+ const decoded = decodeEventstamp(eventstamp);
97
+ return createClock({
98
+ counter: decoded.counter,
99
+ lastMs: decoded.timestampMs,
100
+ lastNonce: decoded.nonce
101
+ });
53
102
  }
54
103
 
55
104
  //#endregion
56
- //#region src/crdt/record.ts
57
- function processRecord(source, process) {
58
- const result = {};
59
- const step = (input, output) => {
60
- for (const key in input) {
61
- if (!Object.hasOwn(input, key)) continue;
62
- const value = input[key];
63
- if (isEncodedValue(value)) output[key] = process(value);
64
- else if (isObject(value)) {
65
- output[key] = {};
66
- step(value, output[key]);
67
- }
68
- }
69
- };
70
- step(source, result);
71
- return result;
105
+ //#region src/document/resource.ts
106
+ function isObject(value) {
107
+ return value != null && typeof value === "object" && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
108
+ }
109
+ /**
110
+ * Get a value from a nested object using a dot-separated path.
111
+ * @internal
112
+ */
113
+ function getValueAtPath(obj, path) {
114
+ const parts = path.split(".");
115
+ let current = obj;
116
+ for (const part of parts) {
117
+ if (current == null) return void 0;
118
+ current = current[part];
119
+ }
120
+ return current;
121
+ }
122
+ /**
123
+ * Set a value in a nested object using a dot-separated path.
124
+ * Creates intermediate objects as needed.
125
+ * @internal
126
+ */
127
+ function setValueAtPath(obj, path, value) {
128
+ const parts = path.split(".");
129
+ let current = obj;
130
+ for (let i = 0; i < parts.length - 1; i++) {
131
+ if (!current[parts[i]] || typeof current[parts[i]] !== "object") current[parts[i]] = {};
132
+ current = current[parts[i]];
133
+ }
134
+ current[parts[parts.length - 1]] = value;
72
135
  }
73
- function encodeRecord(obj, eventstamp) {
74
- const result = {};
75
- const step = (input, output) => {
136
+ /**
137
+ * Compute the latest eventstamp for a resource from its field eventstamps and deletedAt.
138
+ * Used internally and exported for testing/validation.
139
+ * @internal
140
+ */
141
+ function computeResourceLatest(eventstamps, deletedAt, fallback) {
142
+ let max = fallback ?? MIN_EVENTSTAMP;
143
+ for (const stamp of Object.values(eventstamps)) if (stamp > max) max = stamp;
144
+ if (deletedAt && deletedAt > max) return deletedAt;
145
+ return max;
146
+ }
147
+ function makeResource(type, id, obj, eventstamp, deletedAt = null) {
148
+ const eventstamps = {};
149
+ const traverse = (input, path = "") => {
76
150
  for (const key in input) {
77
151
  if (!Object.hasOwn(input, key)) continue;
78
152
  const value = input[key];
79
- if (isObject(value)) {
80
- output[key] = {};
81
- step(value, output[key]);
82
- } else output[key] = encodeValue(value, eventstamp);
153
+ const fieldPath = path ? `${path}.${key}` : key;
154
+ if (isObject(value)) traverse(value, fieldPath);
155
+ else eventstamps[fieldPath] = eventstamp;
83
156
  }
84
157
  };
85
- step(obj, result);
86
- return result;
87
- }
88
- function decodeRecord(obj) {
89
- const result = {};
90
- const step = (input, output) => {
91
- for (const key in input) {
92
- if (!Object.hasOwn(input, key)) continue;
93
- const value = input[key];
94
- if (isEncodedValue(value)) output[key] = decodeValue(value);
95
- else if (isObject(value)) {
96
- output[key] = {};
97
- step(value, output[key]);
98
- }
158
+ traverse(obj);
159
+ return {
160
+ type,
161
+ id,
162
+ attributes: obj,
163
+ meta: {
164
+ eventstamps,
165
+ latest: computeResourceLatest(eventstamps, deletedAt, eventstamp),
166
+ deletedAt
99
167
  }
100
168
  };
101
- step(obj, result);
102
- return result;
103
169
  }
104
- function mergeRecords(into, from) {
105
- const result = {};
106
- let greatestEventstamp = MIN_EVENTSTAMP;
107
- const step = (v1, v2, output) => {
108
- for (const key in v1) {
109
- if (!Object.hasOwn(v1, key)) continue;
110
- const value1 = v1[key];
111
- const value2 = v2[key];
112
- if (isEncodedValue(value1) && isEncodedValue(value2)) {
113
- const [win, eventstamp] = mergeValues(value1, value2);
114
- output[key] = win;
115
- if (eventstamp > greatestEventstamp) greatestEventstamp = eventstamp;
116
- } else if (isEncodedValue(value1)) {
117
- output[key] = value1;
118
- const eventstamp = value1["~eventstamp"];
119
- if (eventstamp > greatestEventstamp) greatestEventstamp = eventstamp;
120
- } else if (isObject(value1) && isObject(value2)) {
121
- output[key] = {};
122
- step(value1, value2, output[key]);
123
- } else if (value1) output[key] = value1;
170
+ function mergeResources(into, from) {
171
+ const resultAttributes = {};
172
+ const resultEventstamps = {};
173
+ const allPaths = new Set([...Object.keys(into.meta.eventstamps), ...Object.keys(from.meta.eventstamps)]);
174
+ for (const path of allPaths) {
175
+ const stamp1 = into.meta.eventstamps[path];
176
+ const stamp2 = from.meta.eventstamps[path];
177
+ if (stamp1 && stamp2) if (stamp1 > stamp2) {
178
+ setValueAtPath(resultAttributes, path, getValueAtPath(into.attributes, path));
179
+ resultEventstamps[path] = stamp1;
180
+ } else {
181
+ setValueAtPath(resultAttributes, path, getValueAtPath(from.attributes, path));
182
+ resultEventstamps[path] = stamp2;
124
183
  }
125
- for (const key in v2) {
126
- if (!Object.hasOwn(v2, key) || Object.hasOwn(output, key)) continue;
127
- const value = v2[key];
128
- if (value !== void 0) {
129
- output[key] = value;
130
- if (isEncodedValue(value)) {
131
- const eventstamp = value["~eventstamp"];
132
- if (eventstamp > greatestEventstamp) greatestEventstamp = eventstamp;
133
- }
134
- }
184
+ else if (stamp1) {
185
+ setValueAtPath(resultAttributes, path, getValueAtPath(into.attributes, path));
186
+ resultEventstamps[path] = stamp1;
187
+ } else {
188
+ setValueAtPath(resultAttributes, path, getValueAtPath(from.attributes, path));
189
+ resultEventstamps[path] = stamp2;
135
190
  }
136
- };
137
- step(into, from, result);
138
- return [result, greatestEventstamp];
139
- }
140
-
141
- //#endregion
142
- //#region src/crdt/document.ts
143
- function encodeDoc(id, obj, eventstamp, deletedAt = null) {
144
- return {
145
- "~id": id,
146
- "~data": isObject(obj) ? encodeRecord(obj, eventstamp) : encodeValue(obj, eventstamp),
147
- "~deletedAt": deletedAt
148
- };
149
- }
150
- function decodeDoc(doc) {
151
- return {
152
- "~id": doc["~id"],
153
- "~data": isEncodedValue(doc["~data"]) ? decodeValue(doc["~data"]) : decodeRecord(doc["~data"]),
154
- "~deletedAt": doc["~deletedAt"]
155
- };
156
- }
157
- function mergeDocs(into, from) {
158
- const intoIsValue = isEncodedValue(into["~data"]);
159
- const fromIsValue = isEncodedValue(from["~data"]);
160
- if (intoIsValue !== fromIsValue) throw new Error("Merge error: Incompatible types");
161
- const [mergedData, dataEventstamp] = intoIsValue && fromIsValue ? mergeValues(into["~data"], from["~data"]) : mergeRecords(into["~data"], from["~data"]);
162
- const mergedDeletedAt = into["~deletedAt"] && from["~deletedAt"] ? into["~deletedAt"] > from["~deletedAt"] ? into["~deletedAt"] : from["~deletedAt"] : into["~deletedAt"] || from["~deletedAt"] || null;
163
- let greatestEventstamp = dataEventstamp;
164
- if (mergedDeletedAt && mergedDeletedAt > greatestEventstamp) greatestEventstamp = mergedDeletedAt;
165
- return [{
166
- "~id": into["~id"],
167
- "~data": mergedData,
168
- "~deletedAt": mergedDeletedAt
169
- }, greatestEventstamp];
170
- }
171
- function deleteDoc(doc, eventstamp) {
191
+ }
192
+ const dataLatest = computeResourceLatest(resultEventstamps, null, into.meta.latest > from.meta.latest ? into.meta.latest : from.meta.latest);
193
+ const mergedDeletedAt = into.meta.deletedAt && from.meta.deletedAt ? into.meta.deletedAt > from.meta.deletedAt ? into.meta.deletedAt : from.meta.deletedAt : into.meta.deletedAt || from.meta.deletedAt || null;
194
+ const finalLatest = mergedDeletedAt && mergedDeletedAt > dataLatest ? mergedDeletedAt : dataLatest;
172
195
  return {
173
- "~id": doc["~id"],
174
- "~data": doc["~data"],
175
- "~deletedAt": eventstamp
196
+ type: into.type,
197
+ id: into.id,
198
+ attributes: resultAttributes,
199
+ meta: {
200
+ eventstamps: resultEventstamps,
201
+ latest: finalLatest,
202
+ deletedAt: mergedDeletedAt
203
+ }
176
204
  };
177
205
  }
178
- /**
179
- * Transform all values in a document using a provided function.
180
- *
181
- * Useful for custom serialization in plugin hooks (encryption, compression, etc.)
182
- *
183
- * @param doc - Document to transform
184
- * @param process - Function to apply to each leaf value
185
- * @returns New document with transformed values
186
- *
187
- * @example
188
- * ```ts
189
- * // Encrypt all values before persisting
190
- * const encrypted = processDocument(doc, (value) => ({
191
- * ...value,
192
- * "~value": encrypt(value["~value"])
193
- * }));
194
- * ```
195
- */
196
- function processDocument(doc, process) {
197
- const processedData = isEncodedValue(doc["~data"]) ? process(doc["~data"]) : processRecord(doc["~data"], process);
206
+ function deleteResource(resource, eventstamp) {
207
+ const dataLatest = resource.meta.deletedAt ? computeResourceLatest(resource.meta.eventstamps, null) : resource.meta.latest;
208
+ const latest = eventstamp > dataLatest ? eventstamp : dataLatest;
198
209
  return {
199
- "~id": doc["~id"],
200
- "~data": processedData,
201
- "~deletedAt": doc["~deletedAt"]
210
+ type: resource.type,
211
+ id: resource.id,
212
+ attributes: resource.attributes,
213
+ meta: {
214
+ eventstamps: resource.meta.eventstamps,
215
+ latest,
216
+ deletedAt: eventstamp
217
+ }
202
218
  };
203
219
  }
204
220
 
205
221
  //#endregion
206
- //#region src/crdt/collection.ts
222
+ //#region src/document/document.ts
207
223
  /**
208
- * Merges two collections using field-level Last-Write-Wins semantics.
224
+ * Merges two JSON:API documents using field-level Last-Write-Wins semantics.
209
225
  *
210
226
  * The merge operation:
211
- * 1. Forwards the clock to the newest eventstamp from either collection
212
- * 2. Merges each document pair using field-level LWW (via mergeDocs)
227
+ * 1. Forwards the clock to the newest eventstamp from either document
228
+ * 2. Merges each resource pair using field-level LWW (via mergeResources)
213
229
  * 3. Tracks what changed for hook notifications (added/updated/deleted)
214
230
  *
215
- * Deletion is final: once a document is deleted, updates to it are merged into
216
- * the document's data but don't restore visibility. Only new documents or
231
+ * Deletion is final: once a resource is deleted, updates to it are merged into
232
+ * the resource's attributes but don't restore visibility. Only new resources or
217
233
  * transitions into the deleted state are tracked.
218
234
  *
219
- * @param into - The base collection to merge into
220
- * @param from - The source collection to merge from
221
- * @returns Merged collection and categorized changes
235
+ * @param into - The base document to merge into
236
+ * @param from - The source document to merge from
237
+ * @returns Merged document and categorized changes
222
238
  *
223
239
  * @example
224
240
  * ```typescript
225
241
  * const into = {
226
- * "~docs": [{ "~id": "doc1", "~data": {...}, "~deletedAt": null }],
227
- * "~eventstamp": "2025-01-01T00:00:00.000Z|0001|a1b2"
242
+ * jsonapi: { version: "1.1" },
243
+ * meta: { latest: "2025-01-01T00:00:00.000Z|0001|a1b2" },
244
+ * data: [{ type: "items", id: "doc1", attributes: {...}, meta: { deletedAt: null, latest: "..." } }]
228
245
  * };
229
246
  *
230
247
  * const from = {
231
- * "~docs": [
232
- * { "~id": "doc1", "~data": {...}, "~deletedAt": null }, // updated
233
- * { "~id": "doc2", "~data": {...}, "~deletedAt": null } // new
234
- * ],
235
- * "~eventstamp": "2025-01-01T00:05:00.000Z|0001|c3d4"
248
+ * jsonapi: { version: "1.1" },
249
+ * meta: { latest: "2025-01-01T00:05:00.000Z|0001|c3d4" },
250
+ * data: [
251
+ * { type: "items", id: "doc1", attributes: {...}, meta: { deletedAt: null, latest: "..." } }, // updated
252
+ * { type: "items", id: "doc2", attributes: {...}, meta: { deletedAt: null, latest: "..." } } // new
253
+ * ]
236
254
  * };
237
255
  *
238
- * const result = mergeCollections(into, from);
239
- * // result.collection.~eventstamp === "2025-01-01T00:05:00.000Z|0001|c3d4"
256
+ * const result = mergeDocuments(into, from);
257
+ * // result.document.meta.latest === "2025-01-01T00:05:00.000Z|0001|c3d4"
240
258
  * // result.changes.added has "doc2"
241
259
  * // result.changes.updated has "doc1"
242
260
  * ```
243
261
  */
244
- function mergeCollections(into, from) {
262
+ function mergeDocuments(into, from) {
245
263
  const intoDocsById = /* @__PURE__ */ new Map();
246
- for (const doc of into["~docs"]) intoDocsById.set(doc["~id"], doc);
264
+ for (const doc of into.data) intoDocsById.set(doc.id, doc);
247
265
  const added = /* @__PURE__ */ new Map();
248
266
  const updated = /* @__PURE__ */ new Map();
249
267
  const deleted = /* @__PURE__ */ new Set();
250
268
  const mergedDocsById = new Map(intoDocsById);
251
- for (const fromDoc of from["~docs"]) {
252
- const id = fromDoc["~id"];
269
+ let newestEventstamp = into.meta.latest >= from.meta.latest ? into.meta.latest : from.meta.latest;
270
+ for (const fromDoc of from.data) {
271
+ const id = fromDoc.id;
253
272
  const intoDoc = intoDocsById.get(id);
254
273
  if (!intoDoc) {
255
274
  mergedDocsById.set(id, fromDoc);
256
- if (!fromDoc["~deletedAt"]) added.set(id, fromDoc);
275
+ if (!fromDoc.meta.deletedAt) added.set(id, fromDoc);
276
+ if (fromDoc.meta.latest > newestEventstamp) newestEventstamp = fromDoc.meta.latest;
257
277
  } else {
258
278
  if (intoDoc === fromDoc) continue;
259
- const [mergedDoc] = mergeDocs(intoDoc, fromDoc);
279
+ const mergedDoc = mergeResources(intoDoc, fromDoc);
260
280
  mergedDocsById.set(id, mergedDoc);
261
- const wasDeleted = intoDoc["~deletedAt"] !== null;
262
- const isDeleted = mergedDoc["~deletedAt"] !== null;
281
+ if (mergedDoc.meta.latest > newestEventstamp) newestEventstamp = mergedDoc.meta.latest;
282
+ const wasDeleted = intoDoc.meta.deletedAt !== null;
283
+ const isDeleted = mergedDoc.meta.deletedAt !== null;
263
284
  if (!wasDeleted && isDeleted) deleted.add(id);
264
- else if (!isDeleted) updated.set(id, mergedDoc);
285
+ else if (!isDeleted) {
286
+ if (intoDoc.meta.latest !== mergedDoc.meta.latest) updated.set(id, mergedDoc);
287
+ }
265
288
  }
266
289
  }
267
- const newestEventstamp = into["~eventstamp"] >= from["~eventstamp"] ? into["~eventstamp"] : from["~eventstamp"];
268
290
  return {
269
- collection: {
270
- "~docs": Array.from(mergedDocsById.values()),
271
- "~eventstamp": newestEventstamp
291
+ document: {
292
+ jsonapi: { version: "1.1" },
293
+ meta: { latest: newestEventstamp },
294
+ data: Array.from(mergedDocsById.values())
272
295
  },
273
296
  changes: {
274
297
  added,
@@ -277,361 +300,124 @@ function mergeCollections(into, from) {
277
300
  }
278
301
  };
279
302
  }
303
+ /**
304
+ * Creates an empty JSON:API document with the given eventstamp.
305
+ * Useful for initializing new stores or testing.
306
+ *
307
+ * @param eventstamp - Initial clock value for this document
308
+ * @returns Empty document
309
+ *
310
+ * @example
311
+ * ```typescript
312
+ * const empty = makeDocument("2025-01-01T00:00:00.000Z|0000|0000");
313
+ * ```
314
+ */
315
+ function makeDocument(eventstamp) {
316
+ return {
317
+ jsonapi: { version: "1.1" },
318
+ meta: { latest: eventstamp },
319
+ data: []
320
+ };
321
+ }
280
322
 
281
323
  //#endregion
282
- //#region src/clock.ts
324
+ //#region src/document/utils.ts
283
325
  /**
284
- * A Hybrid Logical Clock that generates monotonically increasing eventstamps.
285
- * Combines wall-clock time with a counter for handling clock stalls and a
286
- * random nonce for tie-breaking.
287
- *
288
- * The clock automatically increments the counter when the wall clock doesn't
289
- * advance, ensuring eventstamps are always unique and monotonic.
326
+ * Convert a JsonDocument's data array into a Map keyed by resource ID.
327
+ * @param document - JsonDocument containing resource data
328
+ * @returns Map of resource ID to ResourceObject
290
329
  */
291
- var Clock = class {
292
- #counter = 0;
293
- #lastMs = Date.now();
294
- #lastNonce = generateNonce();
295
- /** Generates a new eventstamp, advancing the clock */
296
- now() {
297
- const wallMs = Date.now();
298
- if (wallMs > this.#lastMs) {
299
- this.#lastMs = wallMs;
300
- this.#counter = 0;
301
- this.#lastNonce = generateNonce();
302
- } else {
303
- this.#counter++;
304
- this.#lastNonce = generateNonce();
305
- }
306
- return encodeEventstamp(this.#lastMs, this.#counter, this.#lastNonce);
307
- }
308
- /** Returns the most recent eventstamp without advancing the clock */
309
- latest() {
310
- return encodeEventstamp(this.#lastMs, this.#counter, this.#lastNonce);
311
- }
312
- /** Fast-forwards the clock to match a newer remote eventstamp */
313
- forward(eventstamp) {
314
- if (eventstamp > this.latest()) {
315
- const newer = decodeEventstamp(eventstamp);
316
- this.#lastMs = newer.timestampMs;
317
- this.#counter = newer.counter;
318
- this.#lastNonce = newer.nonce;
319
- }
320
- }
321
- };
330
+ function documentToMap(document) {
331
+ return new Map(document.data.map((doc) => [doc.id, doc]));
332
+ }
333
+ /**
334
+ * Convert a Map of resources into a JsonDocument.
335
+ * @param resources - Map of resource ID to ResourceObject
336
+ * @param fallbackEventstamp - Eventstamp to include when computing the max (optional)
337
+ * @returns JsonDocument representation of the resources
338
+ */
339
+ function mapToDocument(resources, fallbackEventstamp) {
340
+ const resourceArray = Array.from(resources.values());
341
+ const eventstamps = resourceArray.map((r) => r.meta.latest);
342
+ if (fallbackEventstamp) eventstamps.push(fallbackEventstamp);
343
+ return {
344
+ jsonapi: { version: "1.1" },
345
+ meta: { latest: maxEventstamp(eventstamps) },
346
+ data: resourceArray
347
+ };
348
+ }
322
349
 
323
350
  //#endregion
324
- //#region src/store.ts
351
+ //#region src/resource-map/resource-map.ts
325
352
  /**
326
- * Lightweight local-first data store with built-in sync and reactive queries.
353
+ * A ResourceMap container for storing and managing ResourceObjects.
327
354
  *
328
- * Stores plain JavaScript objects with automatic field-level conflict resolution
329
- * using Last-Write-Wins semantics powered by hybrid logical clocks.
355
+ * This factory function creates a ResourceMap with state-based replication
356
+ * and automatic convergence via Last-Write-Wins conflict resolution.
357
+ * It stores complete resource snapshots with encoded metadata, including deletion markers.
330
358
  *
331
- * @template T - The type of documents stored in this collection
359
+ * ResourceMap does NOT filter based on deletion status—it stores and returns
360
+ * all ResourceObjects including deleted ones. The Store class is responsible
361
+ * for filtering what's visible to users.
332
362
  *
333
363
  * @example
334
- * ```ts
335
- * const store = await new Store<{ text: string; completed: boolean }>()
336
- * .use(unstoragePlugin('todos', storage))
337
- * .init();
338
- *
339
- * // Add, update, delete
340
- * const id = store.add({ text: 'Buy milk', completed: false });
341
- * store.update(id, { completed: true });
342
- * store.del(id);
343
- *
344
- * // Reactive queries
345
- * const activeTodos = store.query({ where: (todo) => !todo.completed });
346
- * activeTodos.onChange(() => console.log('Todos changed!'));
364
+ * ```typescript
365
+ * const resourceMap = createMap("todos");
366
+ * resourceMap.set("id1", { name: "Alice" });
367
+ * const resource = resourceMap.get("id1"); // ResourceObject with metadata
347
368
  * ```
348
369
  */
349
- var Store = class {
350
- #readMap = /* @__PURE__ */ new Map();
351
- #clock = new Clock();
352
- #getId;
353
- #onInitHandlers = [];
354
- #onDisposeHandlers = [];
355
- #onAddHandlers = [];
356
- #onUpdateHandlers = [];
357
- #onDeleteHandlers = [];
358
- #queries = /* @__PURE__ */ new Set();
359
- constructor(config = {}) {
360
- this.#getId = config.getId ?? (() => crypto.randomUUID());
361
- }
362
- /**
363
- * Get a document by ID.
364
- * @returns The document, or null if not found or deleted
365
- */
366
- get(key) {
367
- return this.#decodeActive(this.#readMap.get(key) ?? null);
368
- }
369
- /**
370
- * Iterate over all non-deleted documents as [id, document] tuples.
371
- */
372
- entries() {
373
- const self = this;
374
- function* iterator() {
375
- for (const [key, doc] of self.#readMap.entries()) {
376
- const data = self.#decodeActive(doc);
377
- if (data !== null) yield [key, data];
378
- }
379
- }
380
- return iterator();
381
- }
382
- /**
383
- * Get the complete store state as a Collection for persistence or sync.
384
- * @returns Collection containing all documents and the latest eventstamp
385
- */
386
- collection() {
387
- return {
388
- "~docs": Array.from(this.#readMap.values()),
389
- "~eventstamp": this.#clock.latest()
390
- };
391
- }
392
- /**
393
- * Merge a collection from storage or another replica using field-level LWW.
394
- * @param collection - Collection from storage or another store instance
395
- */
396
- merge(collection) {
397
- const result = mergeCollections(this.collection(), collection);
398
- this.#clock.forward(result.collection["~eventstamp"]);
399
- this.#readMap = new Map(result.collection["~docs"].map((doc) => [doc["~id"], doc]));
400
- const addEntries = Array.from(result.changes.added.entries()).map(([key, doc]) => [key, decodeDoc(doc)["~data"]]);
401
- const updateEntries = Array.from(result.changes.updated.entries()).map(([key, doc]) => [key, decodeDoc(doc)["~data"]]);
402
- const deleteKeys = Array.from(result.changes.deleted);
403
- if (addEntries.length > 0 || updateEntries.length > 0 || deleteKeys.length > 0) this.#emitMutations(addEntries, updateEntries, deleteKeys);
404
- }
405
- /**
406
- * Run multiple operations in a transaction with rollback support.
407
- *
408
- * @param callback - Function receiving a transaction context
409
- * @param opts - Optional config. Use `silent: true` to skip plugin hooks.
410
- * @returns The callback's return value
411
- *
412
- * @example
413
- * ```ts
414
- * const id = store.begin((tx) => {
415
- * const newId = tx.add({ text: 'Buy milk' });
416
- * tx.update(newId, { priority: 'high' });
417
- * return newId; // Return value becomes begin()'s return value
418
- * });
419
- * ```
420
- */
421
- begin(callback, opts) {
422
- const silent = opts?.silent ?? false;
423
- const addEntries = [];
424
- const updateEntries = [];
425
- const deleteKeys = [];
426
- const staging = new Map(this.#readMap);
427
- let rolledBack = false;
428
- const result = callback({
429
- add: (value, options) => {
430
- const key = options?.withId ?? this.#getId();
431
- staging.set(key, this.#encodeValue(key, value));
432
- addEntries.push([key, value]);
433
- return key;
434
- },
435
- update: (key, value) => {
436
- const doc = encodeDoc(key, value, this.#clock.now());
437
- const prev = staging.get(key);
438
- const mergedDoc = prev ? mergeDocs(prev, doc)[0] : doc;
439
- staging.set(key, mergedDoc);
440
- const merged = this.#decodeActive(mergedDoc);
441
- if (merged !== null) updateEntries.push([key, merged]);
442
- },
443
- merge: (doc) => {
444
- const existing = staging.get(doc["~id"]);
445
- const mergedDoc = existing ? mergeDocs(existing, doc)[0] : doc;
446
- staging.set(doc["~id"], mergedDoc);
447
- const decoded = this.#decodeActive(mergedDoc);
448
- const isNew = !this.#readMap.has(doc["~id"]);
449
- if (mergedDoc["~deletedAt"]) deleteKeys.push(doc["~id"]);
450
- else if (decoded !== null) if (isNew) addEntries.push([doc["~id"], decoded]);
451
- else updateEntries.push([doc["~id"], decoded]);
452
- },
453
- del: (key) => {
454
- const currentDoc = staging.get(key);
455
- if (!currentDoc) return;
456
- staging.set(key, deleteDoc(currentDoc, this.#clock.now()));
457
- deleteKeys.push(key);
458
- },
459
- get: (key) => this.#decodeActive(staging.get(key) ?? null),
460
- rollback: () => {
461
- rolledBack = true;
462
- }
463
- });
464
- if (!rolledBack) {
465
- this.#readMap = staging;
466
- if (!silent) this.#emitMutations(addEntries, updateEntries, deleteKeys);
467
- }
468
- return result;
469
- }
470
- /**
471
- * Add a document to the store.
472
- * @returns The document's ID (generated or provided via options)
473
- */
474
- add(value, options) {
475
- return this.begin((tx) => tx.add(value, options));
476
- }
477
- /**
478
- * Update a document with a partial value.
479
- *
480
- * Uses field-level merge - only specified fields are updated.
481
- */
482
- update(key, value) {
483
- this.begin((tx) => tx.update(key, value));
484
- }
485
- /**
486
- * Soft-delete a document.
487
- *
488
- * Deleted docs remain in snapshots for sync purposes but are
489
- * excluded from queries and reads.
490
- */
491
- del(key) {
492
- this.begin((tx) => tx.del(key));
493
- }
494
- /**
495
- * Register a plugin for persistence, analytics, etc.
496
- * @returns This store instance for chaining
497
- */
498
- use(plugin) {
499
- this.#onInitHandlers.push(plugin.onInit);
500
- this.#onDisposeHandlers.push(plugin.onDispose);
501
- if (plugin.onAdd) this.#onAddHandlers.push(plugin.onAdd);
502
- if (plugin.onUpdate) this.#onUpdateHandlers.push(plugin.onUpdate);
503
- if (plugin.onDelete) this.#onDeleteHandlers.push(plugin.onDelete);
504
- return this;
505
- }
506
- /**
507
- * Initialize the store and run plugin onInit hooks.
508
- *
509
- * Must be called before using the store. Runs plugin setup (hydrate
510
- * snapshots, start pollers, etc.) and hydrates existing queries.
511
- *
512
- * @returns This store instance for chaining
513
- */
514
- async init() {
515
- for (const hook of this.#onInitHandlers) await hook(this);
516
- for (const query of this.#queries) this.#hydrateQuery(query);
517
- return this;
518
- }
519
- /**
520
- * Dispose the store and run plugin cleanup.
521
- *
522
- * Flushes pending operations, clears queries, and runs plugin teardown.
523
- * Call when shutting down to avoid memory leaks.
524
- */
525
- async dispose() {
526
- for (let i = this.#onDisposeHandlers.length - 1; i >= 0; i--) await this.#onDisposeHandlers[i]?.();
527
- for (const query of this.#queries) {
528
- query.callbacks.clear();
529
- query.results.clear();
530
- }
531
- this.#queries.clear();
532
- this.#onInitHandlers = [];
533
- this.#onDisposeHandlers = [];
534
- this.#onAddHandlers = [];
535
- this.#onUpdateHandlers = [];
536
- this.#onDeleteHandlers = [];
537
- }
538
- /**
539
- * Create a reactive query that auto-updates when matching docs change.
540
- *
541
- * @example
542
- * ```ts
543
- * const active = store.query({ where: (todo) => !todo.completed });
544
- * active.results(); // [[id, todo], ...]
545
- * active.onChange(() => console.log('Updated!'));
546
- * active.dispose(); // Clean up when done
547
- * ```
548
- */
549
- query(config) {
550
- const query = {
551
- where: config.where,
552
- select: config.select,
553
- order: config.order,
554
- results: /* @__PURE__ */ new Map(),
555
- callbacks: /* @__PURE__ */ new Set()
556
- };
557
- this.#queries.add(query);
558
- this.#hydrateQuery(query);
559
- return {
560
- results: () => {
561
- if (query.order) return Array.from(query.results).sort(([, a], [, b]) => query.order(a, b));
562
- return Array.from(query.results);
563
- },
564
- onChange: (callback) => {
565
- query.callbacks.add(callback);
566
- return () => {
567
- query.callbacks.delete(callback);
568
- };
569
- },
570
- dispose: () => {
571
- this.#queries.delete(query);
572
- query.callbacks.clear();
573
- query.results.clear();
574
- }
575
- };
576
- }
577
- #encodeValue(key, value) {
578
- return encodeDoc(key, value, this.#clock.now());
579
- }
580
- #decodeActive(doc) {
581
- if (!doc || doc["~deletedAt"]) return null;
582
- return decodeDoc(doc)["~data"];
583
- }
584
- #emitMutations(addEntries, updateEntries, deleteKeys) {
585
- this.#notifyQueries(addEntries, updateEntries, deleteKeys);
586
- if (addEntries.length > 0) for (const handler of this.#onAddHandlers) handler(addEntries);
587
- if (updateEntries.length > 0) for (const handler of this.#onUpdateHandlers) handler(updateEntries);
588
- if (deleteKeys.length > 0) for (const handler of this.#onDeleteHandlers) handler(deleteKeys);
589
- }
590
- #notifyQueries(addEntries, updateEntries, deleteKeys) {
591
- if (this.#queries.size === 0) return;
592
- const dirtyQueries = /* @__PURE__ */ new Set();
593
- if (addEntries.length > 0) {
594
- for (const [key, value] of addEntries) for (const query of this.#queries) if (query.where(value)) {
595
- const selected = this.#selectValue(query, value);
596
- query.results.set(key, selected);
597
- dirtyQueries.add(query);
598
- }
599
- }
600
- if (updateEntries.length > 0) for (const [key, value] of updateEntries) for (const query of this.#queries) {
601
- const matches = query.where(value);
602
- const inResults = query.results.has(key);
603
- if (matches && !inResults) {
604
- const selected = this.#selectValue(query, value);
605
- query.results.set(key, selected);
606
- dirtyQueries.add(query);
607
- } else if (!matches && inResults) {
608
- query.results.delete(key);
609
- dirtyQueries.add(query);
610
- } else if (matches && inResults) {
611
- const selected = this.#selectValue(query, value);
612
- query.results.set(key, selected);
613
- dirtyQueries.add(query);
370
+ function createMap(resourceType, initialMap = /* @__PURE__ */ new Map(), eventstamp) {
371
+ let internalMap = initialMap;
372
+ const clock = createClock();
373
+ if (eventstamp) clock.forward(eventstamp);
374
+ return {
375
+ has(id) {
376
+ return internalMap.has(id);
377
+ },
378
+ get(id) {
379
+ return internalMap.get(id);
380
+ },
381
+ entries() {
382
+ return internalMap.entries();
383
+ },
384
+ set(id, object) {
385
+ const encoded = makeResource(resourceType, id, object, clock.now());
386
+ const current = internalMap.get(id);
387
+ if (current) {
388
+ const merged = mergeResources(current, encoded);
389
+ internalMap.set(id, merged);
390
+ } else internalMap.set(id, encoded);
391
+ },
392
+ delete(id) {
393
+ const current = internalMap.get(id);
394
+ if (current) {
395
+ const doc = deleteResource(current, clock.now());
396
+ internalMap.set(id, doc);
614
397
  }
398
+ },
399
+ cloneMap() {
400
+ return new Map(internalMap);
401
+ },
402
+ toDocument() {
403
+ return mapToDocument(internalMap, clock.latest());
404
+ },
405
+ merge(document) {
406
+ const result = mergeDocuments(mapToDocument(internalMap, clock.latest()), document);
407
+ clock.forward(result.document.meta.latest);
408
+ internalMap = documentToMap(result.document);
409
+ return result;
615
410
  }
616
- if (deleteKeys.length > 0) {
617
- for (const key of deleteKeys) for (const query of this.#queries) if (query.results.delete(key)) dirtyQueries.add(query);
618
- }
619
- if (dirtyQueries.size > 0) this.#runQueryCallbacks(dirtyQueries);
620
- }
621
- #runQueryCallbacks(dirtyQueries) {
622
- for (const query of dirtyQueries) for (const callback of query.callbacks) callback();
623
- }
624
- #hydrateQuery(query) {
625
- query.results.clear();
626
- for (const [key, value] of this.entries()) if (query.where(value)) {
627
- const selected = this.#selectValue(query, value);
628
- query.results.set(key, selected);
629
- }
630
- }
631
- #selectValue(query, value) {
632
- return query.select ? query.select(value) : value;
633
- }
634
- };
411
+ };
412
+ }
413
+ /**
414
+ * Create a ResourceMap from a JsonDocument snapshot.
415
+ * @param type - Resource type identifier (defaults to "default")
416
+ * @param document - JsonDocument containing resource data
417
+ */
418
+ function createMapFromDocument(type, document) {
419
+ return createMap(document.data[0]?.type ?? type, documentToMap(document), document.meta.latest);
420
+ }
635
421
 
636
422
  //#endregion
637
- export { Store, processDocument };
423
+ export { InvalidEventstampError, MIN_EVENTSTAMP, createClock, createClockFromEventstamp, createMap, createMapFromDocument, deleteResource, documentToMap, isValidEventstamp, makeDocument, makeResource, mapToDocument, maxEventstamp, mergeDocuments, mergeResources };