@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.d.ts +270 -2
- package/dist/index.js +309 -523
- package/package.json +2 -14
- package/dist/plugins/unstorage/plugin.d.ts +0 -54
- package/dist/plugins/unstorage/plugin.js +0 -104
- package/dist/store-F8qPSj_p.d.ts +0 -306
package/dist/index.js
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
|
-
//#region src/
|
|
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
|
|
25
|
+
return EVENTSTAMP_REGEX.test(stamp);
|
|
16
26
|
}
|
|
17
27
|
function decodeEventstamp(eventstamp) {
|
|
18
|
-
if (!isValidEventstamp(eventstamp)) throw new
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
function
|
|
37
|
-
|
|
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/
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
84
|
+
now,
|
|
85
|
+
latest,
|
|
86
|
+
forward
|
|
46
87
|
};
|
|
47
88
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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/
|
|
57
|
-
function
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
86
|
-
return
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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/
|
|
222
|
+
//#region src/document/document.ts
|
|
207
223
|
/**
|
|
208
|
-
* Merges two
|
|
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
|
|
212
|
-
* 2. Merges each
|
|
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
|
|
216
|
-
* the
|
|
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
|
|
220
|
-
* @param from - The source
|
|
221
|
-
* @returns Merged
|
|
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
|
-
*
|
|
227
|
-
*
|
|
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
|
-
* "
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
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 =
|
|
239
|
-
* // result.
|
|
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
|
|
262
|
+
function mergeDocuments(into, from) {
|
|
245
263
|
const intoDocsById = /* @__PURE__ */ new Map();
|
|
246
|
-
for (const doc of into
|
|
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
|
-
|
|
252
|
-
|
|
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
|
|
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
|
|
279
|
+
const mergedDoc = mergeResources(intoDoc, fromDoc);
|
|
260
280
|
mergedDocsById.set(id, mergedDoc);
|
|
261
|
-
|
|
262
|
-
const
|
|
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)
|
|
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
|
-
|
|
270
|
-
"
|
|
271
|
-
|
|
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/
|
|
324
|
+
//#region src/document/utils.ts
|
|
283
325
|
/**
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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/
|
|
351
|
+
//#region src/resource-map/resource-map.ts
|
|
325
352
|
/**
|
|
326
|
-
*
|
|
353
|
+
* A ResourceMap container for storing and managing ResourceObjects.
|
|
327
354
|
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* ```
|
|
335
|
-
* const
|
|
336
|
-
*
|
|
337
|
-
*
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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 {
|
|
423
|
+
export { InvalidEventstampError, MIN_EVENTSTAMP, createClock, createClockFromEventstamp, createMap, createMapFromDocument, deleteResource, documentToMap, isValidEventstamp, makeDocument, makeResource, mapToDocument, maxEventstamp, mergeDocuments, mergeResources };
|