@byearlybird/starling 0.10.0 → 0.11.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,423 +1,462 @@
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
- };
1
+ import { c as makeResource, i as mapToDocument, l as mergeResources, o as mergeDocuments, s as deleteResource, u as createClock } from "./core-DI0FfUjX.js";
8
2
 
9
- //#endregion
10
- //#region src/clock/eventstamp.ts
11
- function generateNonce() {
12
- return Math.random().toString(16).slice(2, 6).padStart(4, "0");
13
- }
14
- function encodeEventstamp(timestampMs, counter, nonce) {
15
- return `${new Date(timestampMs).toISOString()}|${counter.toString(16).padStart(4, "0")}|${nonce}`;
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}$/;
18
- /**
19
- * Validates whether a string is a properly formatted eventstamp.
20
- * Expected format: YYYY-MM-DDTHH:mm:ss.SSSZ|HHHH+|HHHH
21
- * where HHHH+ represents 4 or more hex characters for the counter,
22
- * and HHHH represents exactly 4 hex characters for the nonce.
23
- */
24
- function isValidEventstamp(stamp) {
25
- return EVENTSTAMP_REGEX.test(stamp);
26
- }
27
- function decodeEventstamp(eventstamp) {
28
- if (!isValidEventstamp(eventstamp)) throw new InvalidEventstampError(eventstamp);
29
- const parts = eventstamp.split("|");
30
- const isoString = parts[0];
31
- const hexCounter = parts[1];
32
- const nonce = parts[2];
3
+ //#region src/database/emitter.ts
4
+ function createEmitter() {
5
+ const handlers = /* @__PURE__ */ new Map();
33
6
  return {
34
- timestampMs: new Date(isoString).getTime(),
35
- counter: parseInt(hexCounter, 16),
36
- nonce
7
+ on(type, handler) {
8
+ let set = handlers.get(type);
9
+ if (!set) {
10
+ set = /* @__PURE__ */ new Set();
11
+ handlers.set(type, set);
12
+ }
13
+ set.add(handler);
14
+ return () => {
15
+ set?.delete(handler);
16
+ if (!set?.size) handlers.delete(type);
17
+ };
18
+ },
19
+ emit(type, payload) {
20
+ const set = handlers.get(type);
21
+ if (!set) return;
22
+ for (const handler of Array.from(set)) handler(payload);
23
+ },
24
+ clear() {
25
+ handlers.clear();
26
+ }
37
27
  };
38
28
  }
39
- const MIN_EVENTSTAMP = encodeEventstamp(0, 0, "0000");
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);
29
+
30
+ //#endregion
31
+ //#region src/database/standard-schema.ts
32
+ function standardValidate(schema, input) {
33
+ const result = schema["~standard"].validate(input);
34
+ if (result instanceof Promise) throw new TypeError("Schema validation must be synchronous");
35
+ if (result.issues) throw new Error(JSON.stringify(result.issues, null, 2));
36
+ return result.value;
49
37
  }
50
38
 
51
39
  //#endregion
