@korajs/store 0.3.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/better-sqlite3.cjs +1 -1
- package/dist/adapters/better-sqlite3.cjs.map +1 -1
- package/dist/adapters/better-sqlite3.d.cts +1 -1
- package/dist/adapters/better-sqlite3.d.ts +1 -1
- package/dist/adapters/better-sqlite3.js +1 -1
- package/dist/adapters/better-sqlite3.js.map +1 -1
- package/dist/adapters/indexeddb.cjs +413 -18
- package/dist/adapters/indexeddb.cjs.map +1 -1
- package/dist/adapters/indexeddb.d.cts +23 -5
- package/dist/adapters/indexeddb.d.ts +23 -5
- package/dist/adapters/indexeddb.js +166 -19
- package/dist/adapters/indexeddb.js.map +1 -1
- package/dist/adapters/sqlite-wasm-shared-host.cjs +68 -0
- package/dist/adapters/sqlite-wasm-shared-host.cjs.map +1 -0
- package/dist/adapters/sqlite-wasm-shared-host.d.cts +2 -0
- package/dist/adapters/sqlite-wasm-shared-host.d.ts +2 -0
- package/dist/adapters/sqlite-wasm-shared-host.js +66 -0
- package/dist/adapters/sqlite-wasm-shared-host.js.map +1 -0
- package/dist/adapters/sqlite-wasm-worker.cjs +34 -5
- package/dist/adapters/sqlite-wasm-worker.cjs.map +1 -1
- package/dist/adapters/sqlite-wasm-worker.js +34 -5
- package/dist/adapters/sqlite-wasm-worker.js.map +1 -1
- package/dist/adapters/sqlite-wasm.cjs +249 -2
- package/dist/adapters/sqlite-wasm.cjs.map +1 -1
- package/dist/adapters/sqlite-wasm.d.cts +15 -3
- package/dist/adapters/sqlite-wasm.d.ts +15 -3
- package/dist/adapters/sqlite-wasm.js +2 -2
- package/dist/audit-trace-store-AM4EWQNZ.js +15 -0
- package/dist/backup-JN3LB4YT.js +14 -0
- package/dist/backup-JN3LB4YT.js.map +1 -0
- package/dist/chunk-56LL4XP6.js +205 -0
- package/dist/chunk-56LL4XP6.js.map +1 -0
- package/dist/chunk-6AHYGQQT.js +425 -0
- package/dist/chunk-6AHYGQQT.js.map +1 -0
- package/dist/chunk-7BPETY5Y.js +570 -0
- package/dist/chunk-7BPETY5Y.js.map +1 -0
- package/dist/chunk-AGN3CIPB.js +188 -0
- package/dist/chunk-AGN3CIPB.js.map +1 -0
- package/dist/chunk-EZSTD2XC.js +380 -0
- package/dist/chunk-EZSTD2XC.js.map +1 -0
- package/dist/chunk-MYSJN3DF.js +117 -0
- package/dist/chunk-MYSJN3DF.js.map +1 -0
- package/dist/{chunk-DXKLAQ6P.js → chunk-XO4LMPAH.js} +1 -1
- package/dist/chunk-XO4LMPAH.js.map +1 -0
- package/dist/export-audit-5GV6VRDZ.js +15 -0
- package/dist/export-audit-5GV6VRDZ.js.map +1 -0
- package/dist/index.cjs +3351 -510
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +822 -50
- package/dist/index.d.ts +822 -50
- package/dist/index.js +1976 -558
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +789 -0
- package/dist/internal.cjs.map +1 -0
- package/dist/internal.d.cts +154 -0
- package/dist/internal.d.ts +154 -0
- package/dist/internal.js +48 -0
- package/dist/internal.js.map +1 -0
- package/dist/{sqlite-wasm-channel-46AOWNPM.js → sqlite-wasm-channel-HDDW6UBH.js} +2 -2
- package/dist/sqlite-wasm-channel-HDDW6UBH.js.map +1 -0
- package/dist/{sqlite-wasm-channel-Lakjuk2E.d.cts → sqlite-wasm-channel-I9y_jObg.d.cts} +1 -0
- package/dist/{sqlite-wasm-channel-Lakjuk2E.d.ts → sqlite-wasm-channel-I9y_jObg.d.ts} +1 -0
- package/dist/types-BMyHmwvn.d.ts +163 -0
- package/dist/types-CTr00g_d.d.cts +163 -0
- package/dist/types-DepnRDk_.d.cts +212 -0
- package/dist/types-DepnRDk_.d.ts +212 -0
- package/package.json +23 -2
- package/dist/chunk-DXKLAQ6P.js.map +0 -1
- package/dist/chunk-ZP5AXQ3Z.js +0 -179
- package/dist/chunk-ZP5AXQ3Z.js.map +0 -1
- package/dist/types-DF-KDSK1.d.cts +0 -106
- package/dist/types-DF-KDSK1.d.ts +0 -106
- /package/dist/{sqlite-wasm-channel-46AOWNPM.js.map → audit-trace-store-AM4EWQNZ.js.map} +0 -0
package/dist/index.cjs
CHANGED
|
@@ -5,6 +5,9 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
|
5
5
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
6
|
var __getProtoOf = Object.getPrototypeOf;
|
|
7
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __esm = (fn, res) => function __init() {
|
|
9
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
10
|
+
};
|
|
8
11
|
var __export = (target, all) => {
|
|
9
12
|
for (var name in all)
|
|
10
13
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
@@ -27,237 +30,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
27
30
|
));
|
|
28
31
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
32
|
|
|
30
|
-
// src/index.ts
|
|
31
|
-
var index_exports = {};
|
|
32
|
-
__export(index_exports, {
|
|
33
|
-
AdapterError: () => AdapterError,
|
|
34
|
-
Collection: () => Collection,
|
|
35
|
-
PersistenceError: () => PersistenceError,
|
|
36
|
-
QueryBuilder: () => QueryBuilder,
|
|
37
|
-
QueryError: () => QueryError,
|
|
38
|
-
RecordNotFoundError: () => RecordNotFoundError,
|
|
39
|
-
Store: () => Store,
|
|
40
|
-
StoreNotOpenError: () => StoreNotOpenError,
|
|
41
|
-
SubscriptionManager: () => SubscriptionManager,
|
|
42
|
-
WorkerInitError: () => WorkerInitError,
|
|
43
|
-
WorkerTimeoutError: () => WorkerTimeoutError,
|
|
44
|
-
decodeRichtext: () => decodeRichtext,
|
|
45
|
-
encodeRichtext: () => encodeRichtext,
|
|
46
|
-
pluralize: () => pluralize,
|
|
47
|
-
richtextToPlainText: () => richtextToPlainText,
|
|
48
|
-
singularize: () => singularize
|
|
49
|
-
});
|
|
50
|
-
module.exports = __toCommonJS(index_exports);
|
|
51
|
-
|
|
52
|
-
// src/errors.ts
|
|
53
|
-
var import_core = require("@korajs/core");
|
|
54
|
-
var QueryError = class extends import_core.KoraError {
|
|
55
|
-
constructor(message, context) {
|
|
56
|
-
super(message, "QUERY_ERROR", context);
|
|
57
|
-
this.name = "QueryError";
|
|
58
|
-
}
|
|
59
|
-
};
|
|
60
|
-
var RecordNotFoundError = class extends import_core.KoraError {
|
|
61
|
-
constructor(collection, recordId) {
|
|
62
|
-
super(`Record "${recordId}" not found in collection "${collection}"`, "RECORD_NOT_FOUND", {
|
|
63
|
-
collection,
|
|
64
|
-
recordId
|
|
65
|
-
});
|
|
66
|
-
this.name = "RecordNotFoundError";
|
|
67
|
-
}
|
|
68
|
-
};
|
|
69
|
-
var AdapterError = class extends import_core.KoraError {
|
|
70
|
-
constructor(message, context) {
|
|
71
|
-
super(message, "ADAPTER_ERROR", context);
|
|
72
|
-
this.name = "AdapterError";
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
var StoreNotOpenError = class extends import_core.KoraError {
|
|
76
|
-
constructor() {
|
|
77
|
-
super("Store is not open. Call store.open() before performing operations.", "STORE_NOT_OPEN");
|
|
78
|
-
this.name = "StoreNotOpenError";
|
|
79
|
-
}
|
|
80
|
-
};
|
|
81
|
-
var WorkerInitError = class extends import_core.KoraError {
|
|
82
|
-
constructor(message, context) {
|
|
83
|
-
super(`Worker initialization failed: ${message}`, "WORKER_INIT_ERROR", context);
|
|
84
|
-
this.name = "WorkerInitError";
|
|
85
|
-
}
|
|
86
|
-
};
|
|
87
|
-
var WorkerTimeoutError = class extends import_core.KoraError {
|
|
88
|
-
constructor(operation, timeoutMs) {
|
|
89
|
-
super(
|
|
90
|
-
`Worker did not respond within ${timeoutMs}ms for operation "${operation}"`,
|
|
91
|
-
"WORKER_TIMEOUT",
|
|
92
|
-
{ operation, timeoutMs }
|
|
93
|
-
);
|
|
94
|
-
this.name = "WorkerTimeoutError";
|
|
95
|
-
}
|
|
96
|
-
};
|
|
97
|
-
var PersistenceError = class extends import_core.KoraError {
|
|
98
|
-
constructor(message, context) {
|
|
99
|
-
super(`Persistence error: ${message}`, "PERSISTENCE_ERROR", context);
|
|
100
|
-
this.name = "PersistenceError";
|
|
101
|
-
}
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
// src/store/store.ts
|
|
105
|
-
var import_core4 = require("@korajs/core");
|
|
106
|
-
|
|
107
|
-
// src/collection/collection.ts
|
|
108
|
-
var import_core3 = require("@korajs/core");
|
|
109
|
-
|
|
110
|
-
// src/query/sql-builder.ts
|
|
111
|
-
function buildSelectQuery(descriptor, fields) {
|
|
112
|
-
const params = [];
|
|
113
|
-
const parts = [`SELECT * FROM ${descriptor.collection}`];
|
|
114
|
-
const whereClause = buildWhereClauseParts(descriptor.where, fields, params);
|
|
115
|
-
const deletedFilter = "_deleted = 0";
|
|
116
|
-
if (whereClause) {
|
|
117
|
-
parts.push(`WHERE ${deletedFilter} AND ${whereClause}`);
|
|
118
|
-
} else {
|
|
119
|
-
parts.push(`WHERE ${deletedFilter}`);
|
|
120
|
-
}
|
|
121
|
-
if (descriptor.orderBy.length > 0) {
|
|
122
|
-
const orderParts = descriptor.orderBy.map((o) => {
|
|
123
|
-
validateFieldName(o.field, fields);
|
|
124
|
-
return `${o.field} ${o.direction.toUpperCase()}`;
|
|
125
|
-
});
|
|
126
|
-
parts.push(`ORDER BY ${orderParts.join(", ")}`);
|
|
127
|
-
}
|
|
128
|
-
if (descriptor.limit !== void 0) {
|
|
129
|
-
parts.push(`LIMIT ${descriptor.limit}`);
|
|
130
|
-
}
|
|
131
|
-
if (descriptor.offset !== void 0) {
|
|
132
|
-
parts.push(`OFFSET ${descriptor.offset}`);
|
|
133
|
-
}
|
|
134
|
-
return { sql: parts.join(" "), params };
|
|
135
|
-
}
|
|
136
|
-
function buildCountQuery(descriptor, fields) {
|
|
137
|
-
const params = [];
|
|
138
|
-
const parts = [`SELECT COUNT(*) as count FROM ${descriptor.collection}`];
|
|
139
|
-
const whereClause = buildWhereClauseParts(descriptor.where, fields, params);
|
|
140
|
-
const deletedFilter = "_deleted = 0";
|
|
141
|
-
if (whereClause) {
|
|
142
|
-
parts.push(`WHERE ${deletedFilter} AND ${whereClause}`);
|
|
143
|
-
} else {
|
|
144
|
-
parts.push(`WHERE ${deletedFilter}`);
|
|
145
|
-
}
|
|
146
|
-
return { sql: parts.join(" "), params };
|
|
147
|
-
}
|
|
148
|
-
function buildInsertQuery(collection, record) {
|
|
149
|
-
const columns = Object.keys(record);
|
|
150
|
-
const placeholders = columns.map(() => "?");
|
|
151
|
-
const params = Object.values(record);
|
|
152
|
-
const sql = `INSERT INTO ${collection} (${columns.join(", ")}) VALUES (${placeholders.join(", ")})`;
|
|
153
|
-
return { sql, params };
|
|
154
|
-
}
|
|
155
|
-
function buildUpdateQuery(collection, id, changes) {
|
|
156
|
-
const setClauses = Object.keys(changes).map((col) => `${col} = ?`);
|
|
157
|
-
const params = [...Object.values(changes), id];
|
|
158
|
-
const sql = `UPDATE ${collection} SET ${setClauses.join(", ")} WHERE id = ?`;
|
|
159
|
-
return { sql, params };
|
|
160
|
-
}
|
|
161
|
-
function buildSoftDeleteQuery(collection, id, updatedAt) {
|
|
162
|
-
return {
|
|
163
|
-
sql: `UPDATE ${collection} SET _deleted = 1, _updated_at = ? WHERE id = ?`,
|
|
164
|
-
params: [updatedAt, id]
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
var VALID_OPERATORS = /* @__PURE__ */ new Set(["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in"]);
|
|
168
|
-
function buildWhereClauseParts(where, fields, params) {
|
|
169
|
-
const conditions = [];
|
|
170
|
-
for (const [fieldName, value] of Object.entries(where)) {
|
|
171
|
-
validateFieldName(fieldName, fields);
|
|
172
|
-
const descriptor = fields[fieldName];
|
|
173
|
-
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
174
|
-
const ops = value;
|
|
175
|
-
for (const [op, opValue] of Object.entries(ops)) {
|
|
176
|
-
if (!VALID_OPERATORS.has(op)) {
|
|
177
|
-
throw new QueryError(`Unknown operator "${op}" on field "${fieldName}"`, {
|
|
178
|
-
field: fieldName,
|
|
179
|
-
operator: op,
|
|
180
|
-
validOperators: [...VALID_OPERATORS]
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
conditions.push(buildOperatorCondition(fieldName, op, opValue, descriptor, params));
|
|
184
|
-
}
|
|
185
|
-
} else {
|
|
186
|
-
conditions.push(buildOperatorCondition(fieldName, "$eq", value, descriptor, params));
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
if (conditions.length === 0) return null;
|
|
190
|
-
return conditions.join(" AND ");
|
|
191
|
-
}
|
|
192
|
-
function buildOperatorCondition(fieldName, operator, value, descriptor, params) {
|
|
193
|
-
const sqlValue = descriptor?.kind === "boolean" && typeof value === "boolean" ? value ? 1 : 0 : value;
|
|
194
|
-
switch (operator) {
|
|
195
|
-
case "$eq":
|
|
196
|
-
if (sqlValue === null) {
|
|
197
|
-
return `${fieldName} IS NULL`;
|
|
198
|
-
}
|
|
199
|
-
params.push(sqlValue);
|
|
200
|
-
return `${fieldName} = ?`;
|
|
201
|
-
case "$ne":
|
|
202
|
-
if (sqlValue === null) {
|
|
203
|
-
return `${fieldName} IS NOT NULL`;
|
|
204
|
-
}
|
|
205
|
-
params.push(sqlValue);
|
|
206
|
-
return `${fieldName} != ?`;
|
|
207
|
-
case "$gt":
|
|
208
|
-
params.push(sqlValue);
|
|
209
|
-
return `${fieldName} > ?`;
|
|
210
|
-
case "$gte":
|
|
211
|
-
params.push(sqlValue);
|
|
212
|
-
return `${fieldName} >= ?`;
|
|
213
|
-
case "$lt":
|
|
214
|
-
params.push(sqlValue);
|
|
215
|
-
return `${fieldName} < ?`;
|
|
216
|
-
case "$lte":
|
|
217
|
-
params.push(sqlValue);
|
|
218
|
-
return `${fieldName} <= ?`;
|
|
219
|
-
case "$in": {
|
|
220
|
-
if (!Array.isArray(sqlValue)) {
|
|
221
|
-
throw new QueryError(`$in operator requires an array value for field "${fieldName}"`, {
|
|
222
|
-
field: fieldName,
|
|
223
|
-
received: typeof sqlValue
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
const placeholders = sqlValue.map(() => "?");
|
|
227
|
-
for (const item of sqlValue) {
|
|
228
|
-
params.push(
|
|
229
|
-
descriptor?.kind === "boolean" && typeof item === "boolean" ? item ? 1 : 0 : item
|
|
230
|
-
);
|
|
231
|
-
}
|
|
232
|
-
return `${fieldName} IN (${placeholders.join(", ")})`;
|
|
233
|
-
}
|
|
234
|
-
default:
|
|
235
|
-
throw new QueryError(`Unknown operator "${operator}"`, { operator });
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
function validateFieldName(fieldName, fields) {
|
|
239
|
-
const allowedFields = /* @__PURE__ */ new Set([
|
|
240
|
-
...Object.keys(fields),
|
|
241
|
-
"id",
|
|
242
|
-
"createdAt",
|
|
243
|
-
"updatedAt",
|
|
244
|
-
"_created_at",
|
|
245
|
-
"_updated_at"
|
|
246
|
-
]);
|
|
247
|
-
if (!allowedFields.has(fieldName)) {
|
|
248
|
-
throw new QueryError(
|
|
249
|
-
`Unknown field "${fieldName}" in query. Available fields: ${[...allowedFields].join(", ")}`,
|
|
250
|
-
{ field: fieldName }
|
|
251
|
-
);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// src/serialization/serializer.ts
|
|
256
|
-
var import_core2 = require("@korajs/core");
|
|
257
|
-
|
|
258
33
|
// src/serialization/richtext-serializer.ts
|
|
259
|
-
var Y = __toESM(require("yjs"), 1);
|
|
260
|
-
var TEXT_KEY = "content";
|
|
261
34
|
function encodeRichtext(value) {
|
|
262
35
|
if (value === null || value === void 0) {
|
|
263
36
|
return null;
|
|
@@ -288,7 +61,9 @@ function decodeRichtext(value) {
|
|
|
288
61
|
if (value instanceof ArrayBuffer) {
|
|
289
62
|
return new Uint8Array(value);
|
|
290
63
|
}
|
|
291
|
-
throw new Error(
|
|
64
|
+
throw new Error(
|
|
65
|
+
"Richtext storage value must be Uint8Array, ArrayBuffer, Buffer, null, or undefined."
|
|
66
|
+
);
|
|
292
67
|
}
|
|
293
68
|
function richtextToPlainText(value) {
|
|
294
69
|
const encoded = encodeRichtext(value);
|
|
@@ -297,6 +72,21 @@ function richtextToPlainText(value) {
|
|
|
297
72
|
Y.applyUpdate(doc, encoded);
|
|
298
73
|
return doc.getText(TEXT_KEY).toString();
|
|
299
74
|
}
|
|
75
|
+
function richtextStatesEqual(a, b) {
|
|
76
|
+
try {
|
|
77
|
+
return richtextToPlainText(a) === richtextToPlainText(b);
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
var Y, TEXT_KEY;
|
|
83
|
+
var init_richtext_serializer = __esm({
|
|
84
|
+
"src/serialization/richtext-serializer.ts"() {
|
|
85
|
+
"use strict";
|
|
86
|
+
Y = __toESM(require("yjs"), 1);
|
|
87
|
+
TEXT_KEY = "content";
|
|
88
|
+
}
|
|
89
|
+
});
|
|
300
90
|
|
|
301
91
|
// src/serialization/serializer.ts
|
|
302
92
|
function serializeRecord(data, fields) {
|
|
@@ -328,20 +118,60 @@ function deserializeRecord(row, fields) {
|
|
|
328
118
|
return result;
|
|
329
119
|
}
|
|
330
120
|
function serializeOperation(op) {
|
|
121
|
+
const hasMetadata = op.transactionId !== void 0 || op.mutationName !== void 0;
|
|
122
|
+
let dataPayload = null;
|
|
123
|
+
if (op.data) {
|
|
124
|
+
dataPayload = { ...op.data };
|
|
125
|
+
if (op.atomicOps !== void 0 && Object.keys(op.atomicOps).length > 0) {
|
|
126
|
+
dataPayload[ATOMIC_OPS_KEY] = op.atomicOps;
|
|
127
|
+
}
|
|
128
|
+
if (op.transactionId !== void 0) {
|
|
129
|
+
dataPayload[TX_ID_KEY] = op.transactionId;
|
|
130
|
+
}
|
|
131
|
+
if (op.mutationName !== void 0) {
|
|
132
|
+
dataPayload[MUTATION_NAME_KEY] = op.mutationName;
|
|
133
|
+
}
|
|
134
|
+
} else if (hasMetadata) {
|
|
135
|
+
dataPayload = {};
|
|
136
|
+
if (op.transactionId !== void 0) {
|
|
137
|
+
dataPayload[TX_ID_KEY] = op.transactionId;
|
|
138
|
+
}
|
|
139
|
+
if (op.mutationName !== void 0) {
|
|
140
|
+
dataPayload[MUTATION_NAME_KEY] = op.mutationName;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
331
143
|
return {
|
|
332
144
|
id: op.id,
|
|
333
145
|
node_id: op.nodeId,
|
|
334
146
|
type: op.type,
|
|
335
147
|
record_id: op.recordId,
|
|
336
|
-
data:
|
|
148
|
+
data: dataPayload ? JSON.stringify(dataPayload) : null,
|
|
337
149
|
previous_data: op.previousData ? JSON.stringify(op.previousData) : null,
|
|
338
|
-
timestamp:
|
|
150
|
+
timestamp: import_core4.HybridLogicalClock.serialize(op.timestamp),
|
|
339
151
|
sequence_number: op.sequenceNumber,
|
|
340
152
|
causal_deps: JSON.stringify(op.causalDeps),
|
|
341
153
|
schema_version: op.schemaVersion
|
|
342
154
|
};
|
|
343
155
|
}
|
|
344
156
|
function deserializeOperation(row) {
|
|
157
|
+
let data = null;
|
|
158
|
+
let atomicOps;
|
|
159
|
+
let transactionId;
|
|
160
|
+
let mutationName;
|
|
161
|
+
if (row.data) {
|
|
162
|
+
const parsed = JSON.parse(row.data);
|
|
163
|
+
if (ATOMIC_OPS_KEY in parsed) {
|
|
164
|
+
atomicOps = parsed[ATOMIC_OPS_KEY];
|
|
165
|
+
}
|
|
166
|
+
if (TX_ID_KEY in parsed) {
|
|
167
|
+
transactionId = parsed[TX_ID_KEY];
|
|
168
|
+
}
|
|
169
|
+
if (MUTATION_NAME_KEY in parsed) {
|
|
170
|
+
mutationName = parsed[MUTATION_NAME_KEY];
|
|
171
|
+
}
|
|
172
|
+
const { [ATOMIC_OPS_KEY]: _a, [TX_ID_KEY]: _t, [MUTATION_NAME_KEY]: _m, ...rest } = parsed;
|
|
173
|
+
data = Object.keys(rest).length > 0 ? rest : null;
|
|
174
|
+
}
|
|
345
175
|
return {
|
|
346
176
|
id: row.id,
|
|
347
177
|
nodeId: row.node_id,
|
|
@@ -349,12 +179,15 @@ function deserializeOperation(row) {
|
|
|
349
179
|
collection: "",
|
|
350
180
|
// Collection name is derived from the table name by the caller
|
|
351
181
|
recordId: row.record_id,
|
|
352
|
-
data
|
|
182
|
+
data,
|
|
353
183
|
previousData: row.previous_data ? JSON.parse(row.previous_data) : null,
|
|
354
|
-
timestamp:
|
|
184
|
+
timestamp: import_core4.HybridLogicalClock.deserialize(row.timestamp),
|
|
355
185
|
sequenceNumber: row.sequence_number,
|
|
356
186
|
causalDeps: JSON.parse(row.causal_deps),
|
|
357
|
-
schemaVersion: row.schema_version
|
|
187
|
+
schemaVersion: row.schema_version,
|
|
188
|
+
...atomicOps !== void 0 ? { atomicOps } : {},
|
|
189
|
+
...transactionId !== void 0 ? { transactionId } : {},
|
|
190
|
+
...mutationName !== void 0 ? { mutationName } : {}
|
|
358
191
|
};
|
|
359
192
|
}
|
|
360
193
|
function deserializeOperationWithCollection(row, collection) {
|
|
@@ -391,219 +224,1571 @@ function deserializeValue(value, descriptor) {
|
|
|
391
224
|
return value;
|
|
392
225
|
}
|
|
393
226
|
}
|
|
227
|
+
var import_core4, ATOMIC_OPS_KEY, TX_ID_KEY, MUTATION_NAME_KEY;
|
|
228
|
+
var init_serializer = __esm({
|
|
229
|
+
"src/serialization/serializer.ts"() {
|
|
230
|
+
"use strict";
|
|
231
|
+
import_core4 = require("@korajs/core");
|
|
232
|
+
init_richtext_serializer();
|
|
233
|
+
ATOMIC_OPS_KEY = "__kora_atomic_ops__";
|
|
234
|
+
TX_ID_KEY = "__kora_tx_id__";
|
|
235
|
+
MUTATION_NAME_KEY = "__kora_mutation__";
|
|
236
|
+
}
|
|
237
|
+
});
|
|
394
238
|
|
|
395
|
-
// src/
|
|
396
|
-
var
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
239
|
+
// src/backup/backup.ts
|
|
240
|
+
var backup_exports = {};
|
|
241
|
+
__export(backup_exports, {
|
|
242
|
+
exportBackup: () => exportBackup,
|
|
243
|
+
readBackupManifest: () => readBackupManifest,
|
|
244
|
+
restoreBackup: () => restoreBackup,
|
|
245
|
+
verifyBackupChecksum: () => verifyBackupChecksum
|
|
246
|
+
});
|
|
247
|
+
function encodeSection(name, content) {
|
|
248
|
+
const nameBytes = new TextEncoder().encode(name);
|
|
249
|
+
const header = new Uint8Array(8);
|
|
250
|
+
const dv = new DataView(header.buffer);
|
|
251
|
+
dv.setUint32(0, nameBytes.length, true);
|
|
252
|
+
dv.setUint32(4, content.length, true);
|
|
253
|
+
const result = new Uint8Array(header.length + nameBytes.length + content.length);
|
|
254
|
+
result.set(header, 0);
|
|
255
|
+
result.set(nameBytes, 8);
|
|
256
|
+
result.set(content, 8 + nameBytes.length);
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
function encodeJsonSection(name, data) {
|
|
260
|
+
const json = JSON.stringify(data);
|
|
261
|
+
return encodeSection(name, new TextEncoder().encode(json));
|
|
262
|
+
}
|
|
263
|
+
function readUint32(view, offset) {
|
|
264
|
+
return view.getUint32(offset, true);
|
|
265
|
+
}
|
|
266
|
+
function parseSections(data) {
|
|
267
|
+
const sections = [];
|
|
268
|
+
const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
269
|
+
let offset = 0;
|
|
270
|
+
while (offset + 8 <= data.byteLength) {
|
|
271
|
+
const nameLen = readUint32(dv, offset);
|
|
272
|
+
const contentLen = readUint32(dv, offset + 4);
|
|
273
|
+
offset += 8;
|
|
274
|
+
if (offset + nameLen + contentLen > data.byteLength) break;
|
|
275
|
+
const name = new TextDecoder().decode(data.slice(offset, offset + nameLen));
|
|
276
|
+
offset += nameLen;
|
|
277
|
+
const content = data.slice(offset, offset + contentLen);
|
|
278
|
+
offset += contentLen;
|
|
279
|
+
sections.push({ name, content });
|
|
406
280
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
281
|
+
return sections;
|
|
282
|
+
}
|
|
283
|
+
function findSection(sections, name) {
|
|
284
|
+
for (const s of sections) {
|
|
285
|
+
if (s.name === name) return s.content;
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
function parseJsonSection(sections, name) {
|
|
290
|
+
const content = findSection(sections, name);
|
|
291
|
+
if (!content) return null;
|
|
292
|
+
return JSON.parse(new TextDecoder().decode(content));
|
|
293
|
+
}
|
|
294
|
+
async function exportBackup(adapter, schema, nodeId, schemaVersion, options) {
|
|
295
|
+
const onProgress = options?.onProgress ?? (() => {
|
|
296
|
+
});
|
|
297
|
+
const includeRecords = options?.includeRecords ?? true;
|
|
298
|
+
const collectionFilter = options?.collections;
|
|
299
|
+
const collections = collectionFilter ? Object.keys(schema.collections).filter((c) => collectionFilter.includes(c)) : Object.keys(schema.collections);
|
|
300
|
+
onProgress({ phase: "reading", progress: 0, message: "Reading version vector" });
|
|
301
|
+
const versionVector = await readVersionVector(adapter);
|
|
302
|
+
onProgress({ phase: "reading", progress: 0.1, message: "Reading metadata" });
|
|
303
|
+
const meta = await readMeta(adapter);
|
|
304
|
+
onProgress({ phase: "reading", progress: 0.2, message: "Reading operations" });
|
|
305
|
+
const operations = await readAllOperations(adapter, schema);
|
|
306
|
+
onProgress({ phase: "reading", progress: 0.5, message: "Reading records" });
|
|
307
|
+
const sections = [];
|
|
308
|
+
let allContentForChecksum = new Uint8Array(0);
|
|
309
|
+
const addSection = (name, data) => {
|
|
310
|
+
sections.push(data);
|
|
311
|
+
const newLen = allContentForChecksum.length + data.length;
|
|
312
|
+
const combined = new Uint8Array(newLen);
|
|
313
|
+
combined.set(allContentForChecksum, 0);
|
|
314
|
+
combined.set(data, allContentForChecksum.length);
|
|
315
|
+
allContentForChecksum = combined;
|
|
316
|
+
};
|
|
317
|
+
const vvObj = {};
|
|
318
|
+
for (const [nid, seq] of versionVector) {
|
|
319
|
+
vvObj[nid] = seq;
|
|
320
|
+
}
|
|
321
|
+
addSection("version_vector", encodeJsonSection("version_vector", vvObj));
|
|
322
|
+
const metaObj = {};
|
|
323
|
+
for (const row of meta) {
|
|
324
|
+
metaObj[row.key] = row.value;
|
|
325
|
+
}
|
|
326
|
+
addSection("meta", encodeJsonSection("meta", metaObj));
|
|
327
|
+
const opLines = `${operations.map((op) => JSON.stringify(op)).join("\n")}
|
|
328
|
+
`;
|
|
329
|
+
addSection("operations", encodeSection("operations", new TextEncoder().encode(opLines)));
|
|
330
|
+
let totalRecords = 0;
|
|
331
|
+
if (includeRecords) {
|
|
332
|
+
for (const col of collections) {
|
|
333
|
+
const records = await readCollectionRecords(adapter, col);
|
|
334
|
+
if (records.length > 0) {
|
|
335
|
+
const lines = `${records.map((r) => JSON.stringify(r)).join("\n")}
|
|
336
|
+
`;
|
|
337
|
+
const sectionName = `records:${col}`;
|
|
338
|
+
addSection(sectionName, encodeSection(sectionName, new TextEncoder().encode(lines)));
|
|
339
|
+
totalRecords += records.length;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
onProgress({ phase: "reading", progress: 0.9, message: "Computing checksum" });
|
|
344
|
+
const checksumHex = await computeSha256(allContentForChecksum);
|
|
345
|
+
onProgress({ phase: "writing", progress: 1, message: "Finalizing" });
|
|
346
|
+
const manifest = {
|
|
347
|
+
version: BACKUP_VERSION,
|
|
348
|
+
createdAt: Date.now(),
|
|
349
|
+
nodeId,
|
|
350
|
+
schemaVersion,
|
|
351
|
+
operationCount: operations.length,
|
|
352
|
+
collections,
|
|
353
|
+
includesRecords: includeRecords,
|
|
354
|
+
checksum: checksumHex
|
|
355
|
+
};
|
|
356
|
+
const header = new Uint8Array(0);
|
|
357
|
+
const manifestSection = encodeJsonSection("manifest", manifest);
|
|
358
|
+
const checksumSection = encodeSection("checksum", new TextEncoder().encode(checksumHex));
|
|
359
|
+
const totalLen = manifestSection.length + allContentForChecksum.length + checksumSection.length;
|
|
360
|
+
const result = new Uint8Array(totalLen);
|
|
361
|
+
let pos = 0;
|
|
362
|
+
result.set(manifestSection, pos);
|
|
363
|
+
pos += manifestSection.length;
|
|
364
|
+
result.set(allContentForChecksum, pos);
|
|
365
|
+
pos += allContentForChecksum.length;
|
|
366
|
+
result.set(checksumSection, pos);
|
|
367
|
+
return result;
|
|
368
|
+
}
|
|
369
|
+
function readBackupManifest(data) {
|
|
370
|
+
const sections = parseSections(data);
|
|
371
|
+
const manifest = parseJsonSection(sections, "manifest");
|
|
372
|
+
if (!manifest) {
|
|
373
|
+
throw new Error("Invalid backup: manifest section not found");
|
|
374
|
+
}
|
|
375
|
+
return manifest;
|
|
376
|
+
}
|
|
377
|
+
async function verifyBackupChecksum(data) {
|
|
378
|
+
const sections = parseSections(data);
|
|
379
|
+
const manifest = parseJsonSection(sections, "manifest");
|
|
380
|
+
if (!manifest) return false;
|
|
381
|
+
const storedChecksum = manifest.checksum;
|
|
382
|
+
if (!storedChecksum) return false;
|
|
383
|
+
const manifestSection = findSection(sections, "manifest");
|
|
384
|
+
if (!manifestSection) return false;
|
|
385
|
+
const checksumSection = findSection(sections, "checksum");
|
|
386
|
+
const checksumBytes = checksumSection ? new TextEncoder().encode(manifest.checksum) : new Uint8Array(0);
|
|
387
|
+
const contentSections = sections.filter((s) => s.name !== "manifest" && s.name !== "checksum");
|
|
388
|
+
let contentForHash = new Uint8Array(0);
|
|
389
|
+
for (const section of contentSections) {
|
|
390
|
+
const sectionBytes = encodeSection(section.name, section.content);
|
|
391
|
+
const newLen = contentForHash.length + sectionBytes.length;
|
|
392
|
+
const combined = new Uint8Array(newLen);
|
|
393
|
+
combined.set(contentForHash, 0);
|
|
394
|
+
combined.set(sectionBytes, contentForHash.length);
|
|
395
|
+
contentForHash = combined;
|
|
396
|
+
}
|
|
397
|
+
const computed = await computeSha256(contentForHash);
|
|
398
|
+
return computed === storedChecksum;
|
|
399
|
+
}
|
|
400
|
+
async function restoreBackup(adapter, schema, data, options) {
|
|
401
|
+
const startTime = Date.now();
|
|
402
|
+
const onProgress = options?.onProgress ?? (() => {
|
|
403
|
+
});
|
|
404
|
+
const collectionFilter = options?.collections;
|
|
405
|
+
onProgress({ phase: "verifying", progress: 0, message: "Parsing backup" });
|
|
406
|
+
const sections = parseSections(data);
|
|
407
|
+
const manifest = parseJsonSection(sections, "manifest");
|
|
408
|
+
if (!manifest) {
|
|
409
|
+
return {
|
|
410
|
+
operationsRestored: 0,
|
|
411
|
+
recordsRestored: 0,
|
|
412
|
+
success: false,
|
|
413
|
+
error: "Invalid backup: manifest section not found",
|
|
414
|
+
duration: Date.now() - startTime
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
if (manifest.version !== BACKUP_VERSION) {
|
|
418
|
+
return {
|
|
419
|
+
operationsRestored: 0,
|
|
420
|
+
recordsRestored: 0,
|
|
421
|
+
success: false,
|
|
422
|
+
error: `Unsupported backup version: ${manifest.version}`,
|
|
423
|
+
duration: Date.now() - startTime
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
onProgress({ phase: "verifying", progress: 0.2, message: "Verifying checksum" });
|
|
427
|
+
const valid = await verifyBackupChecksum(data);
|
|
428
|
+
if (!valid) {
|
|
429
|
+
return {
|
|
430
|
+
operationsRestored: 0,
|
|
431
|
+
recordsRestored: 0,
|
|
432
|
+
success: false,
|
|
433
|
+
error: "Backup checksum mismatch: data may be corrupted",
|
|
434
|
+
duration: Date.now() - startTime
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
onProgress({ phase: "restoring", progress: 0.3, message: "Loading operations" });
|
|
438
|
+
const opsContent = findSection(sections, "operations");
|
|
439
|
+
let operations = [];
|
|
440
|
+
if (opsContent) {
|
|
441
|
+
const text = new TextDecoder().decode(opsContent);
|
|
442
|
+
const lines = text.trim().split("\n").filter((l) => l.length > 0);
|
|
443
|
+
operations = lines.map((line) => JSON.parse(line));
|
|
444
|
+
}
|
|
445
|
+
if (collectionFilter) {
|
|
446
|
+
operations = operations.filter((op) => collectionFilter.includes(op.collection));
|
|
447
|
+
}
|
|
448
|
+
const vvData = parseJsonSection(sections, "version_vector");
|
|
449
|
+
const metaData = parseJsonSection(sections, "meta");
|
|
450
|
+
onProgress({ phase: "restoring", progress: 0.4, message: "Applying operations" });
|
|
451
|
+
if (options?.merge) {
|
|
452
|
+
await adapter.transaction(async (tx) => {
|
|
453
|
+
if (vvData) {
|
|
454
|
+
for (const [nid, seq] of Object.entries(vvData)) {
|
|
455
|
+
await tx.execute(
|
|
456
|
+
"INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
|
|
457
|
+
[nid, seq]
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (metaData) {
|
|
462
|
+
for (const [key, value] of Object.entries(metaData)) {
|
|
463
|
+
await tx.execute("INSERT OR REPLACE INTO _kora_meta (key, value) VALUES (?, ?)", [
|
|
464
|
+
key,
|
|
465
|
+
value
|
|
466
|
+
]);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
for (const op of operations) {
|
|
470
|
+
const collection = op.collection;
|
|
471
|
+
const opRow = {
|
|
472
|
+
id: op.id,
|
|
473
|
+
node_id: op.nodeId,
|
|
474
|
+
type: op.type,
|
|
475
|
+
record_id: op.recordId,
|
|
476
|
+
data: op.data !== null ? JSON.stringify(op.data) : null,
|
|
477
|
+
previous_data: op.previousData !== null ? JSON.stringify(op.previousData) : null,
|
|
478
|
+
timestamp: JSON.stringify(op.timestamp),
|
|
479
|
+
sequence_number: op.sequenceNumber,
|
|
480
|
+
causal_deps: JSON.stringify(op.causalDeps),
|
|
481
|
+
schema_version: op.schemaVersion
|
|
482
|
+
};
|
|
483
|
+
await tx.execute(
|
|
484
|
+
`INSERT OR IGNORE INTO _kora_ops_${collection} (id, node_id, type, record_id, data, previous_data, timestamp, sequence_number, causal_deps, schema_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
485
|
+
[
|
|
486
|
+
opRow.id,
|
|
487
|
+
opRow.node_id,
|
|
488
|
+
opRow.type,
|
|
489
|
+
opRow.record_id,
|
|
490
|
+
opRow.data,
|
|
491
|
+
opRow.previous_data,
|
|
492
|
+
opRow.timestamp,
|
|
493
|
+
opRow.sequence_number,
|
|
494
|
+
opRow.causal_deps,
|
|
495
|
+
opRow.schema_version
|
|
496
|
+
]
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
} else {
|
|
501
|
+
await adapter.transaction(async (tx) => {
|
|
502
|
+
for (const collection of Object.keys(schema.collections)) {
|
|
503
|
+
await tx.execute(`DELETE FROM _kora_ops_${collection}`);
|
|
504
|
+
await tx.execute(`DELETE FROM ${collection}`);
|
|
505
|
+
}
|
|
506
|
+
await tx.execute("DELETE FROM _kora_version_vector");
|
|
507
|
+
await tx.execute("DELETE FROM _kora_meta");
|
|
508
|
+
if (vvData) {
|
|
509
|
+
for (const [nid, seq] of Object.entries(vvData)) {
|
|
510
|
+
await tx.execute(
|
|
511
|
+
"INSERT INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
|
|
512
|
+
[nid, seq]
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (metaData) {
|
|
517
|
+
for (const [key, value] of Object.entries(metaData)) {
|
|
518
|
+
await tx.execute("INSERT INTO _kora_meta (key, value) VALUES (?, ?)", [key, value]);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
for (const op of operations) {
|
|
522
|
+
const collection = op.collection;
|
|
523
|
+
await tx.execute(
|
|
524
|
+
`INSERT INTO _kora_ops_${collection} (id, node_id, type, record_id, data, previous_data, timestamp, sequence_number, causal_deps, schema_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
525
|
+
[
|
|
526
|
+
op.id,
|
|
527
|
+
op.nodeId,
|
|
528
|
+
op.type,
|
|
529
|
+
op.recordId,
|
|
530
|
+
op.data !== null ? JSON.stringify(op.data) : null,
|
|
531
|
+
op.previousData !== null ? JSON.stringify(op.previousData) : null,
|
|
532
|
+
JSON.stringify(op.timestamp),
|
|
533
|
+
op.sequenceNumber,
|
|
534
|
+
JSON.stringify(op.causalDeps),
|
|
535
|
+
op.schemaVersion
|
|
536
|
+
]
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
onProgress({ phase: "restoring", progress: 0.8, message: "Restoring records" });
|
|
542
|
+
let recordsRestored = 0;
|
|
543
|
+
if (manifest.includesRecords) {
|
|
544
|
+
for (const col of manifest.collections) {
|
|
545
|
+
if (collectionFilter && !collectionFilter.includes(col)) continue;
|
|
546
|
+
const sectionContent = findSection(sections, `records:${col}`);
|
|
547
|
+
if (!sectionContent) continue;
|
|
548
|
+
const text = new TextDecoder().decode(sectionContent);
|
|
549
|
+
const lines = text.trim().split("\n").filter((l) => l.length > 0);
|
|
550
|
+
const records = lines.map((line) => JSON.parse(line));
|
|
551
|
+
if (records.length === 0) continue;
|
|
552
|
+
await adapter.transaction(async (tx) => {
|
|
553
|
+
for (const record of records) {
|
|
554
|
+
const keys = Object.keys(record);
|
|
555
|
+
const placeholders = keys.map(() => "?").join(", ");
|
|
556
|
+
const values = keys.map((k) => {
|
|
557
|
+
const v = record[k];
|
|
558
|
+
if (typeof v === "boolean") return v ? 1 : 0;
|
|
559
|
+
if (Array.isArray(v) || typeof v === "object" && v !== null) return JSON.stringify(v);
|
|
560
|
+
return v;
|
|
561
|
+
});
|
|
562
|
+
await tx.execute(
|
|
563
|
+
`INSERT OR REPLACE INTO ${col} (${keys.join(", ")}) VALUES (${placeholders})`,
|
|
564
|
+
values
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
recordsRestored += records.length;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
onProgress({ phase: "restoring", progress: 1, message: "Done" });
|
|
572
|
+
return {
|
|
573
|
+
operationsRestored: operations.length,
|
|
574
|
+
recordsRestored,
|
|
575
|
+
success: true,
|
|
576
|
+
duration: Date.now() - startTime
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
async function readVersionVector(adapter) {
|
|
580
|
+
const rows = await adapter.query(
|
|
581
|
+
"SELECT node_id, sequence_number FROM _kora_version_vector"
|
|
582
|
+
);
|
|
583
|
+
const vv = /* @__PURE__ */ new Map();
|
|
584
|
+
for (const row of rows) {
|
|
585
|
+
vv.set(row.node_id, row.sequence_number);
|
|
586
|
+
}
|
|
587
|
+
return vv;
|
|
588
|
+
}
|
|
589
|
+
async function readMeta(adapter) {
|
|
590
|
+
return adapter.query("SELECT key, value FROM _kora_meta");
|
|
591
|
+
}
|
|
592
|
+
async function readAllOperations(adapter, schema) {
|
|
593
|
+
const allOps = [];
|
|
594
|
+
for (const collectionName of Object.keys(schema.collections)) {
|
|
595
|
+
const rows = await adapter.query(
|
|
596
|
+
`SELECT * FROM _kora_ops_${collectionName} ORDER BY sequence_number ASC`
|
|
597
|
+
);
|
|
598
|
+
for (const row of rows) {
|
|
599
|
+
allOps.push(deserializeOperationWithCollection(row, collectionName));
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return allOps;
|
|
603
|
+
}
|
|
604
|
+
async function readCollectionRecords(adapter, collection) {
|
|
605
|
+
return adapter.query(`SELECT * FROM ${collection} WHERE _deleted = 0`);
|
|
606
|
+
}
|
|
607
|
+
async function computeSha256(data) {
|
|
608
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
609
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
610
|
+
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
611
|
+
}
|
|
612
|
+
var import_core5, BACKUP_VERSION;
|
|
613
|
+
var init_backup = __esm({
|
|
614
|
+
"src/backup/backup.ts"() {
|
|
615
|
+
"use strict";
|
|
616
|
+
import_core5 = require("@korajs/core");
|
|
617
|
+
init_serializer();
|
|
618
|
+
BACKUP_VERSION = 1;
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
// src/audit/audit-json.ts
|
|
623
|
+
function auditJsonReplacer(_key, value) {
|
|
624
|
+
if (value instanceof Uint8Array) {
|
|
625
|
+
let binary = "";
|
|
626
|
+
for (let i = 0; i < value.length; i++) {
|
|
627
|
+
binary += String.fromCharCode(value[i]);
|
|
628
|
+
}
|
|
629
|
+
return { [WIRE_BYTES_KEY]: btoa(binary) };
|
|
630
|
+
}
|
|
631
|
+
return value;
|
|
632
|
+
}
|
|
633
|
+
function auditJsonReviver(_key, value) {
|
|
634
|
+
if (value !== null && typeof value === "object" && WIRE_BYTES_KEY in value && typeof value[WIRE_BYTES_KEY] === "string") {
|
|
635
|
+
const encoded = value[WIRE_BYTES_KEY];
|
|
636
|
+
if (typeof encoded !== "string") {
|
|
637
|
+
return value;
|
|
638
|
+
}
|
|
639
|
+
const binary = atob(encoded);
|
|
640
|
+
const bytes = new Uint8Array(binary.length);
|
|
641
|
+
for (let i = 0; i < binary.length; i++) {
|
|
642
|
+
bytes[i] = binary.charCodeAt(i);
|
|
643
|
+
}
|
|
644
|
+
return bytes;
|
|
645
|
+
}
|
|
646
|
+
return value;
|
|
647
|
+
}
|
|
648
|
+
function serializeAuditJson(value) {
|
|
649
|
+
return JSON.stringify(value, auditJsonReplacer);
|
|
650
|
+
}
|
|
651
|
+
function deserializeAuditJson(json) {
|
|
652
|
+
return JSON.parse(json, auditJsonReviver);
|
|
653
|
+
}
|
|
654
|
+
var WIRE_BYTES_KEY;
|
|
655
|
+
var init_audit_json = __esm({
|
|
656
|
+
"src/audit/audit-json.ts"() {
|
|
657
|
+
"use strict";
|
|
658
|
+
WIRE_BYTES_KEY = "__kora_bytes__";
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
// src/audit/audit-trace-store.ts
|
|
663
|
+
var audit_trace_store_exports = {};
|
|
664
|
+
__export(audit_trace_store_exports, {
|
|
665
|
+
appendAuditTrace: () => appendAuditTrace,
|
|
666
|
+
auditTraceIndexFields: () => auditTraceIndexFields,
|
|
667
|
+
countAuditTraces: () => countAuditTraces,
|
|
668
|
+
persistedAuditTraceFromEvent: () => persistedAuditTraceFromEvent,
|
|
669
|
+
readAuditTraces: () => readAuditTraces
|
|
670
|
+
});
|
|
671
|
+
function persistedAuditTraceFromEvent(event) {
|
|
672
|
+
return {
|
|
673
|
+
id: (0, import_core16.generateUUIDv7)(),
|
|
674
|
+
recordedAt: Date.now(),
|
|
675
|
+
eventType: event.type,
|
|
676
|
+
constraint: event.type === "constraint:violated" ? event.constraint : void 0,
|
|
677
|
+
trace: event.trace
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
async function appendAuditTrace(adapter, trace) {
|
|
681
|
+
const collection = trace.trace.operationA.collection;
|
|
682
|
+
const recordId = trace.trace.operationA.recordId;
|
|
683
|
+
await adapter.execute(
|
|
684
|
+
`INSERT INTO _kora_audit_traces (
|
|
685
|
+
id, recorded_at, event_type, collection, record_id, field, strategy, tier, constraint_name, trace_json
|
|
686
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
687
|
+
[
|
|
688
|
+
trace.id,
|
|
689
|
+
trace.recordedAt,
|
|
690
|
+
trace.eventType,
|
|
691
|
+
collection,
|
|
692
|
+
recordId,
|
|
693
|
+
trace.trace.field,
|
|
694
|
+
trace.trace.strategy,
|
|
695
|
+
trace.trace.tier,
|
|
696
|
+
trace.constraint ?? null,
|
|
697
|
+
serializeAuditJson(trace)
|
|
698
|
+
]
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
async function readAuditTraces(adapter, query) {
|
|
702
|
+
const conditions = [];
|
|
703
|
+
const params = [];
|
|
704
|
+
if (query?.collections && query.collections.length > 0) {
|
|
705
|
+
const placeholders = query.collections.map(() => "?").join(", ");
|
|
706
|
+
conditions.push(`collection IN (${placeholders})`);
|
|
707
|
+
params.push(...query.collections);
|
|
708
|
+
}
|
|
709
|
+
if (query?.since !== void 0) {
|
|
710
|
+
conditions.push("recorded_at >= ?");
|
|
711
|
+
params.push(query.since);
|
|
712
|
+
}
|
|
713
|
+
if (query?.until !== void 0) {
|
|
714
|
+
conditions.push("recorded_at <= ?");
|
|
715
|
+
params.push(query.until);
|
|
716
|
+
}
|
|
717
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
718
|
+
const rows = await adapter.query(
|
|
719
|
+
`SELECT * FROM _kora_audit_traces ${whereClause} ORDER BY recorded_at ASC, id ASC`,
|
|
720
|
+
params
|
|
721
|
+
);
|
|
722
|
+
return rows.map(rowToPersistedTrace);
|
|
723
|
+
}
|
|
724
|
+
function rowToPersistedTrace(row) {
|
|
725
|
+
return deserializeAuditJson(row.trace_json);
|
|
726
|
+
}
|
|
727
|
+
async function countAuditTraces(adapter) {
|
|
728
|
+
const rows = await adapter.query(
|
|
729
|
+
"SELECT COUNT(*) as count FROM _kora_audit_traces"
|
|
730
|
+
);
|
|
731
|
+
return rows[0]?.count ?? 0;
|
|
732
|
+
}
|
|
733
|
+
function auditTraceIndexFields(trace) {
|
|
734
|
+
return {
|
|
735
|
+
collection: trace.operationA.collection,
|
|
736
|
+
recordId: trace.operationA.recordId
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
var import_core16;
|
|
740
|
+
var init_audit_trace_store = __esm({
|
|
741
|
+
"src/audit/audit-trace-store.ts"() {
|
|
742
|
+
"use strict";
|
|
743
|
+
import_core16 = require("@korajs/core");
|
|
744
|
+
init_audit_json();
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
// src/audit/export-audit.ts
|
|
749
|
+
var export_audit_exports = {};
|
|
750
|
+
__export(export_audit_exports, {
|
|
751
|
+
decodeAuditExport: () => decodeAuditExport,
|
|
752
|
+
exportAudit: () => exportAudit,
|
|
753
|
+
readAuditExportManifest: () => readAuditExportManifest,
|
|
754
|
+
verifyAuditExportChecksum: () => verifyAuditExportChecksum
|
|
755
|
+
});
|
|
756
|
+
function encodeSection2(name, content) {
|
|
757
|
+
const nameBytes = new TextEncoder().encode(name);
|
|
758
|
+
const header = new Uint8Array(8);
|
|
759
|
+
const view = new DataView(header.buffer);
|
|
760
|
+
view.setUint32(0, nameBytes.length, true);
|
|
761
|
+
view.setUint32(4, content.length, true);
|
|
762
|
+
const result = new Uint8Array(header.length + nameBytes.length + content.length);
|
|
763
|
+
result.set(header, 0);
|
|
764
|
+
result.set(nameBytes, 8);
|
|
765
|
+
result.set(content, 8 + nameBytes.length);
|
|
766
|
+
return result;
|
|
767
|
+
}
|
|
768
|
+
function encodeJsonSection2(name, data) {
|
|
769
|
+
return encodeSection2(name, new TextEncoder().encode(serializeAuditJson(data)));
|
|
770
|
+
}
|
|
771
|
+
function encodeNdjsonSection(name, records) {
|
|
772
|
+
const lines = `${records.map((record) => serializeAuditJson(record)).join("\n")}
|
|
773
|
+
`;
|
|
774
|
+
return encodeSection2(name, new TextEncoder().encode(lines));
|
|
775
|
+
}
|
|
776
|
+
function readUint322(view, offset) {
|
|
777
|
+
return view.getUint32(offset, true);
|
|
778
|
+
}
|
|
779
|
+
function parseSections2(data) {
|
|
780
|
+
const sections = [];
|
|
781
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
782
|
+
let offset = 0;
|
|
783
|
+
while (offset + 8 <= data.byteLength) {
|
|
784
|
+
const nameLen = readUint322(view, offset);
|
|
785
|
+
const contentLen = readUint322(view, offset + 4);
|
|
786
|
+
offset += 8;
|
|
787
|
+
if (offset + nameLen + contentLen > data.byteLength) {
|
|
788
|
+
break;
|
|
789
|
+
}
|
|
790
|
+
const name = new TextDecoder().decode(data.slice(offset, offset + nameLen));
|
|
791
|
+
offset += nameLen;
|
|
792
|
+
const content = data.slice(offset, offset + contentLen);
|
|
793
|
+
offset += contentLen;
|
|
794
|
+
sections.push({ name, content });
|
|
795
|
+
}
|
|
796
|
+
return sections;
|
|
797
|
+
}
|
|
798
|
+
function findSection2(sections, name) {
|
|
799
|
+
for (const section of sections) {
|
|
800
|
+
if (section.name === name) {
|
|
801
|
+
return section.content;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
async function computeSha2562(data) {
|
|
807
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
808
|
+
return Array.from(new Uint8Array(hashBuffer)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
809
|
+
}
|
|
810
|
+
async function readAllOperations2(adapter, schema, collectionFilter) {
|
|
811
|
+
const collections = collectionFilter ? Object.keys(schema.collections).filter((name) => collectionFilter.includes(name)) : Object.keys(schema.collections);
|
|
812
|
+
const allOps = [];
|
|
813
|
+
for (const collectionName of collections) {
|
|
814
|
+
const rows = await adapter.query(
|
|
815
|
+
`SELECT * FROM _kora_ops_${collectionName} ORDER BY sequence_number ASC`
|
|
816
|
+
);
|
|
817
|
+
for (const row of rows) {
|
|
818
|
+
allOps.push(deserializeOperationWithCollection(row, collectionName));
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return allOps;
|
|
822
|
+
}
|
|
823
|
+
function filterOperationsByCollections(operations, collectionFilter) {
|
|
824
|
+
if (!collectionFilter || collectionFilter.length === 0) {
|
|
825
|
+
return operations;
|
|
826
|
+
}
|
|
827
|
+
const allowed = new Set(collectionFilter);
|
|
828
|
+
return operations.filter((op) => allowed.has(op.collection));
|
|
829
|
+
}
|
|
830
|
+
async function exportAudit(adapter, schema, nodeId, schemaVersion, options) {
|
|
831
|
+
const onProgress = options?.onProgress ?? (() => {
|
|
832
|
+
});
|
|
833
|
+
const traceQuery = {
|
|
834
|
+
collections: options?.collections,
|
|
835
|
+
since: options?.since,
|
|
836
|
+
until: options?.until
|
|
837
|
+
};
|
|
838
|
+
onProgress({ phase: "reading", progress: 0, message: "Reading operations" });
|
|
839
|
+
const operations = filterOperationsByCollections(
|
|
840
|
+
await readAllOperations2(adapter, schema, options?.collections),
|
|
841
|
+
options?.collections
|
|
842
|
+
);
|
|
843
|
+
onProgress({ phase: "reading", progress: 0.4, message: "Reading merge traces" });
|
|
844
|
+
const mergeTraces = await readAuditTraces(adapter, traceQuery);
|
|
845
|
+
onProgress({ phase: "writing", progress: 0.7, message: "Encoding audit export" });
|
|
846
|
+
const sections = [];
|
|
847
|
+
let contentForChecksum = new Uint8Array(0);
|
|
848
|
+
const addSection = (section) => {
|
|
849
|
+
sections.push(section);
|
|
850
|
+
const combined = new Uint8Array(contentForChecksum.length + section.length);
|
|
851
|
+
combined.set(contentForChecksum, 0);
|
|
852
|
+
combined.set(section, contentForChecksum.length);
|
|
853
|
+
contentForChecksum = combined;
|
|
854
|
+
};
|
|
855
|
+
addSection(encodeNdjsonSection("operations", operations));
|
|
856
|
+
addSection(encodeNdjsonSection("merge_traces", mergeTraces));
|
|
857
|
+
onProgress({ phase: "verifying", progress: 0.9, message: "Computing checksum" });
|
|
858
|
+
const checksum = await computeSha2562(contentForChecksum);
|
|
859
|
+
const manifest = {
|
|
860
|
+
version: 1,
|
|
861
|
+
exportedAt: Date.now(),
|
|
862
|
+
nodeId,
|
|
863
|
+
schemaVersion,
|
|
864
|
+
operationCount: operations.length,
|
|
865
|
+
mergeTraceCount: mergeTraces.length,
|
|
866
|
+
checksum
|
|
867
|
+
};
|
|
868
|
+
const manifestSection = encodeJsonSection2("manifest", manifest);
|
|
869
|
+
const checksumSection = encodeSection2("checksum", new TextEncoder().encode(checksum));
|
|
870
|
+
onProgress({ phase: "writing", progress: 1, message: "Finalizing" });
|
|
871
|
+
const parts = [manifestSection, ...sections, checksumSection];
|
|
872
|
+
const finalOutput = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
|
|
873
|
+
let offset = 0;
|
|
874
|
+
for (const part of parts) {
|
|
875
|
+
finalOutput.set(part, offset);
|
|
876
|
+
offset += part.length;
|
|
877
|
+
}
|
|
878
|
+
return finalOutput;
|
|
879
|
+
}
|
|
880
|
+
function decodeAuditExport(data) {
|
|
881
|
+
const sections = parseSections2(data);
|
|
882
|
+
const manifest = JSON.parse(
|
|
883
|
+
new TextDecoder().decode(findSection2(sections, "manifest") ?? new Uint8Array())
|
|
884
|
+
);
|
|
885
|
+
const operationsContent = findSection2(sections, "operations");
|
|
886
|
+
const mergeTracesContent = findSection2(sections, "merge_traces");
|
|
887
|
+
const operations = parseNdjsonSection(operationsContent);
|
|
888
|
+
const mergeTraces = parseNdjsonSection(mergeTracesContent);
|
|
889
|
+
return { manifest, operations, mergeTraces };
|
|
890
|
+
}
|
|
891
|
+
function readAuditExportManifest(data) {
|
|
892
|
+
const sections = parseSections2(data);
|
|
893
|
+
const manifestContent = findSection2(sections, "manifest");
|
|
894
|
+
if (!manifestContent) {
|
|
895
|
+
throw new Error("Invalid audit export: missing manifest section");
|
|
896
|
+
}
|
|
897
|
+
return JSON.parse(new TextDecoder().decode(manifestContent));
|
|
898
|
+
}
|
|
899
|
+
async function verifyAuditExportChecksum(data) {
|
|
900
|
+
const sections = parseSections2(data);
|
|
901
|
+
const manifest = readAuditExportManifest(data);
|
|
902
|
+
const contentSections = sections.filter(
|
|
903
|
+
(section) => section.name !== "manifest" && section.name !== "checksum"
|
|
904
|
+
);
|
|
905
|
+
let content = new Uint8Array(0);
|
|
906
|
+
for (const section of contentSections) {
|
|
907
|
+
const encoded = encodeSection2(section.name, section.content);
|
|
908
|
+
const combined = new Uint8Array(content.length + encoded.length);
|
|
909
|
+
combined.set(content, 0);
|
|
910
|
+
combined.set(encoded, content.length);
|
|
911
|
+
content = combined;
|
|
912
|
+
}
|
|
913
|
+
const checksum = await computeSha2562(content);
|
|
914
|
+
return checksum === manifest.checksum;
|
|
915
|
+
}
|
|
916
|
+
function parseNdjsonSection(content) {
|
|
917
|
+
if (!content || content.length === 0) {
|
|
918
|
+
return [];
|
|
919
|
+
}
|
|
920
|
+
const text = new TextDecoder().decode(content).trim();
|
|
921
|
+
if (text.length === 0) {
|
|
922
|
+
return [];
|
|
923
|
+
}
|
|
924
|
+
return text.split("\n").map((line) => deserializeAuditJson(line));
|
|
925
|
+
}
|
|
926
|
+
var init_export_audit = __esm({
|
|
927
|
+
"src/audit/export-audit.ts"() {
|
|
928
|
+
"use strict";
|
|
929
|
+
init_serializer();
|
|
930
|
+
init_audit_json();
|
|
931
|
+
init_audit_trace_store();
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
// src/index.ts
|
|
936
|
+
var index_exports = {};
|
|
937
|
+
__export(index_exports, {
|
|
938
|
+
AdapterError: () => AdapterError,
|
|
939
|
+
COMPACTION_BASELINE_META_KEY: () => COMPACTION_BASELINE_META_KEY,
|
|
940
|
+
Collection: () => Collection,
|
|
941
|
+
InvalidStateTransitionError: () => InvalidStateTransitionError,
|
|
942
|
+
LAST_ACKED_SERVER_VECTOR_META_KEY: () => LAST_ACKED_SERVER_VECTOR_META_KEY,
|
|
943
|
+
PersistenceError: () => PersistenceError,
|
|
944
|
+
QueryBuilder: () => QueryBuilder,
|
|
945
|
+
QueryError: () => QueryError,
|
|
946
|
+
RecordNotFoundError: () => RecordNotFoundError,
|
|
947
|
+
SequenceManager: () => SequenceManager,
|
|
948
|
+
Store: () => Store,
|
|
949
|
+
StoreNotOpenError: () => StoreNotOpenError,
|
|
950
|
+
SubscriptionBloomFilter: () => SubscriptionBloomFilter,
|
|
951
|
+
SubscriptionManager: () => SubscriptionManager,
|
|
952
|
+
TransactionContext: () => TransactionContext,
|
|
953
|
+
WorkerInitError: () => WorkerInitError,
|
|
954
|
+
WorkerTimeoutError: () => WorkerTimeoutError,
|
|
955
|
+
collectOperationsAheadOfServer: () => collectOperationsAheadOfServer,
|
|
956
|
+
compactOperationLog: () => compactOperationLog,
|
|
957
|
+
computeAckCompactionWatermark: () => computeAckCompactionWatermark,
|
|
958
|
+
decodeAuditExport: () => decodeAuditExport,
|
|
959
|
+
decodeRichtext: () => decodeRichtext,
|
|
960
|
+
deserializeVersionVectorFromMeta: () => deserializeVersionVectorFromMeta,
|
|
961
|
+
encodeRichtext: () => encodeRichtext,
|
|
962
|
+
exportBackup: () => exportBackup,
|
|
963
|
+
mergeVersionVectors: () => mergeVersionVectors,
|
|
964
|
+
persistedAuditTraceFromEvent: () => persistedAuditTraceFromEvent,
|
|
965
|
+
pluralize: () => pluralize,
|
|
966
|
+
readAuditExportManifest: () => readAuditExportManifest,
|
|
967
|
+
readBackupManifest: () => readBackupManifest,
|
|
968
|
+
restoreBackup: () => restoreBackup,
|
|
969
|
+
richtextStatesEqual: () => richtextStatesEqual,
|
|
970
|
+
richtextToPlainText: () => richtextToPlainText,
|
|
971
|
+
serializeVersionVectorToMeta: () => serializeVersionVectorToMeta,
|
|
972
|
+
singularize: () => singularize,
|
|
973
|
+
validateStateTransition: () => validateStateTransition,
|
|
974
|
+
validateUpdateStateMachine: () => validateUpdateStateMachine,
|
|
975
|
+
verifyAuditExportChecksum: () => verifyAuditExportChecksum,
|
|
976
|
+
verifyBackupChecksum: () => verifyBackupChecksum
|
|
977
|
+
});
|
|
978
|
+
module.exports = __toCommonJS(index_exports);
|
|
979
|
+
|
|
980
|
+
// src/errors.ts
|
|
981
|
+
var import_core = require("@korajs/core");
|
|
982
|
+
var QueryError = class extends import_core.KoraError {
|
|
983
|
+
constructor(message, context) {
|
|
984
|
+
super(message, "QUERY_ERROR", context);
|
|
985
|
+
this.name = "QueryError";
|
|
986
|
+
}
|
|
987
|
+
};
|
|
988
|
+
var RecordNotFoundError = class extends import_core.KoraError {
|
|
989
|
+
constructor(collection, recordId) {
|
|
990
|
+
super(`Record "${recordId}" not found in collection "${collection}"`, "RECORD_NOT_FOUND", {
|
|
991
|
+
collection,
|
|
992
|
+
recordId
|
|
993
|
+
});
|
|
994
|
+
this.name = "RecordNotFoundError";
|
|
995
|
+
}
|
|
996
|
+
};
|
|
997
|
+
var AdapterError = class extends import_core.KoraError {
|
|
998
|
+
constructor(message, context) {
|
|
999
|
+
super(message, "ADAPTER_ERROR", context);
|
|
1000
|
+
this.name = "AdapterError";
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
var StoreNotOpenError = class extends import_core.KoraError {
|
|
1004
|
+
constructor() {
|
|
1005
|
+
super("Store is not open. Call store.open() before performing operations.", "STORE_NOT_OPEN");
|
|
1006
|
+
this.name = "StoreNotOpenError";
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
var WorkerInitError = class extends import_core.KoraError {
|
|
1010
|
+
constructor(message, context) {
|
|
1011
|
+
super(`Worker initialization failed: ${message}`, "WORKER_INIT_ERROR", context);
|
|
1012
|
+
this.name = "WorkerInitError";
|
|
1013
|
+
}
|
|
1014
|
+
};
|
|
1015
|
+
var WorkerTimeoutError = class extends import_core.KoraError {
|
|
1016
|
+
constructor(operation, timeoutMs) {
|
|
1017
|
+
super(
|
|
1018
|
+
`Worker did not respond within ${timeoutMs}ms for operation "${operation}"`,
|
|
1019
|
+
"WORKER_TIMEOUT",
|
|
1020
|
+
{ operation, timeoutMs }
|
|
1021
|
+
);
|
|
1022
|
+
this.name = "WorkerTimeoutError";
|
|
1023
|
+
}
|
|
1024
|
+
};
|
|
1025
|
+
var PersistenceError = class extends import_core.KoraError {
|
|
1026
|
+
constructor(message, context) {
|
|
1027
|
+
super(`Persistence error: ${message}`, "PERSISTENCE_ERROR", context);
|
|
1028
|
+
this.name = "PersistenceError";
|
|
1029
|
+
}
|
|
1030
|
+
};
|
|
1031
|
+
|
|
1032
|
+
// src/compaction/types.ts
|
|
1033
|
+
var COMPACTION_BASELINE_META_KEY = "compaction_baseline_at";
|
|
1034
|
+
|
|
1035
|
+
// src/compaction/compact-operation-log.ts
|
|
1036
|
+
var import_core2 = require("@korajs/core");
|
|
1037
|
+
function computeAckCompactionWatermark(serverVector) {
|
|
1038
|
+
const watermark = (0, import_core2.createVersionVector)();
|
|
1039
|
+
for (const [nodeId, seq] of serverVector) {
|
|
1040
|
+
if (seq > 0) {
|
|
1041
|
+
watermark.set(nodeId, seq);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
return watermark;
|
|
1045
|
+
}
|
|
1046
|
+
async function compactOperationLog(adapter, schema, strategy, serverVector) {
|
|
1047
|
+
if (strategy.mode === "never") {
|
|
1048
|
+
return { deletedCount: 0, watermark: (0, import_core2.createVersionVector)() };
|
|
1049
|
+
}
|
|
1050
|
+
const watermark = computeAckCompactionWatermark(serverVector);
|
|
1051
|
+
if (watermark.size === 0) {
|
|
1052
|
+
return { deletedCount: 0, watermark };
|
|
1053
|
+
}
|
|
1054
|
+
const ageCutoffWall = strategy.mode === "after-days" ? Date.now() - strategy.days * 24 * 60 * 60 * 1e3 : null;
|
|
1055
|
+
const ageCutoffSerialized = ageCutoffWall !== null ? import_core2.HybridLogicalClock.serialize({
|
|
1056
|
+
wallTime: ageCutoffWall,
|
|
1057
|
+
logical: 0,
|
|
1058
|
+
nodeId: ""
|
|
1059
|
+
}).slice(0, 15) : null;
|
|
1060
|
+
let deletedCount = 0;
|
|
1061
|
+
const collectionNames = Object.keys(schema.collections);
|
|
1062
|
+
await adapter.transaction(async (tx) => {
|
|
1063
|
+
for (const collectionName of collectionNames) {
|
|
1064
|
+
const table = `_kora_ops_${collectionName}`;
|
|
1065
|
+
for (const [nodeId, maxSeq] of watermark) {
|
|
1066
|
+
if (maxSeq <= 0) continue;
|
|
1067
|
+
const countSql = buildCountSql(table, ageCutoffSerialized !== null);
|
|
1068
|
+
const countParams = ageCutoffSerialized !== null ? [nodeId, maxSeq, ageCutoffSerialized] : [nodeId, maxSeq];
|
|
1069
|
+
const countRows = await tx.query(countSql, countParams);
|
|
1070
|
+
deletedCount += countRows[0]?.count ?? 0;
|
|
1071
|
+
const deleteSql = buildDeleteSql(table, ageCutoffSerialized !== null);
|
|
1072
|
+
await tx.execute(deleteSql, countParams);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
await tx.execute("INSERT OR REPLACE INTO _kora_meta (key, value) VALUES (?, ?)", [
|
|
1076
|
+
COMPACTION_BASELINE_META_KEY,
|
|
1077
|
+
String(Date.now())
|
|
1078
|
+
]);
|
|
1079
|
+
});
|
|
1080
|
+
return { deletedCount, watermark };
|
|
1081
|
+
}
|
|
1082
|
+
function buildCountSql(table, withAge) {
|
|
1083
|
+
if (withAge) {
|
|
1084
|
+
return `SELECT COUNT(*) as count FROM ${table} WHERE node_id = ? AND sequence_number <= ? AND SUBSTR(timestamp, 1, 15) < ?`;
|
|
1085
|
+
}
|
|
1086
|
+
return `SELECT COUNT(*) as count FROM ${table} WHERE node_id = ? AND sequence_number <= ?`;
|
|
1087
|
+
}
|
|
1088
|
+
function buildDeleteSql(table, withAge) {
|
|
1089
|
+
if (withAge) {
|
|
1090
|
+
return `DELETE FROM ${table} WHERE node_id = ? AND sequence_number <= ? AND SUBSTR(timestamp, 1, 15) < ?`;
|
|
1091
|
+
}
|
|
1092
|
+
return `DELETE FROM ${table} WHERE node_id = ? AND sequence_number <= ?`;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// src/sync/sync-state.ts
|
|
1096
|
+
var import_core3 = require("@korajs/core");
|
|
1097
|
+
var LAST_ACKED_SERVER_VECTOR_META_KEY = "last_acked_server_vector";
|
|
1098
|
+
var DELTA_CURSOR_META_KEY = "delta_cursor";
|
|
1099
|
+
function serializeVersionVectorToMeta(vector) {
|
|
1100
|
+
const record = {};
|
|
1101
|
+
for (const [nodeId, seq] of vector) {
|
|
1102
|
+
record[nodeId] = seq;
|
|
1103
|
+
}
|
|
1104
|
+
return JSON.stringify(record);
|
|
1105
|
+
}
|
|
1106
|
+
function deserializeVersionVectorFromMeta(value) {
|
|
1107
|
+
const parsed = JSON.parse(value);
|
|
1108
|
+
const vector = (0, import_core3.createVersionVector)();
|
|
1109
|
+
for (const [nodeId, seq] of Object.entries(parsed)) {
|
|
1110
|
+
if (typeof seq === "number" && seq >= 0) {
|
|
1111
|
+
vector.set(nodeId, seq);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
return vector;
|
|
1115
|
+
}
|
|
1116
|
+
function mergeVersionVectors(a, b) {
|
|
1117
|
+
const merged = new Map(a);
|
|
1118
|
+
for (const [nodeId, seq] of b) {
|
|
1119
|
+
merged.set(nodeId, Math.max(merged.get(nodeId) ?? 0, seq));
|
|
1120
|
+
}
|
|
1121
|
+
return merged;
|
|
1122
|
+
}
|
|
1123
|
+
async function collectOperationsAheadOfServer(localVector, serverVector, fetchRange) {
|
|
1124
|
+
const missing = [];
|
|
1125
|
+
for (const [nodeId, localSeq] of localVector) {
|
|
1126
|
+
const serverSeq = serverVector.get(nodeId) ?? 0;
|
|
1127
|
+
if (localSeq > serverSeq) {
|
|
1128
|
+
const ops = await fetchRange(nodeId, serverSeq + 1, localSeq);
|
|
1129
|
+
missing.push(...ops);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
missing.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
|
1133
|
+
return missing;
|
|
1134
|
+
}
|
|
1135
|
+
async function loadLastAckedServerVector(adapter) {
|
|
1136
|
+
const rows = await adapter.query("SELECT value FROM _kora_meta WHERE key = ?", [
|
|
1137
|
+
LAST_ACKED_SERVER_VECTOR_META_KEY
|
|
1138
|
+
]);
|
|
1139
|
+
const row = rows[0];
|
|
1140
|
+
if (!row?.value) {
|
|
1141
|
+
return (0, import_core3.createVersionVector)();
|
|
1142
|
+
}
|
|
1143
|
+
try {
|
|
1144
|
+
return deserializeVersionVectorFromMeta(row.value);
|
|
1145
|
+
} catch {
|
|
1146
|
+
return (0, import_core3.createVersionVector)();
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
async function saveLastAckedServerVector(adapter, vector) {
|
|
1150
|
+
await adapter.execute("INSERT OR REPLACE INTO _kora_meta (key, value) VALUES (?, ?)", [
|
|
1151
|
+
LAST_ACKED_SERVER_VECTOR_META_KEY,
|
|
1152
|
+
serializeVersionVectorToMeta(vector)
|
|
1153
|
+
]);
|
|
1154
|
+
}
|
|
1155
|
+
async function loadDeltaCursor(adapter) {
|
|
1156
|
+
const rows = await adapter.query("SELECT value FROM _kora_meta WHERE key = ?", [
|
|
1157
|
+
DELTA_CURSOR_META_KEY
|
|
1158
|
+
]);
|
|
1159
|
+
return rows[0]?.value ?? null;
|
|
1160
|
+
}
|
|
1161
|
+
async function saveDeltaCursor(adapter, cursor) {
|
|
1162
|
+
if (cursor === null) {
|
|
1163
|
+
await adapter.execute("DELETE FROM _kora_meta WHERE key = ?", [DELTA_CURSOR_META_KEY]);
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
await adapter.execute("INSERT OR REPLACE INTO _kora_meta (key, value) VALUES (?, ?)", [
|
|
1167
|
+
DELTA_CURSOR_META_KEY,
|
|
1168
|
+
cursor
|
|
1169
|
+
]);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// src/store/store.ts
|
|
1173
|
+
var import_core17 = require("@korajs/core");
|
|
1174
|
+
init_backup();
|
|
1175
|
+
|
|
1176
|
+
// src/mutations/execute-delete.ts
|
|
1177
|
+
var import_core7 = require("@korajs/core");
|
|
1178
|
+
|
|
1179
|
+
// src/lww/row-version.ts
|
|
1180
|
+
var import_core6 = require("@korajs/core");
|
|
1181
|
+
var EMPTY_ROW_VERSION = "";
|
|
1182
|
+
function serializeRowVersion(timestamp) {
|
|
1183
|
+
return import_core6.HybridLogicalClock.serialize(timestamp);
|
|
1184
|
+
}
|
|
1185
|
+
function rowVersionFromRecord(row) {
|
|
1186
|
+
const version = row._version;
|
|
1187
|
+
if (typeof version === "string" && version.length > 0) {
|
|
1188
|
+
return import_core6.HybridLogicalClock.deserialize(version);
|
|
1189
|
+
}
|
|
1190
|
+
return { wallTime: row._updated_at, logical: 0, nodeId: "" };
|
|
1191
|
+
}
|
|
1192
|
+
function isIncomingNewerThanRow(incoming, row) {
|
|
1193
|
+
return import_core6.HybridLogicalClock.compare(incoming, rowVersionFromRecord(row)) > 0;
|
|
1194
|
+
}
|
|
1195
|
+
function lwwVersionWhereClause(remoteVersion) {
|
|
1196
|
+
return {
|
|
1197
|
+
sql: "(_version = ? OR _version < ?)",
|
|
1198
|
+
params: [EMPTY_ROW_VERSION, remoteVersion]
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// src/query/sql-builder.ts
|
|
1203
|
+
function buildSelectQuery(descriptor, fields) {
|
|
1204
|
+
const params = [];
|
|
1205
|
+
const parts = [`SELECT * FROM ${descriptor.collection}`];
|
|
1206
|
+
const whereClause = buildWhereClauseParts(descriptor.where, fields, params);
|
|
1207
|
+
const deletedFilter = "_deleted = 0";
|
|
1208
|
+
if (whereClause) {
|
|
1209
|
+
parts.push(`WHERE ${deletedFilter} AND ${whereClause}`);
|
|
1210
|
+
} else {
|
|
1211
|
+
parts.push(`WHERE ${deletedFilter}`);
|
|
1212
|
+
}
|
|
1213
|
+
if (descriptor.orderBy.length > 0) {
|
|
1214
|
+
const orderParts = descriptor.orderBy.map((o) => {
|
|
1215
|
+
validateFieldName(o.field, fields);
|
|
1216
|
+
return `${o.field} ${o.direction.toUpperCase()}`;
|
|
1217
|
+
});
|
|
1218
|
+
parts.push(`ORDER BY ${orderParts.join(", ")}`);
|
|
1219
|
+
}
|
|
1220
|
+
if (descriptor.limit !== void 0) {
|
|
1221
|
+
parts.push(`LIMIT ${descriptor.limit}`);
|
|
1222
|
+
}
|
|
1223
|
+
if (descriptor.offset !== void 0) {
|
|
1224
|
+
parts.push(`OFFSET ${descriptor.offset}`);
|
|
1225
|
+
}
|
|
1226
|
+
return { sql: parts.join(" "), params };
|
|
1227
|
+
}
|
|
1228
|
+
function buildCountQuery(descriptor, fields) {
|
|
1229
|
+
const params = [];
|
|
1230
|
+
const parts = [`SELECT COUNT(*) as count FROM ${descriptor.collection}`];
|
|
1231
|
+
const whereClause = buildWhereClauseParts(descriptor.where, fields, params);
|
|
1232
|
+
const deletedFilter = "_deleted = 0";
|
|
1233
|
+
if (whereClause) {
|
|
1234
|
+
parts.push(`WHERE ${deletedFilter} AND ${whereClause}`);
|
|
1235
|
+
} else {
|
|
1236
|
+
parts.push(`WHERE ${deletedFilter}`);
|
|
1237
|
+
}
|
|
1238
|
+
return { sql: parts.join(" "), params };
|
|
1239
|
+
}
|
|
1240
|
+
function buildInsertQuery(collection, record) {
|
|
1241
|
+
const columns = Object.keys(record);
|
|
1242
|
+
const placeholders = columns.map(() => "?");
|
|
1243
|
+
const params = Object.values(record);
|
|
1244
|
+
const sql = `INSERT INTO ${collection} (${columns.join(", ")}) VALUES (${placeholders.join(", ")})`;
|
|
1245
|
+
return { sql, params };
|
|
1246
|
+
}
|
|
1247
|
+
function buildUpdateQuery(collection, id, changes) {
|
|
1248
|
+
const setClauses = Object.keys(changes).map((col) => `${col} = ?`);
|
|
1249
|
+
const params = [...Object.values(changes), id];
|
|
1250
|
+
const sql = `UPDATE ${collection} SET ${setClauses.join(", ")} WHERE id = ?`;
|
|
1251
|
+
return { sql, params };
|
|
1252
|
+
}
|
|
1253
|
+
function buildSoftDeleteQuery(collection, id, updatedAt, version) {
|
|
1254
|
+
if (version !== void 0) {
|
|
1255
|
+
return {
|
|
1256
|
+
sql: `UPDATE ${collection} SET _deleted = 1, _updated_at = ?, _version = ? WHERE id = ?`,
|
|
1257
|
+
params: [updatedAt, version, id]
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
return {
|
|
1261
|
+
sql: `UPDATE ${collection} SET _deleted = 1, _updated_at = ? WHERE id = ?`,
|
|
1262
|
+
params: [updatedAt, id]
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
function buildLwwUpdateQuery(collection, id, changes, remoteVersion) {
|
|
1266
|
+
const setClauses = Object.keys(changes).map((col) => `${col} = ?`);
|
|
1267
|
+
const lww = lwwVersionWhereClause(remoteVersion);
|
|
1268
|
+
const sql = `UPDATE ${collection} SET ${setClauses.join(", ")} WHERE id = ? AND ${lww.sql}`;
|
|
1269
|
+
const params = [...Object.values(changes), id, ...lww.params];
|
|
1270
|
+
return { sql, params };
|
|
1271
|
+
}
|
|
1272
|
+
function buildLwwSoftDeleteQuery(collection, id, updatedAt, version) {
|
|
1273
|
+
const lww = lwwVersionWhereClause(version);
|
|
1274
|
+
return {
|
|
1275
|
+
sql: `UPDATE ${collection} SET _deleted = 1, _updated_at = ?, _version = ? WHERE id = ? AND ${lww.sql}`,
|
|
1276
|
+
params: [updatedAt, version, id, ...lww.params]
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
var VALID_OPERATORS = /* @__PURE__ */ new Set(["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in"]);
|
|
1280
|
+
function buildWhereClauseParts(where, fields, params) {
|
|
1281
|
+
const conditions = [];
|
|
1282
|
+
for (const [fieldName, value] of Object.entries(where)) {
|
|
1283
|
+
validateFieldName(fieldName, fields);
|
|
1284
|
+
const descriptor = fields[fieldName];
|
|
1285
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
1286
|
+
const ops = value;
|
|
1287
|
+
for (const [op, opValue] of Object.entries(ops)) {
|
|
1288
|
+
if (!VALID_OPERATORS.has(op)) {
|
|
1289
|
+
throw new QueryError(`Unknown operator "${op}" on field "${fieldName}"`, {
|
|
1290
|
+
field: fieldName,
|
|
1291
|
+
operator: op,
|
|
1292
|
+
validOperators: [...VALID_OPERATORS]
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
conditions.push(buildOperatorCondition(fieldName, op, opValue, descriptor, params));
|
|
1296
|
+
}
|
|
1297
|
+
} else {
|
|
1298
|
+
conditions.push(buildOperatorCondition(fieldName, "$eq", value, descriptor, params));
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
if (conditions.length === 0) return null;
|
|
1302
|
+
return conditions.join(" AND ");
|
|
1303
|
+
}
|
|
1304
|
+
function buildOperatorCondition(fieldName, operator, value, descriptor, params) {
|
|
1305
|
+
const sqlValue = descriptor?.kind === "boolean" && typeof value === "boolean" ? value ? 1 : 0 : value;
|
|
1306
|
+
switch (operator) {
|
|
1307
|
+
case "$eq":
|
|
1308
|
+
if (sqlValue === null) {
|
|
1309
|
+
return `${fieldName} IS NULL`;
|
|
1310
|
+
}
|
|
1311
|
+
params.push(sqlValue);
|
|
1312
|
+
return `${fieldName} = ?`;
|
|
1313
|
+
case "$ne":
|
|
1314
|
+
if (sqlValue === null) {
|
|
1315
|
+
return `${fieldName} IS NOT NULL`;
|
|
1316
|
+
}
|
|
1317
|
+
params.push(sqlValue);
|
|
1318
|
+
return `${fieldName} != ?`;
|
|
1319
|
+
case "$gt":
|
|
1320
|
+
params.push(sqlValue);
|
|
1321
|
+
return `${fieldName} > ?`;
|
|
1322
|
+
case "$gte":
|
|
1323
|
+
params.push(sqlValue);
|
|
1324
|
+
return `${fieldName} >= ?`;
|
|
1325
|
+
case "$lt":
|
|
1326
|
+
params.push(sqlValue);
|
|
1327
|
+
return `${fieldName} < ?`;
|
|
1328
|
+
case "$lte":
|
|
1329
|
+
params.push(sqlValue);
|
|
1330
|
+
return `${fieldName} <= ?`;
|
|
1331
|
+
case "$in": {
|
|
1332
|
+
if (!Array.isArray(sqlValue)) {
|
|
1333
|
+
throw new QueryError(`$in operator requires an array value for field "${fieldName}"`, {
|
|
1334
|
+
field: fieldName,
|
|
1335
|
+
received: typeof sqlValue
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
const placeholders = sqlValue.map(() => "?");
|
|
1339
|
+
for (const item of sqlValue) {
|
|
1340
|
+
params.push(
|
|
1341
|
+
descriptor?.kind === "boolean" && typeof item === "boolean" ? item ? 1 : 0 : item
|
|
1342
|
+
);
|
|
429
1343
|
}
|
|
1344
|
+
return `${fieldName} IN (${placeholders.join(", ")})`;
|
|
1345
|
+
}
|
|
1346
|
+
default:
|
|
1347
|
+
throw new QueryError(`Unknown operator "${operator}"`, { operator });
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
function validateFieldName(fieldName, fields) {
|
|
1351
|
+
const allowedFields = /* @__PURE__ */ new Set([
|
|
1352
|
+
...Object.keys(fields),
|
|
1353
|
+
"id",
|
|
1354
|
+
"createdAt",
|
|
1355
|
+
"updatedAt",
|
|
1356
|
+
"_created_at",
|
|
1357
|
+
"_updated_at"
|
|
1358
|
+
]);
|
|
1359
|
+
if (!allowedFields.has(fieldName)) {
|
|
1360
|
+
throw new QueryError(
|
|
1361
|
+
`Unknown field "${fieldName}" in query. Available fields: ${[...allowedFields].join(", ")}`,
|
|
1362
|
+
{ field: fieldName }
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// src/mutations/execute-delete.ts
|
|
1368
|
+
init_serializer();
|
|
1369
|
+
|
|
1370
|
+
// src/store/sequence-allocator.ts
|
|
1371
|
+
async function allocateNextSequenceNumber(adapter, nodeId) {
|
|
1372
|
+
const rows = await adapter.query(
|
|
1373
|
+
`INSERT INTO _kora_version_vector (node_id, sequence_number) VALUES (?, 1)
|
|
1374
|
+
ON CONFLICT(node_id) DO UPDATE SET sequence_number = sequence_number + 1
|
|
1375
|
+
RETURNING sequence_number`,
|
|
1376
|
+
[nodeId]
|
|
1377
|
+
);
|
|
1378
|
+
const seq = rows[0]?.sequence_number;
|
|
1379
|
+
if (seq === void 0) {
|
|
1380
|
+
throw new Error(`Failed to allocate sequence number for node "${nodeId}"`);
|
|
1381
|
+
}
|
|
1382
|
+
return seq;
|
|
1383
|
+
}
|
|
1384
|
+
async function allocateNextSequenceInTransaction(tx, nodeId) {
|
|
1385
|
+
const rows = await tx.query(
|
|
1386
|
+
`INSERT INTO _kora_version_vector (node_id, sequence_number) VALUES (?, 1)
|
|
1387
|
+
ON CONFLICT(node_id) DO UPDATE SET sequence_number = sequence_number + 1
|
|
1388
|
+
RETURNING sequence_number`,
|
|
1389
|
+
[nodeId]
|
|
1390
|
+
);
|
|
1391
|
+
const seq = rows[0]?.sequence_number;
|
|
1392
|
+
if (seq === void 0) {
|
|
1393
|
+
throw new Error(`Failed to allocate sequence number for node "${nodeId}" in transaction`);
|
|
1394
|
+
}
|
|
1395
|
+
return seq;
|
|
1396
|
+
}
|
|
1397
|
+
async function readSequenceNumber(adapter, nodeId) {
|
|
1398
|
+
const rows = await adapter.query(
|
|
1399
|
+
"SELECT sequence_number FROM _kora_version_vector WHERE node_id = ?",
|
|
1400
|
+
[nodeId]
|
|
1401
|
+
);
|
|
1402
|
+
return rows[0]?.sequence_number ?? 0;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// src/mutations/resolve-causal-deps.ts
|
|
1406
|
+
function resolveCausalDeps(ctx) {
|
|
1407
|
+
const merged = [];
|
|
1408
|
+
for (const id of ctx.extraCausalDeps ?? []) {
|
|
1409
|
+
if (!merged.includes(id)) {
|
|
1410
|
+
merged.push(id);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
for (const id of ctx.causalTracker?.nextCausalDeps(ctx.collection, ctx.inTransaction) ?? []) {
|
|
1414
|
+
if (!merged.includes(id)) {
|
|
1415
|
+
merged.push(id);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
return merged;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// src/mutations/execute-delete.ts
|
|
1422
|
+
async function executeDelete(ctx, id, options) {
|
|
1423
|
+
const currentRows = await ctx.adapter.query(
|
|
1424
|
+
`SELECT * FROM ${ctx.collection} WHERE id = ? AND _deleted = 0`,
|
|
1425
|
+
[id]
|
|
1426
|
+
);
|
|
1427
|
+
if (!currentRows[0]) {
|
|
1428
|
+
throw new RecordNotFoundError(ctx.collection, id);
|
|
1429
|
+
}
|
|
1430
|
+
const causalDeps = resolveCausalDeps(ctx);
|
|
1431
|
+
let operation = options?.operation;
|
|
1432
|
+
const cascadedOps = [];
|
|
1433
|
+
await ctx.adapter.transaction(async (tx) => {
|
|
1434
|
+
if (!operation) {
|
|
1435
|
+
const sequenceNumber = await allocateNextSequenceInTransaction(tx, ctx.nodeId);
|
|
1436
|
+
operation = await (0, import_core7.createOperation)(
|
|
1437
|
+
{
|
|
1438
|
+
nodeId: ctx.nodeId,
|
|
1439
|
+
type: "delete",
|
|
1440
|
+
collection: ctx.collection,
|
|
1441
|
+
recordId: id,
|
|
1442
|
+
data: null,
|
|
1443
|
+
previousData: null,
|
|
1444
|
+
sequenceNumber,
|
|
1445
|
+
causalDeps,
|
|
1446
|
+
schemaVersion: ctx.schema.version
|
|
1447
|
+
},
|
|
1448
|
+
ctx.clock
|
|
1449
|
+
);
|
|
1450
|
+
ctx.causalTracker?.afterOperation(ctx.collection, operation.id, ctx.inTransaction);
|
|
1451
|
+
}
|
|
1452
|
+
if (ctx.relationEnforcer && !options?.skipReferentialEnforcement) {
|
|
1453
|
+
const enforcementResult = await ctx.relationEnforcer.enforceDelete(ctx.collection, id, tx, [
|
|
1454
|
+
operation.id
|
|
1455
|
+
]);
|
|
1456
|
+
cascadedOps.push(...enforcementResult.operations);
|
|
1457
|
+
}
|
|
1458
|
+
const version = serializeRowVersion(operation.timestamp);
|
|
1459
|
+
const deleteQuery = buildSoftDeleteQuery(
|
|
1460
|
+
ctx.collection,
|
|
1461
|
+
id,
|
|
1462
|
+
operation.timestamp.wallTime,
|
|
1463
|
+
version
|
|
1464
|
+
);
|
|
1465
|
+
const opInsert = buildInsertQuery(
|
|
1466
|
+
`_kora_ops_${ctx.collection}`,
|
|
1467
|
+
serializeOperation(operation)
|
|
1468
|
+
);
|
|
1469
|
+
await tx.execute(deleteQuery.sql, deleteQuery.params);
|
|
1470
|
+
await tx.execute(opInsert.sql, opInsert.params);
|
|
1471
|
+
});
|
|
1472
|
+
if (!operation) {
|
|
1473
|
+
throw new Error("Delete operation was not created");
|
|
1474
|
+
}
|
|
1475
|
+
ctx.onMutation(ctx.collection, operation);
|
|
1476
|
+
for (const cascadedOp of cascadedOps) {
|
|
1477
|
+
ctx.onMutation(cascadedOp.collection, cascadedOp);
|
|
1478
|
+
}
|
|
1479
|
+
return [operation, ...cascadedOps];
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// src/mutations/execute-insert.ts
|
|
1483
|
+
var import_core8 = require("@korajs/core");
|
|
1484
|
+
init_serializer();
|
|
1485
|
+
async function executeInsert(ctx, data) {
|
|
1486
|
+
const validated = (0, import_core8.validateRecord)(ctx.collection, ctx.definition, data, "insert");
|
|
1487
|
+
const recordId = (0, import_core8.generateUUIDv7)();
|
|
1488
|
+
for (const [fieldName, descriptor] of Object.entries(ctx.definition.fields)) {
|
|
1489
|
+
if (descriptor.auto && descriptor.kind === "timestamp") {
|
|
1490
|
+
validated[fieldName] = Date.now();
|
|
430
1491
|
}
|
|
431
|
-
|
|
432
|
-
|
|
1492
|
+
}
|
|
1493
|
+
const causalDeps = resolveCausalDeps(ctx);
|
|
1494
|
+
let operation;
|
|
1495
|
+
let record;
|
|
1496
|
+
await ctx.adapter.transaction(async (tx) => {
|
|
1497
|
+
const sequenceNumber = await allocateNextSequenceInTransaction(tx, ctx.nodeId);
|
|
1498
|
+
operation = await (0, import_core8.createOperation)(
|
|
433
1499
|
{
|
|
434
|
-
nodeId:
|
|
1500
|
+
nodeId: ctx.nodeId,
|
|
435
1501
|
type: "insert",
|
|
436
|
-
collection:
|
|
1502
|
+
collection: ctx.collection,
|
|
437
1503
|
recordId,
|
|
438
1504
|
data: { ...validated },
|
|
439
1505
|
previousData: null,
|
|
440
1506
|
sequenceNumber,
|
|
441
|
-
causalDeps
|
|
442
|
-
schemaVersion:
|
|
1507
|
+
causalDeps,
|
|
1508
|
+
schemaVersion: ctx.schema.version
|
|
443
1509
|
},
|
|
444
|
-
|
|
1510
|
+
ctx.clock
|
|
445
1511
|
);
|
|
446
|
-
|
|
447
|
-
const
|
|
1512
|
+
ctx.causalTracker?.afterOperation(ctx.collection, operation.id, ctx.inTransaction);
|
|
1513
|
+
const serializedData = serializeRecord(validated, ctx.definition.fields);
|
|
1514
|
+
const version = serializeRowVersion(operation.timestamp);
|
|
1515
|
+
record = {
|
|
448
1516
|
id: recordId,
|
|
449
1517
|
...serializedData,
|
|
450
|
-
_created_at:
|
|
451
|
-
_updated_at:
|
|
1518
|
+
_created_at: operation.timestamp.wallTime,
|
|
1519
|
+
_updated_at: operation.timestamp.wallTime,
|
|
1520
|
+
_version: version
|
|
452
1521
|
};
|
|
453
|
-
const
|
|
454
|
-
const opRow = serializeOperation(operation);
|
|
1522
|
+
const builtInsert = buildInsertQuery(ctx.collection, record);
|
|
455
1523
|
const opInsert = buildInsertQuery(
|
|
456
|
-
`_kora_ops_${
|
|
457
|
-
|
|
1524
|
+
`_kora_ops_${ctx.collection}`,
|
|
1525
|
+
serializeOperation(operation)
|
|
458
1526
|
);
|
|
459
|
-
await
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
`
|
|
481
|
-
|
|
1527
|
+
await tx.execute(builtInsert.sql, builtInsert.params);
|
|
1528
|
+
await tx.execute(opInsert.sql, opInsert.params);
|
|
1529
|
+
});
|
|
1530
|
+
ctx.onMutation(ctx.collection, operation);
|
|
1531
|
+
return {
|
|
1532
|
+
id: recordId,
|
|
1533
|
+
...validated,
|
|
1534
|
+
createdAt: operation.timestamp.wallTime,
|
|
1535
|
+
updatedAt: operation.timestamp.wallTime
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// src/mutations/execute-update.ts
|
|
1540
|
+
var import_core10 = require("@korajs/core");
|
|
1541
|
+
init_serializer();
|
|
1542
|
+
|
|
1543
|
+
// src/state-machine/state-validator.ts
|
|
1544
|
+
var import_core9 = require("@korajs/core");
|
|
1545
|
+
var InvalidStateTransitionError = class extends import_core9.KoraError {
|
|
1546
|
+
constructor(collection, recordId, field, fromState, toState, allowedStates) {
|
|
1547
|
+
super(
|
|
1548
|
+
`Invalid state transition in collection "${collection}": cannot transition field "${field}" from "${fromState}" to "${toState}". Allowed transitions from "${fromState}": ${allowedStates.length > 0 ? allowedStates.join(", ") : "(none -- terminal state)"}`,
|
|
1549
|
+
"INVALID_STATE_TRANSITION",
|
|
1550
|
+
{ collection, recordId, field, fromState, toState, allowedStates }
|
|
482
1551
|
);
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
1552
|
+
this.collection = collection;
|
|
1553
|
+
this.recordId = recordId;
|
|
1554
|
+
this.field = field;
|
|
1555
|
+
this.fromState = fromState;
|
|
1556
|
+
this.toState = toState;
|
|
1557
|
+
this.allowedStates = allowedStates;
|
|
1558
|
+
this.name = "InvalidStateTransitionError";
|
|
486
1559
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
1560
|
+
collection;
|
|
1561
|
+
recordId;
|
|
1562
|
+
field;
|
|
1563
|
+
fromState;
|
|
1564
|
+
toState;
|
|
1565
|
+
allowedStates;
|
|
1566
|
+
};
|
|
1567
|
+
function validateStateTransition(collectionName, recordId, stateMachine, currentState, newState) {
|
|
1568
|
+
if (currentState === null) {
|
|
1569
|
+
return { valid: true, allowedStates: [] };
|
|
1570
|
+
}
|
|
1571
|
+
if (currentState === newState) {
|
|
1572
|
+
return { valid: true, allowedStates: stateMachine.transitions[currentState] ?? [] };
|
|
1573
|
+
}
|
|
1574
|
+
const constraint = {
|
|
1575
|
+
field: stateMachine.field,
|
|
1576
|
+
collection: collectionName,
|
|
1577
|
+
transitions: stateMachine.transitions
|
|
1578
|
+
};
|
|
1579
|
+
const transitionResult = (0, import_core9.validateTransition)(constraint, currentState, newState);
|
|
1580
|
+
if (transitionResult.valid) {
|
|
1581
|
+
return { valid: true, allowedStates: transitionResult.allowedTargets };
|
|
1582
|
+
}
|
|
1583
|
+
const allowedStates = transitionResult.allowedTargets;
|
|
1584
|
+
if (stateMachine.onInvalidTransition === "reject") {
|
|
1585
|
+
throw new InvalidStateTransitionError(
|
|
1586
|
+
collectionName,
|
|
1587
|
+
recordId,
|
|
1588
|
+
stateMachine.field,
|
|
1589
|
+
currentState,
|
|
1590
|
+
newState,
|
|
1591
|
+
allowedStates
|
|
499
1592
|
);
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
1593
|
+
}
|
|
1594
|
+
return { valid: false, allowedStates };
|
|
1595
|
+
}
|
|
1596
|
+
function validateUpdateStateMachine(collectionName, recordId, collectionDef, currentRecord, updateData) {
|
|
1597
|
+
const stateMachine = collectionDef.stateMachine;
|
|
1598
|
+
if (stateMachine === void 0) {
|
|
1599
|
+
return updateData;
|
|
1600
|
+
}
|
|
1601
|
+
const stateField = stateMachine.field;
|
|
1602
|
+
if (!(stateField in updateData)) {
|
|
1603
|
+
return updateData;
|
|
1604
|
+
}
|
|
1605
|
+
const currentState = currentRecord[stateField];
|
|
1606
|
+
const newState = updateData[stateField];
|
|
1607
|
+
if (typeof currentState !== "string" || typeof newState !== "string") {
|
|
1608
|
+
return updateData;
|
|
1609
|
+
}
|
|
1610
|
+
const result = validateStateTransition(
|
|
1611
|
+
collectionName,
|
|
1612
|
+
recordId,
|
|
1613
|
+
stateMachine,
|
|
1614
|
+
currentState,
|
|
1615
|
+
newState
|
|
1616
|
+
);
|
|
1617
|
+
if (result.valid) {
|
|
1618
|
+
return updateData;
|
|
1619
|
+
}
|
|
1620
|
+
const filtered = { ...updateData };
|
|
1621
|
+
delete filtered[stateField];
|
|
1622
|
+
return filtered;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// src/mutations/execute-update.ts
|
|
1626
|
+
async function executeUpdate(ctx, id, data) {
|
|
1627
|
+
const currentRows = await ctx.adapter.query(
|
|
1628
|
+
`SELECT * FROM ${ctx.collection} WHERE id = ? AND _deleted = 0`,
|
|
1629
|
+
[id]
|
|
1630
|
+
);
|
|
1631
|
+
const currentRow = currentRows[0];
|
|
1632
|
+
if (!currentRow) {
|
|
1633
|
+
throw new RecordNotFoundError(ctx.collection, id);
|
|
1634
|
+
}
|
|
1635
|
+
let validated = (0, import_core10.validateRecord)(ctx.collection, ctx.definition, data, "update");
|
|
1636
|
+
const currentRecord = deserializeRecord(currentRow, ctx.definition.fields);
|
|
1637
|
+
validated = validateUpdateStateMachine(
|
|
1638
|
+
ctx.collection,
|
|
1639
|
+
id,
|
|
1640
|
+
ctx.definition,
|
|
1641
|
+
currentRecord,
|
|
1642
|
+
validated
|
|
1643
|
+
);
|
|
1644
|
+
if (Object.keys(validated).length === 0) {
|
|
1645
|
+
return { ...currentRecord };
|
|
1646
|
+
}
|
|
1647
|
+
const previousData = {};
|
|
1648
|
+
const resolvedData = {};
|
|
1649
|
+
const atomicOps = {};
|
|
1650
|
+
for (const key of Object.keys(validated)) {
|
|
1651
|
+
const value = validated[key];
|
|
1652
|
+
previousData[key] = currentRecord[key];
|
|
1653
|
+
if ((0, import_core10.isAtomicOp)(value)) {
|
|
1654
|
+
resolvedData[key] = (0, import_core10.resolveAtomicOp)(currentRecord[key], value);
|
|
1655
|
+
atomicOps[key] = (0, import_core10.toAtomicOp)(value);
|
|
1656
|
+
} else {
|
|
1657
|
+
resolvedData[key] = value;
|
|
510
1658
|
}
|
|
511
|
-
|
|
512
|
-
|
|
1659
|
+
}
|
|
1660
|
+
const hasAtomicOps = Object.keys(atomicOps).length > 0;
|
|
1661
|
+
const causalDeps = resolveCausalDeps(ctx);
|
|
1662
|
+
let operation;
|
|
1663
|
+
await ctx.adapter.transaction(async (tx) => {
|
|
1664
|
+
const sequenceNumber = await allocateNextSequenceInTransaction(tx, ctx.nodeId);
|
|
1665
|
+
operation = await (0, import_core10.createOperation)(
|
|
513
1666
|
{
|
|
514
|
-
nodeId:
|
|
1667
|
+
nodeId: ctx.nodeId,
|
|
515
1668
|
type: "update",
|
|
516
|
-
collection:
|
|
1669
|
+
collection: ctx.collection,
|
|
517
1670
|
recordId: id,
|
|
518
|
-
data: { ...
|
|
1671
|
+
data: { ...resolvedData },
|
|
519
1672
|
previousData,
|
|
520
1673
|
sequenceNumber,
|
|
521
|
-
causalDeps
|
|
522
|
-
schemaVersion:
|
|
1674
|
+
causalDeps,
|
|
1675
|
+
schemaVersion: ctx.schema.version,
|
|
1676
|
+
...hasAtomicOps ? { atomicOps } : {}
|
|
523
1677
|
},
|
|
524
|
-
|
|
1678
|
+
ctx.clock
|
|
525
1679
|
);
|
|
526
|
-
|
|
527
|
-
const
|
|
1680
|
+
ctx.causalTracker?.afterOperation(ctx.collection, operation.id, ctx.inTransaction);
|
|
1681
|
+
const serializedChanges = serializeRecord(resolvedData, ctx.definition.fields);
|
|
1682
|
+
const version = serializeRowVersion(operation.timestamp);
|
|
1683
|
+
const updateQuery = buildUpdateQuery(ctx.collection, id, {
|
|
528
1684
|
...serializedChanges,
|
|
529
|
-
_updated_at:
|
|
1685
|
+
_updated_at: operation.timestamp.wallTime,
|
|
1686
|
+
_version: version
|
|
530
1687
|
});
|
|
531
|
-
const opRow = serializeOperation(operation);
|
|
532
1688
|
const opInsert = buildInsertQuery(
|
|
533
|
-
`_kora_ops_${
|
|
534
|
-
|
|
1689
|
+
`_kora_ops_${ctx.collection}`,
|
|
1690
|
+
serializeOperation(operation)
|
|
535
1691
|
);
|
|
536
|
-
await
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
1692
|
+
await tx.execute(updateQuery.sql, updateQuery.params);
|
|
1693
|
+
await tx.execute(opInsert.sql, opInsert.params);
|
|
1694
|
+
});
|
|
1695
|
+
ctx.onMutation(ctx.collection, operation);
|
|
1696
|
+
const rows = await ctx.adapter.query(
|
|
1697
|
+
`SELECT * FROM ${ctx.collection} WHERE id = ? AND _deleted = 0`,
|
|
1698
|
+
[id]
|
|
1699
|
+
);
|
|
1700
|
+
const row = rows[0];
|
|
1701
|
+
if (!row) {
|
|
1702
|
+
throw new RecordNotFoundError(ctx.collection, id);
|
|
1703
|
+
}
|
|
1704
|
+
return deserializeRecord(row, ctx.definition.fields);
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// src/collection/collection.ts
|
|
1708
|
+
init_serializer();
|
|
1709
|
+
var Collection = class {
|
|
1710
|
+
constructor(name, definition, schema, adapter, clock, nodeId, allocateSequenceNumber, onMutation, relationEnforcer, mutationHandler, causalTracker) {
|
|
1711
|
+
this.name = name;
|
|
1712
|
+
this.definition = definition;
|
|
1713
|
+
this.schema = schema;
|
|
1714
|
+
this.adapter = adapter;
|
|
1715
|
+
this.clock = clock;
|
|
1716
|
+
this.nodeId = nodeId;
|
|
1717
|
+
this.allocateSequenceNumber = allocateSequenceNumber;
|
|
1718
|
+
this.onMutation = onMutation;
|
|
1719
|
+
this.relationEnforcer = relationEnforcer;
|
|
1720
|
+
this.mutationHandler = mutationHandler;
|
|
1721
|
+
this.causalTracker = causalTracker;
|
|
1722
|
+
}
|
|
1723
|
+
name;
|
|
1724
|
+
definition;
|
|
1725
|
+
schema;
|
|
1726
|
+
adapter;
|
|
1727
|
+
clock;
|
|
1728
|
+
nodeId;
|
|
1729
|
+
allocateSequenceNumber;
|
|
1730
|
+
onMutation;
|
|
1731
|
+
relationEnforcer;
|
|
1732
|
+
mutationHandler;
|
|
1733
|
+
causalTracker;
|
|
1734
|
+
mutationContext() {
|
|
1735
|
+
return {
|
|
1736
|
+
collection: this.name,
|
|
1737
|
+
definition: this.definition,
|
|
1738
|
+
schema: this.schema,
|
|
1739
|
+
adapter: this.adapter,
|
|
1740
|
+
clock: this.clock,
|
|
1741
|
+
nodeId: this.nodeId,
|
|
1742
|
+
allocateSequenceNumber: this.allocateSequenceNumber,
|
|
1743
|
+
onMutation: this.onMutation,
|
|
1744
|
+
relationEnforcer: this.relationEnforcer,
|
|
1745
|
+
causalTracker: this.causalTracker,
|
|
1746
|
+
inTransaction: false
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
async insert(data) {
|
|
1750
|
+
if (this.mutationHandler) {
|
|
1751
|
+
return this.mutationHandler.insert(this.name, data);
|
|
548
1752
|
}
|
|
549
|
-
return
|
|
1753
|
+
return executeInsert(this.mutationContext(), data);
|
|
550
1754
|
}
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
*
|
|
554
|
-
* @param id - The record ID to delete
|
|
555
|
-
* @throws {RecordNotFoundError} If the record doesn't exist or is already deleted
|
|
556
|
-
*/
|
|
557
|
-
async delete(id) {
|
|
558
|
-
const currentRows = await this.adapter.query(
|
|
1755
|
+
async findById(id) {
|
|
1756
|
+
const rows = await this.adapter.query(
|
|
559
1757
|
`SELECT * FROM ${this.name} WHERE id = ? AND _deleted = 0`,
|
|
560
1758
|
[id]
|
|
561
1759
|
);
|
|
562
|
-
|
|
563
|
-
|
|
1760
|
+
const row = rows[0];
|
|
1761
|
+
if (!row) return null;
|
|
1762
|
+
return deserializeRecord(row, this.definition.fields);
|
|
1763
|
+
}
|
|
1764
|
+
async update(id, data) {
|
|
1765
|
+
if (this.mutationHandler) {
|
|
1766
|
+
return this.mutationHandler.update(this.name, id, data);
|
|
564
1767
|
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
data: null,
|
|
574
|
-
previousData: null,
|
|
575
|
-
sequenceNumber,
|
|
576
|
-
causalDeps: [],
|
|
577
|
-
schemaVersion: this.schema.version
|
|
578
|
-
},
|
|
579
|
-
this.clock
|
|
580
|
-
);
|
|
581
|
-
const deleteQuery = buildSoftDeleteQuery(this.name, id, now);
|
|
582
|
-
const opRow = serializeOperation(operation);
|
|
583
|
-
const opInsert = buildInsertQuery(
|
|
584
|
-
`_kora_ops_${this.name}`,
|
|
585
|
-
opRow
|
|
586
|
-
);
|
|
587
|
-
await this.adapter.transaction(async (tx) => {
|
|
588
|
-
await tx.execute(deleteQuery.sql, deleteQuery.params);
|
|
589
|
-
await tx.execute(opInsert.sql, opInsert.params);
|
|
590
|
-
await tx.execute(
|
|
591
|
-
"INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
|
|
592
|
-
[this.nodeId, sequenceNumber]
|
|
593
|
-
);
|
|
594
|
-
});
|
|
595
|
-
this.onMutation(this.name, operation);
|
|
1768
|
+
return executeUpdate(this.mutationContext(), id, data);
|
|
1769
|
+
}
|
|
1770
|
+
async delete(id) {
|
|
1771
|
+
if (this.mutationHandler) {
|
|
1772
|
+
await this.mutationHandler.delete(this.name, id);
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
await executeDelete(this.mutationContext(), id);
|
|
596
1776
|
}
|
|
597
|
-
/** Get the collection name */
|
|
598
1777
|
getName() {
|
|
599
1778
|
return this.name;
|
|
600
1779
|
}
|
|
601
|
-
/** Get the collection definition */
|
|
602
1780
|
getDefinition() {
|
|
603
1781
|
return this.definition;
|
|
604
1782
|
}
|
|
1783
|
+
/** Replace the mutation handler (e.g. after ApplyPipeline is wired in createApp). */
|
|
1784
|
+
setMutationHandler(handler) {
|
|
1785
|
+
this.mutationHandler = handler;
|
|
1786
|
+
}
|
|
605
1787
|
};
|
|
606
1788
|
|
|
1789
|
+
// src/query/query-builder.ts
|
|
1790
|
+
init_serializer();
|
|
1791
|
+
|
|
607
1792
|
// src/query/pluralize.ts
|
|
608
1793
|
function isVowel(char) {
|
|
609
1794
|
if (!char) return false;
|
|
@@ -847,62 +2032,709 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
847
2032
|
}
|
|
848
2033
|
}
|
|
849
2034
|
/**
|
|
850
|
-
* One-to-many: collect primary IDs, batch-fetch children, attach as array.
|
|
2035
|
+
* One-to-many: collect primary IDs, batch-fetch children, attach as array.
|
|
2036
|
+
*/
|
|
2037
|
+
async resolveOneToManyInclude(records, relation, target) {
|
|
2038
|
+
const primaryIds = records.map((r) => r.id);
|
|
2039
|
+
const relatedCollection = relation.from;
|
|
2040
|
+
const relatedDef = this.schema?.collections[relatedCollection];
|
|
2041
|
+
if (!relatedDef) return;
|
|
2042
|
+
const fkField = relation.field;
|
|
2043
|
+
const placeholders = primaryIds.map(() => "?").join(", ");
|
|
2044
|
+
const sql = `SELECT * FROM ${relatedCollection} WHERE ${fkField} IN (${placeholders}) AND _deleted = 0`;
|
|
2045
|
+
const rows = await this.adapter.query(sql, primaryIds);
|
|
2046
|
+
const relatedRecords = rows.map((row) => deserializeRecord(row, relatedDef.fields));
|
|
2047
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
2048
|
+
for (const r of relatedRecords) {
|
|
2049
|
+
const fk = r[fkField];
|
|
2050
|
+
if (!grouped.has(fk)) {
|
|
2051
|
+
grouped.set(fk, []);
|
|
2052
|
+
}
|
|
2053
|
+
grouped.get(fk)?.push(r);
|
|
2054
|
+
}
|
|
2055
|
+
const propName = pluralize(target);
|
|
2056
|
+
for (const record of records) {
|
|
2057
|
+
;
|
|
2058
|
+
record[propName] = grouped.get(record.id) ?? [];
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
/**
|
|
2062
|
+
* Find a relation definition matching the include target.
|
|
2063
|
+
* Searches by relation name, target collection name, and singularized/pluralized variants.
|
|
2064
|
+
*/
|
|
2065
|
+
findRelation(target) {
|
|
2066
|
+
if (!this.schema) return null;
|
|
2067
|
+
for (const [_name, rel] of Object.entries(this.schema.relations)) {
|
|
2068
|
+
if (rel.from === this.collectionName && rel.to === target) return rel;
|
|
2069
|
+
if (rel.to === this.collectionName && rel.from === target) return rel;
|
|
2070
|
+
if (rel.from === this.collectionName && rel.to === pluralize(target)) return rel;
|
|
2071
|
+
if (rel.to === this.collectionName && rel.from === pluralize(target)) return rel;
|
|
2072
|
+
if (rel.from === this.collectionName && rel.to === singularize(target)) return rel;
|
|
2073
|
+
if (rel.to === this.collectionName && rel.from === singularize(target)) return rel;
|
|
2074
|
+
}
|
|
2075
|
+
return null;
|
|
2076
|
+
}
|
|
2077
|
+
};
|
|
2078
|
+
var QueryError2 = class extends Error {
|
|
2079
|
+
constructor(message) {
|
|
2080
|
+
super(message);
|
|
2081
|
+
this.name = "QueryError";
|
|
2082
|
+
}
|
|
2083
|
+
};
|
|
2084
|
+
|
|
2085
|
+
// src/relations/relation-enforcer.ts
|
|
2086
|
+
var import_core11 = require("@korajs/core");
|
|
2087
|
+
init_serializer();
|
|
2088
|
+
|
|
2089
|
+
// src/relations/relation-lookup.ts
|
|
2090
|
+
function buildRelationLookup(schema) {
|
|
2091
|
+
const lookup = /* @__PURE__ */ new Map();
|
|
2092
|
+
for (const [relationName, relation] of Object.entries(schema.relations)) {
|
|
2093
|
+
const targetCollection = relation.to;
|
|
2094
|
+
const existing = lookup.get(targetCollection) ?? [];
|
|
2095
|
+
existing.push({
|
|
2096
|
+
relationName,
|
|
2097
|
+
sourceCollection: relation.from,
|
|
2098
|
+
foreignKeyField: relation.field,
|
|
2099
|
+
onDelete: relation.onDelete,
|
|
2100
|
+
relation
|
|
2101
|
+
});
|
|
2102
|
+
lookup.set(targetCollection, existing);
|
|
2103
|
+
}
|
|
2104
|
+
return lookup;
|
|
2105
|
+
}
|
|
2106
|
+
function getIncomingRelations(lookup, collection) {
|
|
2107
|
+
return lookup.get(collection) ?? [];
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
// src/relations/relation-enforcer.ts
|
|
2111
|
+
var ReferentialIntegrityError = class extends import_core11.KoraError {
|
|
2112
|
+
constructor(collection, recordId, referencingCollection, relationName, referencingCount) {
|
|
2113
|
+
super(
|
|
2114
|
+
`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.`,
|
|
2115
|
+
"REFERENTIAL_INTEGRITY",
|
|
2116
|
+
{
|
|
2117
|
+
collection,
|
|
2118
|
+
recordId,
|
|
2119
|
+
referencingCollection,
|
|
2120
|
+
relationName,
|
|
2121
|
+
referencingCount
|
|
2122
|
+
}
|
|
2123
|
+
);
|
|
2124
|
+
this.name = "ReferentialIntegrityError";
|
|
2125
|
+
}
|
|
2126
|
+
};
|
|
2127
|
+
var RelationEnforcer = class {
|
|
2128
|
+
lookup;
|
|
2129
|
+
schema;
|
|
2130
|
+
adapter;
|
|
2131
|
+
clock;
|
|
2132
|
+
nodeId;
|
|
2133
|
+
constructor(config) {
|
|
2134
|
+
this.schema = config.schema;
|
|
2135
|
+
this.adapter = config.adapter;
|
|
2136
|
+
this.clock = config.clock;
|
|
2137
|
+
this.nodeId = config.nodeId;
|
|
2138
|
+
this.lookup = buildRelationLookup(config.schema);
|
|
2139
|
+
}
|
|
2140
|
+
/**
|
|
2141
|
+
* Enforce referential integrity after deleting a record.
|
|
2142
|
+
*
|
|
2143
|
+
* Must be called within a transaction. The transaction handle is used
|
|
2144
|
+
* for all cascaded writes to ensure atomicity.
|
|
2145
|
+
*
|
|
2146
|
+
* @param collection - The collection the deleted record belongs to
|
|
2147
|
+
* @param recordId - The ID of the deleted record
|
|
2148
|
+
* @param tx - The active transaction handle
|
|
2149
|
+
* @param causalDeps - Causal dependencies for generated operations
|
|
2150
|
+
* @returns All additional operations created as side effects
|
|
2151
|
+
* @throws {ReferentialIntegrityError} If a 'restrict' policy is violated
|
|
2152
|
+
*/
|
|
2153
|
+
async enforceDelete(collection, recordId, tx, causalDeps) {
|
|
2154
|
+
const incomingRelations = getIncomingRelations(this.lookup, collection);
|
|
2155
|
+
if (incomingRelations.length === 0) {
|
|
2156
|
+
return { operations: [] };
|
|
2157
|
+
}
|
|
2158
|
+
const allOperations = [];
|
|
2159
|
+
const sortedRelations = [...incomingRelations].sort(
|
|
2160
|
+
(a, b) => a.relationName.localeCompare(b.relationName)
|
|
2161
|
+
);
|
|
2162
|
+
for (const incoming of sortedRelations) {
|
|
2163
|
+
const ops = await this.enforceRelation(incoming, recordId, tx, causalDeps);
|
|
2164
|
+
allOperations.push(...ops);
|
|
2165
|
+
}
|
|
2166
|
+
return { operations: allOperations };
|
|
2167
|
+
}
|
|
2168
|
+
/**
|
|
2169
|
+
* Enforce a single relation's onDelete policy.
|
|
2170
|
+
*/
|
|
2171
|
+
async enforceRelation(incoming, deletedRecordId, tx, causalDeps) {
|
|
2172
|
+
switch (incoming.onDelete) {
|
|
2173
|
+
case "cascade":
|
|
2174
|
+
return this.enforceCascade(incoming, deletedRecordId, tx, causalDeps);
|
|
2175
|
+
case "set-null":
|
|
2176
|
+
return this.enforceSetNull(incoming, deletedRecordId, tx, causalDeps);
|
|
2177
|
+
case "restrict":
|
|
2178
|
+
return this.enforceRestrict(incoming, deletedRecordId, tx);
|
|
2179
|
+
case "no-action":
|
|
2180
|
+
return [];
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
/**
|
|
2184
|
+
* Cascade: delete all records in the source collection that reference
|
|
2185
|
+
* the deleted record, then recursively cascade those deletes.
|
|
2186
|
+
*/
|
|
2187
|
+
async enforceCascade(incoming, deletedRecordId, tx, causalDeps) {
|
|
2188
|
+
const { sourceCollection, foreignKeyField } = incoming;
|
|
2189
|
+
const referencingRows = await tx.query(
|
|
2190
|
+
`SELECT id FROM ${sourceCollection} WHERE ${foreignKeyField} = ? AND _deleted = 0`,
|
|
2191
|
+
[deletedRecordId]
|
|
2192
|
+
);
|
|
2193
|
+
if (referencingRows.length === 0) {
|
|
2194
|
+
return [];
|
|
2195
|
+
}
|
|
2196
|
+
const operations = [];
|
|
2197
|
+
for (const row of referencingRows) {
|
|
2198
|
+
const now = Date.now();
|
|
2199
|
+
const sequenceNumber = await allocateNextSequenceInTransaction(tx, this.nodeId);
|
|
2200
|
+
const operation = await (0, import_core11.createOperation)(
|
|
2201
|
+
{
|
|
2202
|
+
nodeId: this.nodeId,
|
|
2203
|
+
type: "delete",
|
|
2204
|
+
collection: sourceCollection,
|
|
2205
|
+
recordId: row.id,
|
|
2206
|
+
data: null,
|
|
2207
|
+
previousData: null,
|
|
2208
|
+
sequenceNumber,
|
|
2209
|
+
causalDeps: [...causalDeps],
|
|
2210
|
+
schemaVersion: this.schema.version
|
|
2211
|
+
},
|
|
2212
|
+
this.clock
|
|
2213
|
+
);
|
|
2214
|
+
const deleteQuery = buildSoftDeleteQuery(sourceCollection, row.id, now);
|
|
2215
|
+
await tx.execute(deleteQuery.sql, deleteQuery.params);
|
|
2216
|
+
const opInsert = buildInsertQuery(
|
|
2217
|
+
`_kora_ops_${sourceCollection}`,
|
|
2218
|
+
serializeOperation(operation)
|
|
2219
|
+
);
|
|
2220
|
+
await tx.execute(opInsert.sql, opInsert.params);
|
|
2221
|
+
operations.push(operation);
|
|
2222
|
+
const cascadeResult = await this.enforceDelete(sourceCollection, row.id, tx, [operation.id]);
|
|
2223
|
+
operations.push(...cascadeResult.operations);
|
|
2224
|
+
}
|
|
2225
|
+
return operations;
|
|
2226
|
+
}
|
|
2227
|
+
/**
|
|
2228
|
+
* Set-null: update all referencing records to set the foreign key to null.
|
|
2229
|
+
*/
|
|
2230
|
+
async enforceSetNull(incoming, deletedRecordId, tx, causalDeps) {
|
|
2231
|
+
const { sourceCollection, foreignKeyField } = incoming;
|
|
2232
|
+
const collectionDef = this.schema.collections[sourceCollection];
|
|
2233
|
+
if (!collectionDef) {
|
|
2234
|
+
return [];
|
|
2235
|
+
}
|
|
2236
|
+
const referencingRows = await tx.query(
|
|
2237
|
+
`SELECT id FROM ${sourceCollection} WHERE ${foreignKeyField} = ? AND _deleted = 0`,
|
|
2238
|
+
[deletedRecordId]
|
|
2239
|
+
);
|
|
2240
|
+
if (referencingRows.length === 0) {
|
|
2241
|
+
return [];
|
|
2242
|
+
}
|
|
2243
|
+
const operations = [];
|
|
2244
|
+
for (const row of referencingRows) {
|
|
2245
|
+
const now = Date.now();
|
|
2246
|
+
const sequenceNumber = await allocateNextSequenceInTransaction(tx, this.nodeId);
|
|
2247
|
+
const updateData = { [foreignKeyField]: null };
|
|
2248
|
+
const previousData = { [foreignKeyField]: deletedRecordId };
|
|
2249
|
+
const operation = await (0, import_core11.createOperation)(
|
|
2250
|
+
{
|
|
2251
|
+
nodeId: this.nodeId,
|
|
2252
|
+
type: "update",
|
|
2253
|
+
collection: sourceCollection,
|
|
2254
|
+
recordId: row.id,
|
|
2255
|
+
data: { ...updateData },
|
|
2256
|
+
previousData,
|
|
2257
|
+
sequenceNumber,
|
|
2258
|
+
causalDeps: [...causalDeps],
|
|
2259
|
+
schemaVersion: this.schema.version
|
|
2260
|
+
},
|
|
2261
|
+
this.clock
|
|
2262
|
+
);
|
|
2263
|
+
const serializedChanges = serializeRecord(updateData, collectionDef.fields);
|
|
2264
|
+
const updateQuery = buildUpdateQuery(sourceCollection, row.id, {
|
|
2265
|
+
...serializedChanges,
|
|
2266
|
+
_updated_at: now
|
|
2267
|
+
});
|
|
2268
|
+
await tx.execute(updateQuery.sql, updateQuery.params);
|
|
2269
|
+
const opRow = serializeOperation(operation);
|
|
2270
|
+
const opInsert = buildInsertQuery(
|
|
2271
|
+
`_kora_ops_${sourceCollection}`,
|
|
2272
|
+
opRow
|
|
2273
|
+
);
|
|
2274
|
+
await tx.execute(opInsert.sql, opInsert.params);
|
|
2275
|
+
operations.push(operation);
|
|
2276
|
+
}
|
|
2277
|
+
return operations;
|
|
2278
|
+
}
|
|
2279
|
+
/**
|
|
2280
|
+
* Restrict: refuse the delete if any referencing records exist.
|
|
2281
|
+
*/
|
|
2282
|
+
async enforceRestrict(incoming, deletedRecordId, tx) {
|
|
2283
|
+
const { sourceCollection, foreignKeyField, relationName } = incoming;
|
|
2284
|
+
const countRows = await tx.query(
|
|
2285
|
+
`SELECT COUNT(*) as cnt FROM ${sourceCollection} WHERE ${foreignKeyField} = ? AND _deleted = 0`,
|
|
2286
|
+
[deletedRecordId]
|
|
2287
|
+
);
|
|
2288
|
+
const count = countRows[0]?.cnt ?? 0;
|
|
2289
|
+
if (count > 0) {
|
|
2290
|
+
const targetCollection = incoming.relation.to;
|
|
2291
|
+
throw new ReferentialIntegrityError(
|
|
2292
|
+
targetCollection,
|
|
2293
|
+
deletedRecordId,
|
|
2294
|
+
sourceCollection,
|
|
2295
|
+
relationName,
|
|
2296
|
+
count
|
|
2297
|
+
);
|
|
2298
|
+
}
|
|
2299
|
+
return [];
|
|
2300
|
+
}
|
|
2301
|
+
/**
|
|
2302
|
+
* Get the relation lookup map for external use (e.g., by the merge engine).
|
|
2303
|
+
*/
|
|
2304
|
+
getRelationLookup() {
|
|
2305
|
+
return this.lookup;
|
|
2306
|
+
}
|
|
2307
|
+
};
|
|
2308
|
+
|
|
2309
|
+
// src/replay/replay-to.ts
|
|
2310
|
+
var import_core12 = require("@korajs/core");
|
|
2311
|
+
var import_internal = require("@korajs/core/internal");
|
|
2312
|
+
function collectCausalClosure(allOps, targetOperationId) {
|
|
2313
|
+
const opMap = /* @__PURE__ */ new Map();
|
|
2314
|
+
for (const op of allOps) {
|
|
2315
|
+
opMap.set(op.id, op);
|
|
2316
|
+
}
|
|
2317
|
+
const target = opMap.get(targetOperationId);
|
|
2318
|
+
if (!target) {
|
|
2319
|
+
throw new import_core12.OperationError(
|
|
2320
|
+
`Operation "${targetOperationId}" not found in the local operation log`,
|
|
2321
|
+
{
|
|
2322
|
+
operationId: targetOperationId
|
|
2323
|
+
}
|
|
2324
|
+
);
|
|
2325
|
+
}
|
|
2326
|
+
const included = /* @__PURE__ */ new Set();
|
|
2327
|
+
const stack = [targetOperationId];
|
|
2328
|
+
while (stack.length > 0) {
|
|
2329
|
+
const id = stack.pop();
|
|
2330
|
+
if (id === void 0 || included.has(id)) {
|
|
2331
|
+
continue;
|
|
2332
|
+
}
|
|
2333
|
+
included.add(id);
|
|
2334
|
+
const op = opMap.get(id);
|
|
2335
|
+
if (!op) {
|
|
2336
|
+
continue;
|
|
2337
|
+
}
|
|
2338
|
+
for (const depId of op.causalDeps) {
|
|
2339
|
+
if (opMap.has(depId)) {
|
|
2340
|
+
stack.push(depId);
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
const subset = allOps.filter((op) => included.has(op.id));
|
|
2345
|
+
return (0, import_internal.topologicalSort)(subset);
|
|
2346
|
+
}
|
|
2347
|
+
function buildReplaySnapshot(schema, allOps, targetOperationId) {
|
|
2348
|
+
const operationsApplied = collectCausalClosure(allOps, targetOperationId);
|
|
2349
|
+
const targetOperation = operationsApplied.find((op) => op.id === targetOperationId);
|
|
2350
|
+
if (!targetOperation) {
|
|
2351
|
+
throw new import_core12.OperationError(`Operation "${targetOperationId}" not found after causal sort`, {
|
|
2352
|
+
operationId: targetOperationId
|
|
2353
|
+
});
|
|
2354
|
+
}
|
|
2355
|
+
const memory = /* @__PURE__ */ new Map();
|
|
2356
|
+
for (const op of operationsApplied) {
|
|
2357
|
+
applyOperationToMemory(memory, op, schema);
|
|
2358
|
+
}
|
|
2359
|
+
const collections = materializeCollections(schema, memory);
|
|
2360
|
+
return {
|
|
2361
|
+
targetOperation,
|
|
2362
|
+
operationsApplied,
|
|
2363
|
+
collections,
|
|
2364
|
+
findRecord(collection, recordId) {
|
|
2365
|
+
const colMap = memory.get(collection);
|
|
2366
|
+
const record = colMap?.get(recordId);
|
|
2367
|
+
if (!record || record.deleted) {
|
|
2368
|
+
return null;
|
|
2369
|
+
}
|
|
2370
|
+
const definition = schema.collections[collection];
|
|
2371
|
+
if (!definition) {
|
|
2372
|
+
return null;
|
|
2373
|
+
}
|
|
2374
|
+
return toCollectionRecord(record, definition);
|
|
2375
|
+
}
|
|
2376
|
+
};
|
|
2377
|
+
}
|
|
2378
|
+
function applyOperationToMemory(state, op, schema) {
|
|
2379
|
+
const definition = schema.collections[op.collection];
|
|
2380
|
+
if (!definition) {
|
|
2381
|
+
return;
|
|
2382
|
+
}
|
|
2383
|
+
let colMap = state.get(op.collection);
|
|
2384
|
+
if (!colMap) {
|
|
2385
|
+
colMap = /* @__PURE__ */ new Map();
|
|
2386
|
+
state.set(op.collection, colMap);
|
|
2387
|
+
}
|
|
2388
|
+
const wallTime = op.timestamp.wallTime;
|
|
2389
|
+
switch (op.type) {
|
|
2390
|
+
case "insert": {
|
|
2391
|
+
if (!op.data) {
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
colMap.set(op.recordId, {
|
|
2395
|
+
id: op.recordId,
|
|
2396
|
+
fields: { ...op.data },
|
|
2397
|
+
deleted: false,
|
|
2398
|
+
createdAt: wallTime,
|
|
2399
|
+
updatedAt: wallTime
|
|
2400
|
+
});
|
|
2401
|
+
break;
|
|
2402
|
+
}
|
|
2403
|
+
case "update": {
|
|
2404
|
+
if (!op.data) {
|
|
2405
|
+
return;
|
|
2406
|
+
}
|
|
2407
|
+
const existing = colMap.get(op.recordId);
|
|
2408
|
+
if (existing && !existing.deleted) {
|
|
2409
|
+
existing.fields = { ...existing.fields, ...op.data };
|
|
2410
|
+
existing.updatedAt = wallTime;
|
|
2411
|
+
break;
|
|
2412
|
+
}
|
|
2413
|
+
if (existing?.deleted) {
|
|
2414
|
+
return;
|
|
2415
|
+
}
|
|
2416
|
+
colMap.set(op.recordId, {
|
|
2417
|
+
id: op.recordId,
|
|
2418
|
+
fields: { ...op.data },
|
|
2419
|
+
deleted: false,
|
|
2420
|
+
createdAt: wallTime,
|
|
2421
|
+
updatedAt: wallTime
|
|
2422
|
+
});
|
|
2423
|
+
break;
|
|
2424
|
+
}
|
|
2425
|
+
case "delete": {
|
|
2426
|
+
const existing = colMap.get(op.recordId);
|
|
2427
|
+
if (existing) {
|
|
2428
|
+
existing.deleted = true;
|
|
2429
|
+
existing.updatedAt = wallTime;
|
|
2430
|
+
return;
|
|
2431
|
+
}
|
|
2432
|
+
colMap.set(op.recordId, {
|
|
2433
|
+
id: op.recordId,
|
|
2434
|
+
fields: {},
|
|
2435
|
+
deleted: true,
|
|
2436
|
+
createdAt: wallTime,
|
|
2437
|
+
updatedAt: wallTime
|
|
2438
|
+
});
|
|
2439
|
+
break;
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
function materializeCollections(schema, memory) {
|
|
2444
|
+
const collections = {};
|
|
2445
|
+
for (const [collectionName, definition] of Object.entries(schema.collections)) {
|
|
2446
|
+
const colMap = memory.get(collectionName);
|
|
2447
|
+
const records = [];
|
|
2448
|
+
if (colMap) {
|
|
2449
|
+
for (const record of colMap.values()) {
|
|
2450
|
+
if (!record.deleted) {
|
|
2451
|
+
records.push(toCollectionRecord(record, definition));
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
collections[collectionName] = records;
|
|
2456
|
+
}
|
|
2457
|
+
return collections;
|
|
2458
|
+
}
|
|
2459
|
+
function toCollectionRecord(record, definition) {
|
|
2460
|
+
const result = {
|
|
2461
|
+
id: record.id,
|
|
2462
|
+
createdAt: record.createdAt,
|
|
2463
|
+
updatedAt: record.updatedAt
|
|
2464
|
+
};
|
|
2465
|
+
for (const [fieldName] of Object.entries(definition.fields)) {
|
|
2466
|
+
if (fieldName in record.fields) {
|
|
2467
|
+
result[fieldName] = record.fields[fieldName];
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
return result;
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
// src/sequences/sequence-manager.ts
|
|
2474
|
+
var import_core13 = require("@korajs/core");
|
|
2475
|
+
var SequenceManager = class {
|
|
2476
|
+
adapter;
|
|
2477
|
+
nodeId;
|
|
2478
|
+
constructor(adapter, nodeId) {
|
|
2479
|
+
this.adapter = adapter;
|
|
2480
|
+
this.nodeId = nodeId;
|
|
2481
|
+
}
|
|
2482
|
+
/**
|
|
2483
|
+
* Get the next value in a sequence, atomically incrementing the counter.
|
|
2484
|
+
*
|
|
2485
|
+
* @param name - The sequence name (e.g., 'receipt', 'invoice')
|
|
2486
|
+
* @param config - Optional configuration for scope, format, and starting value
|
|
2487
|
+
* @returns The formatted sequence value
|
|
2488
|
+
*/
|
|
2489
|
+
async next(name, config) {
|
|
2490
|
+
const scope = config?.scope ?? "";
|
|
2491
|
+
const startAt = config?.startAt ?? 1;
|
|
2492
|
+
const format = config?.format ?? (0, import_core13.defaultSequenceFormat)(name);
|
|
2493
|
+
let counter = 0;
|
|
2494
|
+
await this.adapter.transaction(async (tx) => {
|
|
2495
|
+
const rows = await tx.query(
|
|
2496
|
+
"SELECT counter FROM _kora_sequences WHERE name = ? AND scope = ? AND node_id = ?",
|
|
2497
|
+
[name, scope, this.nodeId]
|
|
2498
|
+
);
|
|
2499
|
+
if (rows.length > 0) {
|
|
2500
|
+
const row = rows[0];
|
|
2501
|
+
counter = row.counter + 1;
|
|
2502
|
+
await tx.execute(
|
|
2503
|
+
"UPDATE _kora_sequences SET counter = ? WHERE name = ? AND scope = ? AND node_id = ?",
|
|
2504
|
+
[counter, name, scope, this.nodeId]
|
|
2505
|
+
);
|
|
2506
|
+
} else {
|
|
2507
|
+
counter = startAt;
|
|
2508
|
+
await tx.execute(
|
|
2509
|
+
"INSERT INTO _kora_sequences (name, scope, node_id, counter) VALUES (?, ?, ?, ?)",
|
|
2510
|
+
[name, scope, this.nodeId, counter]
|
|
2511
|
+
);
|
|
2512
|
+
}
|
|
2513
|
+
});
|
|
2514
|
+
return (0, import_core13.formatSequenceValue)(format, counter, this.nodeId);
|
|
2515
|
+
}
|
|
2516
|
+
/**
|
|
2517
|
+
* Get the current counter value without incrementing.
|
|
2518
|
+
*
|
|
2519
|
+
* @param name - The sequence name
|
|
2520
|
+
* @param config - Optional scope
|
|
2521
|
+
* @returns The current counter value, or 0 if the sequence has never been used
|
|
2522
|
+
*/
|
|
2523
|
+
async current(name, config) {
|
|
2524
|
+
const scope = config?.scope ?? "";
|
|
2525
|
+
const rows = await this.adapter.query(
|
|
2526
|
+
"SELECT counter FROM _kora_sequences WHERE name = ? AND scope = ? AND node_id = ?",
|
|
2527
|
+
[name, scope, this.nodeId]
|
|
2528
|
+
);
|
|
2529
|
+
if (rows.length > 0) {
|
|
2530
|
+
return rows[0].counter;
|
|
2531
|
+
}
|
|
2532
|
+
return 0;
|
|
2533
|
+
}
|
|
2534
|
+
/**
|
|
2535
|
+
* Reset a sequence counter.
|
|
2536
|
+
*
|
|
2537
|
+
* @param name - The sequence name
|
|
2538
|
+
* @param config - Optional scope and target value
|
|
2539
|
+
*/
|
|
2540
|
+
async reset(name, config) {
|
|
2541
|
+
const scope = config?.scope ?? "";
|
|
2542
|
+
const to = config?.to ?? 0;
|
|
2543
|
+
await this.adapter.execute(
|
|
2544
|
+
"DELETE FROM _kora_sequences WHERE name = ? AND scope = ? AND node_id = ?",
|
|
2545
|
+
[name, scope, this.nodeId]
|
|
2546
|
+
);
|
|
2547
|
+
if (to > 0) {
|
|
2548
|
+
await this.adapter.execute(
|
|
2549
|
+
"INSERT INTO _kora_sequences (name, scope, node_id, counter) VALUES (?, ?, ?, ?)",
|
|
2550
|
+
[name, scope, this.nodeId, to]
|
|
2551
|
+
);
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
};
|
|
2555
|
+
|
|
2556
|
+
// src/store/store.ts
|
|
2557
|
+
init_serializer();
|
|
2558
|
+
|
|
2559
|
+
// src/subscription/bloom-filter.ts
|
|
2560
|
+
var FNV_OFFSET_BASIS = 2166136261;
|
|
2561
|
+
var FNV_PRIME = 16777619;
|
|
2562
|
+
function fnv1a32(input) {
|
|
2563
|
+
let hash = FNV_OFFSET_BASIS;
|
|
2564
|
+
for (let i = 0; i < input.length; i++) {
|
|
2565
|
+
hash ^= input.charCodeAt(i);
|
|
2566
|
+
hash = Math.imul(hash, FNV_PRIME);
|
|
2567
|
+
}
|
|
2568
|
+
return hash >>> 0;
|
|
2569
|
+
}
|
|
2570
|
+
function optimalBitCount(expectedItems, falsePositiveRate) {
|
|
2571
|
+
if (expectedItems <= 0) return 32;
|
|
2572
|
+
if (falsePositiveRate <= 0 || falsePositiveRate >= 1) return 32;
|
|
2573
|
+
const ln2Squared = Math.LN2 * Math.LN2;
|
|
2574
|
+
const rawBits = -(expectedItems * Math.log(falsePositiveRate)) / ln2Squared;
|
|
2575
|
+
const aligned = Math.ceil(rawBits / 32) * 32;
|
|
2576
|
+
return Math.max(32, aligned);
|
|
2577
|
+
}
|
|
2578
|
+
function optimalHashCount(bitCount, expectedItems) {
|
|
2579
|
+
if (expectedItems <= 0) return 1;
|
|
2580
|
+
const raw = bitCount / expectedItems * Math.LN2;
|
|
2581
|
+
return Math.max(1, Math.min(30, Math.round(raw)));
|
|
2582
|
+
}
|
|
2583
|
+
var SubscriptionBloomFilter = class {
|
|
2584
|
+
bits;
|
|
2585
|
+
bitCount;
|
|
2586
|
+
hashCount;
|
|
2587
|
+
itemCount = 0;
|
|
2588
|
+
constructor(expectedItems, falsePositiveRate = 0.01) {
|
|
2589
|
+
this.bitCount = optimalBitCount(expectedItems, falsePositiveRate);
|
|
2590
|
+
this.hashCount = optimalHashCount(this.bitCount, expectedItems);
|
|
2591
|
+
this.bits = new Uint32Array(this.bitCount / 32);
|
|
2592
|
+
}
|
|
2593
|
+
/**
|
|
2594
|
+
* Add a collection (and optional field) to the bloom filter.
|
|
2595
|
+
*
|
|
2596
|
+
* @param collection - Collection name (e.g., "todos")
|
|
2597
|
+
* @param field - Optional field name (e.g., "completed")
|
|
2598
|
+
*/
|
|
2599
|
+
add(collection, field) {
|
|
2600
|
+
const key = field !== void 0 ? `${collection}:${field}` : collection;
|
|
2601
|
+
const positions = this.getPositions(key);
|
|
2602
|
+
for (const pos of positions) {
|
|
2603
|
+
const wordIndex = pos >>> 5;
|
|
2604
|
+
const bitIndex = pos & 31;
|
|
2605
|
+
const current = this.bits[wordIndex] ?? 0;
|
|
2606
|
+
this.bits[wordIndex] = current | 1 << bitIndex;
|
|
2607
|
+
}
|
|
2608
|
+
this.itemCount++;
|
|
2609
|
+
}
|
|
2610
|
+
/**
|
|
2611
|
+
* Check if a collection (and optional field) might be in the filter.
|
|
2612
|
+
*
|
|
2613
|
+
* A return value of `false` means the item is DEFINITELY NOT in the filter.
|
|
2614
|
+
* A return value of `true` means the item MIGHT be in the filter (possible false positive).
|
|
2615
|
+
*
|
|
2616
|
+
* @param collection - Collection name to check
|
|
2617
|
+
* @param field - Optional field name to check
|
|
2618
|
+
* @returns false = definitely absent, true = possibly present
|
|
2619
|
+
*/
|
|
2620
|
+
mightContain(collection, field) {
|
|
2621
|
+
const key = field !== void 0 ? `${collection}:${field}` : collection;
|
|
2622
|
+
const positions = this.getPositions(key);
|
|
2623
|
+
for (const pos of positions) {
|
|
2624
|
+
const wordIndex = pos >>> 5;
|
|
2625
|
+
const bitIndex = pos & 31;
|
|
2626
|
+
if (((this.bits[wordIndex] ?? 0) & 1 << bitIndex) === 0) {
|
|
2627
|
+
return false;
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
return true;
|
|
2631
|
+
}
|
|
2632
|
+
/**
|
|
2633
|
+
* Reset the bloom filter, clearing all bits and the item count.
|
|
851
2634
|
*/
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
2635
|
+
clear() {
|
|
2636
|
+
this.bits.fill(0);
|
|
2637
|
+
this.itemCount = 0;
|
|
2638
|
+
}
|
|
2639
|
+
/**
|
|
2640
|
+
* Estimate the current false positive rate based on the number of items inserted.
|
|
2641
|
+
*
|
|
2642
|
+
* Formula: (1 - e^(-kn/m))^k
|
|
2643
|
+
* where k = hash count, n = item count, m = bit count.
|
|
2644
|
+
*
|
|
2645
|
+
* @returns Estimated false positive rate as a number between 0 and 1
|
|
2646
|
+
*/
|
|
2647
|
+
estimatedFalsePositiveRate() {
|
|
2648
|
+
if (this.itemCount === 0) return 0;
|
|
2649
|
+
const exponent = -(this.hashCount * this.itemCount) / this.bitCount;
|
|
2650
|
+
return (1 - Math.exp(exponent)) ** this.hashCount;
|
|
2651
|
+
}
|
|
2652
|
+
/**
|
|
2653
|
+
* @returns The number of items that have been added to the filter
|
|
2654
|
+
*/
|
|
2655
|
+
getItemCount() {
|
|
2656
|
+
return this.itemCount;
|
|
2657
|
+
}
|
|
2658
|
+
/**
|
|
2659
|
+
* @returns The total number of bits in the filter
|
|
2660
|
+
*/
|
|
2661
|
+
getBitCount() {
|
|
2662
|
+
return this.bitCount;
|
|
2663
|
+
}
|
|
2664
|
+
/**
|
|
2665
|
+
* @returns The number of hash functions used
|
|
2666
|
+
*/
|
|
2667
|
+
getHashCount() {
|
|
2668
|
+
return this.hashCount;
|
|
2669
|
+
}
|
|
2670
|
+
/**
|
|
2671
|
+
* Count the number of bits currently set to 1.
|
|
2672
|
+
* Uses Brian Kernighan's algorithm: each iteration clears the lowest set bit,
|
|
2673
|
+
* so the loop runs exactly as many times as there are set bits.
|
|
2674
|
+
*
|
|
2675
|
+
* @returns Number of bits set to 1
|
|
2676
|
+
*/
|
|
2677
|
+
getSetBitCount() {
|
|
2678
|
+
let count = 0;
|
|
2679
|
+
for (let i = 0; i < this.bits.length; i++) {
|
|
2680
|
+
let word = this.bits[i] ?? 0;
|
|
2681
|
+
while (word !== 0) {
|
|
2682
|
+
word &= word - 1;
|
|
2683
|
+
count++;
|
|
867
2684
|
}
|
|
868
|
-
grouped.get(fk)?.push(r);
|
|
869
|
-
}
|
|
870
|
-
const propName = pluralize(target);
|
|
871
|
-
for (const record of records) {
|
|
872
|
-
;
|
|
873
|
-
record[propName] = grouped.get(record.id) ?? [];
|
|
874
2685
|
}
|
|
2686
|
+
return count;
|
|
875
2687
|
}
|
|
876
2688
|
/**
|
|
877
|
-
*
|
|
878
|
-
*
|
|
2689
|
+
* Compute k bit positions for a given key using Kirsch-Mitzenmacker double hashing.
|
|
2690
|
+
*
|
|
2691
|
+
* Instead of computing k independent hash functions, we compute two base hashes
|
|
2692
|
+
* (h1 and h2) and derive the rest as: h_i = (h1 + i * h2) mod m.
|
|
2693
|
+
* This technique is proven to have the same asymptotic false positive rate as
|
|
2694
|
+
* k independent hash functions.
|
|
2695
|
+
*
|
|
2696
|
+
* We derive h1 and h2 from a single FNV-1a hash by splitting and mixing:
|
|
2697
|
+
* h1 = fnv1a(key), h2 = fnv1a(key + "\0salt") to ensure independence.
|
|
879
2698
|
*/
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
if (rel.to === this.collectionName && rel.from === pluralize(target)) return rel;
|
|
887
|
-
if (rel.from === this.collectionName && rel.to === singularize(target)) return rel;
|
|
888
|
-
if (rel.to === this.collectionName && rel.from === singularize(target)) return rel;
|
|
2699
|
+
getPositions(key) {
|
|
2700
|
+
const h1 = fnv1a32(key);
|
|
2701
|
+
const h2 = fnv1a32(`${key}\0bloom`);
|
|
2702
|
+
const positions = new Array(this.hashCount);
|
|
2703
|
+
for (let i = 0; i < this.hashCount; i++) {
|
|
2704
|
+
positions[i] = (h1 + Math.imul(i, h2) >>> 0) % this.bitCount;
|
|
889
2705
|
}
|
|
890
|
-
return
|
|
891
|
-
}
|
|
892
|
-
};
|
|
893
|
-
var QueryError2 = class extends Error {
|
|
894
|
-
constructor(message) {
|
|
895
|
-
super(message);
|
|
896
|
-
this.name = "QueryError";
|
|
2706
|
+
return positions;
|
|
897
2707
|
}
|
|
898
2708
|
};
|
|
899
2709
|
|
|
900
2710
|
// src/subscription/subscription-manager.ts
|
|
901
2711
|
var nextSubId = 0;
|
|
2712
|
+
var DEFAULT_BLOOM_THRESHOLD = 100;
|
|
2713
|
+
var DEFAULT_BLOOM_EXPECTED_ITEMS = 500;
|
|
2714
|
+
var DEFAULT_BLOOM_FALSE_POSITIVE_RATE = 0.01;
|
|
902
2715
|
var SubscriptionManager = class {
|
|
903
2716
|
subscriptions = /* @__PURE__ */ new Map();
|
|
904
2717
|
pendingCollections = /* @__PURE__ */ new Set();
|
|
905
2718
|
flushScheduled = false;
|
|
2719
|
+
onQuerySubscribed;
|
|
2720
|
+
// Bloom filter state
|
|
2721
|
+
bloomFilter = null;
|
|
2722
|
+
bloomDirty = false;
|
|
2723
|
+
bloomThreshold;
|
|
2724
|
+
bloomExpectedItems;
|
|
2725
|
+
bloomFalsePositiveRate;
|
|
2726
|
+
// Performance stats
|
|
2727
|
+
totalChecks = 0;
|
|
2728
|
+
bloomFilterHits = 0;
|
|
2729
|
+
bloomFilterMisses = 0;
|
|
2730
|
+
falsePositives = 0;
|
|
2731
|
+
totalCheckTimeMs = 0;
|
|
2732
|
+
constructor(options) {
|
|
2733
|
+
this.bloomThreshold = options?.bloomThreshold ?? DEFAULT_BLOOM_THRESHOLD;
|
|
2734
|
+
this.bloomExpectedItems = options?.bloomExpectedItems ?? DEFAULT_BLOOM_EXPECTED_ITEMS;
|
|
2735
|
+
this.bloomFalsePositiveRate = options?.bloomFalsePositiveRate ?? DEFAULT_BLOOM_FALSE_POSITIVE_RATE;
|
|
2736
|
+
this.onQuerySubscribed = options?.onQuerySubscribed;
|
|
2737
|
+
}
|
|
906
2738
|
/**
|
|
907
2739
|
* Register a new subscription.
|
|
908
2740
|
*
|
|
@@ -921,8 +2753,12 @@ var SubscriptionManager = class {
|
|
|
921
2753
|
lastResults: []
|
|
922
2754
|
};
|
|
923
2755
|
this.subscriptions.set(id, subscription);
|
|
2756
|
+
this.bloomDirty = true;
|
|
2757
|
+
const externalCleanup = this.onQuerySubscribed?.(descriptor);
|
|
924
2758
|
return () => {
|
|
925
2759
|
this.subscriptions.delete(id);
|
|
2760
|
+
this.bloomDirty = true;
|
|
2761
|
+
externalCleanup?.();
|
|
926
2762
|
};
|
|
927
2763
|
}
|
|
928
2764
|
/**
|
|
@@ -942,6 +2778,8 @@ var SubscriptionManager = class {
|
|
|
942
2778
|
lastResults: []
|
|
943
2779
|
};
|
|
944
2780
|
this.subscriptions.set(id, subscription);
|
|
2781
|
+
this.bloomDirty = true;
|
|
2782
|
+
const externalCleanup = this.onQuerySubscribed?.(descriptor);
|
|
945
2783
|
executeFn().then((results) => {
|
|
946
2784
|
if (this.subscriptions.has(id)) {
|
|
947
2785
|
subscription.lastResults = results;
|
|
@@ -950,6 +2788,8 @@ var SubscriptionManager = class {
|
|
|
950
2788
|
});
|
|
951
2789
|
return () => {
|
|
952
2790
|
this.subscriptions.delete(id);
|
|
2791
|
+
this.bloomDirty = true;
|
|
2792
|
+
externalCleanup?.();
|
|
953
2793
|
};
|
|
954
2794
|
}
|
|
955
2795
|
/**
|
|
@@ -969,78 +2809,586 @@ var SubscriptionManager = class {
|
|
|
969
2809
|
const collections = new Set(this.pendingCollections);
|
|
970
2810
|
this.pendingCollections.clear();
|
|
971
2811
|
this.flushScheduled = false;
|
|
2812
|
+
const affected = this.findAffectedSubscriptions(collections);
|
|
2813
|
+
for (const sub of affected) {
|
|
2814
|
+
try {
|
|
2815
|
+
const newResults = await sub.executeFn();
|
|
2816
|
+
if (!this.resultsEqual(sub.lastResults, newResults)) {
|
|
2817
|
+
sub.lastResults = newResults;
|
|
2818
|
+
sub.callback(newResults);
|
|
2819
|
+
}
|
|
2820
|
+
} catch {
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
/**
|
|
2825
|
+
* Remove all subscriptions. Called on store close.
|
|
2826
|
+
*/
|
|
2827
|
+
clear() {
|
|
2828
|
+
this.subscriptions.clear();
|
|
2829
|
+
this.pendingCollections.clear();
|
|
2830
|
+
this.flushScheduled = false;
|
|
2831
|
+
this.bloomFilter = null;
|
|
2832
|
+
this.bloomDirty = false;
|
|
2833
|
+
this.totalChecks = 0;
|
|
2834
|
+
this.bloomFilterHits = 0;
|
|
2835
|
+
this.bloomFilterMisses = 0;
|
|
2836
|
+
this.falsePositives = 0;
|
|
2837
|
+
this.totalCheckTimeMs = 0;
|
|
2838
|
+
}
|
|
2839
|
+
/** Number of active subscriptions (for testing/debugging) */
|
|
2840
|
+
get size() {
|
|
2841
|
+
return this.subscriptions.size;
|
|
2842
|
+
}
|
|
2843
|
+
/**
|
|
2844
|
+
* Get performance statistics for monitoring subscription checking efficiency.
|
|
2845
|
+
* Useful for DevTools integration and performance tuning.
|
|
2846
|
+
*/
|
|
2847
|
+
getStats() {
|
|
2848
|
+
return {
|
|
2849
|
+
totalChecks: this.totalChecks,
|
|
2850
|
+
bloomFilterHits: this.bloomFilterHits,
|
|
2851
|
+
bloomFilterMisses: this.bloomFilterMisses,
|
|
2852
|
+
falsePositives: this.falsePositives,
|
|
2853
|
+
averageCheckTimeMs: this.totalChecks > 0 ? this.totalCheckTimeMs / this.totalChecks : 0,
|
|
2854
|
+
bloomFilterActive: this.isBloomActive(),
|
|
2855
|
+
subscriptionCount: this.subscriptions.size
|
|
2856
|
+
};
|
|
2857
|
+
}
|
|
2858
|
+
/**
|
|
2859
|
+
* Check if bloom filter is currently active.
|
|
2860
|
+
* Active when subscription count meets or exceeds the threshold.
|
|
2861
|
+
*/
|
|
2862
|
+
isBloomActive() {
|
|
2863
|
+
return this.subscriptions.size >= this.bloomThreshold;
|
|
2864
|
+
}
|
|
2865
|
+
/**
|
|
2866
|
+
* Find subscriptions affected by mutations to the given collections.
|
|
2867
|
+
* Uses two-level checking when bloom filter is active:
|
|
2868
|
+
*
|
|
2869
|
+
* Level 1: Bloom filter pre-check -- if no subscription depends on any
|
|
2870
|
+
* of the mutated collections, skip everything (O(k) per collection).
|
|
2871
|
+
*
|
|
2872
|
+
* Level 2: Precise check -- linear scan of subscriptions, matching
|
|
2873
|
+
* against the mutated collections.
|
|
2874
|
+
*/
|
|
2875
|
+
findAffectedSubscriptions(collections) {
|
|
2876
|
+
const startTime = performance.now();
|
|
2877
|
+
this.totalChecks++;
|
|
2878
|
+
const useBloom = this.isBloomActive();
|
|
2879
|
+
if (useBloom) {
|
|
2880
|
+
if (this.bloomDirty || this.bloomFilter === null) {
|
|
2881
|
+
this.rebuildBloomFilter();
|
|
2882
|
+
}
|
|
2883
|
+
const filter = this.bloomFilter;
|
|
2884
|
+
if (filter !== null) {
|
|
2885
|
+
let anyPossibleMatch = false;
|
|
2886
|
+
for (const col of collections) {
|
|
2887
|
+
if (filter.mightContain(col)) {
|
|
2888
|
+
anyPossibleMatch = true;
|
|
2889
|
+
break;
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
if (!anyPossibleMatch) {
|
|
2893
|
+
this.bloomFilterMisses++;
|
|
2894
|
+
this.totalCheckTimeMs += performance.now() - startTime;
|
|
2895
|
+
return [];
|
|
2896
|
+
}
|
|
2897
|
+
this.bloomFilterHits++;
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
972
2900
|
const affected = [];
|
|
2901
|
+
let anyPreciseMatch = false;
|
|
973
2902
|
for (const sub of this.subscriptions.values()) {
|
|
974
2903
|
if (collections.has(sub.descriptor.collection)) {
|
|
975
2904
|
affected.push(sub);
|
|
2905
|
+
anyPreciseMatch = true;
|
|
976
2906
|
} else if (sub.descriptor.includeCollections) {
|
|
977
2907
|
for (const incCol of sub.descriptor.includeCollections) {
|
|
978
2908
|
if (collections.has(incCol)) {
|
|
979
2909
|
affected.push(sub);
|
|
2910
|
+
anyPreciseMatch = true;
|
|
980
2911
|
break;
|
|
981
2912
|
}
|
|
982
2913
|
}
|
|
983
2914
|
}
|
|
984
2915
|
}
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
2916
|
+
if (useBloom && !anyPreciseMatch) {
|
|
2917
|
+
this.falsePositives++;
|
|
2918
|
+
}
|
|
2919
|
+
this.totalCheckTimeMs += performance.now() - startTime;
|
|
2920
|
+
return affected;
|
|
2921
|
+
}
|
|
2922
|
+
/**
|
|
2923
|
+
* Rebuild the bloom filter from all current subscriptions.
|
|
2924
|
+
* Adds collection-level dependencies for every subscription, plus
|
|
2925
|
+
* any included collection dependencies.
|
|
2926
|
+
*/
|
|
2927
|
+
rebuildBloomFilter() {
|
|
2928
|
+
const filter = new SubscriptionBloomFilter(this.bloomExpectedItems, this.bloomFalsePositiveRate);
|
|
2929
|
+
for (const sub of this.subscriptions.values()) {
|
|
2930
|
+
filter.add(sub.descriptor.collection);
|
|
2931
|
+
if (sub.descriptor.includeCollections) {
|
|
2932
|
+
for (const incCol of sub.descriptor.includeCollections) {
|
|
2933
|
+
filter.add(incCol);
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
this.bloomFilter = filter;
|
|
2938
|
+
this.bloomDirty = false;
|
|
2939
|
+
}
|
|
2940
|
+
scheduleFlush() {
|
|
2941
|
+
if (this.flushScheduled) return;
|
|
2942
|
+
this.flushScheduled = true;
|
|
2943
|
+
queueMicrotask(() => {
|
|
2944
|
+
this.flush();
|
|
2945
|
+
});
|
|
2946
|
+
}
|
|
2947
|
+
/**
|
|
2948
|
+
* Compare two result sets. Uses ID-based comparison first (same length, same IDs),
|
|
2949
|
+
* then deep comparison of remaining fields when IDs match.
|
|
2950
|
+
* Avoids JSON.stringify which is O(n) and fragile to field ordering differences.
|
|
2951
|
+
*/
|
|
2952
|
+
resultsEqual(prev, next) {
|
|
2953
|
+
if (prev.length !== next.length) return false;
|
|
2954
|
+
if (prev.length === 0) return true;
|
|
2955
|
+
for (let i = 0; i < prev.length; i++) {
|
|
2956
|
+
if (prev[i]?.id !== next[i]?.id) return false;
|
|
2957
|
+
}
|
|
2958
|
+
for (let i = 0; i < prev.length; i++) {
|
|
2959
|
+
const a = prev[i];
|
|
2960
|
+
const b = next[i];
|
|
2961
|
+
const keysA = Object.keys(a);
|
|
2962
|
+
const keysB = Object.keys(b);
|
|
2963
|
+
if (keysA.length !== keysB.length) return false;
|
|
2964
|
+
for (const key of keysA) {
|
|
2965
|
+
if (a[key] !== b[key]) return false;
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
return true;
|
|
2969
|
+
}
|
|
2970
|
+
};
|
|
2971
|
+
|
|
2972
|
+
// src/transaction/transaction-context.ts
|
|
2973
|
+
var import_core14 = require("@korajs/core");
|
|
2974
|
+
init_serializer();
|
|
2975
|
+
var TransactionContext = class {
|
|
2976
|
+
transactionId;
|
|
2977
|
+
mutationName;
|
|
2978
|
+
buffer = [];
|
|
2979
|
+
committed = false;
|
|
2980
|
+
rolledBack = false;
|
|
2981
|
+
config;
|
|
2982
|
+
constructor(config) {
|
|
2983
|
+
this.config = config;
|
|
2984
|
+
this.transactionId = (0, import_core14.generateUUIDv7)();
|
|
2985
|
+
}
|
|
2986
|
+
/**
|
|
2987
|
+
* Set a human-readable mutation name for this transaction.
|
|
2988
|
+
* Propagated to all operations for DevTools display.
|
|
2989
|
+
*/
|
|
2990
|
+
setMutationName(name) {
|
|
2991
|
+
this.mutationName = name;
|
|
2992
|
+
}
|
|
2993
|
+
/**
|
|
2994
|
+
* Get the mutation name, if set.
|
|
2995
|
+
*/
|
|
2996
|
+
getMutationName() {
|
|
2997
|
+
return this.mutationName;
|
|
2998
|
+
}
|
|
2999
|
+
/**
|
|
3000
|
+
* Get a collection accessor for buffered operations within this transaction.
|
|
3001
|
+
*/
|
|
3002
|
+
collection(name) {
|
|
3003
|
+
const definition = this.config.schema.collections[name];
|
|
3004
|
+
if (!definition) {
|
|
3005
|
+
throw new Error(
|
|
3006
|
+
`Unknown collection "${name}". Available: ${Object.keys(this.config.schema.collections).join(", ")}`
|
|
3007
|
+
);
|
|
3008
|
+
}
|
|
3009
|
+
return {
|
|
3010
|
+
insert: (data) => this.insert(name, definition, data),
|
|
3011
|
+
update: (id, data) => this.update(name, definition, id, data),
|
|
3012
|
+
delete: (id) => this.deleteRecord(name, definition, id),
|
|
3013
|
+
findById: (id) => this.findById(name, definition, id)
|
|
3014
|
+
};
|
|
3015
|
+
}
|
|
3016
|
+
/**
|
|
3017
|
+
* Commit all buffered operations atomically.
|
|
3018
|
+
* Returns the list of operations and affected collections for subscription notification.
|
|
3019
|
+
*/
|
|
3020
|
+
async commit() {
|
|
3021
|
+
if (this.committed) {
|
|
3022
|
+
throw new Error("Transaction already committed.");
|
|
3023
|
+
}
|
|
3024
|
+
if (this.rolledBack) {
|
|
3025
|
+
throw new Error("Transaction was rolled back and cannot be committed.");
|
|
3026
|
+
}
|
|
3027
|
+
this.committed = true;
|
|
3028
|
+
if (this.buffer.length === 0) {
|
|
3029
|
+
return { operations: [], affectedCollections: /* @__PURE__ */ new Set() };
|
|
3030
|
+
}
|
|
3031
|
+
const handler = this.config.localMutationHandler;
|
|
3032
|
+
if (handler?.commitTransaction) {
|
|
3033
|
+
return handler.commitTransaction({
|
|
3034
|
+
entries: this.buffer.map((entry) => ({
|
|
3035
|
+
operation: entry.operation,
|
|
3036
|
+
commands: entry.commands,
|
|
3037
|
+
collection: entry.collection
|
|
3038
|
+
})),
|
|
3039
|
+
transactionId: this.transactionId,
|
|
3040
|
+
...this.mutationName !== void 0 ? { mutationName: this.mutationName } : {}
|
|
3041
|
+
});
|
|
3042
|
+
}
|
|
3043
|
+
const operations = [];
|
|
3044
|
+
const affectedCollections = /* @__PURE__ */ new Set();
|
|
3045
|
+
await this.config.adapter.transaction(async (tx) => {
|
|
3046
|
+
for (const entry of this.buffer) {
|
|
3047
|
+
if (entry.operation.type === "delete" && this.config.relationEnforcer) {
|
|
3048
|
+
const cascadeResult = await this.config.relationEnforcer.enforceDelete(
|
|
3049
|
+
entry.collection,
|
|
3050
|
+
entry.operation.recordId,
|
|
3051
|
+
tx,
|
|
3052
|
+
[entry.operation.id]
|
|
3053
|
+
);
|
|
3054
|
+
for (const cascadedOp of cascadeResult.operations) {
|
|
3055
|
+
operations.push(cascadedOp);
|
|
3056
|
+
affectedCollections.add(cascadedOp.collection);
|
|
3057
|
+
this.config.causalTracker?.afterOperation(cascadedOp.collection, cascadedOp.id, true);
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
for (const cmd of entry.commands) {
|
|
3061
|
+
await tx.execute(cmd.sql, cmd.params);
|
|
3062
|
+
}
|
|
3063
|
+
operations.push(entry.operation);
|
|
3064
|
+
affectedCollections.add(entry.collection);
|
|
3065
|
+
}
|
|
3066
|
+
const finalSeq = this.config.sequenceAllocator.getHighWaterMark();
|
|
3067
|
+
if (finalSeq > 0) {
|
|
3068
|
+
await tx.execute(
|
|
3069
|
+
"INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
|
|
3070
|
+
[this.config.nodeId, finalSeq]
|
|
3071
|
+
);
|
|
3072
|
+
}
|
|
3073
|
+
});
|
|
3074
|
+
return { operations, affectedCollections };
|
|
3075
|
+
}
|
|
3076
|
+
/**
|
|
3077
|
+
* Mark the transaction as rolled back. No operations will be committed.
|
|
3078
|
+
*/
|
|
3079
|
+
rollback() {
|
|
3080
|
+
this.rolledBack = true;
|
|
3081
|
+
this.buffer.length = 0;
|
|
3082
|
+
}
|
|
3083
|
+
/**
|
|
3084
|
+
* Get the transaction ID shared by all operations in this transaction.
|
|
3085
|
+
*/
|
|
3086
|
+
getTransactionId() {
|
|
3087
|
+
return this.transactionId;
|
|
3088
|
+
}
|
|
3089
|
+
ensureActive() {
|
|
3090
|
+
if (this.committed) {
|
|
3091
|
+
throw new Error("Cannot perform operations on a committed transaction.");
|
|
3092
|
+
}
|
|
3093
|
+
if (this.rolledBack) {
|
|
3094
|
+
throw new Error("Cannot perform operations on a rolled-back transaction.");
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
async insert(collectionName, definition, data) {
|
|
3098
|
+
this.ensureActive();
|
|
3099
|
+
const validated = (0, import_core14.validateRecord)(collectionName, definition, data, "insert");
|
|
3100
|
+
const recordId = (0, import_core14.generateUUIDv7)();
|
|
3101
|
+
const now = Date.now();
|
|
3102
|
+
for (const [fieldName, descriptor] of Object.entries(definition.fields)) {
|
|
3103
|
+
if (descriptor.auto && descriptor.kind === "timestamp") {
|
|
3104
|
+
validated[fieldName] = now;
|
|
3105
|
+
}
|
|
3106
|
+
}
|
|
3107
|
+
const sequenceNumber = await this.config.sequenceAllocator.allocate();
|
|
3108
|
+
const causalDeps = this.config.causalTracker?.nextCausalDeps(collectionName, true) ?? [];
|
|
3109
|
+
const operation = await (0, import_core14.createOperation)(
|
|
3110
|
+
{
|
|
3111
|
+
nodeId: this.config.nodeId,
|
|
3112
|
+
type: "insert",
|
|
3113
|
+
collection: collectionName,
|
|
3114
|
+
recordId,
|
|
3115
|
+
data: { ...validated },
|
|
3116
|
+
previousData: null,
|
|
3117
|
+
sequenceNumber,
|
|
3118
|
+
causalDeps,
|
|
3119
|
+
schemaVersion: this.config.schema.version,
|
|
3120
|
+
transactionId: this.transactionId,
|
|
3121
|
+
...this.mutationName !== void 0 ? { mutationName: this.mutationName } : {}
|
|
3122
|
+
},
|
|
3123
|
+
this.config.clock
|
|
3124
|
+
);
|
|
3125
|
+
this.config.causalTracker?.afterOperation(collectionName, operation.id, true);
|
|
3126
|
+
const serializedData = serializeRecord(validated, definition.fields);
|
|
3127
|
+
const version = serializeRowVersion(operation.timestamp);
|
|
3128
|
+
const record = {
|
|
3129
|
+
id: recordId,
|
|
3130
|
+
...serializedData,
|
|
3131
|
+
_created_at: operation.timestamp.wallTime,
|
|
3132
|
+
_updated_at: operation.timestamp.wallTime,
|
|
3133
|
+
_version: version
|
|
3134
|
+
};
|
|
3135
|
+
const insertQuery = buildInsertQuery(collectionName, record);
|
|
3136
|
+
const opRow = serializeOperation(operation);
|
|
3137
|
+
const opInsert = buildInsertQuery(
|
|
3138
|
+
`_kora_ops_${collectionName}`,
|
|
3139
|
+
opRow
|
|
3140
|
+
);
|
|
3141
|
+
this.buffer.push({
|
|
3142
|
+
operation,
|
|
3143
|
+
collection: collectionName,
|
|
3144
|
+
commands: [
|
|
3145
|
+
{ sql: insertQuery.sql, params: insertQuery.params },
|
|
3146
|
+
{ sql: opInsert.sql, params: opInsert.params }
|
|
3147
|
+
]
|
|
3148
|
+
});
|
|
3149
|
+
return {
|
|
3150
|
+
id: recordId,
|
|
3151
|
+
...validated,
|
|
3152
|
+
createdAt: now,
|
|
3153
|
+
updatedAt: now
|
|
3154
|
+
};
|
|
3155
|
+
}
|
|
3156
|
+
async update(collectionName, definition, id, data) {
|
|
3157
|
+
this.ensureActive();
|
|
3158
|
+
const currentRecord = await this.getEffectiveRecord(collectionName, definition, id);
|
|
3159
|
+
if (!currentRecord) {
|
|
3160
|
+
throw new RecordNotFoundError(collectionName, id);
|
|
3161
|
+
}
|
|
3162
|
+
const validated = (0, import_core14.validateRecord)(collectionName, definition, data, "update");
|
|
3163
|
+
const now = Date.now();
|
|
3164
|
+
const previousData = {};
|
|
3165
|
+
const resolvedData = {};
|
|
3166
|
+
const atomicOps = {};
|
|
3167
|
+
for (const key of Object.keys(validated)) {
|
|
3168
|
+
const value = validated[key];
|
|
3169
|
+
previousData[key] = currentRecord[key];
|
|
3170
|
+
if ((0, import_core14.isAtomicOp)(value)) {
|
|
3171
|
+
resolvedData[key] = (0, import_core14.resolveAtomicOp)(currentRecord[key], value);
|
|
3172
|
+
atomicOps[key] = (0, import_core14.toAtomicOp)(value);
|
|
3173
|
+
} else {
|
|
3174
|
+
resolvedData[key] = value;
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3177
|
+
const hasAtomicOps = Object.keys(atomicOps).length > 0;
|
|
3178
|
+
const sequenceNumber = await this.config.sequenceAllocator.allocate();
|
|
3179
|
+
const causalDeps = this.config.causalTracker?.nextCausalDeps(collectionName, true) ?? [];
|
|
3180
|
+
const operation = await (0, import_core14.createOperation)(
|
|
3181
|
+
{
|
|
3182
|
+
nodeId: this.config.nodeId,
|
|
3183
|
+
type: "update",
|
|
3184
|
+
collection: collectionName,
|
|
3185
|
+
recordId: id,
|
|
3186
|
+
data: { ...resolvedData },
|
|
3187
|
+
previousData,
|
|
3188
|
+
sequenceNumber,
|
|
3189
|
+
causalDeps,
|
|
3190
|
+
schemaVersion: this.config.schema.version,
|
|
3191
|
+
transactionId: this.transactionId,
|
|
3192
|
+
...this.mutationName !== void 0 ? { mutationName: this.mutationName } : {},
|
|
3193
|
+
...hasAtomicOps ? { atomicOps } : {}
|
|
3194
|
+
},
|
|
3195
|
+
this.config.clock
|
|
3196
|
+
);
|
|
3197
|
+
this.config.causalTracker?.afterOperation(collectionName, operation.id, true);
|
|
3198
|
+
const serializedChanges = serializeRecord(resolvedData, definition.fields);
|
|
3199
|
+
const version = serializeRowVersion(operation.timestamp);
|
|
3200
|
+
const updateQuery = buildUpdateQuery(collectionName, id, {
|
|
3201
|
+
...serializedChanges,
|
|
3202
|
+
_updated_at: operation.timestamp.wallTime,
|
|
3203
|
+
_version: version
|
|
3204
|
+
});
|
|
3205
|
+
const opRow = serializeOperation(operation);
|
|
3206
|
+
const opInsert = buildInsertQuery(
|
|
3207
|
+
`_kora_ops_${collectionName}`,
|
|
3208
|
+
opRow
|
|
3209
|
+
);
|
|
3210
|
+
this.buffer.push({
|
|
3211
|
+
operation,
|
|
3212
|
+
collection: collectionName,
|
|
3213
|
+
commands: [
|
|
3214
|
+
{ sql: updateQuery.sql, params: updateQuery.params },
|
|
3215
|
+
{ sql: opInsert.sql, params: opInsert.params }
|
|
3216
|
+
]
|
|
3217
|
+
});
|
|
3218
|
+
return {
|
|
3219
|
+
...currentRecord,
|
|
3220
|
+
...resolvedData,
|
|
3221
|
+
updatedAt: now
|
|
3222
|
+
};
|
|
3223
|
+
}
|
|
3224
|
+
async deleteRecord(collectionName, definition, id) {
|
|
3225
|
+
this.ensureActive();
|
|
3226
|
+
const currentRecord = await this.getEffectiveRecord(collectionName, definition, id);
|
|
3227
|
+
if (!currentRecord) {
|
|
3228
|
+
throw new RecordNotFoundError(collectionName, id);
|
|
3229
|
+
}
|
|
3230
|
+
const now = Date.now();
|
|
3231
|
+
const sequenceNumber = await this.config.sequenceAllocator.allocate();
|
|
3232
|
+
const causalDeps = this.config.causalTracker?.nextCausalDeps(collectionName, true) ?? [];
|
|
3233
|
+
const operation = await (0, import_core14.createOperation)(
|
|
3234
|
+
{
|
|
3235
|
+
nodeId: this.config.nodeId,
|
|
3236
|
+
type: "delete",
|
|
3237
|
+
collection: collectionName,
|
|
3238
|
+
recordId: id,
|
|
3239
|
+
data: null,
|
|
3240
|
+
previousData: null,
|
|
3241
|
+
sequenceNumber,
|
|
3242
|
+
causalDeps,
|
|
3243
|
+
schemaVersion: this.config.schema.version,
|
|
3244
|
+
transactionId: this.transactionId,
|
|
3245
|
+
...this.mutationName !== void 0 ? { mutationName: this.mutationName } : {}
|
|
3246
|
+
},
|
|
3247
|
+
this.config.clock
|
|
3248
|
+
);
|
|
3249
|
+
this.config.causalTracker?.afterOperation(collectionName, operation.id, true);
|
|
3250
|
+
const deleteQuery = buildSoftDeleteQuery(collectionName, id, now);
|
|
3251
|
+
const opRow = serializeOperation(operation);
|
|
3252
|
+
const opInsert = buildInsertQuery(
|
|
3253
|
+
`_kora_ops_${collectionName}`,
|
|
3254
|
+
opRow
|
|
3255
|
+
);
|
|
3256
|
+
this.buffer.push({
|
|
3257
|
+
operation,
|
|
3258
|
+
collection: collectionName,
|
|
3259
|
+
commands: [
|
|
3260
|
+
{ sql: deleteQuery.sql, params: deleteQuery.params },
|
|
3261
|
+
{ sql: opInsert.sql, params: opInsert.params }
|
|
3262
|
+
]
|
|
3263
|
+
});
|
|
3264
|
+
}
|
|
3265
|
+
async findById(collectionName, definition, id) {
|
|
3266
|
+
return this.getEffectiveRecord(collectionName, definition, id);
|
|
3267
|
+
}
|
|
3268
|
+
/**
|
|
3269
|
+
* Get the effective state of a record, considering both the database and buffered operations.
|
|
3270
|
+
* Buffered inserts/updates take precedence over the database state.
|
|
3271
|
+
*/
|
|
3272
|
+
async getEffectiveRecord(collectionName, definition, id) {
|
|
3273
|
+
for (let i = this.buffer.length - 1; i >= 0; i--) {
|
|
3274
|
+
const entry = this.buffer[i];
|
|
3275
|
+
if (entry.collection === collectionName && entry.operation.recordId === id) {
|
|
3276
|
+
if (entry.operation.type === "delete") {
|
|
3277
|
+
return null;
|
|
3278
|
+
}
|
|
3279
|
+
break;
|
|
3280
|
+
}
|
|
3281
|
+
}
|
|
3282
|
+
const rows = await this.config.adapter.query(
|
|
3283
|
+
`SELECT * FROM ${collectionName} WHERE id = ? AND _deleted = 0`,
|
|
3284
|
+
[id]
|
|
3285
|
+
);
|
|
3286
|
+
let record = rows[0] ? deserializeRecord(rows[0], definition.fields) : null;
|
|
3287
|
+
for (const entry of this.buffer) {
|
|
3288
|
+
if (entry.collection !== collectionName || entry.operation.recordId !== id) {
|
|
3289
|
+
continue;
|
|
3290
|
+
}
|
|
3291
|
+
if (entry.operation.type === "insert" && entry.operation.data) {
|
|
3292
|
+
record = {
|
|
3293
|
+
id,
|
|
3294
|
+
...entry.operation.data,
|
|
3295
|
+
createdAt: entry.operation.timestamp.wallTime,
|
|
3296
|
+
updatedAt: entry.operation.timestamp.wallTime
|
|
3297
|
+
};
|
|
3298
|
+
} else if (entry.operation.type === "update" && entry.operation.data && record) {
|
|
3299
|
+
record = {
|
|
3300
|
+
...record,
|
|
3301
|
+
...entry.operation.data,
|
|
3302
|
+
updatedAt: entry.operation.timestamp.wallTime
|
|
3303
|
+
};
|
|
3304
|
+
} else if (entry.operation.type === "delete") {
|
|
3305
|
+
record = null;
|
|
993
3306
|
}
|
|
994
3307
|
}
|
|
3308
|
+
return record;
|
|
995
3309
|
}
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
this.
|
|
1002
|
-
this.
|
|
1003
|
-
}
|
|
1004
|
-
/** Number of active subscriptions (for testing/debugging) */
|
|
1005
|
-
get size() {
|
|
1006
|
-
return this.subscriptions.size;
|
|
3310
|
+
};
|
|
3311
|
+
|
|
3312
|
+
// src/transaction/transaction-sequence.ts
|
|
3313
|
+
var TransactionSequenceAllocator = class {
|
|
3314
|
+
constructor(adapter, nodeId) {
|
|
3315
|
+
this.adapter = adapter;
|
|
3316
|
+
this.nodeId = nodeId;
|
|
1007
3317
|
}
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
3318
|
+
adapter;
|
|
3319
|
+
nodeId;
|
|
3320
|
+
loaded = false;
|
|
3321
|
+
watermark = 0;
|
|
3322
|
+
async allocate() {
|
|
3323
|
+
if (!this.loaded) {
|
|
3324
|
+
this.watermark = await readSequenceNumber(this.adapter, this.nodeId);
|
|
3325
|
+
this.loaded = true;
|
|
3326
|
+
}
|
|
3327
|
+
this.watermark++;
|
|
3328
|
+
return this.watermark;
|
|
1014
3329
|
}
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
* Sufficient for typical query results. Can be optimized to id-based diffing if profiling shows need.
|
|
1018
|
-
*/
|
|
1019
|
-
resultsEqual(prev, next) {
|
|
1020
|
-
if (prev.length !== next.length) return false;
|
|
1021
|
-
if (prev.length === 0) return true;
|
|
1022
|
-
return JSON.stringify(prev) === JSON.stringify(next);
|
|
3330
|
+
getHighWaterMark() {
|
|
3331
|
+
return this.watermark;
|
|
1023
3332
|
}
|
|
1024
3333
|
};
|
|
1025
3334
|
|
|
3335
|
+
// src/store/tab-node-id.ts
|
|
3336
|
+
var import_core15 = require("@korajs/core");
|
|
3337
|
+
function perTabStorageKey(dbName) {
|
|
3338
|
+
return `kora-tab-node-${dbName}`;
|
|
3339
|
+
}
|
|
3340
|
+
function loadPerTabNodeId(dbName) {
|
|
3341
|
+
if (typeof sessionStorage === "undefined") {
|
|
3342
|
+
return null;
|
|
3343
|
+
}
|
|
3344
|
+
return sessionStorage.getItem(perTabStorageKey(dbName));
|
|
3345
|
+
}
|
|
3346
|
+
function savePerTabNodeId(dbName, nodeId) {
|
|
3347
|
+
if (typeof sessionStorage === "undefined") {
|
|
3348
|
+
return;
|
|
3349
|
+
}
|
|
3350
|
+
sessionStorage.setItem(perTabStorageKey(dbName), nodeId);
|
|
3351
|
+
}
|
|
3352
|
+
function resolvePerTabNodeId(dbName) {
|
|
3353
|
+
const existing = loadPerTabNodeId(dbName);
|
|
3354
|
+
if (existing) {
|
|
3355
|
+
return existing;
|
|
3356
|
+
}
|
|
3357
|
+
const nodeId = (0, import_core15.generateUUIDv7)();
|
|
3358
|
+
savePerTabNodeId(dbName, nodeId);
|
|
3359
|
+
return nodeId;
|
|
3360
|
+
}
|
|
3361
|
+
|
|
1026
3362
|
// src/store/store.ts
|
|
1027
3363
|
var Store = class {
|
|
1028
3364
|
opened = false;
|
|
1029
3365
|
nodeId = "";
|
|
1030
3366
|
sequenceNumber = 0;
|
|
1031
|
-
versionVector = (0,
|
|
3367
|
+
versionVector = (0, import_core17.createVersionVector)();
|
|
1032
3368
|
clock = null;
|
|
1033
3369
|
collections = /* @__PURE__ */ new Map();
|
|
1034
|
-
subscriptionManager
|
|
3370
|
+
subscriptionManager;
|
|
3371
|
+
sequenceManager = null;
|
|
1035
3372
|
schema;
|
|
1036
3373
|
adapter;
|
|
1037
3374
|
configNodeId;
|
|
3375
|
+
dbName;
|
|
3376
|
+
isolation;
|
|
1038
3377
|
emitter;
|
|
3378
|
+
localMutationHandler;
|
|
3379
|
+
relationEnforcer = null;
|
|
3380
|
+
causalTracker = null;
|
|
1039
3381
|
constructor(config) {
|
|
1040
3382
|
this.schema = config.schema;
|
|
1041
3383
|
this.adapter = config.adapter;
|
|
1042
3384
|
this.configNodeId = config.nodeId;
|
|
3385
|
+
this.dbName = config.dbName ?? "kora-db";
|
|
3386
|
+
this.isolation = config.isolation ?? "shared";
|
|
1043
3387
|
this.emitter = config.emitter ?? null;
|
|
3388
|
+
this.localMutationHandler = config.localMutationHandler ?? null;
|
|
3389
|
+
this.subscriptionManager = new SubscriptionManager({
|
|
3390
|
+
onQuerySubscribed: config.onQuerySubscribed
|
|
3391
|
+
});
|
|
1044
3392
|
}
|
|
1045
3393
|
/**
|
|
1046
3394
|
* Open the store: initialize the database, load or generate a node ID,
|
|
@@ -1048,10 +3396,20 @@ var Store = class {
|
|
|
1048
3396
|
*/
|
|
1049
3397
|
async open() {
|
|
1050
3398
|
await this.adapter.open(this.schema);
|
|
3399
|
+
await this.runMigrationsIfNeeded();
|
|
1051
3400
|
this.nodeId = await this.loadOrGenerateNodeId();
|
|
1052
|
-
this.clock = new
|
|
3401
|
+
this.clock = new import_core17.HybridLogicalClock(this.nodeId);
|
|
3402
|
+
this.causalTracker = new import_core17.CausalTracker();
|
|
3403
|
+
this.sequenceManager = new SequenceManager(this.adapter, this.nodeId);
|
|
1053
3404
|
this.sequenceNumber = await this.loadSequenceNumber();
|
|
1054
3405
|
this.versionVector = await this.loadVersionVector();
|
|
3406
|
+
const hasRelations = Object.keys(this.schema.relations).length > 0;
|
|
3407
|
+
this.relationEnforcer = hasRelations ? new RelationEnforcer({
|
|
3408
|
+
schema: this.schema,
|
|
3409
|
+
adapter: this.adapter,
|
|
3410
|
+
clock: this.clock,
|
|
3411
|
+
nodeId: this.nodeId
|
|
3412
|
+
}) : null;
|
|
1055
3413
|
for (const [name, definition] of Object.entries(this.schema.collections)) {
|
|
1056
3414
|
const col = new Collection(
|
|
1057
3415
|
name,
|
|
@@ -1060,13 +3418,17 @@ var Store = class {
|
|
|
1060
3418
|
this.adapter,
|
|
1061
3419
|
this.clock,
|
|
1062
3420
|
this.nodeId,
|
|
1063
|
-
() => this.
|
|
3421
|
+
() => this.allocateSequenceNumber(),
|
|
1064
3422
|
(collectionName, operation) => {
|
|
3423
|
+
this.recordOperationSequence(operation);
|
|
1065
3424
|
this.subscriptionManager.notify(collectionName, operation);
|
|
1066
3425
|
if (this.emitter) {
|
|
1067
3426
|
this.emitter.emit({ type: "operation:created", operation });
|
|
1068
3427
|
}
|
|
1069
|
-
}
|
|
3428
|
+
},
|
|
3429
|
+
this.relationEnforcer,
|
|
3430
|
+
this.localMutationHandler,
|
|
3431
|
+
this.causalTracker
|
|
1070
3432
|
);
|
|
1071
3433
|
this.collections.set(name, col);
|
|
1072
3434
|
}
|
|
@@ -1103,7 +3465,14 @@ var Store = class {
|
|
|
1103
3465
|
findById: (id) => col.findById(id),
|
|
1104
3466
|
update: (id, data) => col.update(id, data),
|
|
1105
3467
|
delete: (id) => col.delete(id),
|
|
1106
|
-
where: (conditions) => new QueryBuilder(
|
|
3468
|
+
where: (conditions) => new QueryBuilder(
|
|
3469
|
+
name,
|
|
3470
|
+
definition,
|
|
3471
|
+
this.adapter,
|
|
3472
|
+
this.subscriptionManager,
|
|
3473
|
+
conditions,
|
|
3474
|
+
this.schema
|
|
3475
|
+
)
|
|
1107
3476
|
};
|
|
1108
3477
|
}
|
|
1109
3478
|
/**
|
|
@@ -1125,7 +3494,7 @@ var Store = class {
|
|
|
1125
3494
|
* Checks for duplicates, applies to the data table, persists the operation,
|
|
1126
3495
|
* and updates the version vector.
|
|
1127
3496
|
*/
|
|
1128
|
-
async applyRemoteOperation(op) {
|
|
3497
|
+
async applyRemoteOperation(op, options) {
|
|
1129
3498
|
this.ensureOpen();
|
|
1130
3499
|
const collection = op.collection;
|
|
1131
3500
|
const definition = this.schema.collections[collection];
|
|
@@ -1142,27 +3511,52 @@ var Store = class {
|
|
|
1142
3511
|
if (this.clock) {
|
|
1143
3512
|
this.clock.receive(op.timestamp);
|
|
1144
3513
|
}
|
|
3514
|
+
const remoteVersion = serializeRowVersion(op.timestamp);
|
|
3515
|
+
const wallTime = op.timestamp.wallTime;
|
|
1145
3516
|
await this.adapter.transaction(async (tx) => {
|
|
1146
3517
|
if (op.type === "insert" && op.data) {
|
|
1147
|
-
const
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
3518
|
+
const existing2 = await tx.query(
|
|
3519
|
+
`SELECT _updated_at, _version FROM ${collection} WHERE id = ?`,
|
|
3520
|
+
[op.recordId]
|
|
3521
|
+
);
|
|
3522
|
+
const row = existing2[0];
|
|
3523
|
+
if (row && !isIncomingNewerThanRow(op.timestamp, row)) {
|
|
3524
|
+
} else {
|
|
3525
|
+
const serializedData = serializeRecord(op.data, definition.fields);
|
|
3526
|
+
const record = {
|
|
3527
|
+
id: op.recordId,
|
|
3528
|
+
...serializedData,
|
|
3529
|
+
_created_at: wallTime,
|
|
3530
|
+
_updated_at: wallTime,
|
|
3531
|
+
_version: remoteVersion
|
|
3532
|
+
};
|
|
3533
|
+
const insertQuery = buildInsertQuery(collection, record);
|
|
3534
|
+
await tx.execute(insertQuery.sql, insertQuery.params);
|
|
3535
|
+
}
|
|
1157
3536
|
} else if (op.type === "update" && op.data) {
|
|
1158
3537
|
const serializedChanges = serializeRecord(op.data, definition.fields);
|
|
1159
|
-
const
|
|
3538
|
+
const updatePayload = {
|
|
1160
3539
|
...serializedChanges,
|
|
1161
|
-
_updated_at:
|
|
1162
|
-
|
|
3540
|
+
_updated_at: wallTime,
|
|
3541
|
+
_version: remoteVersion
|
|
3542
|
+
};
|
|
3543
|
+
if (options?.reactivateIfDeleted) {
|
|
3544
|
+
updatePayload._deleted = 0;
|
|
3545
|
+
}
|
|
3546
|
+
const updateQuery = buildLwwUpdateQuery(
|
|
3547
|
+
collection,
|
|
3548
|
+
op.recordId,
|
|
3549
|
+
updatePayload,
|
|
3550
|
+
remoteVersion
|
|
3551
|
+
);
|
|
1163
3552
|
await tx.execute(updateQuery.sql, updateQuery.params);
|
|
1164
3553
|
} else if (op.type === "delete") {
|
|
1165
|
-
const deleteQuery =
|
|
3554
|
+
const deleteQuery = buildLwwSoftDeleteQuery(
|
|
3555
|
+
collection,
|
|
3556
|
+
op.recordId,
|
|
3557
|
+
wallTime,
|
|
3558
|
+
remoteVersion
|
|
3559
|
+
);
|
|
1166
3560
|
await tx.execute(deleteQuery.sql, deleteQuery.params);
|
|
1167
3561
|
}
|
|
1168
3562
|
const opRow = serializeOperation(op);
|
|
@@ -1187,11 +3581,11 @@ var Store = class {
|
|
|
1187
3581
|
* Get operations from a node within a sequence number range.
|
|
1188
3582
|
* Implements the OperationLog interface for computeDelta.
|
|
1189
3583
|
*/
|
|
1190
|
-
getRange(nodeId, fromSeq, toSeq) {
|
|
1191
|
-
return
|
|
3584
|
+
async getRange(nodeId, fromSeq, toSeq) {
|
|
3585
|
+
return this.getOperationRange(nodeId, fromSeq, toSeq);
|
|
1192
3586
|
}
|
|
1193
3587
|
/**
|
|
1194
|
-
*
|
|
3588
|
+
* Get operations from a node within a sequence number range.
|
|
1195
3589
|
*/
|
|
1196
3590
|
async getOperationRange(nodeId, fromSeq, toSeq) {
|
|
1197
3591
|
this.ensureOpen();
|
|
@@ -1208,36 +3602,449 @@ var Store = class {
|
|
|
1208
3602
|
allOps.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
|
1209
3603
|
return allOps;
|
|
1210
3604
|
}
|
|
3605
|
+
/**
|
|
3606
|
+
* Load every operation from the local append-only log across all collections.
|
|
3607
|
+
* Used by sync delta computation, backup export, and time-travel replay.
|
|
3608
|
+
*/
|
|
3609
|
+
async getAllOperations() {
|
|
3610
|
+
this.ensureOpen();
|
|
3611
|
+
const allOps = [];
|
|
3612
|
+
for (const collectionName of Object.keys(this.schema.collections)) {
|
|
3613
|
+
const rows = await this.adapter.query(
|
|
3614
|
+
`SELECT * FROM _kora_ops_${collectionName} ORDER BY sequence_number ASC`
|
|
3615
|
+
);
|
|
3616
|
+
for (const row of rows) {
|
|
3617
|
+
allOps.push(deserializeOperationWithCollection(row, collectionName));
|
|
3618
|
+
}
|
|
3619
|
+
}
|
|
3620
|
+
return allOps;
|
|
3621
|
+
}
|
|
3622
|
+
/**
|
|
3623
|
+
* Rebuild an in-memory snapshot of materialized state at a causal cut in the op log.
|
|
3624
|
+
* Does not mutate the live store — intended for DevTools time-travel inspection.
|
|
3625
|
+
*
|
|
3626
|
+
* @param operationId - Content-addressed id of the operation to replay through (inclusive)
|
|
3627
|
+
* @throws {OperationError} When the operation id is not present in the local log
|
|
3628
|
+
*/
|
|
3629
|
+
async replayTo(operationId) {
|
|
3630
|
+
this.ensureOpen();
|
|
3631
|
+
const start = Date.now();
|
|
3632
|
+
const allOps = await this.getAllOperations();
|
|
3633
|
+
const snapshot = buildReplaySnapshot(this.schema, allOps, operationId);
|
|
3634
|
+
if (this.emitter) {
|
|
3635
|
+
this.emitter.emit({
|
|
3636
|
+
type: "replay:completed",
|
|
3637
|
+
targetOperationId: operationId,
|
|
3638
|
+
operationsApplied: snapshot.operationsApplied.length,
|
|
3639
|
+
duration: Date.now() - start
|
|
3640
|
+
});
|
|
3641
|
+
}
|
|
3642
|
+
return snapshot;
|
|
3643
|
+
}
|
|
3644
|
+
/**
|
|
3645
|
+
* Persist a merge trace to the durable audit log.
|
|
3646
|
+
*/
|
|
3647
|
+
async appendAuditTrace(trace) {
|
|
3648
|
+
this.ensureOpen();
|
|
3649
|
+
const { appendAuditTrace: append } = await Promise.resolve().then(() => (init_audit_trace_store(), audit_trace_store_exports));
|
|
3650
|
+
await append(this.adapter, trace);
|
|
3651
|
+
}
|
|
3652
|
+
/**
|
|
3653
|
+
* Read persisted audit traces with optional filters.
|
|
3654
|
+
*/
|
|
3655
|
+
async getAuditTraces(query) {
|
|
3656
|
+
this.ensureOpen();
|
|
3657
|
+
const { readAuditTraces: readAuditTraces2 } = await Promise.resolve().then(() => (init_audit_trace_store(), audit_trace_store_exports));
|
|
3658
|
+
return readAuditTraces2(this.adapter, query);
|
|
3659
|
+
}
|
|
3660
|
+
/**
|
|
3661
|
+
* Export operations and merge traces as a portable audit bundle.
|
|
3662
|
+
*/
|
|
3663
|
+
async exportAudit(options) {
|
|
3664
|
+
this.ensureOpen();
|
|
3665
|
+
const { exportAudit: doExport } = await Promise.resolve().then(() => (init_export_audit(), export_audit_exports));
|
|
3666
|
+
return doExport(this.adapter, this.schema, this.nodeId, this.schema.version, options);
|
|
3667
|
+
}
|
|
1211
3668
|
/**
|
|
1212
3669
|
* Get the schema definition.
|
|
1213
3670
|
*/
|
|
1214
3671
|
getSchema() {
|
|
1215
3672
|
return this.schema;
|
|
1216
3673
|
}
|
|
3674
|
+
/**
|
|
3675
|
+
* Route local CRUD through the unified apply pipeline (korajs ApplyPipeline).
|
|
3676
|
+
*/
|
|
3677
|
+
setLocalMutationHandler(handler) {
|
|
3678
|
+
this.localMutationHandler = handler;
|
|
3679
|
+
for (const col of this.collections.values()) {
|
|
3680
|
+
col.setMutationHandler(handler);
|
|
3681
|
+
}
|
|
3682
|
+
}
|
|
3683
|
+
/**
|
|
3684
|
+
* Build mutation context for a collection (used by ApplyPipeline side effects).
|
|
3685
|
+
*/
|
|
3686
|
+
createMutationContext(collection, options) {
|
|
3687
|
+
this.ensureOpen();
|
|
3688
|
+
const definition = this.schema.collections[collection];
|
|
3689
|
+
if (!definition || !this.clock) {
|
|
3690
|
+
throw new StoreNotOpenError();
|
|
3691
|
+
}
|
|
3692
|
+
return {
|
|
3693
|
+
collection,
|
|
3694
|
+
definition,
|
|
3695
|
+
schema: this.schema,
|
|
3696
|
+
adapter: this.adapter,
|
|
3697
|
+
clock: this.clock,
|
|
3698
|
+
nodeId: this.nodeId,
|
|
3699
|
+
allocateSequenceNumber: () => this.allocateSequenceNumber(),
|
|
3700
|
+
onMutation: (collectionName, operation) => {
|
|
3701
|
+
this.recordOperationSequence(operation);
|
|
3702
|
+
this.subscriptionManager.notify(collectionName, operation);
|
|
3703
|
+
if (this.emitter) {
|
|
3704
|
+
this.emitter.emit({ type: "operation:created", operation });
|
|
3705
|
+
}
|
|
3706
|
+
},
|
|
3707
|
+
relationEnforcer: this.relationEnforcer,
|
|
3708
|
+
causalTracker: this.causalTracker,
|
|
3709
|
+
inTransaction: options?.inTransaction ?? false,
|
|
3710
|
+
extraCausalDeps: options?.extraCausalDeps
|
|
3711
|
+
};
|
|
3712
|
+
}
|
|
3713
|
+
/**
|
|
3714
|
+
* Load a materialized row by ID, including soft-deleted tombstones.
|
|
3715
|
+
*/
|
|
3716
|
+
async findMaterializedRow(collection, recordId) {
|
|
3717
|
+
this.ensureOpen();
|
|
3718
|
+
const definition = this.schema.collections[collection];
|
|
3719
|
+
if (!definition) {
|
|
3720
|
+
return null;
|
|
3721
|
+
}
|
|
3722
|
+
const rows = await this.adapter.query(
|
|
3723
|
+
`SELECT * FROM ${collection} WHERE id = ?`,
|
|
3724
|
+
[recordId]
|
|
3725
|
+
);
|
|
3726
|
+
const row = rows[0];
|
|
3727
|
+
if (!row) {
|
|
3728
|
+
return null;
|
|
3729
|
+
}
|
|
3730
|
+
return {
|
|
3731
|
+
record: deserializeRecord(row, definition.fields),
|
|
3732
|
+
deleted: row._deleted === 1
|
|
3733
|
+
};
|
|
3734
|
+
}
|
|
3735
|
+
/**
|
|
3736
|
+
* Latest operation from this device for a record (used for delete-vs-update merge on sync).
|
|
3737
|
+
*/
|
|
3738
|
+
/**
|
|
3739
|
+
* Load the last server version vector acknowledged by this client (persisted in `_kora_meta`).
|
|
3740
|
+
*/
|
|
3741
|
+
async loadLastAckedServerVector() {
|
|
3742
|
+
this.ensureOpen();
|
|
3743
|
+
return loadLastAckedServerVector(this.adapter);
|
|
3744
|
+
}
|
|
3745
|
+
/**
|
|
3746
|
+
* Persist the last server version vector this client believes the server has applied.
|
|
3747
|
+
*/
|
|
3748
|
+
async saveLastAckedServerVector(vector) {
|
|
3749
|
+
this.ensureOpen();
|
|
3750
|
+
await saveLastAckedServerVector(this.adapter, vector);
|
|
3751
|
+
}
|
|
3752
|
+
/**
|
|
3753
|
+
* Load persisted delta cursor for resuming paginated initial sync.
|
|
3754
|
+
*/
|
|
3755
|
+
async loadDeltaCursor() {
|
|
3756
|
+
this.ensureOpen();
|
|
3757
|
+
return loadDeltaCursor(this.adapter);
|
|
3758
|
+
}
|
|
3759
|
+
/**
|
|
3760
|
+
* Persist or clear the delta cursor for paginated initial sync resume.
|
|
3761
|
+
*/
|
|
3762
|
+
async saveDeltaCursor(cursor) {
|
|
3763
|
+
this.ensureOpen();
|
|
3764
|
+
await saveDeltaCursor(this.adapter, cursor);
|
|
3765
|
+
}
|
|
3766
|
+
/**
|
|
3767
|
+
* Local operations not yet reflected on the server version vector.
|
|
3768
|
+
*/
|
|
3769
|
+
async getUnsyncedOperations(serverVector) {
|
|
3770
|
+
this.ensureOpen();
|
|
3771
|
+
return collectOperationsAheadOfServer(
|
|
3772
|
+
this.getVersionVector(),
|
|
3773
|
+
serverVector,
|
|
3774
|
+
(nodeId, fromSeq, toSeq) => this.getOperationRange(nodeId, fromSeq, toSeq)
|
|
3775
|
+
);
|
|
3776
|
+
}
|
|
3777
|
+
/**
|
|
3778
|
+
* Count of local operations ahead of the server version vector.
|
|
3779
|
+
*/
|
|
3780
|
+
async countUnsyncedOperations(serverVector) {
|
|
3781
|
+
const ops = await this.getUnsyncedOperations(serverVector);
|
|
3782
|
+
return ops.length;
|
|
3783
|
+
}
|
|
3784
|
+
/**
|
|
3785
|
+
* Compact the local operation log using materialized rows as the baseline.
|
|
3786
|
+
* Only removes ops the server has acknowledged (per {@link CompactionStrategy}).
|
|
3787
|
+
*/
|
|
3788
|
+
async compact(strategy) {
|
|
3789
|
+
this.ensureOpen();
|
|
3790
|
+
if (strategy.mode === "never") {
|
|
3791
|
+
return compactOperationLog(this.adapter, this.schema, strategy, (0, import_core17.createVersionVector)());
|
|
3792
|
+
}
|
|
3793
|
+
const serverVector = strategy.serverVector ?? await loadLastAckedServerVector(this.adapter);
|
|
3794
|
+
return compactOperationLog(this.adapter, this.schema, strategy, serverVector);
|
|
3795
|
+
}
|
|
3796
|
+
/**
|
|
3797
|
+
* Merge session remote vector with persisted last-acked vector (max per node).
|
|
3798
|
+
*/
|
|
3799
|
+
mergeServerVectors(sessionVector, persistedVector) {
|
|
3800
|
+
return mergeVersionVectors(persistedVector, sessionVector);
|
|
3801
|
+
}
|
|
3802
|
+
async getLatestLocalOperationForRecord(collection, recordId) {
|
|
3803
|
+
this.ensureOpen();
|
|
3804
|
+
const rows = await this.adapter.query(
|
|
3805
|
+
`SELECT * FROM _kora_ops_${collection} WHERE node_id = ? AND record_id = ? ORDER BY sequence_number DESC LIMIT 1`,
|
|
3806
|
+
[this.nodeId, recordId]
|
|
3807
|
+
);
|
|
3808
|
+
const row = rows[0];
|
|
3809
|
+
if (!row) {
|
|
3810
|
+
return null;
|
|
3811
|
+
}
|
|
3812
|
+
return deserializeOperationWithCollection(row, collection);
|
|
3813
|
+
}
|
|
3814
|
+
/**
|
|
3815
|
+
* Latest operation for a record from any node (for 3-way merge when local op log is empty).
|
|
3816
|
+
*/
|
|
3817
|
+
async getLatestOperationForRecord(collection, recordId) {
|
|
3818
|
+
this.ensureOpen();
|
|
3819
|
+
const rows = await this.adapter.query(
|
|
3820
|
+
`SELECT * FROM _kora_ops_${collection} WHERE record_id = ?`,
|
|
3821
|
+
[recordId]
|
|
3822
|
+
);
|
|
3823
|
+
let latest = null;
|
|
3824
|
+
for (const row of rows) {
|
|
3825
|
+
const op = deserializeOperationWithCollection(row, collection);
|
|
3826
|
+
if (!latest || import_core17.HybridLogicalClock.compare(op.timestamp, latest.timestamp) > 0) {
|
|
3827
|
+
latest = op;
|
|
3828
|
+
}
|
|
3829
|
+
}
|
|
3830
|
+
return latest;
|
|
3831
|
+
}
|
|
1217
3832
|
/** Expose the subscription manager for direct access (e.g., by QueryBuilder) */
|
|
1218
3833
|
getSubscriptionManager() {
|
|
1219
3834
|
return this.subscriptionManager;
|
|
1220
3835
|
}
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
3836
|
+
/**
|
|
3837
|
+
* Get the sequence manager for offline-safe sequence generation.
|
|
3838
|
+
* @throws {StoreNotOpenError} If the store is not open
|
|
3839
|
+
*/
|
|
3840
|
+
getSequenceManager() {
|
|
3841
|
+
this.ensureOpen();
|
|
3842
|
+
if (!this.sequenceManager) {
|
|
3843
|
+
throw new StoreNotOpenError();
|
|
3844
|
+
}
|
|
3845
|
+
return this.sequenceManager;
|
|
3846
|
+
}
|
|
3847
|
+
/**
|
|
3848
|
+
* Create a TransactionContext for atomic multi-collection operations.
|
|
3849
|
+
* The returned context buffers all mutations and commits them atomically.
|
|
3850
|
+
*
|
|
3851
|
+
* After commit, the caller is responsible for notifying subscriptions
|
|
3852
|
+
* and emitting events for each operation.
|
|
3853
|
+
*/
|
|
3854
|
+
createTransaction() {
|
|
3855
|
+
this.ensureOpen();
|
|
3856
|
+
if (!this.clock) {
|
|
3857
|
+
throw new StoreNotOpenError();
|
|
3858
|
+
}
|
|
3859
|
+
return new TransactionContext({
|
|
3860
|
+
schema: this.schema,
|
|
3861
|
+
adapter: this.adapter,
|
|
3862
|
+
clock: this.clock,
|
|
3863
|
+
nodeId: this.nodeId,
|
|
3864
|
+
sequenceAllocator: new TransactionSequenceAllocator(this.adapter, this.nodeId),
|
|
3865
|
+
relationEnforcer: this.relationEnforcer,
|
|
3866
|
+
causalTracker: this.causalTracker,
|
|
3867
|
+
localMutationHandler: this.localMutationHandler
|
|
3868
|
+
});
|
|
3869
|
+
}
|
|
3870
|
+
/**
|
|
3871
|
+
* Execute a function within a transaction. All mutations performed on the
|
|
3872
|
+
* TransactionContext are committed atomically. Subscription notifications
|
|
3873
|
+
* are batched and fired after the commit.
|
|
3874
|
+
*
|
|
3875
|
+
* If the function throws, the transaction is rolled back and the error is re-thrown.
|
|
3876
|
+
*
|
|
3877
|
+
* @param fn - Function receiving a TransactionContext for buffered operations
|
|
3878
|
+
* @returns The operations that were committed
|
|
3879
|
+
*/
|
|
3880
|
+
async transaction(fn) {
|
|
3881
|
+
const tx = this.createTransaction();
|
|
3882
|
+
this.causalTracker?.beginTransaction();
|
|
3883
|
+
try {
|
|
3884
|
+
await fn(tx);
|
|
3885
|
+
const { operations } = await tx.commit();
|
|
3886
|
+
this.causalTracker?.clearTransaction();
|
|
3887
|
+
for (const op of operations) {
|
|
3888
|
+
this.recordOperationSequence(op);
|
|
3889
|
+
this.subscriptionManager.notify(op.collection, op);
|
|
3890
|
+
if (this.emitter) {
|
|
3891
|
+
this.emitter.emit({ type: "operation:created", operation: op });
|
|
3892
|
+
}
|
|
3893
|
+
}
|
|
3894
|
+
return operations;
|
|
3895
|
+
} catch (error) {
|
|
3896
|
+
tx.rollback();
|
|
3897
|
+
this.causalTracker?.clearTransaction();
|
|
3898
|
+
throw error;
|
|
3899
|
+
}
|
|
3900
|
+
}
|
|
3901
|
+
/**
|
|
3902
|
+
* Export all data as a portable backup binary.
|
|
3903
|
+
* Includes operations, version vector, metadata, and optionally materialized records.
|
|
3904
|
+
*
|
|
3905
|
+
* @param options - Backup options (includeRecords, collections, onProgress)
|
|
3906
|
+
* @returns Backup as a Uint8Array
|
|
3907
|
+
*/
|
|
3908
|
+
async exportBackup(options) {
|
|
3909
|
+
this.ensureOpen();
|
|
3910
|
+
const { exportBackup: doExport } = await Promise.resolve().then(() => (init_backup(), backup_exports));
|
|
3911
|
+
return doExport(this.adapter, this.schema, this.nodeId, this.schema.version, options);
|
|
3912
|
+
}
|
|
3913
|
+
/**
|
|
3914
|
+
* Restore data from a backup binary.
|
|
3915
|
+
*
|
|
3916
|
+
* @param data - The backup data
|
|
3917
|
+
* @param options - Restore options (merge, collections, onProgress)
|
|
3918
|
+
* @returns Result of the restore operation
|
|
3919
|
+
*/
|
|
3920
|
+
async importBackup(data, options) {
|
|
3921
|
+
this.ensureOpen();
|
|
3922
|
+
const { restoreBackup: doRestore } = await Promise.resolve().then(() => (init_backup(), backup_exports));
|
|
3923
|
+
return doRestore(this.adapter, this.schema, data, options);
|
|
3924
|
+
}
|
|
3925
|
+
/**
|
|
3926
|
+
* Read backup manifest without loading the entire backup.
|
|
3927
|
+
*
|
|
3928
|
+
* @param data - The raw backup data
|
|
3929
|
+
* @returns The backup manifest
|
|
3930
|
+
*/
|
|
3931
|
+
static readBackupManifest(data) {
|
|
3932
|
+
return readBackupManifest(data);
|
|
3933
|
+
}
|
|
3934
|
+
recordOperationSequence(operation) {
|
|
3935
|
+
const prev = this.versionVector.get(operation.nodeId) ?? 0;
|
|
3936
|
+
if (operation.sequenceNumber > prev) {
|
|
3937
|
+
this.versionVector.set(operation.nodeId, operation.sequenceNumber);
|
|
3938
|
+
}
|
|
3939
|
+
if (operation.nodeId === this.nodeId) {
|
|
3940
|
+
this.sequenceNumber = Math.max(this.sequenceNumber, operation.sequenceNumber);
|
|
3941
|
+
}
|
|
3942
|
+
}
|
|
3943
|
+
async allocateSequenceNumber() {
|
|
3944
|
+
const seq = await allocateNextSequenceNumber(this.adapter, this.nodeId);
|
|
3945
|
+
this.sequenceNumber = seq;
|
|
3946
|
+
this.versionVector.set(this.nodeId, seq);
|
|
3947
|
+
return seq;
|
|
3948
|
+
}
|
|
3949
|
+
/**
|
|
3950
|
+
* Check the stored schema version and run any pending migrations.
|
|
3951
|
+
* Migrations are applied in version order within a transaction.
|
|
3952
|
+
*/
|
|
3953
|
+
async runMigrationsIfNeeded() {
|
|
3954
|
+
const storedVersion = await this.getStoredSchemaVersion();
|
|
3955
|
+
const targetVersion = this.schema.version;
|
|
3956
|
+
if (storedVersion >= targetVersion) {
|
|
3957
|
+
if (storedVersion === 0) {
|
|
3958
|
+
await this.adapter.execute(
|
|
3959
|
+
"INSERT OR REPLACE INTO _kora_meta (key, value) VALUES ('schema_version', ?)",
|
|
3960
|
+
[String(targetVersion)]
|
|
3961
|
+
);
|
|
3962
|
+
}
|
|
3963
|
+
return;
|
|
3964
|
+
}
|
|
3965
|
+
const migrations = this.schema.migrations ?? {};
|
|
3966
|
+
for (let v = storedVersion + 1; v <= targetVersion; v++) {
|
|
3967
|
+
const migration = migrations[v];
|
|
3968
|
+
if (!migration) continue;
|
|
3969
|
+
const sqlStatements = (0, import_core17.migrationStepsToSQL)(migration.steps);
|
|
3970
|
+
for (const sql of sqlStatements) {
|
|
3971
|
+
try {
|
|
3972
|
+
await this.adapter.execute(sql);
|
|
3973
|
+
} catch (e) {
|
|
3974
|
+
const msg = e.message || "";
|
|
3975
|
+
if (!msg.includes("duplicate column name")) {
|
|
3976
|
+
throw e;
|
|
3977
|
+
}
|
|
3978
|
+
}
|
|
3979
|
+
}
|
|
3980
|
+
const backfillSteps = migration.steps.filter(
|
|
3981
|
+
(s) => s.type === "backfill"
|
|
3982
|
+
);
|
|
3983
|
+
for (const step of backfillSteps) {
|
|
3984
|
+
await this.runBackfill(step.collection, step.transform);
|
|
3985
|
+
}
|
|
3986
|
+
}
|
|
3987
|
+
await this.adapter.execute(
|
|
3988
|
+
"INSERT OR REPLACE INTO _kora_meta (key, value) VALUES ('schema_version', ?)",
|
|
3989
|
+
[String(targetVersion)]
|
|
3990
|
+
);
|
|
3991
|
+
}
|
|
3992
|
+
/**
|
|
3993
|
+
* Get the stored schema version from _kora_meta. Returns 0 if not set.
|
|
3994
|
+
*/
|
|
3995
|
+
async getStoredSchemaVersion() {
|
|
3996
|
+
const rows = await this.adapter.query(
|
|
3997
|
+
"SELECT value FROM _kora_meta WHERE key = 'schema_version'"
|
|
3998
|
+
);
|
|
3999
|
+
return rows[0] ? Number(rows[0].value) : 0;
|
|
4000
|
+
}
|
|
4001
|
+
/**
|
|
4002
|
+
* Run a backfill transform on all records in a collection.
|
|
4003
|
+
* Reads all rows, applies the transform, and updates changed fields.
|
|
4004
|
+
*/
|
|
4005
|
+
async runBackfill(collection, transform) {
|
|
4006
|
+
const rows = await this.adapter.query(
|
|
4007
|
+
`SELECT * FROM ${collection} WHERE _deleted = 0`
|
|
4008
|
+
);
|
|
4009
|
+
await this.adapter.transaction(async (tx) => {
|
|
4010
|
+
for (const row of rows) {
|
|
4011
|
+
const updates = transform(row);
|
|
4012
|
+
const fields = Object.keys(updates);
|
|
4013
|
+
if (fields.length === 0) continue;
|
|
4014
|
+
const setClauses = fields.map((f) => `${f} = ?`).join(", ");
|
|
4015
|
+
const values = fields.map((f) => {
|
|
4016
|
+
const val = updates[f];
|
|
4017
|
+
if (typeof val === "boolean") return val ? 1 : 0;
|
|
4018
|
+
if (Array.isArray(val) || typeof val === "object" && val !== null) {
|
|
4019
|
+
return JSON.stringify(val);
|
|
4020
|
+
}
|
|
4021
|
+
return val;
|
|
4022
|
+
});
|
|
4023
|
+
values.push(row.id);
|
|
4024
|
+
await tx.execute(`UPDATE ${collection} SET ${setClauses} WHERE id = ?`, values);
|
|
4025
|
+
}
|
|
4026
|
+
});
|
|
1225
4027
|
}
|
|
1226
4028
|
async loadOrGenerateNodeId() {
|
|
1227
4029
|
if (this.configNodeId) {
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
4030
|
+
if (this.isolation !== "per-tab") {
|
|
4031
|
+
await this.adapter.execute(
|
|
4032
|
+
"INSERT OR REPLACE INTO _kora_meta (key, value) VALUES ('node_id', ?)",
|
|
4033
|
+
[this.configNodeId]
|
|
4034
|
+
);
|
|
4035
|
+
}
|
|
1232
4036
|
return this.configNodeId;
|
|
1233
4037
|
}
|
|
4038
|
+
if (this.isolation === "per-tab") {
|
|
4039
|
+
return resolvePerTabNodeId(this.dbName);
|
|
4040
|
+
}
|
|
1234
4041
|
const rows = await this.adapter.query(
|
|
1235
4042
|
"SELECT value FROM _kora_meta WHERE key = 'node_id'"
|
|
1236
4043
|
);
|
|
1237
4044
|
if (rows[0]) {
|
|
1238
4045
|
return rows[0].value;
|
|
1239
4046
|
}
|
|
1240
|
-
const newNodeId = (0,
|
|
4047
|
+
const newNodeId = (0, import_core17.generateUUIDv7)();
|
|
1241
4048
|
await this.adapter.execute("INSERT INTO _kora_meta (key, value) VALUES ('node_id', ?)", [
|
|
1242
4049
|
newNodeId
|
|
1243
4050
|
]);
|
|
@@ -1254,7 +4061,7 @@ var Store = class {
|
|
|
1254
4061
|
const rows = await this.adapter.query(
|
|
1255
4062
|
"SELECT node_id, sequence_number FROM _kora_version_vector"
|
|
1256
4063
|
);
|
|
1257
|
-
const vector = (0,
|
|
4064
|
+
const vector = (0, import_core17.createVersionVector)();
|
|
1258
4065
|
for (const row of rows) {
|
|
1259
4066
|
vector.set(row.node_id, row.sequence_number);
|
|
1260
4067
|
}
|
|
@@ -1266,23 +4073,57 @@ var Store = class {
|
|
|
1266
4073
|
}
|
|
1267
4074
|
}
|
|
1268
4075
|
};
|
|
4076
|
+
|
|
4077
|
+
// src/index.ts
|
|
4078
|
+
init_richtext_serializer();
|
|
4079
|
+
|
|
4080
|
+
// src/backup/index.ts
|
|
4081
|
+
init_backup();
|
|
4082
|
+
|
|
4083
|
+
// src/audit/index.ts
|
|
4084
|
+
init_audit_trace_store();
|
|
4085
|
+
init_audit_json();
|
|
4086
|
+
init_export_audit();
|
|
1269
4087
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1270
4088
|
0 && (module.exports = {
|
|
1271
4089
|
AdapterError,
|
|
4090
|
+
COMPACTION_BASELINE_META_KEY,
|
|
1272
4091
|
Collection,
|
|
4092
|
+
InvalidStateTransitionError,
|
|
4093
|
+
LAST_ACKED_SERVER_VECTOR_META_KEY,
|
|
1273
4094
|
PersistenceError,
|
|
1274
4095
|
QueryBuilder,
|
|
1275
4096
|
QueryError,
|
|
1276
4097
|
RecordNotFoundError,
|
|
4098
|
+
SequenceManager,
|
|
1277
4099
|
Store,
|
|
1278
4100
|
StoreNotOpenError,
|
|
4101
|
+
SubscriptionBloomFilter,
|
|
1279
4102
|
SubscriptionManager,
|
|
4103
|
+
TransactionContext,
|
|
1280
4104
|
WorkerInitError,
|
|
1281
4105
|
WorkerTimeoutError,
|
|
4106
|
+
collectOperationsAheadOfServer,
|
|
4107
|
+
compactOperationLog,
|
|
4108
|
+
computeAckCompactionWatermark,
|
|
4109
|
+
decodeAuditExport,
|
|
1282
4110
|
decodeRichtext,
|
|
4111
|
+
deserializeVersionVectorFromMeta,
|
|
1283
4112
|
encodeRichtext,
|
|
4113
|
+
exportBackup,
|
|
4114
|
+
mergeVersionVectors,
|
|
4115
|
+
persistedAuditTraceFromEvent,
|
|
1284
4116
|
pluralize,
|
|
4117
|
+
readAuditExportManifest,
|
|
4118
|
+
readBackupManifest,
|
|
4119
|
+
restoreBackup,
|
|
4120
|
+
richtextStatesEqual,
|
|
1285
4121
|
richtextToPlainText,
|
|
1286
|
-
|
|
4122
|
+
serializeVersionVectorToMeta,
|
|
4123
|
+
singularize,
|
|
4124
|
+
validateStateTransition,
|
|
4125
|
+
validateUpdateStateMachine,
|
|
4126
|
+
verifyAuditExportChecksum,
|
|
4127
|
+
verifyBackupChecksum
|
|
1287
4128
|
});
|
|
1288
4129
|
//# sourceMappingURL=index.cjs.map
|