@byearlybird/starling 0.10.0 → 0.11.1
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/core-DI0FfUjX.js +423 -0
- package/dist/core.d.ts +2 -0
- package/dist/core.js +3 -0
- package/dist/db-DJ_6dO-K.d.ts +199 -0
- package/dist/index-D7bXWDg6.d.ts +270 -0
- package/dist/index.d.ts +3 -270
- package/dist/index.js +390 -351
- package/dist/plugin-http.d.ts +139 -0
- package/dist/plugin-http.js +191 -0
- package/dist/plugin-idb.d.ts +59 -0
- package/dist/plugin-idb.js +169 -0
- package/package.json +22 -2
package/dist/index.js
CHANGED
|
@@ -1,423 +1,462 @@
|
|
|
1
|
-
|
|
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
|
-
//#
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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/
|
|
40
|
+
//#region src/database/collection.ts
|
|
53
41
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
42
|
+
* Symbols for internal collection methods used by transactions.
|
|
43
|
+
* These are not part of the public Collection type.
|
|
56
44
|
*/
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
135
|
-
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
//#endregion
|
|
206
|
+
//#region src/database/query.ts
|
|
136
207
|
/**
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* @
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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/
|
|
281
|
+
//#region src/database/transaction.ts
|
|
223
282
|
/**
|
|
224
|
-
*
|
|
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
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
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
|
-
* @
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
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
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
*
|
|
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
|
|
308
|
-
* @
|
|
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
|
-
* @
|
|
311
|
-
*
|
|
312
|
-
*
|
|
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
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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/
|
|
375
|
+
//#region src/database/db.ts
|
|
352
376
|
/**
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
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
|
|
366
|
-
*
|
|
367
|
-
*
|
|
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
|
|
371
|
-
|
|
398
|
+
function createDatabase(config) {
|
|
399
|
+
const { name, schema, version = 1 } = config;
|
|
372
400
|
const clock = createClock();
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
379
|
-
return
|
|
421
|
+
query(callback) {
|
|
422
|
+
return executeQuery(db, callback);
|
|
380
423
|
},
|
|
381
|
-
|
|
382
|
-
|
|
424
|
+
toDocuments() {
|
|
425
|
+
const documents = {};
|
|
426
|
+
for (const dbName of Object.keys(collections)) documents[dbName] = collections[dbName].toDocument();
|
|
427
|
+
return documents;
|
|
383
428
|
},
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
400
|
-
|
|
436
|
+
async init() {
|
|
437
|
+
for (const plugin of plugins) if (plugin.handlers.init) await plugin.handlers.init(db);
|
|
438
|
+
return db;
|
|
401
439
|
},
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
return
|
|
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 {
|
|
462
|
+
export { CollectionInternals, DuplicateIdError, IdNotFoundError, createDatabase };
|