52
- //#region src/clock/clock.ts
40
+ //#region src/database/collection.ts
53
41
  /**
54
- * Create a new Clock instance.
55
- * @param initialState - Optional initial state for the clock
42
+ * Symbols for internal collection methods used by transactions.
43
+ * These are not part of the public Collection type.
56
44
  */
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);
45
+ const CollectionInternals = {
46
+ getPendingMutations: Symbol("getPendingMutations"),
47
+ emitMutations: Symbol("emitMutations"),
48
+ replaceData: Symbol("replaceData"),
49
+ data: Symbol("data")
50
+ };
51
+ function createCollection(name, schema, getId, getEventstamp, initialData, options) {
52
+ const autoFlush = options?.autoFlush ?? true;
53
+ const data = initialData ?? /* @__PURE__ */ new Map();
54
+ const emitter = createEmitter();
55
+ const pendingMutations = {
56
+ added: [],
57
+ updated: [],
58
+ removed: []
72
59
  };
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;
60
+ const flushMutations = () => {
61
+ if (pendingMutations.added.length > 0 || pendingMutations.updated.length > 0 || pendingMutations.removed.length > 0) {
62
+ emitter.emit("mutation", {
63
+ added: [...pendingMutations.added],
64
+ updated: [...pendingMutations.updated],
65
+ removed: [...pendingMutations.removed]
66
+ });
67
+ pendingMutations.added = [];
68
+ pendingMutations.updated = [];
69
+ pendingMutations.removed = [];
81
70
  }
82
71
  };
83
72
  return {
84
- now,
85
- latest,
86
- forward
73
+ get(id, opts = {}) {
74
+ const resource = data.get(id);
75
+ if (!resource) return null;
76
+ if (!opts.includeDeleted && resource.meta.deletedAt) return null;
77
+ return resource.attributes;
78
+ },
79
+ getAll(opts = {}) {
80
+ const resources = Array.from(data.values());
81
+ if (opts.includeDeleted) return resources.map((resource) => resource.attributes);
82
+ else return resources.filter((resource) => !resource.meta.deletedAt).map((resource) => resource.attributes);
83
+ },
84
+ find(filter, opts) {
85
+ const results = [];
86
+ for (const [, resource] of data.entries()) {
87
+ if (resource.meta.deletedAt) continue;
88
+ const attributes = resource.attributes;
89
+ if (filter(attributes)) {
90
+ const value = opts?.map ? opts.map(attributes) : attributes;
91
+ results.push(value);
92
+ }
93
+ }
94
+ if (opts?.sort) results.sort(opts.sort);
95
+ return results;
96
+ },
97
+ add(item) {
98
+ const validated = standardValidate(schema, item);
99
+ const id = getId(validated);
100
+ if (data.has(id)) throw new DuplicateIdError(id);
101
+ const resource = makeResource(name, id, validated, getEventstamp());
102
+ data.set(id, resource);
103
+ pendingMutations.added.push({
104
+ id,
105
+ item: validated
106
+ });
107
+ if (autoFlush) flushMutations();
108
+ return validated;
109
+ },
110
+ update(id, updates) {
111
+ const existing = data.get(id);
112
+ if (!existing) throw new IdNotFoundError(id);
113
+ const before = existing.attributes;
114
+ const merged = mergeResources(existing, makeResource(name, id, updates, getEventstamp()));
115
+ standardValidate(schema, merged.attributes);
116
+ data.set(id, merged);
117
+ pendingMutations.updated.push({
118
+ id,
119
+ before,
120
+ after: merged.attributes
121
+ });
122
+ if (autoFlush) flushMutations();
123
+ },
124
+ remove(id) {
125
+ const existing = data.get(id);
126
+ if (!existing) throw new IdNotFoundError(id);
127
+ const item = existing.attributes;
128
+ const removed = deleteResource(existing, getEventstamp());
129
+ data.set(id, removed);
130
+ pendingMutations.removed.push({
131
+ id,
132
+ item
133
+ });
134
+ if (autoFlush) flushMutations();
135
+ },
136
+ merge(document) {
137
+ const beforeState = /* @__PURE__ */ new Map();
138
+ for (const [id, resource] of data.entries()) beforeState.set(id, resource.attributes);
139
+ const result = mergeDocuments(mapToDocument(data, getEventstamp()), document);
140
+ data.clear();
141
+ for (const resource of result.document.data) data.set(resource.id, resource);
142
+ for (const [id, resource] of result.changes.added) {
143
+ standardValidate(schema, resource.attributes);
144
+ pendingMutations.added.push({
145
+ id,
146
+ item: resource.attributes
147
+ });
148
+ }
149
+ for (const [id, resource] of result.changes.updated) {
150
+ standardValidate(schema, resource.attributes);
151
+ const before = beforeState.get(id);
152
+ pendingMutations.updated.push({
153
+ id,
154
+ before,
155
+ after: resource.attributes
156
+ });
157
+ }
158
+ for (const id of result.changes.deleted) {
159
+ const before = beforeState.get(id);
160
+ pendingMutations.removed.push({
161
+ id,
162
+ item: before
163
+ });
164
+ }
165
+ if (autoFlush) flushMutations();
166
+ },
167
+ toDocument() {
168
+ return mapToDocument(data, getEventstamp());
169
+ },
170
+ on(event, handler) {
171
+ return emitter.on(event, handler);
172
+ },
173
+ [CollectionInternals.data]() {
174
+ return new Map(data);
175
+ },
176
+ [CollectionInternals.getPendingMutations]() {
177
+ return {
178
+ added: [...pendingMutations.added],
179
+ updated: [...pendingMutations.updated],
180
+ removed: [...pendingMutations.removed]
181
+ };
182
+ },
183
+ [CollectionInternals.emitMutations](mutations) {
184
+ if (mutations.added.length > 0 || mutations.updated.length > 0 || mutations.removed.length > 0) emitter.emit("mutation", mutations);
185
+ },
186
+ [CollectionInternals.replaceData](newData) {
187
+ data.clear();
188
+ for (const [id, resource] of newData.entries()) data.set(id, resource);
189
+ }
87
190
  };
88
191
  }
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
- });
102
- }
103
-
104
- //#endregion
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];
192
+ var IdNotFoundError = class extends Error {
193
+ constructor(id) {
194
+ super(`Resource with id ${id} not found`);
195
+ this.name = "IdNotFoundError";
119
196
  }
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]];
197
+ };
198
+ var DuplicateIdError = class extends Error {
199
+ constructor(id) {
200
+ super(`Resource with id ${id} already exists`);
201
+ this.name = "DuplicateIdError";
133
202
  }
134
- current[parts[parts.length - 1]] = value;
135
- }
203
+ };
204
+
205
+ //#endregion
206
+ //#region src/database/query.ts
136
207
  /**
137
- * Compute the latest eventstamp for a resource from its field eventstamps and deletedAt.
138
- * Used internally and exported for testing/validation.
139
- * @internal
208
+ * Execute a reactive query with automatic re-computation on mutations.
209
+ *
210
+ * @param db - Database instance to query
211
+ * @param callback - Query callback receiving read-only collection handles
212
+ * @returns QueryHandle with result, subscribe, and dispose methods
140
213
  */
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 = "") => {
150
- for (const key in input) {
151
- if (!Object.hasOwn(input, key)) continue;
152
- const value = input[key];
153
- const fieldPath = path ? `${path}.${key}` : key;
154
- if (isObject(value)) traverse(value, fieldPath);
155
- else eventstamps[fieldPath] = eventstamp;
214
+ function executeQuery(db, callback) {
215
+ const accessedCollections = /* @__PURE__ */ new Set();
216
+ const subscribers = /* @__PURE__ */ new Set();
217
+ let currentResult;
218
+ const createTrackingHandles = () => {
219
+ const handles = {};
220
+ for (const name of db.collectionKeys()) {
221
+ const collection = db[name];
222
+ handles[name] = createTrackingHandle(name, collection, accessedCollections);
156
223
  }
224
+ return handles;
157
225
  };
158
- traverse(obj);
159
- return {
160
- type,
161
- id,
162
- attributes: obj,
163
- meta: {
164
- eventstamps,
165
- latest: computeResourceLatest(eventstamps, deletedAt, eventstamp),
166
- deletedAt
167
- }
226
+ const runQuery = () => {
227
+ return callback(createTrackingHandles());
168
228
  };
169
- }
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;
183
- }
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;
229
+ currentResult = runQuery();
230
+ const unsubscribeMutation = db.on("mutation", (event) => {
231
+ if (accessedCollections.has(event.collection)) {
232
+ currentResult = runQuery();
233
+ for (const subscriber of subscribers) subscriber(currentResult);
190
234
  }
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;
235
+ });
236
+ let disposed = false;
195
237
  return {
196
- type: into.type,
197
- id: into.id,
198
- attributes: resultAttributes,
199
- meta: {
200
- eventstamps: resultEventstamps,
201
- latest: finalLatest,
202
- deletedAt: mergedDeletedAt
238
+ get result() {
239
+ return currentResult;
240
+ },
241
+ subscribe(callback$1) {
242
+ if (disposed) throw new Error("Cannot subscribe to a disposed query");
243
+ subscribers.add(callback$1);
244
+ return () => {
245
+ subscribers.delete(callback$1);
246
+ };
247
+ },
248
+ dispose() {
249
+ if (disposed) return;
250
+ disposed = true;
251
+ unsubscribeMutation();
252
+ subscribers.clear();
253
+ accessedCollections.clear();
203
254
  }
204
255
  };
205
256
  }
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;
257
+ /**
258
+ * Create a read-only collection handle that tracks access.
259
+ */
260
+ function createTrackingHandle(name, collection, accessedCollections) {
261
+ const trackAccess = () => {
262
+ accessedCollections.add(name);
263
+ };
209
264
  return {
210
- type: resource.type,
211
- id: resource.id,
212
- attributes: resource.attributes,
213
- meta: {
214
- eventstamps: resource.meta.eventstamps,
215
- latest,
216
- deletedAt: eventstamp
265
+ get(id, opts) {
266
+ trackAccess();
267
+ return collection.get(id, opts);
268
+ },
269
+ getAll(opts) {
270
+ trackAccess();
271
+ return collection.getAll(opts);
272
+ },
273
+ find(filter, opts) {
274
+ trackAccess();
275
+ return collection.find(filter, opts);
217
276
  }
218
277
  };
219
278
  }
220
279
 
221
280
  //#endregion
222
- //#region src/document/document.ts
281
+ //#region src/database/transaction.ts
223
282
  /**
224
- * Merges two JSON:API documents using field-level Last-Write-Wins semantics.
225
- *
226
- * The merge operation:
227
- * 1. Forwards the clock to the newest eventstamp from either document
228
- * 2. Merges each resource pair using field-level LWW (via mergeResources)
229
- * 3. Tracks what changed for hook notifications (added/updated/deleted)
283
+ * Execute a transaction with snapshot isolation and copy-on-write optimization.
230
284
  *
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
233
- * transitions into the deleted state are tracked.
285
+ * @param configs - Collection configurations for creating new instances
286
+ * @param collections - Active collection instances (mutable reference)
287
+ * @param getEventstamp - Function to generate eventstamps
288
+ * @param callback - Transaction callback with tx context
289
+ * @returns The return value from the callback
234
290
  *
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
238
- *
239
- * @example
240
- * ```typescript
241
- * const into = {
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: "..." } }]
245
- * };
246
- *
247
- * const from = {
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
- * ]
254
- * };
255
- *
256
- * const result = mergeDocuments(into, from);
257
- * // result.document.meta.latest === "2025-01-01T00:05:00.000Z|0001|c3d4"
258
- * // result.changes.added has "doc2"
259
- * // result.changes.updated has "doc1"
260
- * ```
291
+ * @remarks
292
+ * - Collections are cloned lazily on first access (read or write)
293
+ * - Provides snapshot isolation: tx sees consistent data from first access
294
+ * - Explicit rollback via tx.rollback() or implicit on exception
295
+ * - Only modified collections are committed back
261
296
  */
262
- function mergeDocuments(into, from) {
263
- const intoDocsById = /* @__PURE__ */ new Map();
264
- for (const doc of into.data) intoDocsById.set(doc.id, doc);
265
- const added = /* @__PURE__ */ new Map();
266
- const updated = /* @__PURE__ */ new Map();
267
- const deleted = /* @__PURE__ */ new Set();
268
- const mergedDocsById = new Map(intoDocsById);
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;
272
- const intoDoc = intoDocsById.get(id);
273
- if (!intoDoc) {
274
- mergedDocsById.set(id, fromDoc);
275
- if (!fromDoc.meta.deletedAt) added.set(id, fromDoc);
276
- if (fromDoc.meta.latest > newestEventstamp) newestEventstamp = fromDoc.meta.latest;
277
- } else {
278
- if (intoDoc === fromDoc) continue;
279
- const mergedDoc = mergeResources(intoDoc, fromDoc);
280
- mergedDocsById.set(id, mergedDoc);
281
- if (mergedDoc.meta.latest > newestEventstamp) newestEventstamp = mergedDoc.meta.latest;
282
- const wasDeleted = intoDoc.meta.deletedAt !== null;
283
- const isDeleted = mergedDoc.meta.deletedAt !== null;
284
- if (!wasDeleted && isDeleted) deleted.add(id);
285
- else if (!isDeleted) {
286
- if (intoDoc.meta.latest !== mergedDoc.meta.latest) updated.set(id, mergedDoc);
297
+ function executeTransaction(configs, collections, getEventstamp, callback) {
298
+ const clonedCollections = /* @__PURE__ */ new Map();
299
+ const txHandles = {};
300
+ for (const name of Object.keys(collections)) {
301
+ const originalCollection = collections[name];
302
+ const config = configs[name];
303
+ const getClonedCollection = () => {
304
+ if (!clonedCollections.has(name)) {
305
+ const cloned = createCollection(name, config.schema, config.getId, getEventstamp, originalCollection[CollectionInternals.data](), { autoFlush: false });
306
+ clonedCollections.set(name, cloned);
287
307
  }
288
- }
308
+ return clonedCollections.get(name);
309
+ };
310
+ txHandles[name] = createLazyTransactionHandle(originalCollection, getClonedCollection);
289
311
  }
290
- return {
291
- document: {
292
- jsonapi: { version: "1.1" },
293
- meta: { latest: newestEventstamp },
294
- data: Array.from(mergedDocsById.values())
295
- },
296
- changes: {
297
- added,
298
- updated,
299
- deleted
312
+ let shouldRollback = false;
313
+ const tx = {
314
+ ...txHandles,
315
+ rollback() {
316
+ shouldRollback = true;
300
317
  }
301
318
  };
319
+ let result;
320
+ result = callback(tx);
321
+ if (!shouldRollback) for (const [name, clonedCollection] of clonedCollections.entries()) {
322
+ const originalCollection = collections[name];
323
+ const pendingMutations = clonedCollection[CollectionInternals.getPendingMutations]();
324
+ originalCollection[CollectionInternals.replaceData](clonedCollection[CollectionInternals.data]());
325
+ originalCollection[CollectionInternals.emitMutations](pendingMutations);
326
+ }
327
+ return result;
302
328
  }
303
329
  /**
304
- * Creates an empty JSON:API document with the given eventstamp.
305
- * Useful for initializing new stores or testing.
330
+ * Create a transaction handle that lazily clones on first access (copy-on-write).
306
331
  *
307
- * @param eventstamp - Initial clock value for this document
308
- * @returns Empty document
332
+ * @param originalCollection - The base collection (not modified)
333
+ * @param getClonedCollection - Lazy cloner (invoked on first access)
334
+ * @returns A collection handle with snapshot isolation
309
335
  *
310
- * @example
311
- * ```typescript
312
- * const empty = makeDocument("2025-01-01T00:00:00.000Z|0000|0000");
313
- * ```
336
+ * @remarks
337
+ * First read or write triggers cloning, providing snapshot isolation.
338
+ * All subsequent operations use the cloned collection.
339
+ * Excluded methods:
340
+ * - on(): events are only emitted after the transaction commits
341
+ * - toDocument(): serialization should happen outside transactions
314
342
  */
315
- function makeDocument(eventstamp) {
316
- return {
317
- jsonapi: { version: "1.1" },
318
- meta: { latest: eventstamp },
319
- data: []
343
+ function createLazyTransactionHandle(_originalCollection, getClonedCollection) {
344
+ let cloned = null;
345
+ const ensureCloned = () => {
346
+ if (!cloned) cloned = getClonedCollection();
347
+ return cloned;
320
348
  };
321
- }
322
-
323
- //#endregion
324
- //#region src/document/utils.ts
325
- /**
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
329
- */
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
349
  return {
344
- jsonapi: { version: "1.1" },
345
- meta: { latest: maxEventstamp(eventstamps) },
346
- data: resourceArray
350
+ get(id, opts) {
351
+ return ensureCloned().get(id, opts);
352
+ },
353
+ getAll(opts) {
354
+ return ensureCloned().getAll(opts);
355
+ },
356
+ find(filter, opts) {
357
+ return ensureCloned().find(filter, opts);
358
+ },
359
+ add(item) {
360
+ return ensureCloned().add(item);
361
+ },
362
+ update(id, updates) {
363
+ ensureCloned().update(id, updates);
364
+ },
365
+ remove(id) {
366
+ ensureCloned().remove(id);
367
+ },
368
+ merge(document) {
369
+ ensureCloned().merge(document);
370
+ }
347
371
  };
348
372
  }
349
373
 
350
374
  //#endregion
351
- //#region src/resource-map/resource-map.ts
375
+ //#region src/database/db.ts
352
376
  /**
353
- * A ResourceMap container for storing and managing ResourceObjects.
354
- *
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.
358
- *
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.
377
+ * Create a typed database instance with collection access.
378
+ * @param config - Database configuration
379
+ * @param config.name - Database name used for persistence and routing
380
+ * @param config.schema - Collection schema definitions
381
+ * @param config.version - Optional database version, defaults to 1
382
+ * @returns A database instance with typed collection properties
362
383
  *
363
384
  * @example
364
385
  * ```typescript
365
- * const resourceMap = createMap("todos");
366
- * resourceMap.set("id1", { name: "Alice" });
367
- * const resource = resourceMap.get("id1"); // ResourceObject with metadata
386
+ * const db = await createDatabase({
387
+ * name: "my-app",
388
+ * schema: {
389
+ * tasks: { schema: taskSchema, getId: (task) => task.id },
390
+ * },
391
+ * })
392
+ * .use(idbPlugin())
393
+ * .init();
394
+ *
395
+ * const task = db.tasks.add({ title: 'Learn Starling' });
368
396
  * ```
369
397
  */
370
- function createMap(resourceType, initialMap = /* @__PURE__ */ new Map(), eventstamp) {
371
- let internalMap = initialMap;
398
+ function createDatabase(config) {
399
+ const { name, schema, version = 1 } = config;
372
400
  const clock = createClock();
373
- if (eventstamp) clock.forward(eventstamp);
374
- return {
375
- has(id) {
376
- return internalMap.has(id);
401
+ const getEventstamp = () => clock.now();
402
+ const collections = makeCollections(schema, getEventstamp);
403
+ const publicCollections = collections;
404
+ const dbEmitter = createEmitter();
405
+ for (const collectionName of Object.keys(collections)) collections[collectionName].on("mutation", (mutations) => {
406
+ if (mutations.added.length > 0 || mutations.updated.length > 0 || mutations.removed.length > 0) dbEmitter.emit("mutation", {
407
+ collection: collectionName,
408
+ added: mutations.added,
409
+ updated: mutations.updated,
410
+ removed: mutations.removed
411
+ });
412
+ });
413
+ const plugins = [];
414
+ const db = {
415
+ ...publicCollections,
416
+ name,
417
+ version,
418
+ begin(callback) {
419
+ return executeTransaction(schema, collections, getEventstamp, callback);
377
420
  },
378
- get(id) {
379
- return internalMap.get(id);
421
+ query(callback) {
422
+ return executeQuery(db, callback);
380
423
  },
381
- entries() {
382
- return internalMap.entries();
424
+ toDocuments() {
425
+ const documents = {};
426
+ for (const dbName of Object.keys(collections)) documents[dbName] = collections[dbName].toDocument();
427
+ return documents;
383
428
  },
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);
429
+ on(event, handler) {
430
+ return dbEmitter.on(event, handler);
391
431
  },
392
- delete(id) {
393
- const current = internalMap.get(id);
394
- if (current) {
395
- const doc = deleteResource(current, clock.now());
396
- internalMap.set(id, doc);
397
- }
432
+ use(plugin) {
433
+ plugins.push(plugin);
434
+ return db;
398
435
  },
399
- cloneMap() {
400
- return new Map(internalMap);
436
+ async init() {
437
+ for (const plugin of plugins) if (plugin.handlers.init) await plugin.handlers.init(db);
438
+ return db;
401
439
  },
402
- toDocument() {
403
- return mapToDocument(internalMap, clock.latest());
440
+ async dispose() {
441
+ for (let i = plugins.length - 1; i >= 0; i--) {
442
+ const plugin = plugins[i];
443
+ if (plugin?.handlers.dispose) await plugin.handlers.dispose(db);
444
+ }
404
445
  },
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;
446
+ collectionKeys() {
447
+ return Object.keys(collections);
410
448
  }
411
449
  };
450
+ return db;
412
451
  }
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);
452
+ function makeCollections(configs, getEventstamp) {
453
+ const collections = {};
454
+ for (const name of Object.keys(configs)) {
455
+ const config = configs[name];
456
+ collections[name] = createCollection(name, config.schema, config.getId, getEventstamp);
457
+ }
458
+ return collections;
420
459
  }
421
460
 
422
461
  //#endregion
423
- export { InvalidEventstampError, MIN_EVENTSTAMP, createClock, createClockFromEventstamp, createMap, createMapFromDocument, deleteResource, documentToMap, isValidEventstamp, makeDocument, makeResource, mapToDocument, maxEventstamp, mergeDocuments, mergeResources };
462
+ export { CollectionInternals, DuplicateIdError, IdNotFoundError, createDatabase };