@korajs/store 1.0.0-beta.12 → 1.0.0-beta.14
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/README.md +29 -37
- package/dist/adapters/better-sqlite3.cjs +90 -4
- package/dist/adapters/better-sqlite3.cjs.map +1 -1
- package/dist/adapters/better-sqlite3.d.cts +21 -12
- package/dist/adapters/better-sqlite3.d.ts +21 -12
- package/dist/adapters/better-sqlite3.js +69 -5
- package/dist/adapters/better-sqlite3.js.map +1 -1
- package/dist/adapters/indexeddb.cjs +1316 -610
- package/dist/adapters/indexeddb.cjs.map +1 -1
- package/dist/adapters/indexeddb.d.cts +29 -3
- package/dist/adapters/indexeddb.d.ts +29 -3
- package/dist/adapters/indexeddb.js +247 -221
- package/dist/adapters/indexeddb.js.map +1 -1
- package/dist/adapters/sqlite-wasm-worker.cjs +856 -63
- package/dist/adapters/sqlite-wasm-worker.cjs.map +1 -1
- package/dist/adapters/sqlite-wasm-worker.js +529 -63
- package/dist/adapters/sqlite-wasm-worker.js.map +1 -1
- package/dist/adapters/sqlite-wasm.cjs +1032 -344
- package/dist/adapters/sqlite-wasm.cjs.map +1 -1
- package/dist/adapters/sqlite-wasm.d.cts +239 -18
- package/dist/adapters/sqlite-wasm.d.ts +239 -18
- package/dist/adapters/sqlite-wasm.js +111 -5
- package/dist/adapters/sqlite-wasm.js.map +1 -1
- package/dist/backup-IHKVMUAM.js +25 -0
- package/dist/chunk-5AQSGMJV.js +5471 -0
- package/dist/chunk-5AQSGMJV.js.map +1 -0
- package/dist/chunk-5JMGMPUE.js +591 -0
- package/dist/chunk-5JMGMPUE.js.map +1 -0
- package/dist/{chunk-CYO3YKFL.js → chunk-5ZRRF6R2.js} +2 -2
- package/dist/chunk-A6GFH5IG.js +36 -0
- package/dist/chunk-A6GFH5IG.js.map +1 -0
- package/dist/chunk-BILSA6XZ.js +608 -0
- package/dist/chunk-BILSA6XZ.js.map +1 -0
- package/dist/chunk-BQSIL6TT.js +1095 -0
- package/dist/chunk-BQSIL6TT.js.map +1 -0
- package/dist/{chunk-GSJ7EEUC.js → chunk-J5AGXF3D.js} +58 -9
- package/dist/chunk-J5AGXF3D.js.map +1 -0
- package/dist/chunk-KK3GBONL.js +112 -0
- package/dist/chunk-KK3GBONL.js.map +1 -0
- package/dist/chunk-KTVNANUH.js +876 -0
- package/dist/chunk-KTVNANUH.js.map +1 -0
- package/dist/chunk-QW4UU7NG.js +1003 -0
- package/dist/chunk-QW4UU7NG.js.map +1 -0
- package/dist/chunk-SRHDJF5B.js +186 -0
- package/dist/chunk-SRHDJF5B.js.map +1 -0
- package/dist/chunk-UC2LGFPV.js +810 -0
- package/dist/chunk-UC2LGFPV.js.map +1 -0
- package/dist/{export-audit-ODGQTQIK.js → export-audit-J2QXRHD2.js} +3 -3
- package/dist/index.cjs +9533 -4246
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1143 -110
- package/dist/index.d.ts +1143 -110
- package/dist/index.js +298 -3191
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +581 -264
- package/dist/internal.cjs.map +1 -1
- package/dist/internal.d.cts +15 -85
- package/dist/internal.d.ts +15 -85
- package/dist/internal.js +10 -8
- package/dist/restore-MNDPR557.js +299 -0
- package/dist/restore-MNDPR557.js.map +1 -0
- package/dist/sql-builder-D0tC3sv9.d.cts +615 -0
- package/dist/sql-builder-D5UMzHEo.d.ts +615 -0
- package/dist/sqlite-wasm-channel-DVX93Uiq.d.cts +141 -0
- package/dist/sqlite-wasm-channel-DVX93Uiq.d.ts +141 -0
- package/dist/sqlite-wasm-channel-QCCNBYCU.js +12 -0
- package/dist/store-HNGX4KTT.js +16 -0
- package/dist/store-HNGX4KTT.js.map +1 -0
- package/dist/{types-BslDOTk0.d.ts → types-Dh-xyUjP.d.cts} +99 -26
- package/dist/{types-BslDOTk0.d.cts → types-Dh-xyUjP.d.ts} +99 -26
- package/package.json +3 -2
- package/dist/backup-5S7K2Z35.js +0 -14
- package/dist/chunk-GSJ7EEUC.js.map +0 -1
- package/dist/chunk-HS6DBMST.js +0 -383
- package/dist/chunk-HS6DBMST.js.map +0 -1
- package/dist/chunk-RJIEKEN4.js +0 -751
- package/dist/chunk-RJIEKEN4.js.map +0 -1
- package/dist/chunk-TUR7RGEP.js +0 -84
- package/dist/chunk-TUR7RGEP.js.map +0 -1
- package/dist/chunk-VDBJQ75K.js +0 -78
- package/dist/chunk-VDBJQ75K.js.map +0 -1
- package/dist/chunk-YT7MRFCP.js +0 -687
- package/dist/chunk-YT7MRFCP.js.map +0 -1
- package/dist/sqlite-wasm-channel-GGHA2BXU.js +0 -12
- package/dist/sqlite-wasm-channel-L6o3bWQK.d.cts +0 -92
- package/dist/sqlite-wasm-channel-L6o3bWQK.d.ts +0 -92
- package/dist/types-BOO9EPt1.d.ts +0 -170
- package/dist/types-C_GDavG-.d.cts +0 -170
- /package/dist/{backup-5S7K2Z35.js.map → backup-IHKVMUAM.js.map} +0 -0
- /package/dist/{chunk-CYO3YKFL.js.map → chunk-5ZRRF6R2.js.map} +0 -0
- /package/dist/{export-audit-ODGQTQIK.js.map → export-audit-J2QXRHD2.js.map} +0 -0
- /package/dist/{sqlite-wasm-channel-GGHA2BXU.js.map → sqlite-wasm-channel-QCCNBYCU.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -2,3168 +2,194 @@ import {
|
|
|
2
2
|
decodeAuditExport,
|
|
3
3
|
readAuditExportManifest,
|
|
4
4
|
verifyAuditExportChecksum
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-5ZRRF6R2.js";
|
|
6
6
|
import {
|
|
7
7
|
persistedAuditTraceFromEvent
|
|
8
8
|
} from "./chunk-MYSJN3DF.js";
|
|
9
9
|
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
} from "./chunk-
|
|
48
|
-
import {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
encodeRichtext,
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const countRows = await tx.query(countSql, countParams);
|
|
105
|
-
deletedCount += countRows[0]?.count ?? 0;
|
|
106
|
-
const deleteSql = buildDeleteSql(table, ageCutoffSerialized !== null);
|
|
107
|
-
await tx.execute(deleteSql, countParams);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
await tx.execute("INSERT OR REPLACE INTO _kora_meta (key, value) VALUES (?, ?)", [
|
|
111
|
-
COMPACTION_BASELINE_META_KEY,
|
|
112
|
-
String(Date.now())
|
|
113
|
-
]);
|
|
114
|
-
});
|
|
115
|
-
return { deletedCount, watermark };
|
|
116
|
-
}
|
|
117
|
-
function buildCountSql(table, withAge) {
|
|
118
|
-
if (withAge) {
|
|
119
|
-
return `SELECT COUNT(*) as count FROM ${table} WHERE node_id = ? AND sequence_number <= ? AND SUBSTR(timestamp, 1, 15) < ?`;
|
|
120
|
-
}
|
|
121
|
-
return `SELECT COUNT(*) as count FROM ${table} WHERE node_id = ? AND sequence_number <= ?`;
|
|
122
|
-
}
|
|
123
|
-
function buildDeleteSql(table, withAge) {
|
|
124
|
-
if (withAge) {
|
|
125
|
-
return `DELETE FROM ${table} WHERE node_id = ? AND sequence_number <= ? AND SUBSTR(timestamp, 1, 15) < ?`;
|
|
126
|
-
}
|
|
127
|
-
return `DELETE FROM ${table} WHERE node_id = ? AND sequence_number <= ?`;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// src/sync/sync-state.ts
|
|
131
|
-
import { createVersionVector as createVersionVector2 } from "@korajs/core";
|
|
132
|
-
var LAST_ACKED_SERVER_VECTOR_META_KEY = "last_acked_server_vector";
|
|
133
|
-
var DELTA_CURSOR_META_KEY = "delta_cursor";
|
|
134
|
-
var DELIVERY_WATERMARK_META_KEY = "delivery_watermark";
|
|
135
|
-
function serializeVersionVectorToMeta(vector) {
|
|
136
|
-
const record = {};
|
|
137
|
-
for (const [nodeId, seq] of vector) {
|
|
138
|
-
record[nodeId] = seq;
|
|
139
|
-
}
|
|
140
|
-
return JSON.stringify(record);
|
|
141
|
-
}
|
|
142
|
-
function deserializeVersionVectorFromMeta(value) {
|
|
143
|
-
const parsed = JSON.parse(value);
|
|
144
|
-
const vector = createVersionVector2();
|
|
145
|
-
for (const [nodeId, seq] of Object.entries(parsed)) {
|
|
146
|
-
if (typeof seq === "number" && seq >= 0) {
|
|
147
|
-
vector.set(nodeId, seq);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
return vector;
|
|
151
|
-
}
|
|
152
|
-
function mergeVersionVectors(a, b) {
|
|
153
|
-
const merged = new Map(a);
|
|
154
|
-
for (const [nodeId, seq] of b) {
|
|
155
|
-
merged.set(nodeId, Math.max(merged.get(nodeId) ?? 0, seq));
|
|
156
|
-
}
|
|
157
|
-
return merged;
|
|
158
|
-
}
|
|
159
|
-
async function collectOperationsAheadOfServer(localVector, serverVector, fetchRange) {
|
|
160
|
-
const missing = [];
|
|
161
|
-
for (const [nodeId, localSeq] of localVector) {
|
|
162
|
-
const serverSeq = serverVector.get(nodeId) ?? 0;
|
|
163
|
-
if (localSeq > serverSeq) {
|
|
164
|
-
const ops = await fetchRange(nodeId, serverSeq + 1, localSeq);
|
|
165
|
-
missing.push(...ops);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
missing.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
|
169
|
-
return missing;
|
|
170
|
-
}
|
|
171
|
-
async function loadLastAckedServerVector(adapter) {
|
|
172
|
-
const rows = await adapter.query("SELECT value FROM _kora_meta WHERE key = ?", [
|
|
173
|
-
LAST_ACKED_SERVER_VECTOR_META_KEY
|
|
174
|
-
]);
|
|
175
|
-
const row = rows[0];
|
|
176
|
-
if (!row?.value) {
|
|
177
|
-
return createVersionVector2();
|
|
178
|
-
}
|
|
179
|
-
try {
|
|
180
|
-
return deserializeVersionVectorFromMeta(row.value);
|
|
181
|
-
} catch {
|
|
182
|
-
return createVersionVector2();
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
async function saveLastAckedServerVector(adapter, vector) {
|
|
186
|
-
await adapter.execute("INSERT OR REPLACE INTO _kora_meta (key, value) VALUES (?, ?)", [
|
|
187
|
-
LAST_ACKED_SERVER_VECTOR_META_KEY,
|
|
188
|
-
serializeVersionVectorToMeta(vector)
|
|
189
|
-
]);
|
|
190
|
-
}
|
|
191
|
-
async function loadDeltaCursor(adapter) {
|
|
192
|
-
const rows = await adapter.query("SELECT value FROM _kora_meta WHERE key = ?", [
|
|
193
|
-
DELTA_CURSOR_META_KEY
|
|
194
|
-
]);
|
|
195
|
-
return rows[0]?.value ?? null;
|
|
196
|
-
}
|
|
197
|
-
async function saveDeltaCursor(adapter, cursor) {
|
|
198
|
-
if (cursor === null) {
|
|
199
|
-
await adapter.execute("DELETE FROM _kora_meta WHERE key = ?", [DELTA_CURSOR_META_KEY]);
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
await adapter.execute("INSERT OR REPLACE INTO _kora_meta (key, value) VALUES (?, ?)", [
|
|
203
|
-
DELTA_CURSOR_META_KEY,
|
|
204
|
-
cursor
|
|
205
|
-
]);
|
|
206
|
-
}
|
|
207
|
-
function deliveryWatermarkKey(signature) {
|
|
208
|
-
return signature === "" ? DELIVERY_WATERMARK_META_KEY : `${DELIVERY_WATERMARK_META_KEY}:${signature}`;
|
|
209
|
-
}
|
|
210
|
-
async function loadDeliveryWatermark(adapter, signature) {
|
|
211
|
-
const rows = await adapter.query("SELECT value FROM _kora_meta WHERE key = ?", [
|
|
212
|
-
deliveryWatermarkKey(signature)
|
|
213
|
-
]);
|
|
214
|
-
const value = rows[0]?.value;
|
|
215
|
-
if (value === void 0 || value === null) {
|
|
216
|
-
return 0;
|
|
217
|
-
}
|
|
218
|
-
const parsed = Number(value);
|
|
219
|
-
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
|
220
|
-
}
|
|
221
|
-
async function saveDeliveryWatermark(adapter, signature, watermark) {
|
|
222
|
-
await adapter.execute("INSERT OR REPLACE INTO _kora_meta (key, value) VALUES (?, ?)", [
|
|
223
|
-
deliveryWatermarkKey(signature),
|
|
224
|
-
String(watermark)
|
|
225
|
-
]);
|
|
226
|
-
}
|
|
227
|
-
async function deleteDeliveryWatermark(adapter, signature) {
|
|
228
|
-
await adapter.execute("DELETE FROM _kora_meta WHERE key = ?", [deliveryWatermarkKey(signature)]);
|
|
229
|
-
}
|
|
230
|
-
async function loadAllDeliveryWatermarks(adapter) {
|
|
231
|
-
const rows = await adapter.query(
|
|
232
|
-
"SELECT key, value FROM _kora_meta WHERE key = ? OR key LIKE ?",
|
|
233
|
-
[DELIVERY_WATERMARK_META_KEY, `${DELIVERY_WATERMARK_META_KEY}:%`]
|
|
234
|
-
);
|
|
235
|
-
const result = {};
|
|
236
|
-
const prefix = `${DELIVERY_WATERMARK_META_KEY}:`;
|
|
237
|
-
for (const row of rows) {
|
|
238
|
-
const signature = row.key === DELIVERY_WATERMARK_META_KEY ? "" : row.key.slice(prefix.length);
|
|
239
|
-
const parsed = Number(row.value);
|
|
240
|
-
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
241
|
-
result[signature] = parsed;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
return result;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// src/store/store.ts
|
|
248
|
-
import {
|
|
249
|
-
CausalTracker,
|
|
250
|
-
HybridLogicalClock as HybridLogicalClock3,
|
|
251
|
-
createVersionVector as createVersionVector3,
|
|
252
|
-
generateUUIDv7 as generateUUIDv73,
|
|
253
|
-
migrationStepsToSQL,
|
|
254
|
-
quoteIdent as quoteIdent7,
|
|
255
|
-
replayOperationsForRecord
|
|
256
|
-
} from "@korajs/core";
|
|
257
|
-
|
|
258
|
-
// src/collection/collection.ts
|
|
259
|
-
import { quoteIdent as quoteIdent2 } from "@korajs/core";
|
|
260
|
-
var Collection = class {
|
|
261
|
-
constructor(name, definition, schema, adapter, clock, nodeId, allocateSequenceNumber, onMutation, relationEnforcer, mutationHandler, causalTracker, secretKeyProvider) {
|
|
262
|
-
this.name = name;
|
|
263
|
-
this.definition = definition;
|
|
264
|
-
this.schema = schema;
|
|
265
|
-
this.adapter = adapter;
|
|
266
|
-
this.clock = clock;
|
|
267
|
-
this.nodeId = nodeId;
|
|
268
|
-
this.allocateSequenceNumber = allocateSequenceNumber;
|
|
269
|
-
this.onMutation = onMutation;
|
|
270
|
-
this.relationEnforcer = relationEnforcer;
|
|
271
|
-
this.mutationHandler = mutationHandler;
|
|
272
|
-
this.causalTracker = causalTracker;
|
|
273
|
-
this.secretKeyProvider = secretKeyProvider;
|
|
274
|
-
}
|
|
275
|
-
name;
|
|
276
|
-
definition;
|
|
277
|
-
schema;
|
|
278
|
-
adapter;
|
|
279
|
-
clock;
|
|
280
|
-
nodeId;
|
|
281
|
-
allocateSequenceNumber;
|
|
282
|
-
onMutation;
|
|
283
|
-
relationEnforcer;
|
|
284
|
-
mutationHandler;
|
|
285
|
-
causalTracker;
|
|
286
|
-
secretKeyProvider;
|
|
287
|
-
mutationContext() {
|
|
288
|
-
return {
|
|
289
|
-
collection: this.name,
|
|
290
|
-
definition: this.definition,
|
|
291
|
-
schema: this.schema,
|
|
292
|
-
adapter: this.adapter,
|
|
293
|
-
clock: this.clock,
|
|
294
|
-
nodeId: this.nodeId,
|
|
295
|
-
allocateSequenceNumber: this.allocateSequenceNumber,
|
|
296
|
-
onMutation: this.onMutation,
|
|
297
|
-
relationEnforcer: this.relationEnforcer,
|
|
298
|
-
causalTracker: this.causalTracker,
|
|
299
|
-
inTransaction: false,
|
|
300
|
-
secretKeyProvider: this.secretKeyProvider
|
|
301
|
-
};
|
|
302
|
-
}
|
|
303
|
-
async insert(data) {
|
|
304
|
-
if (this.mutationHandler) {
|
|
305
|
-
return this.mutationHandler.insert(this.name, data);
|
|
306
|
-
}
|
|
307
|
-
return executeInsert(this.mutationContext(), data);
|
|
308
|
-
}
|
|
309
|
-
async findById(id) {
|
|
310
|
-
const rows = await this.adapter.query(
|
|
311
|
-
`SELECT * FROM ${quoteIdent2(this.name)} WHERE id = ? AND _deleted = 0`,
|
|
312
|
-
[id]
|
|
313
|
-
);
|
|
314
|
-
const row = rows[0];
|
|
315
|
-
if (!row) return null;
|
|
316
|
-
return deserializeRecord(row, this.definition.fields);
|
|
317
|
-
}
|
|
318
|
-
async update(id, data) {
|
|
319
|
-
if (this.mutationHandler) {
|
|
320
|
-
return this.mutationHandler.update(this.name, id, data);
|
|
321
|
-
}
|
|
322
|
-
return executeUpdate(this.mutationContext(), id, data);
|
|
323
|
-
}
|
|
324
|
-
async delete(id) {
|
|
325
|
-
if (this.mutationHandler) {
|
|
326
|
-
await this.mutationHandler.delete(this.name, id);
|
|
327
|
-
return;
|
|
328
|
-
}
|
|
329
|
-
await executeDelete(this.mutationContext(), id);
|
|
330
|
-
}
|
|
331
|
-
getName() {
|
|
332
|
-
return this.name;
|
|
333
|
-
}
|
|
334
|
-
getDefinition() {
|
|
335
|
-
return this.definition;
|
|
336
|
-
}
|
|
337
|
-
/** Replace the mutation handler (e.g. after ApplyPipeline is wired in createApp). */
|
|
338
|
-
setMutationHandler(handler) {
|
|
339
|
-
this.mutationHandler = handler;
|
|
340
|
-
}
|
|
341
|
-
};
|
|
342
|
-
|
|
343
|
-
// src/query/query-builder.ts
|
|
344
|
-
import { quoteIdent as quoteIdent3 } from "@korajs/core";
|
|
345
|
-
|
|
346
|
-
// src/query/pluralize.ts
|
|
347
|
-
function isVowel(char) {
|
|
348
|
-
if (!char) return false;
|
|
349
|
-
return "aeiouAEIOU".includes(char);
|
|
350
|
-
}
|
|
351
|
-
function pluralize(word) {
|
|
352
|
-
if (word.endsWith("s")) return word;
|
|
353
|
-
if (word.endsWith("y") && !isVowel(word[word.length - 2])) {
|
|
354
|
-
return `${word.slice(0, -1)}ies`;
|
|
355
|
-
}
|
|
356
|
-
if (word.endsWith("sh") || word.endsWith("ch") || word.endsWith("x") || word.endsWith("z")) {
|
|
357
|
-
return `${word}es`;
|
|
358
|
-
}
|
|
359
|
-
return `${word}s`;
|
|
360
|
-
}
|
|
361
|
-
function singularize(word) {
|
|
362
|
-
if (word.endsWith("ies") && !isVowel(word[word.length - 4])) {
|
|
363
|
-
return `${word.slice(0, -3)}y`;
|
|
364
|
-
}
|
|
365
|
-
if (word.endsWith("shes") || word.endsWith("ches") || word.endsWith("xes") || word.endsWith("zes")) {
|
|
366
|
-
return word.slice(0, -2);
|
|
367
|
-
}
|
|
368
|
-
if (word.endsWith("ses")) {
|
|
369
|
-
return word.slice(0, -2);
|
|
370
|
-
}
|
|
371
|
-
if (word.endsWith("s") && !word.endsWith("ss")) {
|
|
372
|
-
return word.slice(0, -1);
|
|
373
|
-
}
|
|
374
|
-
return word;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// src/query/query-builder.ts
|
|
378
|
-
var QueryBuilder = class _QueryBuilder {
|
|
379
|
-
constructor(collectionName, definition, adapter, subscriptionManager, initialWhere = {}, schema) {
|
|
380
|
-
this.collectionName = collectionName;
|
|
381
|
-
this.definition = definition;
|
|
382
|
-
this.adapter = adapter;
|
|
383
|
-
this.subscriptionManager = subscriptionManager;
|
|
384
|
-
this.schema = schema;
|
|
385
|
-
this.descriptor = {
|
|
386
|
-
collection: collectionName,
|
|
387
|
-
where: { ...initialWhere },
|
|
388
|
-
orderBy: []
|
|
389
|
-
};
|
|
390
|
-
}
|
|
391
|
-
collectionName;
|
|
392
|
-
definition;
|
|
393
|
-
adapter;
|
|
394
|
-
subscriptionManager;
|
|
395
|
-
schema;
|
|
396
|
-
descriptor;
|
|
397
|
-
/**
|
|
398
|
-
* Add WHERE conditions (AND semantics, merged with existing conditions).
|
|
399
|
-
*/
|
|
400
|
-
where(conditions) {
|
|
401
|
-
const clone = this.clone();
|
|
402
|
-
clone.descriptor = {
|
|
403
|
-
...clone.descriptor,
|
|
404
|
-
where: { ...clone.descriptor.where, ...conditions }
|
|
405
|
-
};
|
|
406
|
-
return clone;
|
|
407
|
-
}
|
|
408
|
-
/**
|
|
409
|
-
* Add ORDER BY clause.
|
|
410
|
-
*/
|
|
411
|
-
orderBy(field, direction = "asc") {
|
|
412
|
-
const clone = this.clone();
|
|
413
|
-
clone.descriptor = {
|
|
414
|
-
...clone.descriptor,
|
|
415
|
-
orderBy: [...clone.descriptor.orderBy, { field, direction }]
|
|
416
|
-
};
|
|
417
|
-
return clone;
|
|
418
|
-
}
|
|
419
|
-
/**
|
|
420
|
-
* Set result limit.
|
|
421
|
-
*/
|
|
422
|
-
limit(n) {
|
|
423
|
-
const clone = this.clone();
|
|
424
|
-
clone.descriptor = { ...clone.descriptor, limit: n };
|
|
425
|
-
return clone;
|
|
426
|
-
}
|
|
427
|
-
/**
|
|
428
|
-
* Set result offset.
|
|
429
|
-
*/
|
|
430
|
-
offset(n) {
|
|
431
|
-
const clone = this.clone();
|
|
432
|
-
clone.descriptor = { ...clone.descriptor, offset: n };
|
|
433
|
-
return clone;
|
|
434
|
-
}
|
|
435
|
-
/**
|
|
436
|
-
* Include related records in the query results.
|
|
437
|
-
* Follows relations defined in the schema to batch-fetch related data.
|
|
438
|
-
*
|
|
439
|
-
* @param targets - Relation target names (collection names or relation names)
|
|
440
|
-
* @returns A new QueryBuilder with include targets added
|
|
441
|
-
*
|
|
442
|
-
* @example
|
|
443
|
-
* ```typescript
|
|
444
|
-
* const todosWithProject = await app.todos
|
|
445
|
-
* .where({ completed: false })
|
|
446
|
-
* .include('project')
|
|
447
|
-
* .exec()
|
|
448
|
-
* ```
|
|
449
|
-
*/
|
|
450
|
-
include(...targets) {
|
|
451
|
-
const clone = this.clone();
|
|
452
|
-
const existing = clone.descriptor.include ?? [];
|
|
453
|
-
clone.descriptor = {
|
|
454
|
-
...clone.descriptor,
|
|
455
|
-
include: [...existing, ...targets]
|
|
456
|
-
};
|
|
457
|
-
return clone;
|
|
458
|
-
}
|
|
459
|
-
/**
|
|
460
|
-
* Execute the query and return results.
|
|
461
|
-
*/
|
|
462
|
-
async exec() {
|
|
463
|
-
const { sql, params } = buildSelectQuery(this.descriptor, this.definition.fields);
|
|
464
|
-
const rows = await this.adapter.query(sql, params);
|
|
465
|
-
const records = rows.map((row) => deserializeRecord(row, this.definition.fields));
|
|
466
|
-
if (this.descriptor.include && this.descriptor.include.length > 0 && this.schema) {
|
|
467
|
-
await this.resolveIncludes(records);
|
|
468
|
-
}
|
|
469
|
-
return records;
|
|
470
|
-
}
|
|
471
|
-
/**
|
|
472
|
-
* Execute a COUNT query and return the count.
|
|
473
|
-
*/
|
|
474
|
-
async count() {
|
|
475
|
-
const { sql, params } = buildCountQuery(this.descriptor, this.definition.fields);
|
|
476
|
-
const rows = await this.adapter.query(sql, params);
|
|
477
|
-
return rows[0]?.count ?? 0;
|
|
478
|
-
}
|
|
479
|
-
/**
|
|
480
|
-
* Subscribe to query results. Callback is called immediately with current results,
|
|
481
|
-
* then again whenever the results change due to mutations.
|
|
482
|
-
*
|
|
483
|
-
* @returns An unsubscribe function
|
|
484
|
-
*/
|
|
485
|
-
subscribe(callback) {
|
|
486
|
-
const executeFn = () => this.exec();
|
|
487
|
-
const descriptorCopy = { ...this.descriptor };
|
|
488
|
-
if (descriptorCopy.include && descriptorCopy.include.length > 0 && this.schema) {
|
|
489
|
-
descriptorCopy.includeCollections = this.resolveIncludeCollections(descriptorCopy.include);
|
|
490
|
-
}
|
|
491
|
-
return this.subscriptionManager.registerAndFetch(
|
|
492
|
-
descriptorCopy,
|
|
493
|
-
callback,
|
|
494
|
-
executeFn
|
|
495
|
-
);
|
|
496
|
-
}
|
|
497
|
-
/** Get the internal descriptor (for testing/debugging) */
|
|
498
|
-
getDescriptor() {
|
|
499
|
-
return { ...this.descriptor };
|
|
500
|
-
}
|
|
501
|
-
clone() {
|
|
502
|
-
const qb = new _QueryBuilder(
|
|
503
|
-
this.collectionName,
|
|
504
|
-
this.definition,
|
|
505
|
-
this.adapter,
|
|
506
|
-
this.subscriptionManager,
|
|
507
|
-
{},
|
|
508
|
-
this.schema
|
|
509
|
-
);
|
|
510
|
-
qb.descriptor = {
|
|
511
|
-
...this.descriptor,
|
|
512
|
-
where: { ...this.descriptor.where },
|
|
513
|
-
orderBy: [...this.descriptor.orderBy],
|
|
514
|
-
include: this.descriptor.include ? [...this.descriptor.include] : void 0,
|
|
515
|
-
includeCollections: this.descriptor.includeCollections ? [...this.descriptor.includeCollections] : void 0
|
|
516
|
-
};
|
|
517
|
-
return qb;
|
|
518
|
-
}
|
|
519
|
-
/**
|
|
520
|
-
* Resolve include targets to their actual collection names for subscription tracking.
|
|
521
|
-
*/
|
|
522
|
-
resolveIncludeCollections(targets) {
|
|
523
|
-
if (!this.schema) return [];
|
|
524
|
-
const collections = [];
|
|
525
|
-
for (const target of targets) {
|
|
526
|
-
const relation = this.findRelation(target);
|
|
527
|
-
if (relation) {
|
|
528
|
-
if (relation.from === this.collectionName) {
|
|
529
|
-
collections.push(relation.to);
|
|
530
|
-
} else {
|
|
531
|
-
collections.push(relation.from);
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
return collections;
|
|
536
|
-
}
|
|
537
|
-
/**
|
|
538
|
-
* Resolve includes after primary query, batch-fetching related records.
|
|
539
|
-
*/
|
|
540
|
-
async resolveIncludes(records) {
|
|
541
|
-
if (!this.schema || !this.descriptor.include || records.length === 0) return;
|
|
542
|
-
for (const target of this.descriptor.include) {
|
|
543
|
-
const relation = this.findRelation(target);
|
|
544
|
-
if (!relation) {
|
|
545
|
-
throw new QueryError2(
|
|
546
|
-
`No relation found for include target "${target}" on collection "${this.collectionName}". Check that a relation is defined in your schema that connects "${this.collectionName}" to "${target}".`
|
|
547
|
-
);
|
|
548
|
-
}
|
|
549
|
-
if (relation.from === this.collectionName) {
|
|
550
|
-
await this.resolveManyToOneInclude(records, relation, target);
|
|
551
|
-
} else {
|
|
552
|
-
await this.resolveOneToManyInclude(records, relation, target);
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
/**
|
|
557
|
-
* Many-to-one: collect FK values from primary results, batch-fetch related, attach as singular.
|
|
558
|
-
*/
|
|
559
|
-
async resolveManyToOneInclude(records, relation, target) {
|
|
560
|
-
const fkField = relation.field;
|
|
561
|
-
const fkValues = records.map((r) => r[fkField]).filter((v) => v !== null && v !== void 0 && typeof v === "string");
|
|
562
|
-
if (fkValues.length === 0) {
|
|
563
|
-
const propName2 = singularize(target);
|
|
564
|
-
for (const record of records) {
|
|
565
|
-
;
|
|
566
|
-
record[propName2] = null;
|
|
567
|
-
}
|
|
568
|
-
return;
|
|
569
|
-
}
|
|
570
|
-
const uniqueFks = [...new Set(fkValues)];
|
|
571
|
-
const relatedCollection = relation.to;
|
|
572
|
-
const relatedDef = this.schema?.collections[relatedCollection];
|
|
573
|
-
if (!relatedDef) return;
|
|
574
|
-
const placeholders = uniqueFks.map(() => "?").join(", ");
|
|
575
|
-
const sql = `SELECT * FROM ${quoteIdent3(relatedCollection)} WHERE id IN (${placeholders}) AND _deleted = 0`;
|
|
576
|
-
const rows = await this.adapter.query(sql, uniqueFks);
|
|
577
|
-
const relatedRecords = rows.map((row) => deserializeRecord(row, relatedDef.fields));
|
|
578
|
-
const lookup = /* @__PURE__ */ new Map();
|
|
579
|
-
for (const r of relatedRecords) {
|
|
580
|
-
lookup.set(r.id, r);
|
|
581
|
-
}
|
|
582
|
-
const propName = singularize(target);
|
|
583
|
-
for (const record of records) {
|
|
584
|
-
const fk = record[fkField];
|
|
585
|
-
record[propName] = fk ? lookup.get(fk) ?? null : null;
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
/**
|
|
589
|
-
* One-to-many: collect primary IDs, batch-fetch children, attach as array.
|
|
590
|
-
*/
|
|
591
|
-
async resolveOneToManyInclude(records, relation, target) {
|
|
592
|
-
const primaryIds = records.map((r) => r.id);
|
|
593
|
-
const relatedCollection = relation.from;
|
|
594
|
-
const relatedDef = this.schema?.collections[relatedCollection];
|
|
595
|
-
if (!relatedDef) return;
|
|
596
|
-
const fkField = relation.field;
|
|
597
|
-
const placeholders = primaryIds.map(() => "?").join(", ");
|
|
598
|
-
const sql = `SELECT * FROM ${quoteIdent3(relatedCollection)} WHERE ${quoteIdent3(fkField)} IN (${placeholders}) AND _deleted = 0`;
|
|
599
|
-
const rows = await this.adapter.query(sql, primaryIds);
|
|
600
|
-
const relatedRecords = rows.map((row) => deserializeRecord(row, relatedDef.fields));
|
|
601
|
-
const grouped = /* @__PURE__ */ new Map();
|
|
602
|
-
for (const r of relatedRecords) {
|
|
603
|
-
const fk = r[fkField];
|
|
604
|
-
if (!grouped.has(fk)) {
|
|
605
|
-
grouped.set(fk, []);
|
|
606
|
-
}
|
|
607
|
-
grouped.get(fk)?.push(r);
|
|
608
|
-
}
|
|
609
|
-
const propName = pluralize(target);
|
|
610
|
-
for (const record of records) {
|
|
611
|
-
;
|
|
612
|
-
record[propName] = grouped.get(record.id) ?? [];
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
/**
|
|
616
|
-
* Find a relation definition matching the include target.
|
|
617
|
-
* Searches by relation name, target collection name, and singularized/pluralized variants.
|
|
618
|
-
*/
|
|
619
|
-
findRelation(target) {
|
|
620
|
-
if (!this.schema) return null;
|
|
621
|
-
for (const [_name, rel] of Object.entries(this.schema.relations)) {
|
|
622
|
-
if (rel.from === this.collectionName && rel.to === target) return rel;
|
|
623
|
-
if (rel.to === this.collectionName && rel.from === target) return rel;
|
|
624
|
-
if (rel.from === this.collectionName && rel.to === pluralize(target)) return rel;
|
|
625
|
-
if (rel.to === this.collectionName && rel.from === pluralize(target)) return rel;
|
|
626
|
-
if (rel.from === this.collectionName && rel.to === singularize(target)) return rel;
|
|
627
|
-
if (rel.to === this.collectionName && rel.from === singularize(target)) return rel;
|
|
628
|
-
}
|
|
629
|
-
return null;
|
|
630
|
-
}
|
|
631
|
-
};
|
|
632
|
-
var QueryError2 = class extends Error {
|
|
633
|
-
constructor(message) {
|
|
634
|
-
super(message);
|
|
635
|
-
this.name = "QueryError";
|
|
636
|
-
}
|
|
637
|
-
};
|
|
638
|
-
|
|
639
|
-
// src/relations/relation-enforcer.ts
|
|
640
|
-
import { KoraError, createOperation, quoteIdent as quoteIdent4 } from "@korajs/core";
|
|
641
|
-
|
|
642
|
-
// src/relations/relation-lookup.ts
|
|
643
|
-
function buildRelationLookup(schema) {
|
|
644
|
-
const lookup = /* @__PURE__ */ new Map();
|
|
645
|
-
for (const [relationName, relation] of Object.entries(schema.relations)) {
|
|
646
|
-
const targetCollection = relation.to;
|
|
647
|
-
const existing = lookup.get(targetCollection) ?? [];
|
|
648
|
-
existing.push({
|
|
649
|
-
relationName,
|
|
650
|
-
sourceCollection: relation.from,
|
|
651
|
-
foreignKeyField: relation.field,
|
|
652
|
-
onDelete: relation.onDelete,
|
|
653
|
-
relation
|
|
654
|
-
});
|
|
655
|
-
lookup.set(targetCollection, existing);
|
|
656
|
-
}
|
|
657
|
-
return lookup;
|
|
658
|
-
}
|
|
659
|
-
function getIncomingRelations(lookup, collection) {
|
|
660
|
-
return lookup.get(collection) ?? [];
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
// src/relations/relation-enforcer.ts
|
|
664
|
-
var ReferentialIntegrityError = class extends KoraError {
|
|
665
|
-
constructor(collection, recordId, referencingCollection, relationName, referencingCount) {
|
|
666
|
-
super(
|
|
667
|
-
`Cannot delete record "${recordId}" from "${collection}": ${referencingCount} record(s) in "${referencingCollection}" reference it via relation "${relationName}" with onDelete: 'restrict'. Delete or reassign the referencing records first.`,
|
|
668
|
-
"REFERENTIAL_INTEGRITY",
|
|
669
|
-
{
|
|
670
|
-
collection,
|
|
671
|
-
recordId,
|
|
672
|
-
referencingCollection,
|
|
673
|
-
relationName,
|
|
674
|
-
referencingCount
|
|
675
|
-
}
|
|
676
|
-
);
|
|
677
|
-
this.name = "ReferentialIntegrityError";
|
|
678
|
-
}
|
|
679
|
-
};
|
|
680
|
-
var RelationEnforcer = class {
|
|
681
|
-
lookup;
|
|
682
|
-
schema;
|
|
683
|
-
adapter;
|
|
684
|
-
clock;
|
|
685
|
-
nodeId;
|
|
686
|
-
constructor(config) {
|
|
687
|
-
this.schema = config.schema;
|
|
688
|
-
this.adapter = config.adapter;
|
|
689
|
-
this.clock = config.clock;
|
|
690
|
-
this.nodeId = config.nodeId;
|
|
691
|
-
this.lookup = buildRelationLookup(config.schema);
|
|
692
|
-
}
|
|
693
|
-
/**
|
|
694
|
-
* Enforce referential integrity after deleting a record.
|
|
695
|
-
*
|
|
696
|
-
* Must be called within a transaction. The transaction handle is used
|
|
697
|
-
* for all cascaded writes to ensure atomicity.
|
|
698
|
-
*
|
|
699
|
-
* @param collection - The collection the deleted record belongs to
|
|
700
|
-
* @param recordId - The ID of the deleted record
|
|
701
|
-
* @param tx - The active transaction handle
|
|
702
|
-
* @param causalDeps - Causal dependencies for generated operations
|
|
703
|
-
* @returns All additional operations created as side effects
|
|
704
|
-
* @throws {ReferentialIntegrityError} If a 'restrict' policy is violated
|
|
705
|
-
*/
|
|
706
|
-
async enforceDelete(collection, recordId, tx, causalDeps) {
|
|
707
|
-
const incomingRelations = getIncomingRelations(this.lookup, collection);
|
|
708
|
-
if (incomingRelations.length === 0) {
|
|
709
|
-
return { operations: [] };
|
|
710
|
-
}
|
|
711
|
-
const allOperations = [];
|
|
712
|
-
const sortedRelations = [...incomingRelations].sort(
|
|
713
|
-
(a, b) => a.relationName.localeCompare(b.relationName)
|
|
714
|
-
);
|
|
715
|
-
for (const incoming of sortedRelations) {
|
|
716
|
-
const ops = await this.enforceRelation(incoming, recordId, tx, causalDeps);
|
|
717
|
-
allOperations.push(...ops);
|
|
718
|
-
}
|
|
719
|
-
return { operations: allOperations };
|
|
720
|
-
}
|
|
721
|
-
/**
|
|
722
|
-
* Enforce a single relation's onDelete policy.
|
|
723
|
-
*/
|
|
724
|
-
async enforceRelation(incoming, deletedRecordId, tx, causalDeps) {
|
|
725
|
-
switch (incoming.onDelete) {
|
|
726
|
-
case "cascade":
|
|
727
|
-
return this.enforceCascade(incoming, deletedRecordId, tx, causalDeps);
|
|
728
|
-
case "set-null":
|
|
729
|
-
return this.enforceSetNull(incoming, deletedRecordId, tx, causalDeps);
|
|
730
|
-
case "restrict":
|
|
731
|
-
return this.enforceRestrict(incoming, deletedRecordId, tx);
|
|
732
|
-
case "no-action":
|
|
733
|
-
return [];
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
/**
|
|
737
|
-
* Cascade: delete all records in the source collection that reference
|
|
738
|
-
* the deleted record, then recursively cascade those deletes.
|
|
739
|
-
*/
|
|
740
|
-
async enforceCascade(incoming, deletedRecordId, tx, causalDeps) {
|
|
741
|
-
const { sourceCollection, foreignKeyField } = incoming;
|
|
742
|
-
const referencingRows = await tx.query(
|
|
743
|
-
`SELECT id FROM ${quoteIdent4(sourceCollection)} WHERE ${quoteIdent4(foreignKeyField)} = ? AND _deleted = 0`,
|
|
744
|
-
[deletedRecordId]
|
|
745
|
-
);
|
|
746
|
-
if (referencingRows.length === 0) {
|
|
747
|
-
return [];
|
|
748
|
-
}
|
|
749
|
-
const operations = [];
|
|
750
|
-
for (const row of referencingRows) {
|
|
751
|
-
const now = Date.now();
|
|
752
|
-
const sequenceNumber = await allocateNextSequenceInTransaction(tx, this.nodeId);
|
|
753
|
-
const operation = await createOperation(
|
|
754
|
-
{
|
|
755
|
-
nodeId: this.nodeId,
|
|
756
|
-
type: "delete",
|
|
757
|
-
collection: sourceCollection,
|
|
758
|
-
recordId: row.id,
|
|
759
|
-
data: null,
|
|
760
|
-
previousData: null,
|
|
761
|
-
sequenceNumber,
|
|
762
|
-
causalDeps: [...causalDeps],
|
|
763
|
-
schemaVersion: this.schema.version
|
|
764
|
-
},
|
|
765
|
-
this.clock
|
|
766
|
-
);
|
|
767
|
-
const deleteQuery = buildSoftDeleteQuery(sourceCollection, row.id, now);
|
|
768
|
-
await tx.execute(deleteQuery.sql, deleteQuery.params);
|
|
769
|
-
const opInsert = buildInsertQuery(
|
|
770
|
-
`_kora_ops_${sourceCollection}`,
|
|
771
|
-
serializeOperation(operation)
|
|
772
|
-
);
|
|
773
|
-
await tx.execute(opInsert.sql, opInsert.params);
|
|
774
|
-
operations.push(operation);
|
|
775
|
-
const cascadeResult = await this.enforceDelete(sourceCollection, row.id, tx, [operation.id]);
|
|
776
|
-
operations.push(...cascadeResult.operations);
|
|
777
|
-
}
|
|
778
|
-
return operations;
|
|
779
|
-
}
|
|
780
|
-
/**
|
|
781
|
-
* Set-null: update all referencing records to set the foreign key to null.
|
|
782
|
-
*/
|
|
783
|
-
async enforceSetNull(incoming, deletedRecordId, tx, causalDeps) {
|
|
784
|
-
const { sourceCollection, foreignKeyField } = incoming;
|
|
785
|
-
const collectionDef = this.schema.collections[sourceCollection];
|
|
786
|
-
if (!collectionDef) {
|
|
787
|
-
return [];
|
|
788
|
-
}
|
|
789
|
-
const referencingRows = await tx.query(
|
|
790
|
-
`SELECT id FROM ${quoteIdent4(sourceCollection)} WHERE ${quoteIdent4(foreignKeyField)} = ? AND _deleted = 0`,
|
|
791
|
-
[deletedRecordId]
|
|
792
|
-
);
|
|
793
|
-
if (referencingRows.length === 0) {
|
|
794
|
-
return [];
|
|
795
|
-
}
|
|
796
|
-
const operations = [];
|
|
797
|
-
for (const row of referencingRows) {
|
|
798
|
-
const now = Date.now();
|
|
799
|
-
const sequenceNumber = await allocateNextSequenceInTransaction(tx, this.nodeId);
|
|
800
|
-
const updateData = { [foreignKeyField]: null };
|
|
801
|
-
const previousData = { [foreignKeyField]: deletedRecordId };
|
|
802
|
-
const operation = await createOperation(
|
|
803
|
-
{
|
|
804
|
-
nodeId: this.nodeId,
|
|
805
|
-
type: "update",
|
|
806
|
-
collection: sourceCollection,
|
|
807
|
-
recordId: row.id,
|
|
808
|
-
data: { ...updateData },
|
|
809
|
-
previousData,
|
|
810
|
-
sequenceNumber,
|
|
811
|
-
causalDeps: [...causalDeps],
|
|
812
|
-
schemaVersion: this.schema.version
|
|
813
|
-
},
|
|
814
|
-
this.clock
|
|
815
|
-
);
|
|
816
|
-
const serializedChanges = serializeRecord(updateData, collectionDef.fields);
|
|
817
|
-
const updateQuery = buildUpdateQuery(sourceCollection, row.id, {
|
|
818
|
-
...serializedChanges,
|
|
819
|
-
_updated_at: now
|
|
820
|
-
});
|
|
821
|
-
await tx.execute(updateQuery.sql, updateQuery.params);
|
|
822
|
-
const opRow = serializeOperation(operation);
|
|
823
|
-
const opInsert = buildInsertQuery(
|
|
824
|
-
`_kora_ops_${sourceCollection}`,
|
|
825
|
-
opRow
|
|
826
|
-
);
|
|
827
|
-
await tx.execute(opInsert.sql, opInsert.params);
|
|
828
|
-
operations.push(operation);
|
|
829
|
-
}
|
|
830
|
-
return operations;
|
|
831
|
-
}
|
|
832
|
-
/**
|
|
833
|
-
* Restrict: refuse the delete if any referencing records exist.
|
|
834
|
-
*/
|
|
835
|
-
async enforceRestrict(incoming, deletedRecordId, tx) {
|
|
836
|
-
const { sourceCollection, foreignKeyField, relationName } = incoming;
|
|
837
|
-
const countRows = await tx.query(
|
|
838
|
-
`SELECT COUNT(*) as cnt FROM ${quoteIdent4(sourceCollection)} WHERE ${quoteIdent4(foreignKeyField)} = ? AND _deleted = 0`,
|
|
839
|
-
[deletedRecordId]
|
|
840
|
-
);
|
|
841
|
-
const count = countRows[0]?.cnt ?? 0;
|
|
842
|
-
if (count > 0) {
|
|
843
|
-
const targetCollection = incoming.relation.to;
|
|
844
|
-
throw new ReferentialIntegrityError(
|
|
845
|
-
targetCollection,
|
|
846
|
-
deletedRecordId,
|
|
847
|
-
sourceCollection,
|
|
848
|
-
relationName,
|
|
849
|
-
count
|
|
850
|
-
);
|
|
851
|
-
}
|
|
852
|
-
return [];
|
|
853
|
-
}
|
|
854
|
-
/**
|
|
855
|
-
* Get the relation lookup map for external use (e.g., by the merge engine).
|
|
856
|
-
*/
|
|
857
|
-
getRelationLookup() {
|
|
858
|
-
return this.lookup;
|
|
859
|
-
}
|
|
860
|
-
};
|
|
861
|
-
|
|
862
|
-
// src/replay/replay-to.ts
|
|
863
|
-
import { OperationError } from "@korajs/core";
|
|
864
|
-
import { topologicalSort } from "@korajs/core/internal";
|
|
865
|
-
function collectCausalClosure(allOps, targetOperationId) {
|
|
866
|
-
const opMap = /* @__PURE__ */ new Map();
|
|
867
|
-
for (const op of allOps) {
|
|
868
|
-
opMap.set(op.id, op);
|
|
869
|
-
}
|
|
870
|
-
const target = opMap.get(targetOperationId);
|
|
871
|
-
if (!target) {
|
|
872
|
-
throw new OperationError(
|
|
873
|
-
`Operation "${targetOperationId}" not found in the local operation log`,
|
|
874
|
-
{
|
|
875
|
-
operationId: targetOperationId
|
|
876
|
-
}
|
|
877
|
-
);
|
|
878
|
-
}
|
|
879
|
-
const included = /* @__PURE__ */ new Set();
|
|
880
|
-
const stack = [targetOperationId];
|
|
881
|
-
while (stack.length > 0) {
|
|
882
|
-
const id = stack.pop();
|
|
883
|
-
if (id === void 0 || included.has(id)) {
|
|
884
|
-
continue;
|
|
885
|
-
}
|
|
886
|
-
included.add(id);
|
|
887
|
-
const op = opMap.get(id);
|
|
888
|
-
if (!op) {
|
|
889
|
-
continue;
|
|
890
|
-
}
|
|
891
|
-
for (const depId of op.causalDeps) {
|
|
892
|
-
if (opMap.has(depId)) {
|
|
893
|
-
stack.push(depId);
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
const subset = allOps.filter((op) => included.has(op.id));
|
|
898
|
-
return topologicalSort(subset);
|
|
899
|
-
}
|
|
900
|
-
function buildReplaySnapshot(schema, allOps, targetOperationId) {
|
|
901
|
-
const operationsApplied = collectCausalClosure(allOps, targetOperationId);
|
|
902
|
-
const targetOperation = operationsApplied.find((op) => op.id === targetOperationId);
|
|
903
|
-
if (!targetOperation) {
|
|
904
|
-
throw new OperationError(`Operation "${targetOperationId}" not found after causal sort`, {
|
|
905
|
-
operationId: targetOperationId
|
|
906
|
-
});
|
|
907
|
-
}
|
|
908
|
-
const memory = /* @__PURE__ */ new Map();
|
|
909
|
-
for (const op of operationsApplied) {
|
|
910
|
-
applyOperationToMemory(memory, op, schema);
|
|
911
|
-
}
|
|
912
|
-
const collections = materializeCollections(schema, memory);
|
|
913
|
-
return {
|
|
914
|
-
targetOperation,
|
|
915
|
-
operationsApplied,
|
|
916
|
-
collections,
|
|
917
|
-
findRecord(collection, recordId) {
|
|
918
|
-
const colMap = memory.get(collection);
|
|
919
|
-
const record = colMap?.get(recordId);
|
|
920
|
-
if (!record || record.deleted) {
|
|
921
|
-
return null;
|
|
922
|
-
}
|
|
923
|
-
const definition = schema.collections[collection];
|
|
924
|
-
if (!definition) {
|
|
925
|
-
return null;
|
|
926
|
-
}
|
|
927
|
-
return toCollectionRecord(record, definition);
|
|
928
|
-
}
|
|
929
|
-
};
|
|
930
|
-
}
|
|
931
|
-
function applyOperationToMemory(state, op, schema) {
|
|
932
|
-
const definition = schema.collections[op.collection];
|
|
933
|
-
if (!definition) {
|
|
934
|
-
return;
|
|
935
|
-
}
|
|
936
|
-
let colMap = state.get(op.collection);
|
|
937
|
-
if (!colMap) {
|
|
938
|
-
colMap = /* @__PURE__ */ new Map();
|
|
939
|
-
state.set(op.collection, colMap);
|
|
940
|
-
}
|
|
941
|
-
const wallTime = op.timestamp.wallTime;
|
|
942
|
-
switch (op.type) {
|
|
943
|
-
case "insert": {
|
|
944
|
-
if (!op.data) {
|
|
945
|
-
return;
|
|
946
|
-
}
|
|
947
|
-
colMap.set(op.recordId, {
|
|
948
|
-
id: op.recordId,
|
|
949
|
-
// op.data stores binary richtext as tagged JSON; snapshots must
|
|
950
|
-
// expose record-shaped values (Uint8Array/string).
|
|
951
|
-
fields: decodeRichtextFieldsFromOpData(op.data, definition.fields),
|
|
952
|
-
deleted: false,
|
|
953
|
-
createdAt: wallTime,
|
|
954
|
-
updatedAt: wallTime
|
|
955
|
-
});
|
|
956
|
-
break;
|
|
957
|
-
}
|
|
958
|
-
case "update": {
|
|
959
|
-
if (!op.data) {
|
|
960
|
-
return;
|
|
961
|
-
}
|
|
962
|
-
const decodedData = decodeRichtextFieldsFromOpData(op.data, definition.fields);
|
|
963
|
-
const existing = colMap.get(op.recordId);
|
|
964
|
-
if (existing && !existing.deleted) {
|
|
965
|
-
existing.fields = { ...existing.fields, ...decodedData };
|
|
966
|
-
existing.updatedAt = wallTime;
|
|
967
|
-
break;
|
|
968
|
-
}
|
|
969
|
-
if (existing?.deleted) {
|
|
970
|
-
return;
|
|
971
|
-
}
|
|
972
|
-
colMap.set(op.recordId, {
|
|
973
|
-
id: op.recordId,
|
|
974
|
-
fields: decodedData,
|
|
975
|
-
deleted: false,
|
|
976
|
-
createdAt: wallTime,
|
|
977
|
-
updatedAt: wallTime
|
|
978
|
-
});
|
|
979
|
-
break;
|
|
980
|
-
}
|
|
981
|
-
case "delete": {
|
|
982
|
-
const existing = colMap.get(op.recordId);
|
|
983
|
-
if (existing) {
|
|
984
|
-
existing.deleted = true;
|
|
985
|
-
existing.updatedAt = wallTime;
|
|
986
|
-
return;
|
|
987
|
-
}
|
|
988
|
-
colMap.set(op.recordId, {
|
|
989
|
-
id: op.recordId,
|
|
990
|
-
fields: {},
|
|
991
|
-
deleted: true,
|
|
992
|
-
createdAt: wallTime,
|
|
993
|
-
updatedAt: wallTime
|
|
994
|
-
});
|
|
995
|
-
break;
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
function materializeCollections(schema, memory) {
|
|
1000
|
-
const collections = {};
|
|
1001
|
-
for (const [collectionName, definition] of Object.entries(schema.collections)) {
|
|
1002
|
-
const colMap = memory.get(collectionName);
|
|
1003
|
-
const records = [];
|
|
1004
|
-
if (colMap) {
|
|
1005
|
-
for (const record of colMap.values()) {
|
|
1006
|
-
if (!record.deleted) {
|
|
1007
|
-
records.push(toCollectionRecord(record, definition));
|
|
1008
|
-
}
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
collections[collectionName] = records;
|
|
1012
|
-
}
|
|
1013
|
-
return collections;
|
|
1014
|
-
}
|
|
1015
|
-
function toCollectionRecord(record, definition) {
|
|
1016
|
-
const result = {
|
|
1017
|
-
id: record.id,
|
|
1018
|
-
createdAt: record.createdAt,
|
|
1019
|
-
updatedAt: record.updatedAt
|
|
1020
|
-
};
|
|
1021
|
-
for (const [fieldName] of Object.entries(definition.fields)) {
|
|
1022
|
-
if (fieldName in record.fields) {
|
|
1023
|
-
result[fieldName] = record.fields[fieldName];
|
|
1024
|
-
}
|
|
1025
|
-
}
|
|
1026
|
-
return result;
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
// src/sequences/sequence-manager.ts
|
|
1030
|
-
import { defaultSequenceFormat, formatSequenceValue } from "@korajs/core";
|
|
1031
|
-
var SequenceManager = class {
|
|
1032
|
-
adapter;
|
|
1033
|
-
nodeId;
|
|
1034
|
-
constructor(adapter, nodeId) {
|
|
1035
|
-
this.adapter = adapter;
|
|
1036
|
-
this.nodeId = nodeId;
|
|
1037
|
-
}
|
|
1038
|
-
/**
|
|
1039
|
-
* Get the next value in a sequence, atomically incrementing the counter.
|
|
1040
|
-
*
|
|
1041
|
-
* @param name - The sequence name (e.g., 'receipt', 'invoice')
|
|
1042
|
-
* @param config - Optional configuration for scope, format, and starting value
|
|
1043
|
-
* @returns The formatted sequence value
|
|
1044
|
-
*/
|
|
1045
|
-
async next(name, config) {
|
|
1046
|
-
const scope = config?.scope ?? "";
|
|
1047
|
-
const startAt = config?.startAt ?? 1;
|
|
1048
|
-
const format = config?.format ?? defaultSequenceFormat(name);
|
|
1049
|
-
let counter = 0;
|
|
1050
|
-
await this.adapter.transaction(async (tx) => {
|
|
1051
|
-
const rows = await tx.query(
|
|
1052
|
-
"SELECT counter FROM _kora_sequences WHERE name = ? AND scope = ? AND node_id = ?",
|
|
1053
|
-
[name, scope, this.nodeId]
|
|
1054
|
-
);
|
|
1055
|
-
if (rows.length > 0) {
|
|
1056
|
-
const row = rows[0];
|
|
1057
|
-
counter = row.counter + 1;
|
|
1058
|
-
await tx.execute(
|
|
1059
|
-
"UPDATE _kora_sequences SET counter = ? WHERE name = ? AND scope = ? AND node_id = ?",
|
|
1060
|
-
[counter, name, scope, this.nodeId]
|
|
1061
|
-
);
|
|
1062
|
-
} else {
|
|
1063
|
-
counter = startAt;
|
|
1064
|
-
await tx.execute(
|
|
1065
|
-
"INSERT INTO _kora_sequences (name, scope, node_id, counter) VALUES (?, ?, ?, ?)",
|
|
1066
|
-
[name, scope, this.nodeId, counter]
|
|
1067
|
-
);
|
|
1068
|
-
}
|
|
1069
|
-
});
|
|
1070
|
-
return formatSequenceValue(format, counter, this.nodeId);
|
|
1071
|
-
}
|
|
1072
|
-
/**
|
|
1073
|
-
* Get the current counter value without incrementing.
|
|
1074
|
-
*
|
|
1075
|
-
* @param name - The sequence name
|
|
1076
|
-
* @param config - Optional scope
|
|
1077
|
-
* @returns The current counter value, or 0 if the sequence has never been used
|
|
1078
|
-
*/
|
|
1079
|
-
async current(name, config) {
|
|
1080
|
-
const scope = config?.scope ?? "";
|
|
1081
|
-
const rows = await this.adapter.query(
|
|
1082
|
-
"SELECT counter FROM _kora_sequences WHERE name = ? AND scope = ? AND node_id = ?",
|
|
1083
|
-
[name, scope, this.nodeId]
|
|
1084
|
-
);
|
|
1085
|
-
if (rows.length > 0) {
|
|
1086
|
-
return rows[0].counter;
|
|
1087
|
-
}
|
|
1088
|
-
return 0;
|
|
1089
|
-
}
|
|
1090
|
-
/**
|
|
1091
|
-
* Reset a sequence counter.
|
|
1092
|
-
*
|
|
1093
|
-
* @param name - The sequence name
|
|
1094
|
-
* @param config - Optional scope and target value
|
|
1095
|
-
*/
|
|
1096
|
-
async reset(name, config) {
|
|
1097
|
-
const scope = config?.scope ?? "";
|
|
1098
|
-
const to = config?.to ?? 0;
|
|
1099
|
-
await this.adapter.execute(
|
|
1100
|
-
"DELETE FROM _kora_sequences WHERE name = ? AND scope = ? AND node_id = ?",
|
|
1101
|
-
[name, scope, this.nodeId]
|
|
1102
|
-
);
|
|
1103
|
-
if (to > 0) {
|
|
1104
|
-
await this.adapter.execute(
|
|
1105
|
-
"INSERT INTO _kora_sequences (name, scope, node_id, counter) VALUES (?, ?, ?, ?)",
|
|
1106
|
-
[name, scope, this.nodeId, to]
|
|
1107
|
-
);
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
};
|
|
1111
|
-
|
|
1112
|
-
// src/subscription/bloom-filter.ts
|
|
1113
|
-
var FNV_OFFSET_BASIS = 2166136261;
|
|
1114
|
-
var FNV_PRIME = 16777619;
|
|
1115
|
-
function fnv1a32(input) {
|
|
1116
|
-
let hash = FNV_OFFSET_BASIS;
|
|
1117
|
-
for (let i = 0; i < input.length; i++) {
|
|
1118
|
-
hash ^= input.charCodeAt(i);
|
|
1119
|
-
hash = Math.imul(hash, FNV_PRIME);
|
|
1120
|
-
}
|
|
1121
|
-
return hash >>> 0;
|
|
1122
|
-
}
|
|
1123
|
-
function optimalBitCount(expectedItems, falsePositiveRate) {
|
|
1124
|
-
if (expectedItems <= 0) return 32;
|
|
1125
|
-
if (falsePositiveRate <= 0 || falsePositiveRate >= 1) return 32;
|
|
1126
|
-
const ln2Squared = Math.LN2 * Math.LN2;
|
|
1127
|
-
const rawBits = -(expectedItems * Math.log(falsePositiveRate)) / ln2Squared;
|
|
1128
|
-
const aligned = Math.ceil(rawBits / 32) * 32;
|
|
1129
|
-
return Math.max(32, aligned);
|
|
1130
|
-
}
|
|
1131
|
-
function optimalHashCount(bitCount, expectedItems) {
|
|
1132
|
-
if (expectedItems <= 0) return 1;
|
|
1133
|
-
const raw = bitCount / expectedItems * Math.LN2;
|
|
1134
|
-
return Math.max(1, Math.min(30, Math.round(raw)));
|
|
1135
|
-
}
|
|
1136
|
-
var SubscriptionBloomFilter = class {
|
|
1137
|
-
bits;
|
|
1138
|
-
bitCount;
|
|
1139
|
-
hashCount;
|
|
1140
|
-
itemCount = 0;
|
|
1141
|
-
constructor(expectedItems, falsePositiveRate = 0.01) {
|
|
1142
|
-
this.bitCount = optimalBitCount(expectedItems, falsePositiveRate);
|
|
1143
|
-
this.hashCount = optimalHashCount(this.bitCount, expectedItems);
|
|
1144
|
-
this.bits = new Uint32Array(this.bitCount / 32);
|
|
1145
|
-
}
|
|
1146
|
-
/**
|
|
1147
|
-
* Add a collection (and optional field) to the bloom filter.
|
|
1148
|
-
*
|
|
1149
|
-
* @param collection - Collection name (e.g., "todos")
|
|
1150
|
-
* @param field - Optional field name (e.g., "completed")
|
|
1151
|
-
*/
|
|
1152
|
-
add(collection, field) {
|
|
1153
|
-
const key = field !== void 0 ? `${collection}:${field}` : collection;
|
|
1154
|
-
const positions = this.getPositions(key);
|
|
1155
|
-
for (const pos of positions) {
|
|
1156
|
-
const wordIndex = pos >>> 5;
|
|
1157
|
-
const bitIndex = pos & 31;
|
|
1158
|
-
const current = this.bits[wordIndex] ?? 0;
|
|
1159
|
-
this.bits[wordIndex] = current | 1 << bitIndex;
|
|
1160
|
-
}
|
|
1161
|
-
this.itemCount++;
|
|
1162
|
-
}
|
|
1163
|
-
/**
|
|
1164
|
-
* Check if a collection (and optional field) might be in the filter.
|
|
1165
|
-
*
|
|
1166
|
-
* A return value of `false` means the item is DEFINITELY NOT in the filter.
|
|
1167
|
-
* A return value of `true` means the item MIGHT be in the filter (possible false positive).
|
|
1168
|
-
*
|
|
1169
|
-
* @param collection - Collection name to check
|
|
1170
|
-
* @param field - Optional field name to check
|
|
1171
|
-
* @returns false = definitely absent, true = possibly present
|
|
1172
|
-
*/
|
|
1173
|
-
mightContain(collection, field) {
|
|
1174
|
-
const key = field !== void 0 ? `${collection}:${field}` : collection;
|
|
1175
|
-
const positions = this.getPositions(key);
|
|
1176
|
-
for (const pos of positions) {
|
|
1177
|
-
const wordIndex = pos >>> 5;
|
|
1178
|
-
const bitIndex = pos & 31;
|
|
1179
|
-
if (((this.bits[wordIndex] ?? 0) & 1 << bitIndex) === 0) {
|
|
1180
|
-
return false;
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
return true;
|
|
1184
|
-
}
|
|
1185
|
-
/**
|
|
1186
|
-
* Reset the bloom filter, clearing all bits and the item count.
|
|
1187
|
-
*/
|
|
1188
|
-
clear() {
|
|
1189
|
-
this.bits.fill(0);
|
|
1190
|
-
this.itemCount = 0;
|
|
1191
|
-
}
|
|
1192
|
-
/**
|
|
1193
|
-
* Estimate the current false positive rate based on the number of items inserted.
|
|
1194
|
-
*
|
|
1195
|
-
* Formula: (1 - e^(-kn/m))^k
|
|
1196
|
-
* where k = hash count, n = item count, m = bit count.
|
|
1197
|
-
*
|
|
1198
|
-
* @returns Estimated false positive rate as a number between 0 and 1
|
|
1199
|
-
*/
|
|
1200
|
-
estimatedFalsePositiveRate() {
|
|
1201
|
-
if (this.itemCount === 0) return 0;
|
|
1202
|
-
const exponent = -(this.hashCount * this.itemCount) / this.bitCount;
|
|
1203
|
-
return (1 - Math.exp(exponent)) ** this.hashCount;
|
|
1204
|
-
}
|
|
1205
|
-
/**
|
|
1206
|
-
* @returns The number of items that have been added to the filter
|
|
1207
|
-
*/
|
|
1208
|
-
getItemCount() {
|
|
1209
|
-
return this.itemCount;
|
|
1210
|
-
}
|
|
1211
|
-
/**
|
|
1212
|
-
* @returns The total number of bits in the filter
|
|
1213
|
-
*/
|
|
1214
|
-
getBitCount() {
|
|
1215
|
-
return this.bitCount;
|
|
1216
|
-
}
|
|
1217
|
-
/**
|
|
1218
|
-
* @returns The number of hash functions used
|
|
1219
|
-
*/
|
|
1220
|
-
getHashCount() {
|
|
1221
|
-
return this.hashCount;
|
|
1222
|
-
}
|
|
1223
|
-
/**
|
|
1224
|
-
* Count the number of bits currently set to 1.
|
|
1225
|
-
* Uses Brian Kernighan's algorithm: each iteration clears the lowest set bit,
|
|
1226
|
-
* so the loop runs exactly as many times as there are set bits.
|
|
1227
|
-
*
|
|
1228
|
-
* @returns Number of bits set to 1
|
|
1229
|
-
*/
|
|
1230
|
-
getSetBitCount() {
|
|
1231
|
-
let count = 0;
|
|
1232
|
-
for (let i = 0; i < this.bits.length; i++) {
|
|
1233
|
-
let word = this.bits[i] ?? 0;
|
|
1234
|
-
while (word !== 0) {
|
|
1235
|
-
word &= word - 1;
|
|
1236
|
-
count++;
|
|
1237
|
-
}
|
|
1238
|
-
}
|
|
1239
|
-
return count;
|
|
1240
|
-
}
|
|
1241
|
-
/**
|
|
1242
|
-
* Compute k bit positions for a given key using Kirsch-Mitzenmacker double hashing.
|
|
1243
|
-
*
|
|
1244
|
-
* Instead of computing k independent hash functions, we compute two base hashes
|
|
1245
|
-
* (h1 and h2) and derive the rest as: h_i = (h1 + i * h2) mod m.
|
|
1246
|
-
* This technique is proven to have the same asymptotic false positive rate as
|
|
1247
|
-
* k independent hash functions.
|
|
1248
|
-
*
|
|
1249
|
-
* We derive h1 and h2 from a single FNV-1a hash by splitting and mixing:
|
|
1250
|
-
* h1 = fnv1a(key), h2 = fnv1a(key + "\0salt") to ensure independence.
|
|
1251
|
-
*/
|
|
1252
|
-
getPositions(key) {
|
|
1253
|
-
const h1 = fnv1a32(key);
|
|
1254
|
-
const h2 = fnv1a32(`${key}\0bloom`);
|
|
1255
|
-
const positions = new Array(this.hashCount);
|
|
1256
|
-
for (let i = 0; i < this.hashCount; i++) {
|
|
1257
|
-
positions[i] = (h1 + Math.imul(i, h2) >>> 0) % this.bitCount;
|
|
1258
|
-
}
|
|
1259
|
-
return positions;
|
|
1260
|
-
}
|
|
1261
|
-
};
|
|
1262
|
-
|
|
1263
|
-
// src/subscription/subscription-manager.ts
|
|
1264
|
-
var nextSubId = 0;
|
|
1265
|
-
var DEFAULT_BLOOM_THRESHOLD = 100;
|
|
1266
|
-
var DEFAULT_BLOOM_EXPECTED_ITEMS = 500;
|
|
1267
|
-
var DEFAULT_BLOOM_FALSE_POSITIVE_RATE = 0.01;
|
|
1268
|
-
var SubscriptionManager = class {
|
|
1269
|
-
subscriptions = /* @__PURE__ */ new Map();
|
|
1270
|
-
pendingCollections = /* @__PURE__ */ new Set();
|
|
1271
|
-
flushScheduled = false;
|
|
1272
|
-
onQuerySubscribed;
|
|
1273
|
-
// Bloom filter state
|
|
1274
|
-
bloomFilter = null;
|
|
1275
|
-
bloomDirty = false;
|
|
1276
|
-
bloomThreshold;
|
|
1277
|
-
bloomExpectedItems;
|
|
1278
|
-
bloomFalsePositiveRate;
|
|
1279
|
-
// Performance stats
|
|
1280
|
-
totalChecks = 0;
|
|
1281
|
-
bloomFilterHits = 0;
|
|
1282
|
-
bloomFilterMisses = 0;
|
|
1283
|
-
falsePositives = 0;
|
|
1284
|
-
totalCheckTimeMs = 0;
|
|
1285
|
-
constructor(options) {
|
|
1286
|
-
this.bloomThreshold = options?.bloomThreshold ?? DEFAULT_BLOOM_THRESHOLD;
|
|
1287
|
-
this.bloomExpectedItems = options?.bloomExpectedItems ?? DEFAULT_BLOOM_EXPECTED_ITEMS;
|
|
1288
|
-
this.bloomFalsePositiveRate = options?.bloomFalsePositiveRate ?? DEFAULT_BLOOM_FALSE_POSITIVE_RATE;
|
|
1289
|
-
this.onQuerySubscribed = options?.onQuerySubscribed;
|
|
1290
|
-
}
|
|
1291
|
-
/**
|
|
1292
|
-
* Register a new subscription.
|
|
1293
|
-
*
|
|
1294
|
-
* @param descriptor - The query descriptor defining what this subscription watches
|
|
1295
|
-
* @param callback - Called with results whenever they change
|
|
1296
|
-
* @param executeFn - Function to re-execute the query and get current results
|
|
1297
|
-
* @returns An unsubscribe function
|
|
1298
|
-
*/
|
|
1299
|
-
register(descriptor, callback, executeFn) {
|
|
1300
|
-
const id = `sub_${++nextSubId}`;
|
|
1301
|
-
const subscription = {
|
|
1302
|
-
id,
|
|
1303
|
-
descriptor,
|
|
1304
|
-
callback,
|
|
1305
|
-
executeFn,
|
|
1306
|
-
lastResults: []
|
|
1307
|
-
};
|
|
1308
|
-
this.subscriptions.set(id, subscription);
|
|
1309
|
-
this.bloomDirty = true;
|
|
1310
|
-
const externalCleanup = this.onQuerySubscribed?.(descriptor);
|
|
1311
|
-
return () => {
|
|
1312
|
-
this.subscriptions.delete(id);
|
|
1313
|
-
this.bloomDirty = true;
|
|
1314
|
-
externalCleanup?.();
|
|
1315
|
-
};
|
|
1316
|
-
}
|
|
1317
|
-
/**
|
|
1318
|
-
* Register a subscription and immediately execute the query.
|
|
1319
|
-
* The initial results are stored as lastResults so subsequent flushes
|
|
1320
|
-
* correctly diff against the initial state.
|
|
1321
|
-
*
|
|
1322
|
-
* @returns An unsubscribe function
|
|
1323
|
-
*/
|
|
1324
|
-
registerAndFetch(descriptor, callback, executeFn) {
|
|
1325
|
-
const id = `sub_${++nextSubId}`;
|
|
1326
|
-
const subscription = {
|
|
1327
|
-
id,
|
|
1328
|
-
descriptor,
|
|
1329
|
-
callback,
|
|
1330
|
-
executeFn,
|
|
1331
|
-
lastResults: []
|
|
1332
|
-
};
|
|
1333
|
-
this.subscriptions.set(id, subscription);
|
|
1334
|
-
this.bloomDirty = true;
|
|
1335
|
-
const externalCleanup = this.onQuerySubscribed?.(descriptor);
|
|
1336
|
-
executeFn().then((results) => {
|
|
1337
|
-
if (this.subscriptions.has(id)) {
|
|
1338
|
-
subscription.lastResults = results;
|
|
1339
|
-
callback(results);
|
|
1340
|
-
}
|
|
1341
|
-
});
|
|
1342
|
-
return () => {
|
|
1343
|
-
this.subscriptions.delete(id);
|
|
1344
|
-
this.bloomDirty = true;
|
|
1345
|
-
externalCleanup?.();
|
|
1346
|
-
};
|
|
1347
|
-
}
|
|
1348
|
-
/**
|
|
1349
|
-
* Notify the manager that a mutation occurred on a collection.
|
|
1350
|
-
* Schedules a microtask flush to batch multiple mutations in the same tick.
|
|
1351
|
-
*/
|
|
1352
|
-
notify(collection, _operation) {
|
|
1353
|
-
this.invalidate(collection);
|
|
1354
|
-
}
|
|
1355
|
-
/** Invalidate a collection after a local-view change that has no domain operation. */
|
|
1356
|
-
invalidate(collection) {
|
|
1357
|
-
this.pendingCollections.add(collection);
|
|
1358
|
-
this.scheduleFlush();
|
|
1359
|
-
}
|
|
1360
|
-
/**
|
|
1361
|
-
* Immediately flush all pending notifications.
|
|
1362
|
-
* Useful for testing. In production, flushing happens via microtask.
|
|
1363
|
-
*/
|
|
1364
|
-
async flush() {
|
|
1365
|
-
if (this.pendingCollections.size === 0) return;
|
|
1366
|
-
const collections = new Set(this.pendingCollections);
|
|
1367
|
-
this.pendingCollections.clear();
|
|
1368
|
-
this.flushScheduled = false;
|
|
1369
|
-
const affected = this.findAffectedSubscriptions(collections);
|
|
1370
|
-
for (const sub of affected) {
|
|
1371
|
-
try {
|
|
1372
|
-
const newResults = await sub.executeFn();
|
|
1373
|
-
if (!this.resultsEqual(sub.lastResults, newResults)) {
|
|
1374
|
-
sub.lastResults = newResults;
|
|
1375
|
-
sub.callback(newResults);
|
|
1376
|
-
}
|
|
1377
|
-
} catch {
|
|
1378
|
-
}
|
|
1379
|
-
}
|
|
1380
|
-
}
|
|
1381
|
-
/**
|
|
1382
|
-
* Remove all subscriptions. Called on store close.
|
|
1383
|
-
*/
|
|
1384
|
-
clear() {
|
|
1385
|
-
this.subscriptions.clear();
|
|
1386
|
-
this.pendingCollections.clear();
|
|
1387
|
-
this.flushScheduled = false;
|
|
1388
|
-
this.bloomFilter = null;
|
|
1389
|
-
this.bloomDirty = false;
|
|
1390
|
-
this.totalChecks = 0;
|
|
1391
|
-
this.bloomFilterHits = 0;
|
|
1392
|
-
this.bloomFilterMisses = 0;
|
|
1393
|
-
this.falsePositives = 0;
|
|
1394
|
-
this.totalCheckTimeMs = 0;
|
|
1395
|
-
}
|
|
1396
|
-
/** Number of active subscriptions (for testing/debugging) */
|
|
1397
|
-
get size() {
|
|
1398
|
-
return this.subscriptions.size;
|
|
1399
|
-
}
|
|
1400
|
-
/**
|
|
1401
|
-
* Get performance statistics for monitoring subscription checking efficiency.
|
|
1402
|
-
* Useful for DevTools integration and performance tuning.
|
|
1403
|
-
*/
|
|
1404
|
-
getStats() {
|
|
1405
|
-
return {
|
|
1406
|
-
totalChecks: this.totalChecks,
|
|
1407
|
-
bloomFilterHits: this.bloomFilterHits,
|
|
1408
|
-
bloomFilterMisses: this.bloomFilterMisses,
|
|
1409
|
-
falsePositives: this.falsePositives,
|
|
1410
|
-
averageCheckTimeMs: this.totalChecks > 0 ? this.totalCheckTimeMs / this.totalChecks : 0,
|
|
1411
|
-
bloomFilterActive: this.isBloomActive(),
|
|
1412
|
-
subscriptionCount: this.subscriptions.size
|
|
1413
|
-
};
|
|
1414
|
-
}
|
|
1415
|
-
/**
|
|
1416
|
-
* Check if bloom filter is currently active.
|
|
1417
|
-
* Active when subscription count meets or exceeds the threshold.
|
|
1418
|
-
*/
|
|
1419
|
-
isBloomActive() {
|
|
1420
|
-
return this.subscriptions.size >= this.bloomThreshold;
|
|
1421
|
-
}
|
|
1422
|
-
/**
|
|
1423
|
-
* Find subscriptions affected by mutations to the given collections.
|
|
1424
|
-
* Uses two-level checking when bloom filter is active:
|
|
1425
|
-
*
|
|
1426
|
-
* Level 1: Bloom filter pre-check -- if no subscription depends on any
|
|
1427
|
-
* of the mutated collections, skip everything (O(k) per collection).
|
|
1428
|
-
*
|
|
1429
|
-
* Level 2: Precise check -- linear scan of subscriptions, matching
|
|
1430
|
-
* against the mutated collections.
|
|
1431
|
-
*/
|
|
1432
|
-
findAffectedSubscriptions(collections) {
|
|
1433
|
-
const startTime = performance.now();
|
|
1434
|
-
this.totalChecks++;
|
|
1435
|
-
const useBloom = this.isBloomActive();
|
|
1436
|
-
if (useBloom) {
|
|
1437
|
-
if (this.bloomDirty || this.bloomFilter === null) {
|
|
1438
|
-
this.rebuildBloomFilter();
|
|
1439
|
-
}
|
|
1440
|
-
const filter = this.bloomFilter;
|
|
1441
|
-
if (filter !== null) {
|
|
1442
|
-
let anyPossibleMatch = false;
|
|
1443
|
-
for (const col of collections) {
|
|
1444
|
-
if (filter.mightContain(col)) {
|
|
1445
|
-
anyPossibleMatch = true;
|
|
1446
|
-
break;
|
|
1447
|
-
}
|
|
1448
|
-
}
|
|
1449
|
-
if (!anyPossibleMatch) {
|
|
1450
|
-
this.bloomFilterMisses++;
|
|
1451
|
-
this.totalCheckTimeMs += performance.now() - startTime;
|
|
1452
|
-
return [];
|
|
1453
|
-
}
|
|
1454
|
-
this.bloomFilterHits++;
|
|
1455
|
-
}
|
|
1456
|
-
}
|
|
1457
|
-
const affected = [];
|
|
1458
|
-
let anyPreciseMatch = false;
|
|
1459
|
-
for (const sub of this.subscriptions.values()) {
|
|
1460
|
-
if (collections.has(sub.descriptor.collection)) {
|
|
1461
|
-
affected.push(sub);
|
|
1462
|
-
anyPreciseMatch = true;
|
|
1463
|
-
} else if (sub.descriptor.includeCollections) {
|
|
1464
|
-
for (const incCol of sub.descriptor.includeCollections) {
|
|
1465
|
-
if (collections.has(incCol)) {
|
|
1466
|
-
affected.push(sub);
|
|
1467
|
-
anyPreciseMatch = true;
|
|
1468
|
-
break;
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
}
|
|
1473
|
-
if (useBloom && !anyPreciseMatch) {
|
|
1474
|
-
this.falsePositives++;
|
|
1475
|
-
}
|
|
1476
|
-
this.totalCheckTimeMs += performance.now() - startTime;
|
|
1477
|
-
return affected;
|
|
1478
|
-
}
|
|
1479
|
-
/**
|
|
1480
|
-
* Rebuild the bloom filter from all current subscriptions.
|
|
1481
|
-
* Adds collection-level dependencies for every subscription, plus
|
|
1482
|
-
* any included collection dependencies.
|
|
1483
|
-
*/
|
|
1484
|
-
rebuildBloomFilter() {
|
|
1485
|
-
const filter = new SubscriptionBloomFilter(this.bloomExpectedItems, this.bloomFalsePositiveRate);
|
|
1486
|
-
for (const sub of this.subscriptions.values()) {
|
|
1487
|
-
filter.add(sub.descriptor.collection);
|
|
1488
|
-
if (sub.descriptor.includeCollections) {
|
|
1489
|
-
for (const incCol of sub.descriptor.includeCollections) {
|
|
1490
|
-
filter.add(incCol);
|
|
1491
|
-
}
|
|
1492
|
-
}
|
|
1493
|
-
}
|
|
1494
|
-
this.bloomFilter = filter;
|
|
1495
|
-
this.bloomDirty = false;
|
|
1496
|
-
}
|
|
1497
|
-
scheduleFlush() {
|
|
1498
|
-
if (this.flushScheduled) return;
|
|
1499
|
-
this.flushScheduled = true;
|
|
1500
|
-
queueMicrotask(() => {
|
|
1501
|
-
this.flush();
|
|
1502
|
-
});
|
|
1503
|
-
}
|
|
1504
|
-
/**
|
|
1505
|
-
* Compare two result sets. Uses ID-based comparison first (same length, same IDs),
|
|
1506
|
-
* then deep comparison of remaining fields when IDs match.
|
|
1507
|
-
* Avoids JSON.stringify which is O(n) and fragile to field ordering differences.
|
|
1508
|
-
*/
|
|
1509
|
-
resultsEqual(prev, next) {
|
|
1510
|
-
if (prev.length !== next.length) return false;
|
|
1511
|
-
if (prev.length === 0) return true;
|
|
1512
|
-
for (let i = 0; i < prev.length; i++) {
|
|
1513
|
-
if (prev[i]?.id !== next[i]?.id) return false;
|
|
1514
|
-
}
|
|
1515
|
-
for (let i = 0; i < prev.length; i++) {
|
|
1516
|
-
const a = prev[i];
|
|
1517
|
-
const b = next[i];
|
|
1518
|
-
const keysA = Object.keys(a);
|
|
1519
|
-
const keysB = Object.keys(b);
|
|
1520
|
-
if (keysA.length !== keysB.length) return false;
|
|
1521
|
-
for (const key of keysA) {
|
|
1522
|
-
if (a[key] !== b[key]) return false;
|
|
1523
|
-
}
|
|
1524
|
-
}
|
|
1525
|
-
return true;
|
|
1526
|
-
}
|
|
1527
|
-
};
|
|
1528
|
-
|
|
1529
|
-
// src/sync/rebase-unsynced-operations.ts
|
|
1530
|
-
import { HybridLogicalClock as HybridLogicalClock2, MAX_LOGICAL, quoteIdent as quoteIdent5 } from "@korajs/core";
|
|
1531
|
-
import { computeOperationId } from "@korajs/core/internal";
|
|
1532
|
-
var LOGICAL_COUNTER_LIMIT = MAX_LOGICAL + 1;
|
|
1533
|
-
function emptyResult() {
|
|
1534
|
-
return { operations: [], idMapping: {}, rebasedCount: 0, newMaxTimestamp: null };
|
|
1535
|
-
}
|
|
1536
|
-
async function rebaseUnsyncedOperationsInLog(adapter, schema, unsyncedOpIds, correctedNowMs) {
|
|
1537
|
-
if (unsyncedOpIds.length === 0) {
|
|
1538
|
-
return emptyResult();
|
|
1539
|
-
}
|
|
1540
|
-
const rebaseIds = new Set(unsyncedOpIds);
|
|
1541
|
-
const rebased = [];
|
|
1542
|
-
let maxOtherSerializedTs = null;
|
|
1543
|
-
for (const collectionName of Object.keys(schema.collections)) {
|
|
1544
|
-
const rows = await adapter.query(
|
|
1545
|
-
`SELECT * FROM ${quoteIdent5(`_kora_ops_${collectionName}`)}`
|
|
1546
|
-
);
|
|
1547
|
-
for (const row of rows) {
|
|
1548
|
-
if (rebaseIds.has(row.id)) {
|
|
1549
|
-
rebased.push(deserializeOperationWithCollection(row, collectionName));
|
|
1550
|
-
} else if (maxOtherSerializedTs === null || row.timestamp > maxOtherSerializedTs) {
|
|
1551
|
-
maxOtherSerializedTs = row.timestamp;
|
|
1552
|
-
}
|
|
1553
|
-
}
|
|
1554
|
-
}
|
|
1555
|
-
if (rebased.length === 0) {
|
|
1556
|
-
return emptyResult();
|
|
1557
|
-
}
|
|
1558
|
-
const maxOtherWallTime = maxOtherSerializedTs === null ? Number.NEGATIVE_INFINITY : HybridLogicalClock2.deserialize(maxOtherSerializedTs).wallTime;
|
|
1559
|
-
const baseWallTime = Math.max(correctedNowMs, maxOtherWallTime + 1);
|
|
1560
|
-
rebased.sort((a, b) => HybridLogicalClock2.compare(a.timestamp, b.timestamp));
|
|
1561
|
-
const newTimestamps = rebased.map((op, i) => ({
|
|
1562
|
-
// Spill into wallTime past the 5-digit logical serialization limit so the
|
|
1563
|
-
// serialized order stays correct even for enormous offline queues.
|
|
1564
|
-
wallTime: baseWallTime + Math.floor(i / LOGICAL_COUNTER_LIMIT),
|
|
1565
|
-
logical: i % LOGICAL_COUNTER_LIMIT,
|
|
1566
|
-
nodeId: op.nodeId
|
|
1567
|
-
}));
|
|
1568
|
-
const idMapping = {};
|
|
1569
|
-
const newIds = [];
|
|
1570
|
-
for (let i = 0; i < rebased.length; i++) {
|
|
1571
|
-
const op = rebased[i];
|
|
1572
|
-
const ts = newTimestamps[i];
|
|
1573
|
-
if (!op || !ts) {
|
|
1574
|
-
continue;
|
|
1575
|
-
}
|
|
1576
|
-
const input = {
|
|
1577
|
-
nodeId: op.nodeId,
|
|
1578
|
-
type: op.type,
|
|
1579
|
-
collection: op.collection,
|
|
1580
|
-
recordId: op.recordId,
|
|
1581
|
-
data: op.data,
|
|
1582
|
-
previousData: op.previousData,
|
|
1583
|
-
sequenceNumber: op.sequenceNumber,
|
|
1584
|
-
causalDeps: op.causalDeps,
|
|
1585
|
-
schemaVersion: op.schemaVersion,
|
|
1586
|
-
...op.atomicOps !== void 0 ? { atomicOps: op.atomicOps } : {}
|
|
1587
|
-
};
|
|
1588
|
-
const newId = await computeOperationId(input, HybridLogicalClock2.serialize(ts));
|
|
1589
|
-
idMapping[op.id] = newId;
|
|
1590
|
-
newIds.push(newId);
|
|
1591
|
-
}
|
|
1592
|
-
const newOperations = [];
|
|
1593
|
-
for (let i = 0; i < rebased.length; i++) {
|
|
1594
|
-
const op = rebased[i];
|
|
1595
|
-
const ts = newTimestamps[i];
|
|
1596
|
-
const newId = newIds[i];
|
|
1597
|
-
if (!op || !ts || newId === void 0) {
|
|
1598
|
-
continue;
|
|
1599
|
-
}
|
|
1600
|
-
newOperations.push({
|
|
1601
|
-
...op,
|
|
1602
|
-
id: newId,
|
|
1603
|
-
timestamp: ts,
|
|
1604
|
-
causalDeps: op.causalDeps.map((dep) => idMapping[dep] ?? dep)
|
|
1605
|
-
});
|
|
1606
|
-
}
|
|
1607
|
-
await adapter.transaction(async (tx) => {
|
|
1608
|
-
for (let i = 0; i < rebased.length; i++) {
|
|
1609
|
-
const oldOp = rebased[i];
|
|
1610
|
-
const newOp = newOperations[i];
|
|
1611
|
-
if (!oldOp || !newOp) {
|
|
1612
|
-
continue;
|
|
1613
|
-
}
|
|
1614
|
-
const opsTable = `_kora_ops_${oldOp.collection}`;
|
|
1615
|
-
await tx.execute(`DELETE FROM ${quoteIdent5(opsTable)} WHERE id = ?`, [oldOp.id]);
|
|
1616
|
-
const opInsert = buildInsertQuery(
|
|
1617
|
-
opsTable,
|
|
1618
|
-
serializeOperation(newOp)
|
|
1619
|
-
);
|
|
1620
|
-
await tx.execute(opInsert.sql, opInsert.params);
|
|
1621
|
-
const oldVersion = serializeRowVersion(oldOp.timestamp);
|
|
1622
|
-
const newVersion = serializeRowVersion(newOp.timestamp);
|
|
1623
|
-
await tx.execute(
|
|
1624
|
-
`UPDATE ${quoteIdent5(oldOp.collection)} SET _version = ?, _updated_at = ? WHERE id = ? AND _version = ?`,
|
|
1625
|
-
[newVersion, newOp.timestamp.wallTime, oldOp.recordId, oldVersion]
|
|
1626
|
-
);
|
|
1627
|
-
const rows = await tx.query(
|
|
1628
|
-
`SELECT _field_versions FROM ${quoteIdent5(oldOp.collection)} WHERE id = ?`,
|
|
1629
|
-
[oldOp.recordId]
|
|
1630
|
-
);
|
|
1631
|
-
const fieldVersions = parseFieldVersions(rows[0]?._field_versions);
|
|
1632
|
-
let changed = false;
|
|
1633
|
-
for (const [field, version] of Object.entries(fieldVersions)) {
|
|
1634
|
-
if (version === oldVersion) {
|
|
1635
|
-
fieldVersions[field] = newVersion;
|
|
1636
|
-
changed = true;
|
|
1637
|
-
}
|
|
1638
|
-
}
|
|
1639
|
-
if (changed) {
|
|
1640
|
-
await tx.execute(
|
|
1641
|
-
`UPDATE ${quoteIdent5(oldOp.collection)} SET _field_versions = ? WHERE id = ?`,
|
|
1642
|
-
[serializeFieldVersions(fieldVersions), oldOp.recordId]
|
|
1643
|
-
);
|
|
1644
|
-
}
|
|
1645
|
-
if (oldOp.type === "insert") {
|
|
1646
|
-
await tx.execute(
|
|
1647
|
-
`UPDATE ${quoteIdent5(oldOp.collection)} SET _created_at = ? WHERE id = ? AND _created_at = ?`,
|
|
1648
|
-
[newOp.timestamp.wallTime, oldOp.recordId, oldOp.timestamp.wallTime]
|
|
1649
|
-
);
|
|
1650
|
-
}
|
|
1651
|
-
}
|
|
1652
|
-
});
|
|
1653
|
-
const newMaxTimestamp = newTimestamps[newTimestamps.length - 1] ?? null;
|
|
1654
|
-
return {
|
|
1655
|
-
operations: newOperations,
|
|
1656
|
-
idMapping,
|
|
1657
|
-
rebasedCount: newOperations.length,
|
|
1658
|
-
newMaxTimestamp
|
|
1659
|
-
};
|
|
1660
|
-
}
|
|
1661
|
-
|
|
1662
|
-
// src/transaction/transaction-context.ts
|
|
1663
|
-
import {
|
|
1664
|
-
createOperation as createOperation2,
|
|
1665
|
-
generateUUIDv7,
|
|
1666
|
-
isAtomicOp,
|
|
1667
|
-
quoteIdent as quoteIdent6,
|
|
1668
|
-
resolveAtomicOp,
|
|
1669
|
-
toAtomicOp,
|
|
1670
|
-
validateRecord
|
|
1671
|
-
} from "@korajs/core";
|
|
1672
|
-
var TransactionContext = class {
|
|
1673
|
-
transactionId;
|
|
1674
|
-
mutationName;
|
|
1675
|
-
buffer = [];
|
|
1676
|
-
committed = false;
|
|
1677
|
-
rolledBack = false;
|
|
1678
|
-
config;
|
|
1679
|
-
constructor(config) {
|
|
1680
|
-
this.config = config;
|
|
1681
|
-
this.transactionId = generateUUIDv7();
|
|
1682
|
-
}
|
|
1683
|
-
/**
|
|
1684
|
-
* Set a human-readable mutation name for this transaction.
|
|
1685
|
-
* Propagated to all operations for DevTools display.
|
|
1686
|
-
*/
|
|
1687
|
-
setMutationName(name) {
|
|
1688
|
-
this.mutationName = name;
|
|
1689
|
-
}
|
|
1690
|
-
/**
|
|
1691
|
-
* Get the mutation name, if set.
|
|
1692
|
-
*/
|
|
1693
|
-
getMutationName() {
|
|
1694
|
-
return this.mutationName;
|
|
1695
|
-
}
|
|
1696
|
-
/**
|
|
1697
|
-
* Get a collection accessor for buffered operations within this transaction.
|
|
1698
|
-
*/
|
|
1699
|
-
collection(name) {
|
|
1700
|
-
const definition = this.config.schema.collections[name];
|
|
1701
|
-
if (!definition) {
|
|
1702
|
-
throw new Error(
|
|
1703
|
-
`Unknown collection "${name}". Available: ${Object.keys(this.config.schema.collections).join(", ")}`
|
|
1704
|
-
);
|
|
1705
|
-
}
|
|
1706
|
-
return {
|
|
1707
|
-
insert: (data) => this.insert(name, definition, data),
|
|
1708
|
-
update: (id, data) => this.update(name, definition, id, data),
|
|
1709
|
-
delete: (id) => this.deleteRecord(name, definition, id),
|
|
1710
|
-
findById: (id) => this.findById(name, definition, id)
|
|
1711
|
-
};
|
|
1712
|
-
}
|
|
1713
|
-
/**
|
|
1714
|
-
* Commit all buffered operations atomically.
|
|
1715
|
-
* Returns the list of operations and affected collections for subscription notification.
|
|
1716
|
-
*/
|
|
1717
|
-
async commit() {
|
|
1718
|
-
if (this.committed) {
|
|
1719
|
-
throw new Error("Transaction already committed.");
|
|
1720
|
-
}
|
|
1721
|
-
if (this.rolledBack) {
|
|
1722
|
-
throw new Error("Transaction was rolled back and cannot be committed.");
|
|
1723
|
-
}
|
|
1724
|
-
this.committed = true;
|
|
1725
|
-
if (this.buffer.length === 0) {
|
|
1726
|
-
return { operations: [], affectedCollections: /* @__PURE__ */ new Set() };
|
|
1727
|
-
}
|
|
1728
|
-
const handler = this.config.localMutationHandler;
|
|
1729
|
-
if (handler?.commitTransaction) {
|
|
1730
|
-
return handler.commitTransaction({
|
|
1731
|
-
entries: this.buffer.map((entry) => ({
|
|
1732
|
-
operation: entry.operation,
|
|
1733
|
-
commands: entry.commands,
|
|
1734
|
-
collection: entry.collection
|
|
1735
|
-
})),
|
|
1736
|
-
transactionId: this.transactionId,
|
|
1737
|
-
...this.mutationName !== void 0 ? { mutationName: this.mutationName } : {}
|
|
1738
|
-
});
|
|
1739
|
-
}
|
|
1740
|
-
const operations = [];
|
|
1741
|
-
const affectedCollections = /* @__PURE__ */ new Set();
|
|
1742
|
-
await this.config.adapter.transaction(async (tx) => {
|
|
1743
|
-
for (const entry of this.buffer) {
|
|
1744
|
-
if (entry.operation.type === "delete" && this.config.relationEnforcer) {
|
|
1745
|
-
const cascadeResult = await this.config.relationEnforcer.enforceDelete(
|
|
1746
|
-
entry.collection,
|
|
1747
|
-
entry.operation.recordId,
|
|
1748
|
-
tx,
|
|
1749
|
-
[entry.operation.id]
|
|
1750
|
-
);
|
|
1751
|
-
for (const cascadedOp of cascadeResult.operations) {
|
|
1752
|
-
operations.push(cascadedOp);
|
|
1753
|
-
affectedCollections.add(cascadedOp.collection);
|
|
1754
|
-
this.config.causalTracker?.afterOperation(cascadedOp.collection, cascadedOp.id, true);
|
|
1755
|
-
}
|
|
1756
|
-
}
|
|
1757
|
-
for (const cmd of entry.commands) {
|
|
1758
|
-
await tx.execute(cmd.sql, cmd.params);
|
|
1759
|
-
}
|
|
1760
|
-
operations.push(entry.operation);
|
|
1761
|
-
affectedCollections.add(entry.collection);
|
|
1762
|
-
}
|
|
1763
|
-
const finalSeq = this.config.sequenceAllocator.getHighWaterMark();
|
|
1764
|
-
if (finalSeq > 0) {
|
|
1765
|
-
await tx.execute(
|
|
1766
|
-
"INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
|
|
1767
|
-
[this.config.nodeId, finalSeq]
|
|
1768
|
-
);
|
|
1769
|
-
}
|
|
1770
|
-
});
|
|
1771
|
-
return { operations, affectedCollections };
|
|
1772
|
-
}
|
|
1773
|
-
/**
|
|
1774
|
-
* Mark the transaction as rolled back. No operations will be committed.
|
|
1775
|
-
*/
|
|
1776
|
-
rollback() {
|
|
1777
|
-
this.rolledBack = true;
|
|
1778
|
-
this.buffer.length = 0;
|
|
1779
|
-
}
|
|
1780
|
-
/**
|
|
1781
|
-
* Get the transaction ID shared by all operations in this transaction.
|
|
1782
|
-
*/
|
|
1783
|
-
getTransactionId() {
|
|
1784
|
-
return this.transactionId;
|
|
1785
|
-
}
|
|
1786
|
-
ensureActive() {
|
|
1787
|
-
if (this.committed) {
|
|
1788
|
-
throw new Error("Cannot perform operations on a committed transaction.");
|
|
1789
|
-
}
|
|
1790
|
-
if (this.rolledBack) {
|
|
1791
|
-
throw new Error("Cannot perform operations on a rolled-back transaction.");
|
|
1792
|
-
}
|
|
1793
|
-
}
|
|
1794
|
-
async insert(collectionName, definition, data) {
|
|
1795
|
-
this.ensureActive();
|
|
1796
|
-
const validated = validateRecord(collectionName, definition, data, "insert");
|
|
1797
|
-
const recordId = generateUUIDv7();
|
|
1798
|
-
const now = Date.now();
|
|
1799
|
-
for (const [fieldName, descriptor] of Object.entries(definition.fields)) {
|
|
1800
|
-
if (descriptor.auto && descriptor.kind === "timestamp") {
|
|
1801
|
-
validated[fieldName] = now;
|
|
1802
|
-
}
|
|
1803
|
-
}
|
|
1804
|
-
const sequenceNumber = await this.config.sequenceAllocator.allocate();
|
|
1805
|
-
const causalDeps = this.config.causalTracker?.nextCausalDeps(collectionName, true) ?? [];
|
|
1806
|
-
const operation = await createOperation2(
|
|
1807
|
-
{
|
|
1808
|
-
nodeId: this.config.nodeId,
|
|
1809
|
-
type: "insert",
|
|
1810
|
-
collection: collectionName,
|
|
1811
|
-
recordId,
|
|
1812
|
-
// Binary richtext values are tagged as canonical JSON before the
|
|
1813
|
-
// operation is content-hashed (see encodeRichtextFieldsForOpData).
|
|
1814
|
-
data: encodeRichtextFieldsForOpData(validated, definition.fields),
|
|
1815
|
-
previousData: null,
|
|
1816
|
-
sequenceNumber,
|
|
1817
|
-
causalDeps,
|
|
1818
|
-
schemaVersion: this.config.schema.version,
|
|
1819
|
-
transactionId: this.transactionId,
|
|
1820
|
-
...this.mutationName !== void 0 ? { mutationName: this.mutationName } : {}
|
|
1821
|
-
},
|
|
1822
|
-
this.config.clock
|
|
1823
|
-
);
|
|
1824
|
-
this.config.causalTracker?.afterOperation(collectionName, operation.id, true);
|
|
1825
|
-
const serializedData = serializeRecord(validated, definition.fields);
|
|
1826
|
-
const version = serializeRowVersion(operation.timestamp);
|
|
1827
|
-
const record = {
|
|
1828
|
-
id: recordId,
|
|
1829
|
-
...serializedData,
|
|
1830
|
-
_created_at: operation.timestamp.wallTime,
|
|
1831
|
-
_updated_at: operation.timestamp.wallTime,
|
|
1832
|
-
_version: version
|
|
1833
|
-
};
|
|
1834
|
-
const insertQuery = buildInsertQuery(collectionName, record);
|
|
1835
|
-
const opRow = serializeOperation(operation);
|
|
1836
|
-
const opInsert = buildInsertQuery(
|
|
1837
|
-
`_kora_ops_${collectionName}`,
|
|
1838
|
-
opRow
|
|
1839
|
-
);
|
|
1840
|
-
this.buffer.push({
|
|
1841
|
-
operation,
|
|
1842
|
-
collection: collectionName,
|
|
1843
|
-
commands: [
|
|
1844
|
-
{ sql: insertQuery.sql, params: insertQuery.params },
|
|
1845
|
-
{ sql: opInsert.sql, params: opInsert.params }
|
|
1846
|
-
]
|
|
1847
|
-
});
|
|
1848
|
-
return {
|
|
1849
|
-
id: recordId,
|
|
1850
|
-
...validated,
|
|
1851
|
-
createdAt: now,
|
|
1852
|
-
updatedAt: now
|
|
1853
|
-
};
|
|
1854
|
-
}
|
|
1855
|
-
async update(collectionName, definition, id, data) {
|
|
1856
|
-
this.ensureActive();
|
|
1857
|
-
const currentRecord = await this.getEffectiveRecord(collectionName, definition, id);
|
|
1858
|
-
if (!currentRecord) {
|
|
1859
|
-
throw new RecordNotFoundError(collectionName, id);
|
|
1860
|
-
}
|
|
1861
|
-
const validated = validateRecord(collectionName, definition, data, "update");
|
|
1862
|
-
const now = Date.now();
|
|
1863
|
-
const previousData = {};
|
|
1864
|
-
const resolvedData = {};
|
|
1865
|
-
const atomicOps = {};
|
|
1866
|
-
for (const key of Object.keys(validated)) {
|
|
1867
|
-
const value = validated[key];
|
|
1868
|
-
previousData[key] = currentRecord[key];
|
|
1869
|
-
if (isAtomicOp(value)) {
|
|
1870
|
-
resolvedData[key] = resolveAtomicOp(currentRecord[key], value);
|
|
1871
|
-
atomicOps[key] = toAtomicOp(value);
|
|
1872
|
-
} else {
|
|
1873
|
-
resolvedData[key] = value;
|
|
1874
|
-
}
|
|
1875
|
-
}
|
|
1876
|
-
const hasAtomicOps = Object.keys(atomicOps).length > 0;
|
|
1877
|
-
const sequenceNumber = await this.config.sequenceAllocator.allocate();
|
|
1878
|
-
const causalDeps = this.config.causalTracker?.nextCausalDeps(collectionName, true) ?? [];
|
|
1879
|
-
const operation = await createOperation2(
|
|
1880
|
-
{
|
|
1881
|
-
nodeId: this.config.nodeId,
|
|
1882
|
-
type: "update",
|
|
1883
|
-
collection: collectionName,
|
|
1884
|
-
recordId: id,
|
|
1885
|
-
// Binary richtext values (new and previous) are tagged as canonical
|
|
1886
|
-
// JSON before the operation is content-hashed.
|
|
1887
|
-
data: encodeRichtextFieldsForOpData(resolvedData, definition.fields),
|
|
1888
|
-
previousData: encodeRichtextFieldsForOpData(previousData, definition.fields),
|
|
1889
|
-
sequenceNumber,
|
|
1890
|
-
causalDeps,
|
|
1891
|
-
schemaVersion: this.config.schema.version,
|
|
1892
|
-
transactionId: this.transactionId,
|
|
1893
|
-
...this.mutationName !== void 0 ? { mutationName: this.mutationName } : {},
|
|
1894
|
-
...hasAtomicOps ? { atomicOps } : {}
|
|
1895
|
-
},
|
|
1896
|
-
this.config.clock
|
|
1897
|
-
);
|
|
1898
|
-
this.config.causalTracker?.afterOperation(collectionName, operation.id, true);
|
|
1899
|
-
const serializedChanges = serializeRecord(resolvedData, definition.fields);
|
|
1900
|
-
const version = serializeRowVersion(operation.timestamp);
|
|
1901
|
-
const updateQuery = buildUpdateQuery(collectionName, id, {
|
|
1902
|
-
...serializedChanges,
|
|
1903
|
-
_updated_at: operation.timestamp.wallTime,
|
|
1904
|
-
_version: version
|
|
1905
|
-
});
|
|
1906
|
-
const opRow = serializeOperation(operation);
|
|
1907
|
-
const opInsert = buildInsertQuery(
|
|
1908
|
-
`_kora_ops_${collectionName}`,
|
|
1909
|
-
opRow
|
|
1910
|
-
);
|
|
1911
|
-
this.buffer.push({
|
|
1912
|
-
operation,
|
|
1913
|
-
collection: collectionName,
|
|
1914
|
-
commands: [
|
|
1915
|
-
{ sql: updateQuery.sql, params: updateQuery.params },
|
|
1916
|
-
{ sql: opInsert.sql, params: opInsert.params }
|
|
1917
|
-
]
|
|
1918
|
-
});
|
|
1919
|
-
return {
|
|
1920
|
-
...currentRecord,
|
|
1921
|
-
...resolvedData,
|
|
1922
|
-
updatedAt: now
|
|
1923
|
-
};
|
|
1924
|
-
}
|
|
1925
|
-
async deleteRecord(collectionName, definition, id) {
|
|
1926
|
-
this.ensureActive();
|
|
1927
|
-
const currentRecord = await this.getEffectiveRecord(collectionName, definition, id);
|
|
1928
|
-
if (!currentRecord) {
|
|
1929
|
-
throw new RecordNotFoundError(collectionName, id);
|
|
1930
|
-
}
|
|
1931
|
-
const now = Date.now();
|
|
1932
|
-
const sequenceNumber = await this.config.sequenceAllocator.allocate();
|
|
1933
|
-
const causalDeps = this.config.causalTracker?.nextCausalDeps(collectionName, true) ?? [];
|
|
1934
|
-
const operation = await createOperation2(
|
|
1935
|
-
{
|
|
1936
|
-
nodeId: this.config.nodeId,
|
|
1937
|
-
type: "delete",
|
|
1938
|
-
collection: collectionName,
|
|
1939
|
-
recordId: id,
|
|
1940
|
-
data: null,
|
|
1941
|
-
previousData: null,
|
|
1942
|
-
sequenceNumber,
|
|
1943
|
-
causalDeps,
|
|
1944
|
-
schemaVersion: this.config.schema.version,
|
|
1945
|
-
transactionId: this.transactionId,
|
|
1946
|
-
...this.mutationName !== void 0 ? { mutationName: this.mutationName } : {}
|
|
1947
|
-
},
|
|
1948
|
-
this.config.clock
|
|
1949
|
-
);
|
|
1950
|
-
this.config.causalTracker?.afterOperation(collectionName, operation.id, true);
|
|
1951
|
-
const deleteQuery = buildSoftDeleteQuery(collectionName, id, now);
|
|
1952
|
-
const opRow = serializeOperation(operation);
|
|
1953
|
-
const opInsert = buildInsertQuery(
|
|
1954
|
-
`_kora_ops_${collectionName}`,
|
|
1955
|
-
opRow
|
|
1956
|
-
);
|
|
1957
|
-
this.buffer.push({
|
|
1958
|
-
operation,
|
|
1959
|
-
collection: collectionName,
|
|
1960
|
-
commands: [
|
|
1961
|
-
{ sql: deleteQuery.sql, params: deleteQuery.params },
|
|
1962
|
-
{ sql: opInsert.sql, params: opInsert.params }
|
|
1963
|
-
]
|
|
1964
|
-
});
|
|
1965
|
-
}
|
|
1966
|
-
async findById(collectionName, definition, id) {
|
|
1967
|
-
return this.getEffectiveRecord(collectionName, definition, id);
|
|
1968
|
-
}
|
|
1969
|
-
/**
|
|
1970
|
-
* Get the effective state of a record, considering both the database and buffered operations.
|
|
1971
|
-
* Buffered inserts/updates take precedence over the database state.
|
|
1972
|
-
*/
|
|
1973
|
-
async getEffectiveRecord(collectionName, definition, id) {
|
|
1974
|
-
for (let i = this.buffer.length - 1; i >= 0; i--) {
|
|
1975
|
-
const entry = this.buffer[i];
|
|
1976
|
-
if (entry.collection === collectionName && entry.operation.recordId === id) {
|
|
1977
|
-
if (entry.operation.type === "delete") {
|
|
1978
|
-
return null;
|
|
1979
|
-
}
|
|
1980
|
-
break;
|
|
1981
|
-
}
|
|
1982
|
-
}
|
|
1983
|
-
const rows = await this.config.adapter.query(
|
|
1984
|
-
`SELECT * FROM ${quoteIdent6(collectionName)} WHERE id = ? AND _deleted = 0`,
|
|
1985
|
-
[id]
|
|
1986
|
-
);
|
|
1987
|
-
let record = rows[0] ? deserializeRecord(rows[0], definition.fields) : null;
|
|
1988
|
-
for (const entry of this.buffer) {
|
|
1989
|
-
if (entry.collection !== collectionName || entry.operation.recordId !== id) {
|
|
1990
|
-
continue;
|
|
1991
|
-
}
|
|
1992
|
-
if (entry.operation.type === "insert" && entry.operation.data) {
|
|
1993
|
-
record = {
|
|
1994
|
-
id,
|
|
1995
|
-
// op.data stores binary richtext as tagged JSON; the effective
|
|
1996
|
-
// record must expose record-shaped values (Uint8Array/string).
|
|
1997
|
-
...decodeRichtextFieldsFromOpData(entry.operation.data, definition.fields),
|
|
1998
|
-
createdAt: entry.operation.timestamp.wallTime,
|
|
1999
|
-
updatedAt: entry.operation.timestamp.wallTime
|
|
2000
|
-
};
|
|
2001
|
-
} else if (entry.operation.type === "update" && entry.operation.data && record) {
|
|
2002
|
-
record = {
|
|
2003
|
-
...record,
|
|
2004
|
-
...decodeRichtextFieldsFromOpData(entry.operation.data, definition.fields),
|
|
2005
|
-
updatedAt: entry.operation.timestamp.wallTime
|
|
2006
|
-
};
|
|
2007
|
-
} else if (entry.operation.type === "delete") {
|
|
2008
|
-
record = null;
|
|
2009
|
-
}
|
|
2010
|
-
}
|
|
2011
|
-
return record;
|
|
2012
|
-
}
|
|
2013
|
-
};
|
|
2014
|
-
|
|
2015
|
-
// src/transaction/transaction-sequence.ts
|
|
2016
|
-
var TransactionSequenceAllocator = class {
|
|
2017
|
-
constructor(adapter, nodeId) {
|
|
2018
|
-
this.adapter = adapter;
|
|
2019
|
-
this.nodeId = nodeId;
|
|
2020
|
-
}
|
|
2021
|
-
adapter;
|
|
2022
|
-
nodeId;
|
|
2023
|
-
loaded = false;
|
|
2024
|
-
watermark = 0;
|
|
2025
|
-
async allocate() {
|
|
2026
|
-
if (!this.loaded) {
|
|
2027
|
-
this.watermark = await readSequenceNumber(this.adapter, this.nodeId);
|
|
2028
|
-
this.loaded = true;
|
|
2029
|
-
}
|
|
2030
|
-
this.watermark++;
|
|
2031
|
-
return this.watermark;
|
|
2032
|
-
}
|
|
2033
|
-
getHighWaterMark() {
|
|
2034
|
-
return this.watermark;
|
|
2035
|
-
}
|
|
2036
|
-
};
|
|
2037
|
-
|
|
2038
|
-
// src/store/tab-node-id.ts
|
|
2039
|
-
import { generateUUIDv7 as generateUUIDv72 } from "@korajs/core";
|
|
2040
|
-
function perTabStorageKey(dbName) {
|
|
2041
|
-
return `kora-tab-node-${dbName}`;
|
|
2042
|
-
}
|
|
2043
|
-
function loadPerTabNodeId(dbName) {
|
|
2044
|
-
if (typeof sessionStorage === "undefined") {
|
|
2045
|
-
return null;
|
|
2046
|
-
}
|
|
2047
|
-
return sessionStorage.getItem(perTabStorageKey(dbName));
|
|
2048
|
-
}
|
|
2049
|
-
function savePerTabNodeId(dbName, nodeId) {
|
|
2050
|
-
if (typeof sessionStorage === "undefined") {
|
|
2051
|
-
return;
|
|
2052
|
-
}
|
|
2053
|
-
sessionStorage.setItem(perTabStorageKey(dbName), nodeId);
|
|
2054
|
-
}
|
|
2055
|
-
function resolvePerTabNodeId(dbName) {
|
|
2056
|
-
const existing = loadPerTabNodeId(dbName);
|
|
2057
|
-
if (existing) {
|
|
2058
|
-
return existing;
|
|
2059
|
-
}
|
|
2060
|
-
const nodeId = generateUUIDv72();
|
|
2061
|
-
savePerTabNodeId(dbName, nodeId);
|
|
2062
|
-
return nodeId;
|
|
2063
|
-
}
|
|
2064
|
-
|
|
2065
|
-
// src/store/store.ts
|
|
2066
|
-
var Store = class {
|
|
2067
|
-
opened = false;
|
|
2068
|
-
nodeId = "";
|
|
2069
|
-
sequenceNumber = 0;
|
|
2070
|
-
versionVector = createVersionVector3();
|
|
2071
|
-
clock = null;
|
|
2072
|
-
collections = /* @__PURE__ */ new Map();
|
|
2073
|
-
subscriptionManager;
|
|
2074
|
-
sequenceManager = null;
|
|
2075
|
-
schema;
|
|
2076
|
-
adapter;
|
|
2077
|
-
configNodeId;
|
|
2078
|
-
dbName;
|
|
2079
|
-
isolation;
|
|
2080
|
-
emitter;
|
|
2081
|
-
localMutationHandler;
|
|
2082
|
-
relationEnforcer = null;
|
|
2083
|
-
causalTracker = null;
|
|
2084
|
-
secretKeyProvider;
|
|
2085
|
-
constructor(config) {
|
|
2086
|
-
this.schema = config.schema;
|
|
2087
|
-
this.adapter = config.adapter;
|
|
2088
|
-
this.configNodeId = config.nodeId;
|
|
2089
|
-
this.dbName = config.dbName ?? "kora-db";
|
|
2090
|
-
this.isolation = config.isolation ?? "shared";
|
|
2091
|
-
this.emitter = config.emitter ?? null;
|
|
2092
|
-
this.localMutationHandler = config.localMutationHandler ?? null;
|
|
2093
|
-
this.secretKeyProvider = config.secretKeyProvider;
|
|
2094
|
-
this.subscriptionManager = new SubscriptionManager({
|
|
2095
|
-
onQuerySubscribed: config.onQuerySubscribed
|
|
2096
|
-
});
|
|
2097
|
-
}
|
|
2098
|
-
/**
|
|
2099
|
-
* Open the store: initialize the database, load or generate a node ID,
|
|
2100
|
-
* restore the sequence number and version vector, and create Collection instances.
|
|
2101
|
-
*/
|
|
2102
|
-
async open() {
|
|
2103
|
-
await this.adapter.open(this.schema);
|
|
2104
|
-
await this.adapter.execute(
|
|
2105
|
-
"CREATE TABLE IF NOT EXISTS _kora_scope_retractions (collection TEXT NOT NULL, record_id TEXT NOT NULL, PRIMARY KEY (collection, record_id))"
|
|
2106
|
-
);
|
|
2107
|
-
await this.runMigrationsIfNeeded();
|
|
2108
|
-
this.nodeId = await this.loadOrGenerateNodeId();
|
|
2109
|
-
this.clock = new HybridLogicalClock3(this.nodeId);
|
|
2110
|
-
this.causalTracker = new CausalTracker();
|
|
2111
|
-
this.sequenceManager = new SequenceManager(this.adapter, this.nodeId);
|
|
2112
|
-
this.sequenceNumber = await this.loadSequenceNumber();
|
|
2113
|
-
this.versionVector = await this.loadVersionVector();
|
|
2114
|
-
const hasRelations = Object.keys(this.schema.relations).length > 0;
|
|
2115
|
-
this.relationEnforcer = hasRelations ? new RelationEnforcer({
|
|
2116
|
-
schema: this.schema,
|
|
2117
|
-
adapter: this.adapter,
|
|
2118
|
-
clock: this.clock,
|
|
2119
|
-
nodeId: this.nodeId
|
|
2120
|
-
}) : null;
|
|
2121
|
-
for (const [name, definition] of Object.entries(this.schema.collections)) {
|
|
2122
|
-
const col = new Collection(
|
|
2123
|
-
name,
|
|
2124
|
-
definition,
|
|
2125
|
-
this.schema,
|
|
2126
|
-
this.adapter,
|
|
2127
|
-
this.clock,
|
|
2128
|
-
this.nodeId,
|
|
2129
|
-
() => this.allocateSequenceNumber(),
|
|
2130
|
-
(collectionName, operation) => {
|
|
2131
|
-
this.recordOperationSequence(operation);
|
|
2132
|
-
this.subscriptionManager.notify(collectionName, operation);
|
|
2133
|
-
if (this.emitter) {
|
|
2134
|
-
this.emitter.emit({ type: "operation:created", operation });
|
|
2135
|
-
}
|
|
2136
|
-
},
|
|
2137
|
-
this.relationEnforcer,
|
|
2138
|
-
this.localMutationHandler,
|
|
2139
|
-
this.causalTracker,
|
|
2140
|
-
this.secretKeyProvider
|
|
2141
|
-
);
|
|
2142
|
-
this.collections.set(name, col);
|
|
2143
|
-
}
|
|
2144
|
-
this.opened = true;
|
|
2145
|
-
}
|
|
2146
|
-
/**
|
|
2147
|
-
* Close the store: clear subscriptions and close the adapter.
|
|
2148
|
-
*/
|
|
2149
|
-
async close() {
|
|
2150
|
-
this.subscriptionManager.clear();
|
|
2151
|
-
this.collections.clear();
|
|
2152
|
-
this.opened = false;
|
|
2153
|
-
await this.adapter.close();
|
|
2154
|
-
}
|
|
2155
|
-
/**
|
|
2156
|
-
* Get a Collection instance for CRUD operations.
|
|
2157
|
-
* @throws {StoreNotOpenError} If the store is not open
|
|
2158
|
-
* @throws {Error} If the collection name is not in the schema
|
|
2159
|
-
*/
|
|
2160
|
-
collection(name) {
|
|
2161
|
-
this.ensureOpen();
|
|
2162
|
-
const col = this.collections.get(name);
|
|
2163
|
-
if (!col) {
|
|
2164
|
-
throw new Error(
|
|
2165
|
-
`Unknown collection "${name}". Available: ${[...this.collections.keys()].join(", ")}`
|
|
2166
|
-
);
|
|
2167
|
-
}
|
|
2168
|
-
const definition = this.schema.collections[name];
|
|
2169
|
-
if (!definition) {
|
|
2170
|
-
throw new Error(`Collection definition not found for "${name}"`);
|
|
2171
|
-
}
|
|
2172
|
-
return {
|
|
2173
|
-
insert: (data) => col.insert(data),
|
|
2174
|
-
findById: (id) => col.findById(id),
|
|
2175
|
-
update: (id, data) => col.update(id, data),
|
|
2176
|
-
delete: (id) => col.delete(id),
|
|
2177
|
-
where: (conditions) => new QueryBuilder(
|
|
2178
|
-
name,
|
|
2179
|
-
definition,
|
|
2180
|
-
this.adapter,
|
|
2181
|
-
this.subscriptionManager,
|
|
2182
|
-
conditions,
|
|
2183
|
-
this.schema
|
|
2184
|
-
)
|
|
2185
|
-
};
|
|
2186
|
-
}
|
|
2187
|
-
/**
|
|
2188
|
-
* Get the current version vector.
|
|
2189
|
-
*/
|
|
2190
|
-
getVersionVector() {
|
|
2191
|
-
this.ensureOpen();
|
|
2192
|
-
return new Map(this.versionVector);
|
|
2193
|
-
}
|
|
2194
|
-
/**
|
|
2195
|
-
* Get the node ID for this store instance.
|
|
2196
|
-
*/
|
|
2197
|
-
/**
|
|
2198
|
-
* Records the offset between server time and this device's clock
|
|
2199
|
-
* (serverTime - localTime), learned at sync handshake. Lets the store's HLC
|
|
2200
|
-
* validate remote timestamps correctly even when the local clock is wrong.
|
|
2201
|
-
*/
|
|
2202
|
-
setClockReferenceOffset(offsetMs) {
|
|
2203
|
-
this.clock?.setReferenceOffset(offsetMs);
|
|
2204
|
-
}
|
|
2205
|
-
getNodeId() {
|
|
2206
|
-
this.ensureOpen();
|
|
2207
|
-
return this.nodeId;
|
|
2208
|
-
}
|
|
2209
|
-
/**
|
|
2210
|
-
* Apply a remote operation received from sync.
|
|
2211
|
-
* Checks for duplicates, applies to the data table, persists the operation,
|
|
2212
|
-
* and updates the version vector.
|
|
2213
|
-
*/
|
|
2214
|
-
async applyRemoteOperation(op, options) {
|
|
2215
|
-
this.ensureOpen();
|
|
2216
|
-
const collection = op.collection;
|
|
2217
|
-
const definition = this.schema.collections[collection];
|
|
2218
|
-
if (!definition) {
|
|
2219
|
-
return "skipped";
|
|
2220
|
-
}
|
|
2221
|
-
const existing = await this.adapter.query(
|
|
2222
|
-
`SELECT id FROM ${quoteIdent7(`_kora_ops_${collection}`)} WHERE id = ?`,
|
|
2223
|
-
[op.id]
|
|
2224
|
-
);
|
|
2225
|
-
if (existing.length > 0) {
|
|
2226
|
-
const retracted = await this.adapter.query(
|
|
2227
|
-
"SELECT record_id FROM _kora_scope_retractions WHERE collection = ? AND record_id = ?",
|
|
2228
|
-
[collection, op.recordId]
|
|
2229
|
-
);
|
|
2230
|
-
if (retracted.length > 0) {
|
|
2231
|
-
await this.adapter.transaction(async (tx) => {
|
|
2232
|
-
await tx.execute(`UPDATE ${quoteIdent7(collection)} SET _deleted = 0 WHERE id = ?`, [
|
|
2233
|
-
op.recordId
|
|
2234
|
-
]);
|
|
2235
|
-
await tx.execute(
|
|
2236
|
-
"DELETE FROM _kora_scope_retractions WHERE collection = ? AND record_id = ?",
|
|
2237
|
-
[collection, op.recordId]
|
|
2238
|
-
);
|
|
2239
|
-
});
|
|
2240
|
-
this.subscriptionManager.invalidate(collection);
|
|
2241
|
-
}
|
|
2242
|
-
return "duplicate";
|
|
2243
|
-
}
|
|
2244
|
-
if (this.clock) {
|
|
2245
|
-
this.clock.receive(op.timestamp);
|
|
2246
|
-
}
|
|
2247
|
-
const materializeTimestamp = options?.materializeTimestamp ?? op.timestamp;
|
|
2248
|
-
const remoteVersion = serializeRowVersion(materializeTimestamp);
|
|
2249
|
-
const wallTime = materializeTimestamp.wallTime;
|
|
2250
|
-
const materializeSource = options?.materializeData ?? op.data;
|
|
2251
|
-
await this.adapter.transaction(async (tx) => {
|
|
2252
|
-
const checkGuard = (row) => {
|
|
2253
|
-
const guard = options?.guardRowState;
|
|
2254
|
-
if (!guard) {
|
|
2255
|
-
return;
|
|
2256
|
-
}
|
|
2257
|
-
const version = typeof row?._version === "string" ? row._version : null;
|
|
2258
|
-
const fieldVersions = typeof row?._field_versions === "string" ? row._field_versions : null;
|
|
2259
|
-
if (version !== guard.version || fieldVersions !== guard.fieldVersions) {
|
|
2260
|
-
throw new OptimisticLockError(collection, op.recordId);
|
|
2261
|
-
}
|
|
2262
|
-
};
|
|
2263
|
-
if (options?.logOnly) {
|
|
2264
|
-
} else if (op.type === "insert" && materializeSource) {
|
|
2265
|
-
const serializedData = serializeRecord(materializeSource, definition.fields);
|
|
2266
|
-
const existing2 = await tx.query(
|
|
2267
|
-
`SELECT _updated_at, _version, _field_versions, _deleted FROM ${collection} WHERE id = ?`,
|
|
2268
|
-
[op.recordId]
|
|
2269
|
-
);
|
|
2270
|
-
const row = existing2[0];
|
|
2271
|
-
checkGuard(row);
|
|
2272
|
-
if (!row) {
|
|
2273
|
-
const record = {
|
|
2274
|
-
id: op.recordId,
|
|
2275
|
-
...serializedData,
|
|
2276
|
-
_created_at: wallTime,
|
|
2277
|
-
_updated_at: wallTime,
|
|
2278
|
-
_version: remoteVersion,
|
|
2279
|
-
// Stamp every inserted field so per-field LWW has a real baseline.
|
|
2280
|
-
_field_versions: serializeFieldVersions(
|
|
2281
|
-
fieldVersionsForFields(Object.keys(serializedData), remoteVersion)
|
|
2282
|
-
)
|
|
2283
|
-
};
|
|
2284
|
-
const insertQuery = buildInsertQuery(collection, record);
|
|
2285
|
-
await tx.execute(insertQuery.sql, insertQuery.params);
|
|
2286
|
-
await this.foldOrphanedOperations(tx, collection, definition, op.recordId, op);
|
|
2287
|
-
} else if (row._deleted === 1) {
|
|
2288
|
-
if (isIncomingNewerThanRow(materializeTimestamp, row)) {
|
|
2289
|
-
const fieldChanges = {
|
|
2290
|
-
...serializedData,
|
|
2291
|
-
_deleted: 0,
|
|
2292
|
-
_field_versions: serializeFieldVersions(
|
|
2293
|
-
fieldVersionsForFields(Object.keys(serializedData), remoteVersion)
|
|
2294
|
-
)
|
|
2295
|
-
};
|
|
2296
|
-
const upsert = buildFieldFastForwardUpdateQuery(
|
|
2297
|
-
collection,
|
|
2298
|
-
op.recordId,
|
|
2299
|
-
fieldChanges,
|
|
2300
|
-
remoteVersion,
|
|
2301
|
-
wallTime,
|
|
2302
|
-
{ maxCreatedAt: wallTime }
|
|
2303
|
-
);
|
|
2304
|
-
await tx.execute(upsert.sql, upsert.params);
|
|
2305
|
-
}
|
|
2306
|
-
} else {
|
|
2307
|
-
const currentVersions = parseFieldVersions(row._field_versions);
|
|
2308
|
-
const rowVersion = typeof row._version === "string" ? row._version : void 0;
|
|
2309
|
-
const fieldChanges = {};
|
|
2310
|
-
let fieldVersionsJson;
|
|
2311
|
-
if (options?.forceMaterialize) {
|
|
2312
|
-
const merged = { ...currentVersions };
|
|
2313
|
-
for (const field of Object.keys(serializedData)) {
|
|
2314
|
-
fieldChanges[field] = serializedData[field];
|
|
2315
|
-
merged[field] = remoteVersion;
|
|
2316
|
-
}
|
|
2317
|
-
fieldVersionsJson = serializeFieldVersions(merged);
|
|
2318
|
-
} else {
|
|
2319
|
-
const { winners, merged } = resolvePerFieldLww(
|
|
2320
|
-
currentVersions,
|
|
2321
|
-
Object.keys(serializedData),
|
|
2322
|
-
remoteVersion,
|
|
2323
|
-
rowVersion
|
|
2324
|
-
);
|
|
2325
|
-
for (const field of winners) {
|
|
2326
|
-
fieldChanges[field] = serializedData[field];
|
|
2327
|
-
}
|
|
2328
|
-
fieldVersionsJson = serializeFieldVersions(merged);
|
|
2329
|
-
}
|
|
2330
|
-
const upsert = buildFieldFastForwardUpdateQuery(
|
|
2331
|
-
collection,
|
|
2332
|
-
op.recordId,
|
|
2333
|
-
{ ...fieldChanges, _field_versions: fieldVersionsJson },
|
|
2334
|
-
remoteVersion,
|
|
2335
|
-
wallTime,
|
|
2336
|
-
{ maxCreatedAt: wallTime }
|
|
2337
|
-
);
|
|
2338
|
-
await tx.execute(upsert.sql, upsert.params);
|
|
2339
|
-
}
|
|
2340
|
-
} else if (op.type === "update" && materializeSource) {
|
|
2341
|
-
const serializedChanges = serializeRecord(materializeSource, definition.fields);
|
|
2342
|
-
const changedFields = Object.keys(serializedChanges);
|
|
2343
|
-
const currentRows = await tx.query(
|
|
2344
|
-
`SELECT _version, _field_versions FROM ${quoteIdent7(collection)} WHERE id = ?`,
|
|
2345
|
-
[op.recordId]
|
|
2346
|
-
);
|
|
2347
|
-
const currentRow = currentRows[0];
|
|
2348
|
-
checkGuard(currentRow);
|
|
2349
|
-
if (currentRow) {
|
|
2350
|
-
const currentVersions = parseFieldVersions(currentRow._field_versions);
|
|
2351
|
-
const rowVersion = typeof currentRow._version === "string" ? currentRow._version : void 0;
|
|
2352
|
-
if (options?.forceMaterialize) {
|
|
2353
|
-
const merged = { ...currentVersions };
|
|
2354
|
-
for (const field of changedFields) {
|
|
2355
|
-
merged[field] = remoteVersion;
|
|
2356
|
-
}
|
|
2357
|
-
const fieldChanges = {
|
|
2358
|
-
...serializedChanges,
|
|
2359
|
-
_field_versions: serializeFieldVersions(merged)
|
|
2360
|
-
};
|
|
2361
|
-
if (options?.reactivateIfDeleted) {
|
|
2362
|
-
fieldChanges._deleted = 0;
|
|
2363
|
-
}
|
|
2364
|
-
const forceQuery = buildFieldFastForwardUpdateQuery(
|
|
2365
|
-
collection,
|
|
2366
|
-
op.recordId,
|
|
2367
|
-
fieldChanges,
|
|
2368
|
-
remoteVersion,
|
|
2369
|
-
wallTime
|
|
2370
|
-
);
|
|
2371
|
-
await tx.execute(forceQuery.sql, forceQuery.params);
|
|
2372
|
-
} else {
|
|
2373
|
-
const { winners, merged } = resolvePerFieldLww(
|
|
2374
|
-
currentVersions,
|
|
2375
|
-
changedFields,
|
|
2376
|
-
remoteVersion,
|
|
2377
|
-
rowVersion
|
|
2378
|
-
);
|
|
2379
|
-
if (winners.length > 0 || options?.reactivateIfDeleted) {
|
|
2380
|
-
const winningChanges = {
|
|
2381
|
-
_field_versions: serializeFieldVersions(merged)
|
|
2382
|
-
};
|
|
2383
|
-
for (const field of winners) {
|
|
2384
|
-
winningChanges[field] = serializedChanges[field];
|
|
2385
|
-
}
|
|
2386
|
-
if (options?.reactivateIfDeleted) {
|
|
2387
|
-
winningChanges._deleted = 0;
|
|
2388
|
-
}
|
|
2389
|
-
const forceQuery = buildFieldFastForwardUpdateQuery(
|
|
2390
|
-
collection,
|
|
2391
|
-
op.recordId,
|
|
2392
|
-
winningChanges,
|
|
2393
|
-
remoteVersion,
|
|
2394
|
-
wallTime
|
|
2395
|
-
);
|
|
2396
|
-
await tx.execute(forceQuery.sql, forceQuery.params);
|
|
2397
|
-
}
|
|
2398
|
-
}
|
|
2399
|
-
}
|
|
2400
|
-
} else if (op.type === "delete") {
|
|
2401
|
-
const deleteQuery = buildLwwSoftDeleteQuery(
|
|
2402
|
-
collection,
|
|
2403
|
-
op.recordId,
|
|
2404
|
-
wallTime,
|
|
2405
|
-
remoteVersion
|
|
2406
|
-
);
|
|
2407
|
-
await tx.execute(deleteQuery.sql, deleteQuery.params);
|
|
2408
|
-
}
|
|
2409
|
-
const opRow = serializeOperation(op);
|
|
2410
|
-
const opInsert = buildInsertQuery(
|
|
2411
|
-
`_kora_ops_${collection}`,
|
|
2412
|
-
opRow
|
|
2413
|
-
);
|
|
2414
|
-
await tx.execute(opInsert.sql, opInsert.params);
|
|
2415
|
-
const currentSeq = this.versionVector.get(op.nodeId) ?? 0;
|
|
2416
|
-
if (op.sequenceNumber > currentSeq) {
|
|
2417
|
-
this.versionVector.set(op.nodeId, op.sequenceNumber);
|
|
2418
|
-
await tx.execute(
|
|
2419
|
-
"INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
|
|
2420
|
-
[op.nodeId, op.sequenceNumber]
|
|
2421
|
-
);
|
|
2422
|
-
}
|
|
2423
|
-
});
|
|
2424
|
-
this.subscriptionManager.notify(collection, op);
|
|
2425
|
-
return "applied";
|
|
2426
|
-
}
|
|
2427
|
-
/**
|
|
2428
|
-
* Hide a record from this client's authorized materialized view. This writes no
|
|
2429
|
-
* replicated operation and therefore cannot be mistaken for a domain deletion.
|
|
2430
|
-
* The marker lets a later authoritative backfill reactivate the retained row.
|
|
2431
|
-
*/
|
|
2432
|
-
async applyScopeRetraction(collection, recordId) {
|
|
2433
|
-
this.ensureOpen();
|
|
2434
|
-
if (!this.schema.collections[collection]) return;
|
|
2435
|
-
await this.adapter.transaction(async (tx) => {
|
|
2436
|
-
await tx.execute(`UPDATE ${quoteIdent7(collection)} SET _deleted = 1 WHERE id = ?`, [recordId]);
|
|
2437
|
-
await tx.execute(
|
|
2438
|
-
"INSERT OR REPLACE INTO _kora_scope_retractions (collection, record_id) VALUES (?, ?)",
|
|
2439
|
-
[collection, recordId]
|
|
2440
|
-
);
|
|
2441
|
-
});
|
|
2442
|
-
this.subscriptionManager.invalidate(collection);
|
|
2443
|
-
}
|
|
2444
|
-
/** Hide all live rows that no longer match a newly accepted server scope. */
|
|
2445
|
-
async applyScopeNarrowing(scopes) {
|
|
2446
|
-
this.ensureOpen();
|
|
2447
|
-
const retractions = [];
|
|
2448
|
-
for (const [collection, definition] of Object.entries(this.schema.collections)) {
|
|
2449
|
-
const rows = await this.adapter.query(
|
|
2450
|
-
`SELECT * FROM ${quoteIdent7(collection)} WHERE _deleted = 0`
|
|
2451
|
-
);
|
|
2452
|
-
const predicate = scopes[collection];
|
|
2453
|
-
for (const row of rows) {
|
|
2454
|
-
const record = deserializeRecord(row, definition.fields);
|
|
2455
|
-
const matches = predicate !== void 0 && Object.entries(predicate).every(([field, expected]) => {
|
|
2456
|
-
if (expected && typeof expected === "object" && !Array.isArray(expected) && "$in" in expected) {
|
|
2457
|
-
const values = expected.$in;
|
|
2458
|
-
return Array.isArray(values) && values.some((value) => Object.is(record[field], value));
|
|
2459
|
-
}
|
|
2460
|
-
return Object.is(record[field], expected);
|
|
2461
|
-
});
|
|
2462
|
-
if (!matches) {
|
|
2463
|
-
await this.applyScopeRetraction(collection, String(record.id));
|
|
2464
|
-
retractions.push({ collection, recordId: String(record.id) });
|
|
2465
|
-
}
|
|
2466
|
-
}
|
|
2467
|
-
}
|
|
2468
|
-
return retractions;
|
|
2469
|
-
}
|
|
2470
|
-
/**
|
|
2471
|
-
* Materialize update/delete operations that were logged for a record BEFORE
|
|
2472
|
-
* its insert arrived (reordered delivery). Runs inside the insert's write
|
|
2473
|
-
* transaction, folding each orphan in timestamp order through the exact
|
|
2474
|
-
* per-field LWW / LWW-delete rules the normal apply paths use, so the final
|
|
2475
|
-
* row state equals what a device that received the operations in causal
|
|
2476
|
-
* order computed. Called only when the insert created a previously-absent
|
|
2477
|
-
* row — in that case every logged update/delete for the record is
|
|
2478
|
-
* necessarily an orphan (rows are soft-deleted, never removed, so a row
|
|
2479
|
-
* that is absent now was never materialized).
|
|
2480
|
-
*/
|
|
2481
|
-
async foldOrphanedOperations(tx, collection, definition, recordId, insertOp) {
|
|
2482
|
-
const orphanRows = await tx.query(
|
|
2483
|
-
`SELECT * FROM ${quoteIdent7(`_kora_ops_${collection}`)} WHERE record_id = ? AND type IN ('update', 'delete')`,
|
|
2484
|
-
[recordId]
|
|
2485
|
-
);
|
|
2486
|
-
if (orphanRows.length === 0) {
|
|
2487
|
-
return;
|
|
2488
|
-
}
|
|
2489
|
-
const orphans = orphanRows.map((r) => deserializeOperationWithCollection(r, collection)).sort((a, b) => HybridLogicalClock3.compare(a.timestamp, b.timestamp));
|
|
2490
|
-
for (const orphan of orphans) {
|
|
2491
|
-
const orphanVersion = serializeRowVersion(orphan.timestamp);
|
|
2492
|
-
const orphanWall = orphan.timestamp.wallTime;
|
|
2493
|
-
if (orphan.type === "update" && orphan.data) {
|
|
2494
|
-
const orphanChanges = serializeRecord(orphan.data, definition.fields);
|
|
2495
|
-
const rows = await tx.query(
|
|
2496
|
-
`SELECT _version, _field_versions FROM ${quoteIdent7(collection)} WHERE id = ?`,
|
|
2497
|
-
[recordId]
|
|
2498
|
-
);
|
|
2499
|
-
const current = rows[0];
|
|
2500
|
-
if (!current) {
|
|
2501
|
-
continue;
|
|
2502
|
-
}
|
|
2503
|
-
const { winners, merged } = resolvePerFieldLww(
|
|
2504
|
-
parseFieldVersions(current._field_versions),
|
|
2505
|
-
Object.keys(orphanChanges),
|
|
2506
|
-
orphanVersion,
|
|
2507
|
-
typeof current._version === "string" ? current._version : void 0
|
|
2508
|
-
);
|
|
2509
|
-
if (winners.length > 0) {
|
|
2510
|
-
const winningChanges = {
|
|
2511
|
-
_field_versions: serializeFieldVersions(merged)
|
|
2512
|
-
};
|
|
2513
|
-
for (const field of winners) {
|
|
2514
|
-
winningChanges[field] = orphanChanges[field];
|
|
2515
|
-
}
|
|
2516
|
-
const query = buildFieldFastForwardUpdateQuery(
|
|
2517
|
-
collection,
|
|
2518
|
-
recordId,
|
|
2519
|
-
winningChanges,
|
|
2520
|
-
orphanVersion,
|
|
2521
|
-
orphanWall
|
|
2522
|
-
);
|
|
2523
|
-
await tx.execute(query.sql, query.params);
|
|
2524
|
-
}
|
|
2525
|
-
} else if (orphan.type === "delete") {
|
|
2526
|
-
const query = buildLwwSoftDeleteQuery(collection, recordId, orphanWall, orphanVersion);
|
|
2527
|
-
await tx.execute(query.sql, query.params);
|
|
2528
|
-
}
|
|
2529
|
-
}
|
|
2530
|
-
const atomicFields = /* @__PURE__ */ new Set();
|
|
2531
|
-
for (const orphan of orphans) {
|
|
2532
|
-
if (orphan.type === "update" && orphan.atomicOps) {
|
|
2533
|
-
for (const field of Object.keys(orphan.atomicOps)) {
|
|
2534
|
-
atomicFields.add(field);
|
|
2535
|
-
}
|
|
2536
|
-
}
|
|
2537
|
-
}
|
|
2538
|
-
if (atomicFields.size > 0) {
|
|
2539
|
-
const ordered = [insertOp, ...orphans].sort(
|
|
2540
|
-
(a, b) => HybridLogicalClock3.compare(a.timestamp, b.timestamp)
|
|
2541
|
-
);
|
|
2542
|
-
const folded = replayOperationsForRecord(ordered);
|
|
2543
|
-
if (folded) {
|
|
2544
|
-
const composed = {};
|
|
2545
|
-
for (const field of atomicFields) {
|
|
2546
|
-
if (field in folded) {
|
|
2547
|
-
composed[field] = folded[field];
|
|
2548
|
-
}
|
|
2549
|
-
}
|
|
2550
|
-
const serialized = serializeRecord(composed, definition.fields);
|
|
2551
|
-
const cols = Object.keys(serialized);
|
|
2552
|
-
if (cols.length > 0) {
|
|
2553
|
-
const setClause = cols.map((c) => `${quoteIdent7(c)} = ?`).join(", ");
|
|
2554
|
-
await tx.execute(`UPDATE ${quoteIdent7(collection)} SET ${setClause} WHERE id = ?`, [
|
|
2555
|
-
...cols.map((c) => serialized[c]),
|
|
2556
|
-
recordId
|
|
2557
|
-
]);
|
|
2558
|
-
}
|
|
2559
|
-
}
|
|
2560
|
-
}
|
|
2561
|
-
}
|
|
2562
|
-
/**
|
|
2563
|
-
* Get operations from a node within a sequence number range.
|
|
2564
|
-
* Implements the OperationLog interface for computeDelta.
|
|
2565
|
-
*/
|
|
2566
|
-
async getRange(nodeId, fromSeq, toSeq) {
|
|
2567
|
-
return this.getOperationRange(nodeId, fromSeq, toSeq);
|
|
2568
|
-
}
|
|
2569
|
-
/**
|
|
2570
|
-
* Get operations from a node within a sequence number range.
|
|
2571
|
-
*/
|
|
2572
|
-
async getOperationRange(nodeId, fromSeq, toSeq) {
|
|
2573
|
-
this.ensureOpen();
|
|
2574
|
-
const allOps = [];
|
|
2575
|
-
for (const collectionName of Object.keys(this.schema.collections)) {
|
|
2576
|
-
const rows = await this.adapter.query(
|
|
2577
|
-
`SELECT * FROM ${quoteIdent7(`_kora_ops_${collectionName}`)} WHERE node_id = ? AND sequence_number >= ? AND sequence_number <= ? ORDER BY sequence_number ASC`,
|
|
2578
|
-
[nodeId, fromSeq, toSeq]
|
|
2579
|
-
);
|
|
2580
|
-
for (const row of rows) {
|
|
2581
|
-
allOps.push(deserializeOperationWithCollection(row, collectionName));
|
|
2582
|
-
}
|
|
2583
|
-
}
|
|
2584
|
-
allOps.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
|
2585
|
-
return allOps;
|
|
2586
|
-
}
|
|
2587
|
-
/**
|
|
2588
|
-
* Load every operation from the local append-only log across all collections.
|
|
2589
|
-
* Used by sync delta computation, backup export, and time-travel replay.
|
|
2590
|
-
*/
|
|
2591
|
-
async getAllOperations() {
|
|
2592
|
-
this.ensureOpen();
|
|
2593
|
-
const allOps = [];
|
|
2594
|
-
for (const collectionName of Object.keys(this.schema.collections)) {
|
|
2595
|
-
const rows = await this.adapter.query(
|
|
2596
|
-
`SELECT * FROM ${quoteIdent7(`_kora_ops_${collectionName}`)} ORDER BY sequence_number ASC`
|
|
2597
|
-
);
|
|
2598
|
-
for (const row of rows) {
|
|
2599
|
-
allOps.push(deserializeOperationWithCollection(row, collectionName));
|
|
2600
|
-
}
|
|
2601
|
-
}
|
|
2602
|
-
return allOps;
|
|
2603
|
-
}
|
|
2604
|
-
/**
|
|
2605
|
-
* Rebuild an in-memory snapshot of materialized state at a causal cut in the op log.
|
|
2606
|
-
* Does not mutate the live store — intended for DevTools time-travel inspection.
|
|
2607
|
-
*
|
|
2608
|
-
* @param operationId - Content-addressed id of the operation to replay through (inclusive)
|
|
2609
|
-
* @throws {OperationError} When the operation id is not present in the local log
|
|
2610
|
-
*/
|
|
2611
|
-
async replayTo(operationId) {
|
|
2612
|
-
this.ensureOpen();
|
|
2613
|
-
const start = Date.now();
|
|
2614
|
-
const allOps = await this.getAllOperations();
|
|
2615
|
-
const snapshot = buildReplaySnapshot(this.schema, allOps, operationId);
|
|
2616
|
-
if (this.emitter) {
|
|
2617
|
-
this.emitter.emit({
|
|
2618
|
-
type: "replay:completed",
|
|
2619
|
-
targetOperationId: operationId,
|
|
2620
|
-
operationsApplied: snapshot.operationsApplied.length,
|
|
2621
|
-
duration: Date.now() - start
|
|
2622
|
-
});
|
|
2623
|
-
}
|
|
2624
|
-
return snapshot;
|
|
2625
|
-
}
|
|
2626
|
-
/**
|
|
2627
|
-
* Persist a merge trace to the durable audit log.
|
|
2628
|
-
*/
|
|
2629
|
-
async appendAuditTrace(trace) {
|
|
2630
|
-
this.ensureOpen();
|
|
2631
|
-
const { appendAuditTrace: append } = await import("./audit-trace-store-AM4EWQNZ.js");
|
|
2632
|
-
await append(this.adapter, trace);
|
|
2633
|
-
}
|
|
2634
|
-
/**
|
|
2635
|
-
* Read persisted audit traces with optional filters.
|
|
2636
|
-
*/
|
|
2637
|
-
async getAuditTraces(query) {
|
|
2638
|
-
this.ensureOpen();
|
|
2639
|
-
const { readAuditTraces: readAuditTraces2 } = await import("./audit-trace-store-AM4EWQNZ.js");
|
|
2640
|
-
return readAuditTraces2(this.adapter, query);
|
|
2641
|
-
}
|
|
2642
|
-
/**
|
|
2643
|
-
* Export operations and merge traces as a portable audit bundle.
|
|
2644
|
-
*/
|
|
2645
|
-
async exportAudit(options) {
|
|
2646
|
-
this.ensureOpen();
|
|
2647
|
-
const { exportAudit: doExport } = await import("./export-audit-ODGQTQIK.js");
|
|
2648
|
-
return doExport(this.adapter, this.schema, this.nodeId, this.schema.version, options);
|
|
2649
|
-
}
|
|
2650
|
-
/**
|
|
2651
|
-
* Get the schema definition.
|
|
2652
|
-
*/
|
|
2653
|
-
getSchema() {
|
|
2654
|
-
return this.schema;
|
|
2655
|
-
}
|
|
2656
|
-
/**
|
|
2657
|
-
* Route local CRUD through the unified apply pipeline (korajs ApplyPipeline).
|
|
2658
|
-
*/
|
|
2659
|
-
setLocalMutationHandler(handler) {
|
|
2660
|
-
this.localMutationHandler = handler;
|
|
2661
|
-
for (const col of this.collections.values()) {
|
|
2662
|
-
col.setMutationHandler(handler);
|
|
2663
|
-
}
|
|
2664
|
-
}
|
|
2665
|
-
/**
|
|
2666
|
-
* Notify this store that another same-origin runtime committed an operation to
|
|
2667
|
-
* the shared local database. The operation has already been persisted and
|
|
2668
|
-
* materialized by the database owner; this method only advances in-memory causal
|
|
2669
|
-
* watermarks and invalidates local reactive queries so already-open tabs stay
|
|
2670
|
-
* live without reapplying or rewriting the operation.
|
|
2671
|
-
*/
|
|
2672
|
-
notifyExternalOperation(operation) {
|
|
2673
|
-
this.ensureOpen();
|
|
2674
|
-
const definition = this.schema.collections[operation.collection];
|
|
2675
|
-
if (!definition) {
|
|
2676
|
-
return;
|
|
2677
|
-
}
|
|
2678
|
-
this.recordOperationSequence(operation);
|
|
2679
|
-
this.subscriptionManager.notify(operation.collection, operation);
|
|
2680
|
-
}
|
|
2681
|
-
/**
|
|
2682
|
-
* Build mutation context for a collection (used by ApplyPipeline side effects).
|
|
2683
|
-
*/
|
|
2684
|
-
createMutationContext(collection, options) {
|
|
2685
|
-
this.ensureOpen();
|
|
2686
|
-
const definition = this.schema.collections[collection];
|
|
2687
|
-
if (!definition || !this.clock) {
|
|
2688
|
-
throw new StoreNotOpenError();
|
|
2689
|
-
}
|
|
2690
|
-
return {
|
|
2691
|
-
collection,
|
|
2692
|
-
definition,
|
|
2693
|
-
schema: this.schema,
|
|
2694
|
-
adapter: this.adapter,
|
|
2695
|
-
clock: this.clock,
|
|
2696
|
-
nodeId: this.nodeId,
|
|
2697
|
-
allocateSequenceNumber: () => this.allocateSequenceNumber(),
|
|
2698
|
-
onMutation: (collectionName, operation) => {
|
|
2699
|
-
this.recordOperationSequence(operation);
|
|
2700
|
-
this.subscriptionManager.notify(collectionName, operation);
|
|
2701
|
-
if (this.emitter) {
|
|
2702
|
-
this.emitter.emit({ type: "operation:created", operation });
|
|
2703
|
-
}
|
|
2704
|
-
},
|
|
2705
|
-
relationEnforcer: this.relationEnforcer,
|
|
2706
|
-
causalTracker: this.causalTracker,
|
|
2707
|
-
inTransaction: options?.inTransaction ?? false,
|
|
2708
|
-
extraCausalDeps: options?.extraCausalDeps,
|
|
2709
|
-
secretKeyProvider: this.secretKeyProvider
|
|
2710
|
-
};
|
|
2711
|
-
}
|
|
2712
|
-
/**
|
|
2713
|
-
* Load a materialized row by ID, including soft-deleted tombstones.
|
|
2714
|
-
*/
|
|
2715
|
-
async findMaterializedRow(collection, recordId) {
|
|
2716
|
-
this.ensureOpen();
|
|
2717
|
-
const definition = this.schema.collections[collection];
|
|
2718
|
-
if (!definition) {
|
|
2719
|
-
return null;
|
|
2720
|
-
}
|
|
2721
|
-
const rows = await this.adapter.query(
|
|
2722
|
-
`SELECT * FROM ${quoteIdent7(collection)} WHERE id = ?`,
|
|
2723
|
-
[recordId]
|
|
2724
|
-
);
|
|
2725
|
-
const row = rows[0];
|
|
2726
|
-
if (!row) {
|
|
2727
|
-
return null;
|
|
2728
|
-
}
|
|
2729
|
-
return {
|
|
2730
|
-
record: deserializeRecord(row, definition.fields),
|
|
2731
|
-
deleted: row._deleted === 1
|
|
2732
|
-
};
|
|
2733
|
-
}
|
|
2734
|
-
/**
|
|
2735
|
-
* Raw version state of a materialized row, for optimistic-concurrency guarded
|
|
2736
|
-
* applies: capture this snapshot before computing a merge, pass it as
|
|
2737
|
-
* `guardRowState`, and the apply refuses to write if the row changed since.
|
|
2738
|
-
* Returns null when no row exists (which is itself a valid guard state — the
|
|
2739
|
-
* apply then requires the row to still be absent).
|
|
2740
|
-
*/
|
|
2741
|
-
async getRowVersionState(collection, recordId) {
|
|
2742
|
-
this.ensureOpen();
|
|
2743
|
-
if (!this.schema.collections[collection]) {
|
|
2744
|
-
return null;
|
|
2745
|
-
}
|
|
2746
|
-
const rows = await this.adapter.query(
|
|
2747
|
-
`SELECT _version, _field_versions FROM ${quoteIdent7(collection)} WHERE id = ?`,
|
|
2748
|
-
[recordId]
|
|
2749
|
-
);
|
|
2750
|
-
const row = rows[0];
|
|
2751
|
-
if (!row) {
|
|
2752
|
-
return null;
|
|
2753
|
-
}
|
|
2754
|
-
return {
|
|
2755
|
-
version: typeof row._version === "string" ? row._version : null,
|
|
2756
|
-
fieldVersions: typeof row._field_versions === "string" ? row._field_versions : null
|
|
2757
|
-
};
|
|
2758
|
-
}
|
|
2759
|
-
/**
|
|
2760
|
-
* Latest operation from this device for a record (used for delete-vs-update merge on sync).
|
|
2761
|
-
*/
|
|
2762
|
-
/**
|
|
2763
|
-
* Load the last server version vector acknowledged by this client (persisted in `_kora_meta`).
|
|
2764
|
-
*/
|
|
2765
|
-
async loadLastAckedServerVector() {
|
|
2766
|
-
this.ensureOpen();
|
|
2767
|
-
return loadLastAckedServerVector(this.adapter);
|
|
2768
|
-
}
|
|
2769
|
-
/**
|
|
2770
|
-
* Persist the last server version vector this client believes the server has applied.
|
|
2771
|
-
*/
|
|
2772
|
-
async saveLastAckedServerVector(vector) {
|
|
2773
|
-
this.ensureOpen();
|
|
2774
|
-
await saveLastAckedServerVector(this.adapter, vector);
|
|
2775
|
-
}
|
|
2776
|
-
/**
|
|
2777
|
-
* Load persisted delta cursor for resuming paginated initial sync.
|
|
2778
|
-
*/
|
|
2779
|
-
async loadDeltaCursor() {
|
|
2780
|
-
this.ensureOpen();
|
|
2781
|
-
return loadDeltaCursor(this.adapter);
|
|
2782
|
-
}
|
|
2783
|
-
/**
|
|
2784
|
-
* Persist or clear the delta cursor for paginated initial sync resume.
|
|
2785
|
-
*/
|
|
2786
|
-
async saveDeltaCursor(cursor) {
|
|
2787
|
-
this.ensureOpen();
|
|
2788
|
-
await saveDeltaCursor(this.adapter, cursor);
|
|
2789
|
-
}
|
|
2790
|
-
/**
|
|
2791
|
-
* Load the persisted delivery watermark for a view signature (0 when none recorded).
|
|
2792
|
-
* Defaults to the unfiltered view so existing single-watermark reads keep working.
|
|
2793
|
-
*/
|
|
2794
|
-
async loadDeliveryWatermark(signature = "") {
|
|
2795
|
-
this.ensureOpen();
|
|
2796
|
-
return loadDeliveryWatermark(this.adapter, signature);
|
|
2797
|
-
}
|
|
2798
|
-
/**
|
|
2799
|
-
* Persist the delivery watermark for a view signature so it survives a client restart.
|
|
2800
|
-
*/
|
|
2801
|
-
async saveDeliveryWatermark(signature, watermark) {
|
|
2802
|
-
this.ensureOpen();
|
|
2803
|
-
await saveDeliveryWatermark(this.adapter, signature, watermark);
|
|
2804
|
-
}
|
|
2805
|
-
/**
|
|
2806
|
-
* Load every persisted view watermark, keyed by signature.
|
|
2807
|
-
*/
|
|
2808
|
-
async loadAllDeliveryWatermarks() {
|
|
2809
|
-
this.ensureOpen();
|
|
2810
|
-
return loadAllDeliveryWatermarks(this.adapter);
|
|
2811
|
-
}
|
|
2812
|
-
/**
|
|
2813
|
-
* Delete a persisted view watermark (safe: the view back-fills from 0 when next visited).
|
|
2814
|
-
*/
|
|
2815
|
-
async deleteDeliveryWatermark(signature) {
|
|
2816
|
-
this.ensureOpen();
|
|
2817
|
-
await deleteDeliveryWatermark(this.adapter, signature);
|
|
2818
|
-
}
|
|
2819
|
-
/**
|
|
2820
|
-
* Local operations not yet reflected on the server version vector.
|
|
2821
|
-
*/
|
|
2822
|
-
async getUnsyncedOperations(serverVector) {
|
|
2823
|
-
this.ensureOpen();
|
|
2824
|
-
return collectOperationsAheadOfServer(
|
|
2825
|
-
this.getVersionVector(),
|
|
2826
|
-
serverVector,
|
|
2827
|
-
(nodeId, fromSeq, toSeq) => this.getOperationRange(nodeId, fromSeq, toSeq)
|
|
2828
|
-
);
|
|
2829
|
-
}
|
|
2830
|
-
/**
|
|
2831
|
-
* Re-stamp never-acknowledged local operations after a fast device clock was
|
|
2832
|
-
* corrected, so sync can resume immediately. Safe because unacknowledged
|
|
2833
|
-
* operations are private to this device (like unpushed git commits);
|
|
2834
|
-
* acknowledged/shared operations are immutable and never touched.
|
|
2835
|
-
*
|
|
2836
|
-
* After the rewrite the store's HLC is advanced past the highest new
|
|
2837
|
-
* timestamp so subsequent local writes keep sorting after the rebased ops.
|
|
2838
|
-
* No data-change notifications are emitted: materialized values are
|
|
2839
|
-
* unchanged, only version stamps move.
|
|
2840
|
-
*/
|
|
2841
|
-
async rebaseUnsyncedOperations(unsyncedOpIds, correctedNowMs) {
|
|
2842
|
-
this.ensureOpen();
|
|
2843
|
-
const result = await rebaseUnsyncedOperationsInLog(
|
|
2844
|
-
this.adapter,
|
|
2845
|
-
this.schema,
|
|
2846
|
-
unsyncedOpIds,
|
|
2847
|
-
correctedNowMs
|
|
2848
|
-
);
|
|
2849
|
-
if (result.newMaxTimestamp && this.clock) {
|
|
2850
|
-
this.clock.advanceTo(result.newMaxTimestamp);
|
|
2851
|
-
}
|
|
2852
|
-
return result;
|
|
2853
|
-
}
|
|
2854
|
-
/**
|
|
2855
|
-
* Count of local operations ahead of the server version vector.
|
|
2856
|
-
*/
|
|
2857
|
-
async countUnsyncedOperations(serverVector) {
|
|
2858
|
-
const ops = await this.getUnsyncedOperations(serverVector);
|
|
2859
|
-
return ops.length;
|
|
2860
|
-
}
|
|
2861
|
-
/**
|
|
2862
|
-
* Compact the local operation log using materialized rows as the baseline.
|
|
2863
|
-
* Only removes ops the server has acknowledged (per {@link CompactionStrategy}).
|
|
2864
|
-
*/
|
|
2865
|
-
async compact(strategy) {
|
|
2866
|
-
this.ensureOpen();
|
|
2867
|
-
if (strategy.mode === "never") {
|
|
2868
|
-
return compactOperationLog(this.adapter, this.schema, strategy, createVersionVector3());
|
|
2869
|
-
}
|
|
2870
|
-
const serverVector = strategy.serverVector ?? await loadLastAckedServerVector(this.adapter);
|
|
2871
|
-
return compactOperationLog(this.adapter, this.schema, strategy, serverVector);
|
|
2872
|
-
}
|
|
2873
|
-
/**
|
|
2874
|
-
* Merge session remote vector with persisted last-acked vector (max per node).
|
|
2875
|
-
*/
|
|
2876
|
-
mergeServerVectors(sessionVector, persistedVector) {
|
|
2877
|
-
return mergeVersionVectors(persistedVector, sessionVector);
|
|
2878
|
-
}
|
|
2879
|
-
async getLatestLocalOperationForRecord(collection, recordId) {
|
|
2880
|
-
this.ensureOpen();
|
|
2881
|
-
const rows = await this.adapter.query(
|
|
2882
|
-
`SELECT * FROM ${quoteIdent7(`_kora_ops_${collection}`)} WHERE node_id = ? AND record_id = ? ORDER BY sequence_number DESC LIMIT 1`,
|
|
2883
|
-
[this.nodeId, recordId]
|
|
2884
|
-
);
|
|
2885
|
-
const row = rows[0];
|
|
2886
|
-
if (!row) {
|
|
2887
|
-
return null;
|
|
2888
|
-
}
|
|
2889
|
-
return deserializeOperationWithCollection(row, collection);
|
|
2890
|
-
}
|
|
2891
|
-
/**
|
|
2892
|
-
* Latest operation for a record from any node (for 3-way merge when local op log is empty).
|
|
2893
|
-
*/
|
|
2894
|
-
async getLatestOperationForRecord(collection, recordId) {
|
|
2895
|
-
this.ensureOpen();
|
|
2896
|
-
const rows = await this.adapter.query(
|
|
2897
|
-
`SELECT * FROM ${quoteIdent7(`_kora_ops_${collection}`)} WHERE record_id = ?`,
|
|
2898
|
-
[recordId]
|
|
2899
|
-
);
|
|
2900
|
-
let latest = null;
|
|
2901
|
-
for (const row of rows) {
|
|
2902
|
-
const op = deserializeOperationWithCollection(row, collection);
|
|
2903
|
-
if (!latest || HybridLogicalClock3.compare(op.timestamp, latest.timestamp) > 0) {
|
|
2904
|
-
latest = op;
|
|
2905
|
-
}
|
|
2906
|
-
}
|
|
2907
|
-
return latest;
|
|
2908
|
-
}
|
|
2909
|
-
/**
|
|
2910
|
-
* All operations for a record, sorted in HLC total order (wallTime, logical,
|
|
2911
|
-
* nodeId). Used by the apply pipeline to materialize atomic-op fields by folding
|
|
2912
|
-
* the record's log, matching the server's materialization exactly.
|
|
2913
|
-
*/
|
|
2914
|
-
async getOperationsForRecord(collection, recordId) {
|
|
2915
|
-
this.ensureOpen();
|
|
2916
|
-
const rows = await this.adapter.query(
|
|
2917
|
-
`SELECT * FROM ${quoteIdent7(`_kora_ops_${collection}`)} WHERE record_id = ?`,
|
|
2918
|
-
[recordId]
|
|
2919
|
-
);
|
|
2920
|
-
const ops = rows.map((row) => deserializeOperationWithCollection(row, collection));
|
|
2921
|
-
ops.sort((a, b) => HybridLogicalClock3.compare(a.timestamp, b.timestamp));
|
|
2922
|
-
return ops;
|
|
2923
|
-
}
|
|
2924
|
-
/** Expose the subscription manager for direct access (e.g., by QueryBuilder) */
|
|
2925
|
-
getSubscriptionManager() {
|
|
2926
|
-
return this.subscriptionManager;
|
|
10
|
+
Collection,
|
|
11
|
+
QueryBuilder,
|
|
12
|
+
SequenceManager,
|
|
13
|
+
Store,
|
|
14
|
+
SubscriptionBloomFilter,
|
|
15
|
+
SubscriptionManager,
|
|
16
|
+
TransactionContext,
|
|
17
|
+
compactOperationLog,
|
|
18
|
+
computeAckCompactionWatermark,
|
|
19
|
+
normalizeWhere,
|
|
20
|
+
pluralize,
|
|
21
|
+
queryKey,
|
|
22
|
+
singularize
|
|
23
|
+
} from "./chunk-5AQSGMJV.js";
|
|
24
|
+
import {
|
|
25
|
+
BlobIntegrityError,
|
|
26
|
+
MemoryBlobStore
|
|
27
|
+
} from "./chunk-W4MTTNEZ.js";
|
|
28
|
+
import {
|
|
29
|
+
InvalidStateTransitionError,
|
|
30
|
+
validateStateTransition,
|
|
31
|
+
validateUpdateStateMachine
|
|
32
|
+
} from "./chunk-BILSA6XZ.js";
|
|
33
|
+
import "./chunk-A6GFH5IG.js";
|
|
34
|
+
import {
|
|
35
|
+
BACKUP_VERSION,
|
|
36
|
+
BackupFormatError,
|
|
37
|
+
COMPACTION_BASELINE_META_KEY,
|
|
38
|
+
LOG_QUARANTINE_TABLE,
|
|
39
|
+
convertBackupV1,
|
|
40
|
+
exportBackup,
|
|
41
|
+
readBackupManifest,
|
|
42
|
+
restoreBackup,
|
|
43
|
+
verifyBackupChecksum
|
|
44
|
+
} from "./chunk-UC2LGFPV.js";
|
|
45
|
+
import {
|
|
46
|
+
mergeYjsUpdates
|
|
47
|
+
} from "./chunk-KTVNANUH.js";
|
|
48
|
+
import {
|
|
49
|
+
LAST_ACKED_SERVER_VECTOR_META_KEY,
|
|
50
|
+
VIRTUAL_TIMESTAMP_FIELDS,
|
|
51
|
+
VIRTUAL_TIMESTAMP_FIELD_NAMES,
|
|
52
|
+
collectOperationsAheadOfServer,
|
|
53
|
+
deserializeVersionVectorFromMeta,
|
|
54
|
+
mergeVersionVectors,
|
|
55
|
+
serializeVersionVectorToMeta
|
|
56
|
+
} from "./chunk-QW4UU7NG.js";
|
|
57
|
+
import {
|
|
58
|
+
decodeRichtext,
|
|
59
|
+
encodeRichtext,
|
|
60
|
+
richtextStatesEqual,
|
|
61
|
+
richtextToPlainText
|
|
62
|
+
} from "./chunk-J5AGXF3D.js";
|
|
63
|
+
import {
|
|
64
|
+
AdapterError,
|
|
65
|
+
BridgeTerminatedError,
|
|
66
|
+
LeaderUnresponsiveError,
|
|
67
|
+
NoLeaderError,
|
|
68
|
+
NodeOwnedByAnotherUserError,
|
|
69
|
+
OptimisticLockError,
|
|
70
|
+
PersistenceError,
|
|
71
|
+
QueryError,
|
|
72
|
+
RecordNotFoundError,
|
|
73
|
+
RequestAbortedError,
|
|
74
|
+
SchemaVersionAheadError,
|
|
75
|
+
StorageBackendMismatchError,
|
|
76
|
+
StorageDurabilityError,
|
|
77
|
+
StorageInUseError,
|
|
78
|
+
StoreNotOpenError,
|
|
79
|
+
UnsyncedDataError,
|
|
80
|
+
WorkerInitError,
|
|
81
|
+
WorkerTimeoutError
|
|
82
|
+
} from "./chunk-SRHDJF5B.js";
|
|
83
|
+
|
|
84
|
+
// src/persistence/storage-persistence.ts
|
|
85
|
+
function defaultStorageManager() {
|
|
86
|
+
const nav = globalThis.navigator;
|
|
87
|
+
return nav?.storage ?? null;
|
|
88
|
+
}
|
|
89
|
+
function errorMessage(error) {
|
|
90
|
+
return error instanceof Error ? error.message : String(error);
|
|
91
|
+
}
|
|
92
|
+
var StoragePersistence = class {
|
|
93
|
+
current = {
|
|
94
|
+
state: "unknown",
|
|
95
|
+
persisted: false,
|
|
96
|
+
requested: false
|
|
97
|
+
};
|
|
98
|
+
inFlightRequest = null;
|
|
99
|
+
emitter;
|
|
100
|
+
storageOverride;
|
|
101
|
+
constructor(options = {}) {
|
|
102
|
+
this.emitter = options.emitter ?? null;
|
|
103
|
+
this.storageOverride = options.storage;
|
|
2927
104
|
}
|
|
2928
|
-
/**
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
*/
|
|
2932
|
-
getSequenceManager() {
|
|
2933
|
-
this.ensureOpen();
|
|
2934
|
-
if (!this.sequenceManager) {
|
|
2935
|
-
throw new StoreNotOpenError();
|
|
2936
|
-
}
|
|
2937
|
-
return this.sequenceManager;
|
|
105
|
+
/** Synchronous snapshot of the last known state. Never prompts. */
|
|
106
|
+
status() {
|
|
107
|
+
return { ...this.current };
|
|
2938
108
|
}
|
|
2939
109
|
/**
|
|
2940
|
-
*
|
|
2941
|
-
*
|
|
2942
|
-
*
|
|
2943
|
-
* After commit, the caller is responsible for notifying subscriptions
|
|
2944
|
-
* and emitting events for each operation.
|
|
110
|
+
* Read whether storage is already persistent (`navigator.storage.persisted()`).
|
|
111
|
+
* Never prompts and never throws: a failure becomes `state: 'error'`.
|
|
2945
112
|
*/
|
|
2946
|
-
|
|
2947
|
-
this.
|
|
2948
|
-
if (!
|
|
2949
|
-
|
|
113
|
+
async check() {
|
|
114
|
+
const storage = this.storage();
|
|
115
|
+
if (!storage || typeof storage.persisted !== "function") {
|
|
116
|
+
this.update({ state: "unsupported", persisted: false });
|
|
117
|
+
if (storage) this.emit("unsupported", false);
|
|
118
|
+
return this.status();
|
|
2950
119
|
}
|
|
2951
|
-
return new TransactionContext({
|
|
2952
|
-
schema: this.schema,
|
|
2953
|
-
adapter: this.adapter,
|
|
2954
|
-
clock: this.clock,
|
|
2955
|
-
nodeId: this.nodeId,
|
|
2956
|
-
sequenceAllocator: new TransactionSequenceAllocator(this.adapter, this.nodeId),
|
|
2957
|
-
relationEnforcer: this.relationEnforcer,
|
|
2958
|
-
causalTracker: this.causalTracker,
|
|
2959
|
-
localMutationHandler: this.localMutationHandler
|
|
2960
|
-
});
|
|
2961
|
-
}
|
|
2962
|
-
/**
|
|
2963
|
-
* Execute a function within a transaction. All mutations performed on the
|
|
2964
|
-
* TransactionContext are committed atomically. Subscription notifications
|
|
2965
|
-
* are batched and fired after the commit.
|
|
2966
|
-
*
|
|
2967
|
-
* If the function throws, the transaction is rolled back and the error is re-thrown.
|
|
2968
|
-
*
|
|
2969
|
-
* @param fn - Function receiving a TransactionContext for buffered operations
|
|
2970
|
-
* @returns The operations that were committed
|
|
2971
|
-
*/
|
|
2972
|
-
async transaction(fn) {
|
|
2973
|
-
const tx = this.createTransaction();
|
|
2974
|
-
this.causalTracker?.beginTransaction();
|
|
2975
120
|
try {
|
|
2976
|
-
await
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
for (const op of operations) {
|
|
2980
|
-
this.recordOperationSequence(op);
|
|
2981
|
-
this.subscriptionManager.notify(op.collection, op);
|
|
2982
|
-
if (this.emitter) {
|
|
2983
|
-
this.emitter.emit({ type: "operation:created", operation: op });
|
|
2984
|
-
}
|
|
121
|
+
const persisted = await storage.persisted() === true;
|
|
122
|
+
if (!this.current.persisted) {
|
|
123
|
+
this.update({ state: persisted ? "persisted" : "best-effort", persisted });
|
|
2985
124
|
}
|
|
2986
|
-
|
|
125
|
+
this.emit("checked", this.current.persisted);
|
|
2987
126
|
} catch (error) {
|
|
2988
|
-
|
|
2989
|
-
this.
|
|
2990
|
-
throw error;
|
|
127
|
+
this.update({ state: "error", persisted: false, lastError: errorMessage(error) });
|
|
128
|
+
this.emit("error", false, errorMessage(error));
|
|
2991
129
|
}
|
|
130
|
+
return this.status();
|
|
2992
131
|
}
|
|
2993
132
|
/**
|
|
2994
|
-
*
|
|
2995
|
-
*
|
|
2996
|
-
*
|
|
2997
|
-
*
|
|
2998
|
-
* @returns Backup as a Uint8Array
|
|
2999
|
-
*/
|
|
3000
|
-
async exportBackup(options) {
|
|
3001
|
-
this.ensureOpen();
|
|
3002
|
-
const { exportBackup: doExport } = await import("./backup-5S7K2Z35.js");
|
|
3003
|
-
return doExport(this.adapter, this.schema, this.nodeId, this.schema.version, options);
|
|
3004
|
-
}
|
|
3005
|
-
/**
|
|
3006
|
-
* Restore data from a backup binary.
|
|
133
|
+
* Ask the browser for persistent storage (`navigator.storage.persist()`).
|
|
134
|
+
* May show a permission prompt (Firefox), so call it from a user gesture or a
|
|
135
|
+
* meaningful moment, and do not block rendering on it. Concurrent calls share
|
|
136
|
+
* one request. Never throws: a failure becomes `state: 'error'`.
|
|
3007
137
|
*
|
|
3008
|
-
* @
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
138
|
+
* @returns The state after the browser answered
|
|
139
|
+
*/
|
|
140
|
+
request() {
|
|
141
|
+
if (this.inFlightRequest) return this.inFlightRequest;
|
|
142
|
+
const storage = this.storage();
|
|
143
|
+
if (!storage || typeof storage.persist !== "function") {
|
|
144
|
+
this.update({ state: "unsupported", persisted: false });
|
|
145
|
+
if (storage) this.emit("unsupported", false);
|
|
146
|
+
return Promise.resolve(this.status());
|
|
147
|
+
}
|
|
148
|
+
if (this.current.persisted) return Promise.resolve(this.status());
|
|
149
|
+
const persist = storage.persist.bind(storage);
|
|
150
|
+
this.current = { ...this.current, requested: true };
|
|
151
|
+
const pending = (async () => {
|
|
152
|
+
try {
|
|
153
|
+
const persisted = await persist() === true;
|
|
154
|
+
this.update({ state: persisted ? "persisted" : "best-effort", persisted });
|
|
155
|
+
this.emit("requested", persisted);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
this.update({ state: "error", persisted: false, lastError: errorMessage(error) });
|
|
158
|
+
this.emit("error", false, errorMessage(error));
|
|
159
|
+
} finally {
|
|
160
|
+
this.inFlightRequest = null;
|
|
161
|
+
}
|
|
162
|
+
return this.status();
|
|
163
|
+
})();
|
|
164
|
+
this.inFlightRequest = pending;
|
|
165
|
+
return pending;
|
|
3016
166
|
}
|
|
3017
167
|
/**
|
|
3018
|
-
*
|
|
168
|
+
* Fire-and-forget `request()` for an automatic trigger. Never awaited by Kora,
|
|
169
|
+
* so a pending prompt cannot block anything; the outcome arrives as a
|
|
170
|
+
* `storage:persistence` event. Skipped when storage is already persistent or a
|
|
171
|
+
* request was already made this session.
|
|
3019
172
|
*
|
|
3020
|
-
* @param
|
|
3021
|
-
* @returns The backup manifest
|
|
173
|
+
* @param _reason - Which trigger fired (kept for tracing at call sites)
|
|
3022
174
|
*/
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
recordOperationSequence(operation) {
|
|
3027
|
-
const prev = this.versionVector.get(operation.nodeId) ?? 0;
|
|
3028
|
-
if (operation.sequenceNumber > prev) {
|
|
3029
|
-
this.versionVector.set(operation.nodeId, operation.sequenceNumber);
|
|
3030
|
-
}
|
|
3031
|
-
if (operation.nodeId === this.nodeId) {
|
|
3032
|
-
this.sequenceNumber = Math.max(this.sequenceNumber, operation.sequenceNumber);
|
|
3033
|
-
}
|
|
175
|
+
requestInBackground(_reason) {
|
|
176
|
+
if (this.current.persisted || this.current.requested) return;
|
|
177
|
+
void this.request();
|
|
3034
178
|
}
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
this.sequenceNumber = seq;
|
|
3038
|
-
this.versionVector.set(this.nodeId, seq);
|
|
3039
|
-
return seq;
|
|
3040
|
-
}
|
|
3041
|
-
/**
|
|
3042
|
-
* Check the stored schema version and run any pending migrations.
|
|
3043
|
-
* Migrations are applied in version order within a transaction.
|
|
3044
|
-
*/
|
|
3045
|
-
async runMigrationsIfNeeded() {
|
|
3046
|
-
const storedVersion = await this.getStoredSchemaVersion();
|
|
3047
|
-
const targetVersion = this.schema.version;
|
|
3048
|
-
if (storedVersion >= targetVersion) {
|
|
3049
|
-
if (storedVersion === 0) {
|
|
3050
|
-
await this.adapter.execute(
|
|
3051
|
-
"INSERT OR REPLACE INTO _kora_meta (key, value) VALUES ('schema_version', ?)",
|
|
3052
|
-
[String(targetVersion)]
|
|
3053
|
-
);
|
|
3054
|
-
}
|
|
3055
|
-
return;
|
|
3056
|
-
}
|
|
3057
|
-
const migrations = this.schema.migrations ?? {};
|
|
3058
|
-
for (let v = storedVersion + 1; v <= targetVersion; v++) {
|
|
3059
|
-
const migration = migrations[v];
|
|
3060
|
-
if (!migration) continue;
|
|
3061
|
-
const sqlStatements = migrationStepsToSQL(migration.steps);
|
|
3062
|
-
for (const sql of sqlStatements) {
|
|
3063
|
-
try {
|
|
3064
|
-
await this.adapter.execute(sql);
|
|
3065
|
-
} catch (e) {
|
|
3066
|
-
const msg = e.message || "";
|
|
3067
|
-
if (!msg.includes("duplicate column name")) {
|
|
3068
|
-
throw e;
|
|
3069
|
-
}
|
|
3070
|
-
}
|
|
3071
|
-
}
|
|
3072
|
-
const backfillSteps = migration.steps.filter(
|
|
3073
|
-
(s) => s.type === "backfill"
|
|
3074
|
-
);
|
|
3075
|
-
for (const step of backfillSteps) {
|
|
3076
|
-
await this.runBackfill(step.collection, step.transform);
|
|
3077
|
-
}
|
|
3078
|
-
}
|
|
3079
|
-
await this.adapter.execute(
|
|
3080
|
-
"INSERT OR REPLACE INTO _kora_meta (key, value) VALUES ('schema_version', ?)",
|
|
3081
|
-
[String(targetVersion)]
|
|
3082
|
-
);
|
|
179
|
+
storage() {
|
|
180
|
+
return this.storageOverride === void 0 ? defaultStorageManager() : this.storageOverride;
|
|
3083
181
|
}
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
*/
|
|
3087
|
-
async getStoredSchemaVersion() {
|
|
3088
|
-
const rows = await this.adapter.query(
|
|
3089
|
-
"SELECT value FROM _kora_meta WHERE key = 'schema_version'"
|
|
3090
|
-
);
|
|
3091
|
-
return rows[0] ? Number(rows[0].value) : 0;
|
|
182
|
+
update(next) {
|
|
183
|
+
this.current = { ...next, requested: this.current.requested };
|
|
3092
184
|
}
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
`SELECT * FROM ${quoteIdent7(collection)} WHERE _deleted = 0`
|
|
3100
|
-
);
|
|
3101
|
-
await this.adapter.transaction(async (tx) => {
|
|
3102
|
-
for (const row of rows) {
|
|
3103
|
-
const updates = transform(row);
|
|
3104
|
-
const fields = Object.keys(updates);
|
|
3105
|
-
if (fields.length === 0) continue;
|
|
3106
|
-
const setClauses = fields.map((f) => `${quoteIdent7(f)} = ?`).join(", ");
|
|
3107
|
-
const values = fields.map((f) => {
|
|
3108
|
-
const val = updates[f];
|
|
3109
|
-
if (typeof val === "boolean") return val ? 1 : 0;
|
|
3110
|
-
if (Array.isArray(val) || typeof val === "object" && val !== null) {
|
|
3111
|
-
return JSON.stringify(val);
|
|
3112
|
-
}
|
|
3113
|
-
return val;
|
|
3114
|
-
});
|
|
3115
|
-
values.push(row.id);
|
|
3116
|
-
await tx.execute(`UPDATE ${quoteIdent7(collection)} SET ${setClauses} WHERE id = ?`, values);
|
|
3117
|
-
}
|
|
185
|
+
emit(state, persisted, message) {
|
|
186
|
+
this.emitter?.emit({
|
|
187
|
+
type: "storage:persistence",
|
|
188
|
+
state,
|
|
189
|
+
persisted,
|
|
190
|
+
...message !== void 0 ? { message } : {}
|
|
3118
191
|
});
|
|
3119
192
|
}
|
|
3120
|
-
async loadOrGenerateNodeId() {
|
|
3121
|
-
if (this.configNodeId) {
|
|
3122
|
-
if (this.isolation !== "per-tab") {
|
|
3123
|
-
await this.adapter.execute(
|
|
3124
|
-
"INSERT OR REPLACE INTO _kora_meta (key, value) VALUES ('node_id', ?)",
|
|
3125
|
-
[this.configNodeId]
|
|
3126
|
-
);
|
|
3127
|
-
}
|
|
3128
|
-
return this.configNodeId;
|
|
3129
|
-
}
|
|
3130
|
-
if (this.isolation === "per-tab") {
|
|
3131
|
-
return resolvePerTabNodeId(this.dbName);
|
|
3132
|
-
}
|
|
3133
|
-
const rows = await this.adapter.query(
|
|
3134
|
-
"SELECT value FROM _kora_meta WHERE key = 'node_id'"
|
|
3135
|
-
);
|
|
3136
|
-
if (rows[0]) {
|
|
3137
|
-
return rows[0].value;
|
|
3138
|
-
}
|
|
3139
|
-
const newNodeId = generateUUIDv73();
|
|
3140
|
-
await this.adapter.execute("INSERT INTO _kora_meta (key, value) VALUES ('node_id', ?)", [
|
|
3141
|
-
newNodeId
|
|
3142
|
-
]);
|
|
3143
|
-
return newNodeId;
|
|
3144
|
-
}
|
|
3145
|
-
async loadSequenceNumber() {
|
|
3146
|
-
const rows = await this.adapter.query(
|
|
3147
|
-
"SELECT sequence_number FROM _kora_version_vector WHERE node_id = ?",
|
|
3148
|
-
[this.nodeId]
|
|
3149
|
-
);
|
|
3150
|
-
return rows[0]?.sequence_number ?? 0;
|
|
3151
|
-
}
|
|
3152
|
-
async loadVersionVector() {
|
|
3153
|
-
const rows = await this.adapter.query(
|
|
3154
|
-
"SELECT node_id, sequence_number FROM _kora_version_vector"
|
|
3155
|
-
);
|
|
3156
|
-
const vector = createVersionVector3();
|
|
3157
|
-
for (const row of rows) {
|
|
3158
|
-
vector.set(row.node_id, row.sequence_number);
|
|
3159
|
-
}
|
|
3160
|
-
return vector;
|
|
3161
|
-
}
|
|
3162
|
-
ensureOpen() {
|
|
3163
|
-
if (!this.opened) {
|
|
3164
|
-
throw new StoreNotOpenError();
|
|
3165
|
-
}
|
|
3166
|
-
}
|
|
3167
193
|
};
|
|
3168
194
|
|
|
3169
195
|
// src/reactivity/query-store.ts
|
|
@@ -3174,6 +200,7 @@ var QueryStore = class {
|
|
|
3174
200
|
unsubscribeQuery = null;
|
|
3175
201
|
active = false;
|
|
3176
202
|
hasEmittedSnapshot = false;
|
|
203
|
+
error = null;
|
|
3177
204
|
queryBuilder;
|
|
3178
205
|
constructor(queryBuilder) {
|
|
3179
206
|
this.queryBuilder = queryBuilder;
|
|
@@ -3206,21 +233,42 @@ var QueryStore = class {
|
|
|
3206
233
|
hasSnapshot = () => {
|
|
3207
234
|
return this.hasEmittedSnapshot;
|
|
3208
235
|
};
|
|
236
|
+
/**
|
|
237
|
+
* The latest query failure, or null. Set when the query (or a re-run after a
|
|
238
|
+
* write) fails; cleared by the next successful delivery. Listeners are notified
|
|
239
|
+
* on both transitions, so bindings read it next to `getSnapshot()`.
|
|
240
|
+
*/
|
|
241
|
+
getError = () => {
|
|
242
|
+
return this.error;
|
|
243
|
+
};
|
|
3209
244
|
/** Tear down listeners and the underlying query subscription. */
|
|
3210
245
|
destroy() {
|
|
3211
246
|
this.stopSubscription();
|
|
3212
247
|
this.listeners.clear();
|
|
3213
248
|
this.snapshot = EMPTY_ARRAY;
|
|
3214
249
|
this.hasEmittedSnapshot = false;
|
|
250
|
+
this.error = null;
|
|
3215
251
|
}
|
|
3216
252
|
startSubscription() {
|
|
3217
253
|
this.active = true;
|
|
3218
|
-
this.unsubscribeQuery = this.queryBuilder.subscribe(
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
254
|
+
this.unsubscribeQuery = this.queryBuilder.subscribe(
|
|
255
|
+
(results) => {
|
|
256
|
+
if (!this.active) return;
|
|
257
|
+
this.hasEmittedSnapshot = true;
|
|
258
|
+
this.error = null;
|
|
259
|
+
this.snapshot = Object.freeze([...results]);
|
|
260
|
+
this.notifyListeners();
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
// STORE-12: the failure reaches bindings as error state; the last
|
|
264
|
+
// snapshot stays readable and the next success clears the error.
|
|
265
|
+
onError: (failure) => {
|
|
266
|
+
if (!this.active) return;
|
|
267
|
+
this.error = failure.error;
|
|
268
|
+
this.notifyListeners();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
);
|
|
3224
272
|
}
|
|
3225
273
|
stopSubscription() {
|
|
3226
274
|
this.active = false;
|
|
@@ -3291,7 +339,7 @@ var QueryStoreCache = class {
|
|
|
3291
339
|
return this.entries.size;
|
|
3292
340
|
}
|
|
3293
341
|
getKey(queryBuilder) {
|
|
3294
|
-
return `${this.scopeKey}:${
|
|
342
|
+
return `${this.scopeKey}:${queryKey(queryBuilder.getDescriptor())}`;
|
|
3295
343
|
}
|
|
3296
344
|
};
|
|
3297
345
|
var sharedCache = null;
|
|
@@ -3308,7 +356,6 @@ var LOAD_ORIGIN = "kora-load";
|
|
|
3308
356
|
var REMOTE_ORIGIN = "kora-remote";
|
|
3309
357
|
var DOC_CHANNEL_ORIGIN = "kora-doc-channel";
|
|
3310
358
|
var TEXT_KEY = "content";
|
|
3311
|
-
var COMPACT_AFTER_DELTAS = 20;
|
|
3312
359
|
var PERSIST_DEBOUNCE_MS = 400;
|
|
3313
360
|
function createRichTextController(options) {
|
|
3314
361
|
const {
|
|
@@ -3330,16 +377,19 @@ function createRichTextController(options) {
|
|
|
3330
377
|
let canUndo = false;
|
|
3331
378
|
let canRedo = false;
|
|
3332
379
|
let cursors = [];
|
|
380
|
+
let editGeneration = 0;
|
|
381
|
+
let savedGeneration = 0;
|
|
382
|
+
let saving = null;
|
|
383
|
+
let saveAgain = false;
|
|
3333
384
|
const listeners = /* @__PURE__ */ new Set();
|
|
3334
385
|
let snapshot = {
|
|
3335
386
|
ready: false,
|
|
3336
387
|
error: null,
|
|
3337
388
|
canUndo: false,
|
|
3338
389
|
canRedo: false,
|
|
3339
|
-
cursors: []
|
|
390
|
+
cursors: [],
|
|
391
|
+
hasUnsavedChanges: false
|
|
3340
392
|
};
|
|
3341
|
-
const baseUpdateRef = { current: null };
|
|
3342
|
-
const pendingDeltasRef = { current: [] };
|
|
3343
393
|
const docChannelActiveRef = { current: false };
|
|
3344
394
|
let persistTimer = null;
|
|
3345
395
|
let recordUnsubscribe = null;
|
|
@@ -3351,7 +401,8 @@ function createRichTextController(options) {
|
|
|
3351
401
|
error,
|
|
3352
402
|
canUndo,
|
|
3353
403
|
canRedo,
|
|
3354
|
-
cursors: [...cursors]
|
|
404
|
+
cursors: [...cursors],
|
|
405
|
+
hasUnsavedChanges: editGeneration > savedGeneration
|
|
3355
406
|
};
|
|
3356
407
|
};
|
|
3357
408
|
const notify = () => {
|
|
@@ -3422,26 +473,38 @@ function createRichTextController(options) {
|
|
|
3422
473
|
return;
|
|
3423
474
|
}
|
|
3424
475
|
Y.applyUpdate(doc, encoded, REMOTE_ORIGIN);
|
|
3425
|
-
baseUpdateRef.current = encoded;
|
|
3426
|
-
pendingDeltasRef.current = [];
|
|
3427
476
|
syncHistoryState();
|
|
3428
477
|
};
|
|
3429
|
-
const
|
|
3430
|
-
const
|
|
3431
|
-
|
|
3432
|
-
baseUpdateRef.current = snapshot2;
|
|
3433
|
-
pendingDeltasRef.current = [];
|
|
3434
|
-
}
|
|
478
|
+
const persistOnce = async () => {
|
|
479
|
+
const generation = editGeneration;
|
|
480
|
+
const state = Y.encodeStateAsUpdate(doc);
|
|
3435
481
|
try {
|
|
3436
482
|
await collection.update(recordId, {
|
|
3437
|
-
[fieldName]:
|
|
483
|
+
[fieldName]: state
|
|
3438
484
|
});
|
|
485
|
+
if (generation > savedGeneration) savedGeneration = generation;
|
|
486
|
+
error = null;
|
|
3439
487
|
} catch (cause) {
|
|
3440
|
-
|
|
3441
|
-
error = cause instanceof Error ? cause : new Error(String(cause));
|
|
3442
|
-
notify();
|
|
3443
|
-
}
|
|
488
|
+
error = cause instanceof Error ? cause : new Error(String(cause));
|
|
3444
489
|
}
|
|
490
|
+
if (!disposed) notify();
|
|
491
|
+
};
|
|
492
|
+
const flushPersist = () => {
|
|
493
|
+
if (saving) {
|
|
494
|
+
saveAgain = true;
|
|
495
|
+
return saving;
|
|
496
|
+
}
|
|
497
|
+
const run = (async () => {
|
|
498
|
+
do {
|
|
499
|
+
saveAgain = false;
|
|
500
|
+
await persistOnce();
|
|
501
|
+
} while (saveAgain);
|
|
502
|
+
})();
|
|
503
|
+
saving = run;
|
|
504
|
+
void run.finally(() => {
|
|
505
|
+
if (saving === run) saving = null;
|
|
506
|
+
});
|
|
507
|
+
return run;
|
|
3445
508
|
};
|
|
3446
509
|
const schedulePersist = () => {
|
|
3447
510
|
if (persistTimer) {
|
|
@@ -3457,7 +520,7 @@ function createRichTextController(options) {
|
|
|
3457
520
|
if (origin === LOAD_ORIGIN || origin === REMOTE_ORIGIN || origin === DOC_CHANNEL_ORIGIN) {
|
|
3458
521
|
return;
|
|
3459
522
|
}
|
|
3460
|
-
|
|
523
|
+
editGeneration += 1;
|
|
3461
524
|
if (docChannelActiveRef.current && syncEngine) {
|
|
3462
525
|
syncEngine.getRichtextDocChannel?.().send(collectionName, recordId, fieldName, update);
|
|
3463
526
|
schedulePersist();
|
|
@@ -3541,8 +604,6 @@ function createRichTextController(options) {
|
|
|
3541
604
|
target.delete(0, target.length);
|
|
3542
605
|
}, LOAD_ORIGIN);
|
|
3543
606
|
const encoded = encodeRichtextInput(record?.[fieldName]);
|
|
3544
|
-
baseUpdateRef.current = encoded;
|
|
3545
|
-
pendingDeltasRef.current = [];
|
|
3546
607
|
if (encoded) {
|
|
3547
608
|
Y.applyUpdate(doc, encoded, LOAD_ORIGIN);
|
|
3548
609
|
}
|
|
@@ -3585,6 +646,16 @@ function createRichTextController(options) {
|
|
|
3585
646
|
setUser(nextUser) {
|
|
3586
647
|
user = nextUser;
|
|
3587
648
|
},
|
|
649
|
+
retrySave() {
|
|
650
|
+
if (persistTimer) {
|
|
651
|
+
clearTimeout(persistTimer);
|
|
652
|
+
persistTimer = null;
|
|
653
|
+
}
|
|
654
|
+
return flushPersist();
|
|
655
|
+
},
|
|
656
|
+
getUnsavedState() {
|
|
657
|
+
return editGeneration > savedGeneration ? Y.encodeStateAsUpdate(doc) : null;
|
|
658
|
+
},
|
|
3588
659
|
destroy() {
|
|
3589
660
|
if (disposed) {
|
|
3590
661
|
return;
|
|
@@ -3594,14 +665,15 @@ function createRichTextController(options) {
|
|
|
3594
665
|
clearTimeout(persistTimer);
|
|
3595
666
|
persistTimer = null;
|
|
3596
667
|
}
|
|
668
|
+
if (editGeneration > savedGeneration) {
|
|
669
|
+
void flushPersist();
|
|
670
|
+
}
|
|
3597
671
|
doc.off("update", onDocUpdate);
|
|
3598
672
|
recordUnsubscribe?.();
|
|
3599
673
|
docChannelUnsubscribe?.();
|
|
3600
674
|
awarenessUnsubscribe?.();
|
|
3601
675
|
clearCursor();
|
|
3602
676
|
undoManager.destroy();
|
|
3603
|
-
baseUpdateRef.current = null;
|
|
3604
|
-
pendingDeltasRef.current = [];
|
|
3605
677
|
docChannelActiveRef.current = false;
|
|
3606
678
|
listeners.clear();
|
|
3607
679
|
}
|
|
@@ -3622,16 +694,6 @@ function encodeRichtextInput(value) {
|
|
|
3622
694
|
throw new Error("Richtext record value must be a string, Uint8Array, ArrayBuffer, or null.");
|
|
3623
695
|
}
|
|
3624
696
|
}
|
|
3625
|
-
function composeRichtextSnapshot(base, deltas) {
|
|
3626
|
-
const mergedDoc = new Y.Doc();
|
|
3627
|
-
if (base) {
|
|
3628
|
-
Y.applyUpdate(mergedDoc, base);
|
|
3629
|
-
}
|
|
3630
|
-
for (const delta of deltas) {
|
|
3631
|
-
Y.applyUpdate(mergedDoc, delta);
|
|
3632
|
-
}
|
|
3633
|
-
return Y.encodeStateAsUpdate(mergedDoc);
|
|
3634
|
-
}
|
|
3635
697
|
function updatesEqual(left, right) {
|
|
3636
698
|
if (left.length !== right.length) {
|
|
3637
699
|
return false;
|
|
@@ -3711,12 +773,6 @@ function isNotFoundError(error) {
|
|
|
3711
773
|
}
|
|
3712
774
|
async function createOpfsBlobDirectory(rootDirName = "kora-blobs") {
|
|
3713
775
|
const storage = getStorageManager();
|
|
3714
|
-
if (typeof storage.persist === "function") {
|
|
3715
|
-
try {
|
|
3716
|
-
await storage.persist();
|
|
3717
|
-
} catch {
|
|
3718
|
-
}
|
|
3719
|
-
}
|
|
3720
776
|
const opfsRoot = await storage.getDirectory();
|
|
3721
777
|
const root = await opfsRoot.getDirectoryHandle(rootDirName, { create: true });
|
|
3722
778
|
async function shardHandle(shard, create) {
|
|
@@ -4078,10 +1134,19 @@ async function collectBlobGarbage(store, liveRefs, options = {}) {
|
|
|
4078
1134
|
}
|
|
4079
1135
|
|
|
4080
1136
|
// src/blob/blob-chunk-transport.ts
|
|
4081
|
-
import { generateUUIDv7
|
|
1137
|
+
import { generateUUIDv7 } from "@korajs/core";
|
|
4082
1138
|
function createRemoteChunkProvider(port, options = {}) {
|
|
4083
1139
|
const timeoutMs = options.timeoutMs ?? 3e4;
|
|
1140
|
+
const maxThrottleWaitMs = options.maxThrottleWaitMs ?? 10 * 6e4;
|
|
1141
|
+
const minDelayMs = options.minThrottleDelayMs ?? 250;
|
|
1142
|
+
const maxDelayMs = options.maxThrottleDelayMs ?? 3e4;
|
|
4084
1143
|
const pending = /* @__PURE__ */ new Map();
|
|
1144
|
+
const armTimeout = (requestId, hash) => setTimeout(() => {
|
|
1145
|
+
const entry = pending.get(requestId);
|
|
1146
|
+
if (!entry) return;
|
|
1147
|
+
pending.delete(requestId);
|
|
1148
|
+
entry.reject(new Error(`Blob chunk request for ${hash} timed out after ${timeoutMs}ms`));
|
|
1149
|
+
}, timeoutMs);
|
|
4085
1150
|
port.onMessage((message) => {
|
|
4086
1151
|
if (message.type !== "blob-chunk-response") {
|
|
4087
1152
|
return;
|
|
@@ -4090,20 +1155,43 @@ function createRemoteChunkProvider(port, options = {}) {
|
|
|
4090
1155
|
if (!entry) {
|
|
4091
1156
|
return;
|
|
4092
1157
|
}
|
|
4093
|
-
pending.delete(message.requestId);
|
|
4094
1158
|
clearTimeout(entry.timer);
|
|
1159
|
+
if (message.throttled === true) {
|
|
1160
|
+
entry.throttledCount += 1;
|
|
1161
|
+
const backoff = Math.min(maxDelayMs, minDelayMs * 2 ** (entry.throttledCount - 1));
|
|
1162
|
+
const delay = Math.min(maxDelayMs, Math.max(minDelayMs, message.retryAfterMs ?? backoff));
|
|
1163
|
+
if (Date.now() + delay - entry.startedAtMs > maxThrottleWaitMs) {
|
|
1164
|
+
pending.delete(message.requestId);
|
|
1165
|
+
entry.reject(
|
|
1166
|
+
new Error(
|
|
1167
|
+
`Blob chunk request for ${entry.hash} was throttled for longer than ${maxThrottleWaitMs}ms`
|
|
1168
|
+
)
|
|
1169
|
+
);
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
entry.timer = setTimeout(() => {
|
|
1173
|
+
if (pending.get(message.requestId) !== entry) return;
|
|
1174
|
+
entry.timer = armTimeout(message.requestId, entry.hash);
|
|
1175
|
+
port.send({ type: "blob-chunk-request", requestId: message.requestId, hash: entry.hash });
|
|
1176
|
+
}, delay);
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
pending.delete(message.requestId);
|
|
4095
1180
|
entry.resolve(message.bytes);
|
|
4096
1181
|
});
|
|
4097
1182
|
return {
|
|
4098
1183
|
pendingCount: () => pending.size,
|
|
4099
1184
|
getChunk(hash) {
|
|
4100
|
-
const requestId =
|
|
1185
|
+
const requestId = generateUUIDv7();
|
|
4101
1186
|
return new Promise((resolve, reject) => {
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
1187
|
+
pending.set(requestId, {
|
|
1188
|
+
hash,
|
|
1189
|
+
resolve,
|
|
1190
|
+
reject,
|
|
1191
|
+
timer: armTimeout(requestId, hash),
|
|
1192
|
+
startedAtMs: Date.now(),
|
|
1193
|
+
throttledCount: 0
|
|
1194
|
+
});
|
|
4107
1195
|
port.send({ type: "blob-chunk-request", requestId, hash });
|
|
4108
1196
|
});
|
|
4109
1197
|
}
|
|
@@ -4155,14 +1243,20 @@ function createChunkPortPair() {
|
|
|
4155
1243
|
}
|
|
4156
1244
|
export {
|
|
4157
1245
|
AdapterError,
|
|
1246
|
+
BACKUP_VERSION,
|
|
1247
|
+
BackupFormatError,
|
|
4158
1248
|
BlobIntegrityError,
|
|
1249
|
+
BridgeTerminatedError,
|
|
4159
1250
|
COMPACTION_BASELINE_META_KEY,
|
|
4160
1251
|
Collection,
|
|
4161
1252
|
DEFAULT_CHUNK_SIZE,
|
|
4162
1253
|
InvalidStateTransitionError,
|
|
4163
1254
|
LAST_ACKED_SERVER_VECTOR_META_KEY,
|
|
1255
|
+
LOG_QUARANTINE_TABLE,
|
|
1256
|
+
LeaderUnresponsiveError,
|
|
4164
1257
|
MemoryBlobStore,
|
|
4165
1258
|
NoLeaderError,
|
|
1259
|
+
NodeOwnedByAnotherUserError,
|
|
4166
1260
|
OpfsBlobStore,
|
|
4167
1261
|
OptimisticLockError,
|
|
4168
1262
|
PersistenceError,
|
|
@@ -4171,12 +1265,21 @@ export {
|
|
|
4171
1265
|
QueryStore,
|
|
4172
1266
|
QueryStoreCache,
|
|
4173
1267
|
RecordNotFoundError,
|
|
1268
|
+
RequestAbortedError,
|
|
1269
|
+
SchemaVersionAheadError,
|
|
4174
1270
|
SequenceManager,
|
|
1271
|
+
StorageBackendMismatchError,
|
|
1272
|
+
StorageDurabilityError,
|
|
1273
|
+
StorageInUseError,
|
|
1274
|
+
StoragePersistence,
|
|
4175
1275
|
Store,
|
|
4176
1276
|
StoreNotOpenError,
|
|
4177
1277
|
SubscriptionBloomFilter,
|
|
4178
1278
|
SubscriptionManager,
|
|
4179
1279
|
TransactionContext,
|
|
1280
|
+
UnsyncedDataError,
|
|
1281
|
+
VIRTUAL_TIMESTAMP_FIELDS,
|
|
1282
|
+
VIRTUAL_TIMESTAMP_FIELD_NAMES,
|
|
4180
1283
|
WorkerInitError,
|
|
4181
1284
|
WorkerTimeoutError,
|
|
4182
1285
|
asRichTextSyncEngine,
|
|
@@ -4186,6 +1289,7 @@ export {
|
|
|
4186
1289
|
collectOperationsAheadOfServer,
|
|
4187
1290
|
compactOperationLog,
|
|
4188
1291
|
computeAckCompactionWatermark,
|
|
1292
|
+
convertBackupV1,
|
|
4189
1293
|
createChunkPortPair,
|
|
4190
1294
|
createMemoryServerBlobStore,
|
|
4191
1295
|
createOpfsBlobDirectory,
|
|
@@ -4201,11 +1305,14 @@ export {
|
|
|
4201
1305
|
fetchBlobManifest,
|
|
4202
1306
|
getSharedQueryStoreCache,
|
|
4203
1307
|
mergeVersionVectors,
|
|
1308
|
+
mergeYjsUpdates,
|
|
1309
|
+
normalizeWhere,
|
|
4204
1310
|
parseBlobManifest,
|
|
4205
1311
|
persistedAuditTraceFromEvent,
|
|
4206
1312
|
pluralize,
|
|
4207
1313
|
prepareBlobForSend,
|
|
4208
1314
|
putBlobForTransfer,
|
|
1315
|
+
queryKey,
|
|
4209
1316
|
readAuditExportManifest,
|
|
4210
1317
|
readBackupManifest,
|
|
4211
1318
|
reassembleBlob,
|