@korajs/store 0.4.0 → 0.6.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.d.cts +1 -1
- package/dist/adapters/better-sqlite3.d.ts +1 -1
- package/dist/adapters/indexeddb.cjs +410 -17
- 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 +163 -18
- 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 +2662 -577
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +565 -187
- package/dist/index.d.ts +565 -187
- package/dist/index.js +1381 -739
- 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.js
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodeAuditExport,
|
|
3
|
+
readAuditExportManifest,
|
|
4
|
+
verifyAuditExportChecksum
|
|
5
|
+
} from "./chunk-AGN3CIPB.js";
|
|
6
|
+
import {
|
|
7
|
+
persistedAuditTraceFromEvent
|
|
8
|
+
} from "./chunk-MYSJN3DF.js";
|
|
9
|
+
import {
|
|
10
|
+
InvalidStateTransitionError,
|
|
11
|
+
allocateNextSequenceInTransaction,
|
|
12
|
+
allocateNextSequenceNumber,
|
|
13
|
+
buildCountQuery,
|
|
14
|
+
buildInsertQuery,
|
|
15
|
+
buildLwwSoftDeleteQuery,
|
|
16
|
+
buildLwwUpdateQuery,
|
|
17
|
+
buildSelectQuery,
|
|
18
|
+
buildSoftDeleteQuery,
|
|
19
|
+
buildUpdateQuery,
|
|
20
|
+
executeDelete,
|
|
21
|
+
executeInsert,
|
|
22
|
+
executeUpdate,
|
|
23
|
+
isIncomingNewerThanRow,
|
|
24
|
+
readSequenceNumber,
|
|
25
|
+
serializeRowVersion,
|
|
26
|
+
validateStateTransition,
|
|
27
|
+
validateUpdateStateMachine
|
|
28
|
+
} from "./chunk-7BPETY5Y.js";
|
|
1
29
|
import {
|
|
2
30
|
AdapterError,
|
|
3
31
|
PersistenceError,
|
|
@@ -7,452 +35,186 @@ import {
|
|
|
7
35
|
WorkerInitError,
|
|
8
36
|
WorkerTimeoutError
|
|
9
37
|
} from "./chunk-LAWV6CFH.js";
|
|
10
|
-
|
|
11
|
-
// src/store/store.ts
|
|
12
38
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
} from "
|
|
18
|
-
|
|
19
|
-
// src/collection/collection.ts
|
|
39
|
+
exportBackup,
|
|
40
|
+
readBackupManifest,
|
|
41
|
+
restoreBackup,
|
|
42
|
+
verifyBackupChecksum
|
|
43
|
+
} from "./chunk-EZSTD2XC.js";
|
|
20
44
|
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
45
|
+
decodeRichtext,
|
|
46
|
+
deserializeOperationWithCollection,
|
|
47
|
+
deserializeRecord,
|
|
48
|
+
encodeRichtext,
|
|
49
|
+
richtextStatesEqual,
|
|
50
|
+
richtextToPlainText,
|
|
51
|
+
serializeOperation,
|
|
52
|
+
serializeRecord
|
|
53
|
+
} from "./chunk-56LL4XP6.js";
|
|
28
54
|
|
|
29
|
-
// src/
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
if (descriptor.orderBy.length > 0) {
|
|
41
|
-
const orderParts = descriptor.orderBy.map((o) => {
|
|
42
|
-
validateFieldName(o.field, fields);
|
|
43
|
-
return `${o.field} ${o.direction.toUpperCase()}`;
|
|
44
|
-
});
|
|
45
|
-
parts.push(`ORDER BY ${orderParts.join(", ")}`);
|
|
46
|
-
}
|
|
47
|
-
if (descriptor.limit !== void 0) {
|
|
48
|
-
parts.push(`LIMIT ${descriptor.limit}`);
|
|
49
|
-
}
|
|
50
|
-
if (descriptor.offset !== void 0) {
|
|
51
|
-
parts.push(`OFFSET ${descriptor.offset}`);
|
|
52
|
-
}
|
|
53
|
-
return { sql: parts.join(" "), params };
|
|
54
|
-
}
|
|
55
|
-
function buildCountQuery(descriptor, fields) {
|
|
56
|
-
const params = [];
|
|
57
|
-
const parts = [`SELECT COUNT(*) as count FROM ${descriptor.collection}`];
|
|
58
|
-
const whereClause = buildWhereClauseParts(descriptor.where, fields, params);
|
|
59
|
-
const deletedFilter = "_deleted = 0";
|
|
60
|
-
if (whereClause) {
|
|
61
|
-
parts.push(`WHERE ${deletedFilter} AND ${whereClause}`);
|
|
62
|
-
} else {
|
|
63
|
-
parts.push(`WHERE ${deletedFilter}`);
|
|
64
|
-
}
|
|
65
|
-
return { sql: parts.join(" "), params };
|
|
66
|
-
}
|
|
67
|
-
function buildInsertQuery(collection, record) {
|
|
68
|
-
const columns = Object.keys(record);
|
|
69
|
-
const placeholders = columns.map(() => "?");
|
|
70
|
-
const params = Object.values(record);
|
|
71
|
-
const sql = `INSERT INTO ${collection} (${columns.join(", ")}) VALUES (${placeholders.join(", ")})`;
|
|
72
|
-
return { sql, params };
|
|
73
|
-
}
|
|
74
|
-
function buildUpdateQuery(collection, id, changes) {
|
|
75
|
-
const setClauses = Object.keys(changes).map((col) => `${col} = ?`);
|
|
76
|
-
const params = [...Object.values(changes), id];
|
|
77
|
-
const sql = `UPDATE ${collection} SET ${setClauses.join(", ")} WHERE id = ?`;
|
|
78
|
-
return { sql, params };
|
|
79
|
-
}
|
|
80
|
-
function buildSoftDeleteQuery(collection, id, updatedAt) {
|
|
81
|
-
return {
|
|
82
|
-
sql: `UPDATE ${collection} SET _deleted = 1, _updated_at = ? WHERE id = ?`,
|
|
83
|
-
params: [updatedAt, id]
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
var VALID_OPERATORS = /* @__PURE__ */ new Set(["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in"]);
|
|
87
|
-
function buildWhereClauseParts(where, fields, params) {
|
|
88
|
-
const conditions = [];
|
|
89
|
-
for (const [fieldName, value] of Object.entries(where)) {
|
|
90
|
-
validateFieldName(fieldName, fields);
|
|
91
|
-
const descriptor = fields[fieldName];
|
|
92
|
-
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
93
|
-
const ops = value;
|
|
94
|
-
for (const [op, opValue] of Object.entries(ops)) {
|
|
95
|
-
if (!VALID_OPERATORS.has(op)) {
|
|
96
|
-
throw new QueryError(`Unknown operator "${op}" on field "${fieldName}"`, {
|
|
97
|
-
field: fieldName,
|
|
98
|
-
operator: op,
|
|
99
|
-
validOperators: [...VALID_OPERATORS]
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
conditions.push(buildOperatorCondition(fieldName, op, opValue, descriptor, params));
|
|
103
|
-
}
|
|
104
|
-
} else {
|
|
105
|
-
conditions.push(buildOperatorCondition(fieldName, "$eq", value, descriptor, params));
|
|
55
|
+
// src/compaction/types.ts
|
|
56
|
+
var COMPACTION_BASELINE_META_KEY = "compaction_baseline_at";
|
|
57
|
+
|
|
58
|
+
// src/compaction/compact-operation-log.ts
|
|
59
|
+
import { HybridLogicalClock, createVersionVector } from "@korajs/core";
|
|
60
|
+
function computeAckCompactionWatermark(serverVector) {
|
|
61
|
+
const watermark = createVersionVector();
|
|
62
|
+
for (const [nodeId, seq] of serverVector) {
|
|
63
|
+
if (seq > 0) {
|
|
64
|
+
watermark.set(nodeId, seq);
|
|
106
65
|
}
|
|
107
66
|
}
|
|
108
|
-
|
|
109
|
-
return conditions.join(" AND ");
|
|
67
|
+
return watermark;
|
|
110
68
|
}
|
|
111
|
-
function
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
case "$in": {
|
|
139
|
-
if (!Array.isArray(sqlValue)) {
|
|
140
|
-
throw new QueryError(`$in operator requires an array value for field "${fieldName}"`, {
|
|
141
|
-
field: fieldName,
|
|
142
|
-
received: typeof sqlValue
|
|
143
|
-
});
|
|
69
|
+
async function compactOperationLog(adapter, schema, strategy, serverVector) {
|
|
70
|
+
if (strategy.mode === "never") {
|
|
71
|
+
return { deletedCount: 0, watermark: createVersionVector() };
|
|
72
|
+
}
|
|
73
|
+
const watermark = computeAckCompactionWatermark(serverVector);
|
|
74
|
+
if (watermark.size === 0) {
|
|
75
|
+
return { deletedCount: 0, watermark };
|
|
76
|
+
}
|
|
77
|
+
const ageCutoffWall = strategy.mode === "after-days" ? Date.now() - strategy.days * 24 * 60 * 60 * 1e3 : null;
|
|
78
|
+
const ageCutoffSerialized = ageCutoffWall !== null ? HybridLogicalClock.serialize({
|
|
79
|
+
wallTime: ageCutoffWall,
|
|
80
|
+
logical: 0,
|
|
81
|
+
nodeId: ""
|
|
82
|
+
}).slice(0, 15) : null;
|
|
83
|
+
let deletedCount = 0;
|
|
84
|
+
const collectionNames = Object.keys(schema.collections);
|
|
85
|
+
await adapter.transaction(async (tx) => {
|
|
86
|
+
for (const collectionName of collectionNames) {
|
|
87
|
+
const table = `_kora_ops_${collectionName}`;
|
|
88
|
+
for (const [nodeId, maxSeq] of watermark) {
|
|
89
|
+
if (maxSeq <= 0) continue;
|
|
90
|
+
const countSql = buildCountSql(table, ageCutoffSerialized !== null);
|
|
91
|
+
const countParams = ageCutoffSerialized !== null ? [nodeId, maxSeq, ageCutoffSerialized] : [nodeId, maxSeq];
|
|
92
|
+
const countRows = await tx.query(countSql, countParams);
|
|
93
|
+
deletedCount += countRows[0]?.count ?? 0;
|
|
94
|
+
const deleteSql = buildDeleteSql(table, ageCutoffSerialized !== null);
|
|
95
|
+
await tx.execute(deleteSql, countParams);
|
|
144
96
|
}
|
|
145
|
-
const placeholders = sqlValue.map(() => "?");
|
|
146
|
-
for (const item of sqlValue) {
|
|
147
|
-
params.push(
|
|
148
|
-
descriptor?.kind === "boolean" && typeof item === "boolean" ? item ? 1 : 0 : item
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
return `${fieldName} IN (${placeholders.join(", ")})`;
|
|
152
97
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
...Object.keys(fields),
|
|
160
|
-
"id",
|
|
161
|
-
"createdAt",
|
|
162
|
-
"updatedAt",
|
|
163
|
-
"_created_at",
|
|
164
|
-
"_updated_at"
|
|
165
|
-
]);
|
|
166
|
-
if (!allowedFields.has(fieldName)) {
|
|
167
|
-
throw new QueryError(
|
|
168
|
-
`Unknown field "${fieldName}" in query. Available fields: ${[...allowedFields].join(", ")}`,
|
|
169
|
-
{ field: fieldName }
|
|
170
|
-
);
|
|
171
|
-
}
|
|
98
|
+
await tx.execute("INSERT OR REPLACE INTO _kora_meta (key, value) VALUES (?, ?)", [
|
|
99
|
+
COMPACTION_BASELINE_META_KEY,
|
|
100
|
+
String(Date.now())
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
return { deletedCount, watermark };
|
|
172
104
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
// src/serialization/richtext-serializer.ts
|
|
178
|
-
import * as Y from "yjs";
|
|
179
|
-
var TEXT_KEY = "content";
|
|
180
|
-
function encodeRichtext(value) {
|
|
181
|
-
if (value === null || value === void 0) {
|
|
182
|
-
return null;
|
|
183
|
-
}
|
|
184
|
-
if (typeof value === "string") {
|
|
185
|
-
const doc = new Y.Doc();
|
|
186
|
-
doc.getText(TEXT_KEY).insert(0, value);
|
|
187
|
-
return Y.encodeStateAsUpdate(doc);
|
|
188
|
-
}
|
|
189
|
-
if (value instanceof Uint8Array) {
|
|
190
|
-
return value;
|
|
105
|
+
function buildCountSql(table, withAge) {
|
|
106
|
+
if (withAge) {
|
|
107
|
+
return `SELECT COUNT(*) as count FROM ${table} WHERE node_id = ? AND sequence_number <= ? AND SUBSTR(timestamp, 1, 15) < ?`;
|
|
191
108
|
}
|
|
192
|
-
|
|
193
|
-
return new Uint8Array(value);
|
|
194
|
-
}
|
|
195
|
-
throw new Error("Richtext value must be a string, Uint8Array, ArrayBuffer, null, or undefined.");
|
|
109
|
+
return `SELECT COUNT(*) as count FROM ${table} WHERE node_id = ? AND sequence_number <= ?`;
|
|
196
110
|
}
|
|
197
|
-
function
|
|
198
|
-
if (
|
|
199
|
-
return
|
|
200
|
-
}
|
|
201
|
-
if (typeof Buffer !== "undefined" && Buffer.isBuffer(value)) {
|
|
202
|
-
return new Uint8Array(value);
|
|
111
|
+
function buildDeleteSql(table, withAge) {
|
|
112
|
+
if (withAge) {
|
|
113
|
+
return `DELETE FROM ${table} WHERE node_id = ? AND sequence_number <= ? AND SUBSTR(timestamp, 1, 15) < ?`;
|
|
203
114
|
}
|
|
204
|
-
|
|
205
|
-
return value;
|
|
206
|
-
}
|
|
207
|
-
if (value instanceof ArrayBuffer) {
|
|
208
|
-
return new Uint8Array(value);
|
|
209
|
-
}
|
|
210
|
-
throw new Error(
|
|
211
|
-
"Richtext storage value must be Uint8Array, ArrayBuffer, Buffer, null, or undefined."
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
function richtextToPlainText(value) {
|
|
215
|
-
const encoded = encodeRichtext(value);
|
|
216
|
-
if (!encoded) return "";
|
|
217
|
-
const doc = new Y.Doc();
|
|
218
|
-
Y.applyUpdate(doc, encoded);
|
|
219
|
-
return doc.getText(TEXT_KEY).toString();
|
|
115
|
+
return `DELETE FROM ${table} WHERE node_id = ? AND sequence_number <= ?`;
|
|
220
116
|
}
|
|
221
117
|
|
|
222
|
-
// src/
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
return result;
|
|
118
|
+
// src/sync/sync-state.ts
|
|
119
|
+
import { createVersionVector as createVersionVector2 } from "@korajs/core";
|
|
120
|
+
var LAST_ACKED_SERVER_VECTOR_META_KEY = "last_acked_server_vector";
|
|
121
|
+
var DELTA_CURSOR_META_KEY = "delta_cursor";
|
|
122
|
+
function serializeVersionVectorToMeta(vector) {
|
|
123
|
+
const record = {};
|
|
124
|
+
for (const [nodeId, seq] of vector) {
|
|
125
|
+
record[nodeId] = seq;
|
|
126
|
+
}
|
|
127
|
+
return JSON.stringify(record);
|
|
234
128
|
}
|
|
235
|
-
function
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
for (const [key, descriptor] of Object.entries(fields)) {
|
|
242
|
-
const rawValue = row[key];
|
|
243
|
-
if (rawValue === void 0 || rawValue === null) {
|
|
244
|
-
result[key] = rawValue ?? null;
|
|
245
|
-
continue;
|
|
129
|
+
function deserializeVersionVectorFromMeta(value) {
|
|
130
|
+
const parsed = JSON.parse(value);
|
|
131
|
+
const vector = createVersionVector2();
|
|
132
|
+
for (const [nodeId, seq] of Object.entries(parsed)) {
|
|
133
|
+
if (typeof seq === "number" && seq >= 0) {
|
|
134
|
+
vector.set(nodeId, seq);
|
|
246
135
|
}
|
|
247
|
-
result[key] = deserializeValue(rawValue, descriptor);
|
|
248
136
|
}
|
|
249
|
-
return
|
|
137
|
+
return vector;
|
|
250
138
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const hasMetadata = op.transactionId !== void 0 || op.mutationName !== void 0;
|
|
256
|
-
let dataPayload = null;
|
|
257
|
-
if (op.data) {
|
|
258
|
-
dataPayload = { ...op.data };
|
|
259
|
-
if (op.atomicOps !== void 0 && Object.keys(op.atomicOps).length > 0) {
|
|
260
|
-
dataPayload[ATOMIC_OPS_KEY] = op.atomicOps;
|
|
261
|
-
}
|
|
262
|
-
if (op.transactionId !== void 0) {
|
|
263
|
-
dataPayload[TX_ID_KEY] = op.transactionId;
|
|
264
|
-
}
|
|
265
|
-
if (op.mutationName !== void 0) {
|
|
266
|
-
dataPayload[MUTATION_NAME_KEY] = op.mutationName;
|
|
267
|
-
}
|
|
268
|
-
} else if (hasMetadata) {
|
|
269
|
-
dataPayload = {};
|
|
270
|
-
if (op.transactionId !== void 0) {
|
|
271
|
-
dataPayload[TX_ID_KEY] = op.transactionId;
|
|
272
|
-
}
|
|
273
|
-
if (op.mutationName !== void 0) {
|
|
274
|
-
dataPayload[MUTATION_NAME_KEY] = op.mutationName;
|
|
275
|
-
}
|
|
139
|
+
function mergeVersionVectors(a, b) {
|
|
140
|
+
const merged = new Map(a);
|
|
141
|
+
for (const [nodeId, seq] of b) {
|
|
142
|
+
merged.set(nodeId, Math.max(merged.get(nodeId) ?? 0, seq));
|
|
276
143
|
}
|
|
277
|
-
return
|
|
278
|
-
id: op.id,
|
|
279
|
-
node_id: op.nodeId,
|
|
280
|
-
type: op.type,
|
|
281
|
-
record_id: op.recordId,
|
|
282
|
-
data: dataPayload ? JSON.stringify(dataPayload) : null,
|
|
283
|
-
previous_data: op.previousData ? JSON.stringify(op.previousData) : null,
|
|
284
|
-
timestamp: HybridLogicalClock.serialize(op.timestamp),
|
|
285
|
-
sequence_number: op.sequenceNumber,
|
|
286
|
-
causal_deps: JSON.stringify(op.causalDeps),
|
|
287
|
-
schema_version: op.schemaVersion
|
|
288
|
-
};
|
|
144
|
+
return merged;
|
|
289
145
|
}
|
|
290
|
-
function
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
if (ATOMIC_OPS_KEY in parsed) {
|
|
298
|
-
atomicOps = parsed[ATOMIC_OPS_KEY];
|
|
299
|
-
}
|
|
300
|
-
if (TX_ID_KEY in parsed) {
|
|
301
|
-
transactionId = parsed[TX_ID_KEY];
|
|
146
|
+
async function collectOperationsAheadOfServer(localVector, serverVector, fetchRange) {
|
|
147
|
+
const missing = [];
|
|
148
|
+
for (const [nodeId, localSeq] of localVector) {
|
|
149
|
+
const serverSeq = serverVector.get(nodeId) ?? 0;
|
|
150
|
+
if (localSeq > serverSeq) {
|
|
151
|
+
const ops = await fetchRange(nodeId, serverSeq + 1, localSeq);
|
|
152
|
+
missing.push(...ops);
|
|
302
153
|
}
|
|
303
|
-
if (MUTATION_NAME_KEY in parsed) {
|
|
304
|
-
mutationName = parsed[MUTATION_NAME_KEY];
|
|
305
|
-
}
|
|
306
|
-
const { [ATOMIC_OPS_KEY]: _a, [TX_ID_KEY]: _t, [MUTATION_NAME_KEY]: _m, ...rest } = parsed;
|
|
307
|
-
data = Object.keys(rest).length > 0 ? rest : null;
|
|
308
154
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
nodeId: row.node_id,
|
|
312
|
-
type: row.type,
|
|
313
|
-
collection: "",
|
|
314
|
-
// Collection name is derived from the table name by the caller
|
|
315
|
-
recordId: row.record_id,
|
|
316
|
-
data,
|
|
317
|
-
previousData: row.previous_data ? JSON.parse(row.previous_data) : null,
|
|
318
|
-
timestamp: HybridLogicalClock.deserialize(row.timestamp),
|
|
319
|
-
sequenceNumber: row.sequence_number,
|
|
320
|
-
causalDeps: JSON.parse(row.causal_deps),
|
|
321
|
-
schemaVersion: row.schema_version,
|
|
322
|
-
...atomicOps !== void 0 ? { atomicOps } : {},
|
|
323
|
-
...transactionId !== void 0 ? { transactionId } : {},
|
|
324
|
-
...mutationName !== void 0 ? { mutationName } : {}
|
|
325
|
-
};
|
|
326
|
-
}
|
|
327
|
-
function deserializeOperationWithCollection(row, collection) {
|
|
328
|
-
const op = deserializeOperation(row);
|
|
329
|
-
return { ...op, collection };
|
|
155
|
+
missing.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
|
156
|
+
return missing;
|
|
330
157
|
}
|
|
331
|
-
function
|
|
332
|
-
|
|
333
|
-
|
|
158
|
+
async function loadLastAckedServerVector(adapter) {
|
|
159
|
+
const rows = await adapter.query("SELECT value FROM _kora_meta WHERE key = ?", [
|
|
160
|
+
LAST_ACKED_SERVER_VECTOR_META_KEY
|
|
161
|
+
]);
|
|
162
|
+
const row = rows[0];
|
|
163
|
+
if (!row?.value) {
|
|
164
|
+
return createVersionVector2();
|
|
334
165
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
return JSON.stringify(value);
|
|
340
|
-
case "richtext":
|
|
341
|
-
return encodeRichtext(value);
|
|
342
|
-
default:
|
|
343
|
-
return value;
|
|
166
|
+
try {
|
|
167
|
+
return deserializeVersionVectorFromMeta(row.value);
|
|
168
|
+
} catch {
|
|
169
|
+
return createVersionVector2();
|
|
344
170
|
}
|
|
345
171
|
}
|
|
346
|
-
function
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
if (typeof value === "string") {
|
|
352
|
-
return JSON.parse(value);
|
|
353
|
-
}
|
|
354
|
-
return value;
|
|
355
|
-
case "richtext":
|
|
356
|
-
return decodeRichtext(value);
|
|
357
|
-
default:
|
|
358
|
-
return value;
|
|
359
|
-
}
|
|
172
|
+
async function saveLastAckedServerVector(adapter, vector) {
|
|
173
|
+
await adapter.execute("INSERT OR REPLACE INTO _kora_meta (key, value) VALUES (?, ?)", [
|
|
174
|
+
LAST_ACKED_SERVER_VECTOR_META_KEY,
|
|
175
|
+
serializeVersionVectorToMeta(vector)
|
|
176
|
+
]);
|
|
360
177
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
super(
|
|
367
|
-
`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)"}`,
|
|
368
|
-
"INVALID_STATE_TRANSITION",
|
|
369
|
-
{ collection, recordId, field, fromState, toState, allowedStates }
|
|
370
|
-
);
|
|
371
|
-
this.collection = collection;
|
|
372
|
-
this.recordId = recordId;
|
|
373
|
-
this.field = field;
|
|
374
|
-
this.fromState = fromState;
|
|
375
|
-
this.toState = toState;
|
|
376
|
-
this.allowedStates = allowedStates;
|
|
377
|
-
this.name = "InvalidStateTransitionError";
|
|
378
|
-
}
|
|
379
|
-
collection;
|
|
380
|
-
recordId;
|
|
381
|
-
field;
|
|
382
|
-
fromState;
|
|
383
|
-
toState;
|
|
384
|
-
allowedStates;
|
|
385
|
-
};
|
|
386
|
-
function validateStateTransition(collectionName, recordId, stateMachine, currentState, newState) {
|
|
387
|
-
if (currentState === null) {
|
|
388
|
-
return { valid: true, allowedStates: [] };
|
|
389
|
-
}
|
|
390
|
-
if (currentState === newState) {
|
|
391
|
-
return { valid: true, allowedStates: stateMachine.transitions[currentState] ?? [] };
|
|
392
|
-
}
|
|
393
|
-
const constraint = {
|
|
394
|
-
field: stateMachine.field,
|
|
395
|
-
collection: collectionName,
|
|
396
|
-
transitions: stateMachine.transitions
|
|
397
|
-
};
|
|
398
|
-
const transitionResult = validateTransition(constraint, currentState, newState);
|
|
399
|
-
if (transitionResult.valid) {
|
|
400
|
-
return { valid: true, allowedStates: transitionResult.allowedTargets };
|
|
401
|
-
}
|
|
402
|
-
const allowedStates = transitionResult.allowedTargets;
|
|
403
|
-
if (stateMachine.onInvalidTransition === "reject") {
|
|
404
|
-
throw new InvalidStateTransitionError(
|
|
405
|
-
collectionName,
|
|
406
|
-
recordId,
|
|
407
|
-
stateMachine.field,
|
|
408
|
-
currentState,
|
|
409
|
-
newState,
|
|
410
|
-
allowedStates
|
|
411
|
-
);
|
|
412
|
-
}
|
|
413
|
-
return { valid: false, allowedStates };
|
|
178
|
+
async function loadDeltaCursor(adapter) {
|
|
179
|
+
const rows = await adapter.query("SELECT value FROM _kora_meta WHERE key = ?", [
|
|
180
|
+
DELTA_CURSOR_META_KEY
|
|
181
|
+
]);
|
|
182
|
+
return rows[0]?.value ?? null;
|
|
414
183
|
}
|
|
415
|
-
function
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
return
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
const currentState = currentRecord[stateField];
|
|
425
|
-
const newState = updateData[stateField];
|
|
426
|
-
if (typeof currentState !== "string" || typeof newState !== "string") {
|
|
427
|
-
return updateData;
|
|
428
|
-
}
|
|
429
|
-
const result = validateStateTransition(
|
|
430
|
-
collectionName,
|
|
431
|
-
recordId,
|
|
432
|
-
stateMachine,
|
|
433
|
-
currentState,
|
|
434
|
-
newState
|
|
435
|
-
);
|
|
436
|
-
if (result.valid) {
|
|
437
|
-
return updateData;
|
|
438
|
-
}
|
|
439
|
-
const filtered = { ...updateData };
|
|
440
|
-
delete filtered[stateField];
|
|
441
|
-
return filtered;
|
|
184
|
+
async function saveDeltaCursor(adapter, cursor) {
|
|
185
|
+
if (cursor === null) {
|
|
186
|
+
await adapter.execute("DELETE FROM _kora_meta WHERE key = ?", [DELTA_CURSOR_META_KEY]);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
await adapter.execute("INSERT OR REPLACE INTO _kora_meta (key, value) VALUES (?, ?)", [
|
|
190
|
+
DELTA_CURSOR_META_KEY,
|
|
191
|
+
cursor
|
|
192
|
+
]);
|
|
442
193
|
}
|
|
443
194
|
|
|
195
|
+
// src/store/store.ts
|
|
196
|
+
import {
|
|
197
|
+
CausalTracker,
|
|
198
|
+
HybridLogicalClock as HybridLogicalClock2,
|
|
199
|
+
createVersionVector as createVersionVector3,
|
|
200
|
+
generateUUIDv7 as generateUUIDv73,
|
|
201
|
+
migrationStepsToSQL
|
|
202
|
+
} from "@korajs/core";
|
|
203
|
+
|
|
444
204
|
// src/collection/collection.ts
|
|
445
205
|
var Collection = class {
|
|
446
|
-
constructor(name, definition, schema, adapter, clock, nodeId,
|
|
206
|
+
constructor(name, definition, schema, adapter, clock, nodeId, allocateSequenceNumber, onMutation, relationEnforcer, mutationHandler, causalTracker) {
|
|
447
207
|
this.name = name;
|
|
448
208
|
this.definition = definition;
|
|
449
209
|
this.schema = schema;
|
|
450
210
|
this.adapter = adapter;
|
|
451
211
|
this.clock = clock;
|
|
452
212
|
this.nodeId = nodeId;
|
|
453
|
-
this.
|
|
213
|
+
this.allocateSequenceNumber = allocateSequenceNumber;
|
|
454
214
|
this.onMutation = onMutation;
|
|
455
|
-
this.relationEnforcer = relationEnforcer
|
|
215
|
+
this.relationEnforcer = relationEnforcer;
|
|
216
|
+
this.mutationHandler = mutationHandler;
|
|
217
|
+
this.causalTracker = causalTracker;
|
|
456
218
|
}
|
|
457
219
|
name;
|
|
458
220
|
definition;
|
|
@@ -460,72 +222,32 @@ var Collection = class {
|
|
|
460
222
|
adapter;
|
|
461
223
|
clock;
|
|
462
224
|
nodeId;
|
|
463
|
-
|
|
225
|
+
allocateSequenceNumber;
|
|
464
226
|
onMutation;
|
|
465
227
|
relationEnforcer;
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
*
|
|
470
|
-
* @param data - The record data (auto fields and defaults are applied automatically)
|
|
471
|
-
* @returns The inserted record with id, createdAt, updatedAt
|
|
472
|
-
*/
|
|
473
|
-
async insert(data) {
|
|
474
|
-
const validated = validateRecord(this.name, this.definition, data, "insert");
|
|
475
|
-
const recordId = generateUUIDv7();
|
|
476
|
-
const now = Date.now();
|
|
477
|
-
for (const [fieldName, descriptor] of Object.entries(this.definition.fields)) {
|
|
478
|
-
if (descriptor.auto && descriptor.kind === "timestamp") {
|
|
479
|
-
validated[fieldName] = now;
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
const sequenceNumber = this.getSequenceNumber();
|
|
483
|
-
const operation = await createOperation(
|
|
484
|
-
{
|
|
485
|
-
nodeId: this.nodeId,
|
|
486
|
-
type: "insert",
|
|
487
|
-
collection: this.name,
|
|
488
|
-
recordId,
|
|
489
|
-
data: { ...validated },
|
|
490
|
-
previousData: null,
|
|
491
|
-
sequenceNumber,
|
|
492
|
-
causalDeps: [],
|
|
493
|
-
schemaVersion: this.schema.version
|
|
494
|
-
},
|
|
495
|
-
this.clock
|
|
496
|
-
);
|
|
497
|
-
const serializedData = serializeRecord(validated, this.definition.fields);
|
|
498
|
-
const record = {
|
|
499
|
-
id: recordId,
|
|
500
|
-
...serializedData,
|
|
501
|
-
_created_at: now,
|
|
502
|
-
_updated_at: now
|
|
503
|
-
};
|
|
504
|
-
const insertQuery = buildInsertQuery(this.name, record);
|
|
505
|
-
const opRow = serializeOperation(operation);
|
|
506
|
-
const opInsert = buildInsertQuery(
|
|
507
|
-
`_kora_ops_${this.name}`,
|
|
508
|
-
opRow
|
|
509
|
-
);
|
|
510
|
-
await this.adapter.transaction(async (tx) => {
|
|
511
|
-
await tx.execute(insertQuery.sql, insertQuery.params);
|
|
512
|
-
await tx.execute(opInsert.sql, opInsert.params);
|
|
513
|
-
await tx.execute(
|
|
514
|
-
"INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
|
|
515
|
-
[this.nodeId, sequenceNumber]
|
|
516
|
-
);
|
|
517
|
-
});
|
|
518
|
-
this.onMutation(this.name, operation);
|
|
228
|
+
mutationHandler;
|
|
229
|
+
causalTracker;
|
|
230
|
+
mutationContext() {
|
|
519
231
|
return {
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
232
|
+
collection: this.name,
|
|
233
|
+
definition: this.definition,
|
|
234
|
+
schema: this.schema,
|
|
235
|
+
adapter: this.adapter,
|
|
236
|
+
clock: this.clock,
|
|
237
|
+
nodeId: this.nodeId,
|
|
238
|
+
allocateSequenceNumber: this.allocateSequenceNumber,
|
|
239
|
+
onMutation: this.onMutation,
|
|
240
|
+
relationEnforcer: this.relationEnforcer,
|
|
241
|
+
causalTracker: this.causalTracker,
|
|
242
|
+
inTransaction: false
|
|
524
243
|
};
|
|
525
244
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
245
|
+
async insert(data) {
|
|
246
|
+
if (this.mutationHandler) {
|
|
247
|
+
return this.mutationHandler.insert(this.name, data);
|
|
248
|
+
}
|
|
249
|
+
return executeInsert(this.mutationContext(), data);
|
|
250
|
+
}
|
|
529
251
|
async findById(id) {
|
|
530
252
|
const rows = await this.adapter.query(
|
|
531
253
|
`SELECT * FROM ${this.name} WHERE id = ? AND _deleted = 0`,
|
|
@@ -535,173 +257,29 @@ var Collection = class {
|
|
|
535
257
|
if (!row) return null;
|
|
536
258
|
return deserializeRecord(row, this.definition.fields);
|
|
537
259
|
}
|
|
538
|
-
/**
|
|
539
|
-
* Update an existing record. Only the provided fields are changed.
|
|
540
|
-
*
|
|
541
|
-
* @param id - The record ID to update
|
|
542
|
-
* @param data - Partial data with only the fields to change
|
|
543
|
-
* @returns The updated record
|
|
544
|
-
* @throws {RecordNotFoundError} If the record doesn't exist or is deleted
|
|
545
|
-
*/
|
|
546
260
|
async update(id, data) {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
[id]
|
|
550
|
-
);
|
|
551
|
-
const currentRow = currentRows[0];
|
|
552
|
-
if (!currentRow) {
|
|
553
|
-
throw new RecordNotFoundError(this.name, id);
|
|
554
|
-
}
|
|
555
|
-
let validated = validateRecord(this.name, this.definition, data, "update");
|
|
556
|
-
const now = Date.now();
|
|
557
|
-
const currentRecord = deserializeRecord(currentRow, this.definition.fields);
|
|
558
|
-
validated = validateUpdateStateMachine(
|
|
559
|
-
this.name,
|
|
560
|
-
id,
|
|
561
|
-
this.definition,
|
|
562
|
-
currentRecord,
|
|
563
|
-
validated
|
|
564
|
-
);
|
|
565
|
-
if (Object.keys(validated).length === 0) {
|
|
566
|
-
const fullRecord = await this.findById(id);
|
|
567
|
-
if (!fullRecord) {
|
|
568
|
-
throw new RecordNotFoundError(this.name, id);
|
|
569
|
-
}
|
|
570
|
-
return fullRecord;
|
|
571
|
-
}
|
|
572
|
-
const previousData = {};
|
|
573
|
-
const resolvedData = {};
|
|
574
|
-
const atomicOps = {};
|
|
575
|
-
for (const key of Object.keys(validated)) {
|
|
576
|
-
const value = validated[key];
|
|
577
|
-
previousData[key] = currentRecord[key];
|
|
578
|
-
if (isAtomicOp(value)) {
|
|
579
|
-
resolvedData[key] = resolveAtomicOp(currentRecord[key], value);
|
|
580
|
-
atomicOps[key] = toAtomicOp(value);
|
|
581
|
-
} else {
|
|
582
|
-
resolvedData[key] = value;
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
const hasAtomicOps = Object.keys(atomicOps).length > 0;
|
|
586
|
-
const sequenceNumber = this.getSequenceNumber();
|
|
587
|
-
const operation = await createOperation(
|
|
588
|
-
{
|
|
589
|
-
nodeId: this.nodeId,
|
|
590
|
-
type: "update",
|
|
591
|
-
collection: this.name,
|
|
592
|
-
recordId: id,
|
|
593
|
-
data: { ...resolvedData },
|
|
594
|
-
previousData,
|
|
595
|
-
sequenceNumber,
|
|
596
|
-
causalDeps: [],
|
|
597
|
-
schemaVersion: this.schema.version,
|
|
598
|
-
...hasAtomicOps ? { atomicOps } : {}
|
|
599
|
-
},
|
|
600
|
-
this.clock
|
|
601
|
-
);
|
|
602
|
-
const serializedChanges = serializeRecord(resolvedData, this.definition.fields);
|
|
603
|
-
const updateQuery = buildUpdateQuery(this.name, id, {
|
|
604
|
-
...serializedChanges,
|
|
605
|
-
_updated_at: now
|
|
606
|
-
});
|
|
607
|
-
const opRow = serializeOperation(operation);
|
|
608
|
-
const opInsert = buildInsertQuery(
|
|
609
|
-
`_kora_ops_${this.name}`,
|
|
610
|
-
opRow
|
|
611
|
-
);
|
|
612
|
-
await this.adapter.transaction(async (tx) => {
|
|
613
|
-
await tx.execute(updateQuery.sql, updateQuery.params);
|
|
614
|
-
await tx.execute(opInsert.sql, opInsert.params);
|
|
615
|
-
await tx.execute(
|
|
616
|
-
"INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
|
|
617
|
-
[this.nodeId, sequenceNumber]
|
|
618
|
-
);
|
|
619
|
-
});
|
|
620
|
-
this.onMutation(this.name, operation);
|
|
621
|
-
const updatedRow = await this.findById(id);
|
|
622
|
-
if (!updatedRow) {
|
|
623
|
-
throw new RecordNotFoundError(this.name, id);
|
|
261
|
+
if (this.mutationHandler) {
|
|
262
|
+
return this.mutationHandler.update(this.name, id, data);
|
|
624
263
|
}
|
|
625
|
-
return
|
|
264
|
+
return executeUpdate(this.mutationContext(), id, data);
|
|
626
265
|
}
|
|
627
|
-
/**
|
|
628
|
-
* Soft-delete a record by its ID.
|
|
629
|
-
*
|
|
630
|
-
* When a RelationEnforcer is configured, referential integrity policies
|
|
631
|
-
* are enforced within the same transaction:
|
|
632
|
-
* - **cascade**: Recursively deletes all referencing records
|
|
633
|
-
* - **set-null**: Sets foreign keys to null on referencing records
|
|
634
|
-
* - **restrict**: Throws ReferentialIntegrityError if references exist
|
|
635
|
-
* - **no-action**: Does nothing (dangling references are left)
|
|
636
|
-
*
|
|
637
|
-
* All cascaded operations are created atomically within the same transaction
|
|
638
|
-
* and share causal dependencies with the original delete.
|
|
639
|
-
*
|
|
640
|
-
* @param id - The record ID to delete
|
|
641
|
-
* @throws {RecordNotFoundError} If the record doesn't exist or is already deleted
|
|
642
|
-
* @throws {ReferentialIntegrityError} If a 'restrict' policy is violated
|
|
643
|
-
*/
|
|
644
266
|
async delete(id) {
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
);
|
|
649
|
-
if (!currentRows[0]) {
|
|
650
|
-
throw new RecordNotFoundError(this.name, id);
|
|
651
|
-
}
|
|
652
|
-
const now = Date.now();
|
|
653
|
-
const sequenceNumber = this.getSequenceNumber();
|
|
654
|
-
const operation = await createOperation(
|
|
655
|
-
{
|
|
656
|
-
nodeId: this.nodeId,
|
|
657
|
-
type: "delete",
|
|
658
|
-
collection: this.name,
|
|
659
|
-
recordId: id,
|
|
660
|
-
data: null,
|
|
661
|
-
previousData: null,
|
|
662
|
-
sequenceNumber,
|
|
663
|
-
causalDeps: [],
|
|
664
|
-
schemaVersion: this.schema.version
|
|
665
|
-
},
|
|
666
|
-
this.clock
|
|
667
|
-
);
|
|
668
|
-
const deleteQuery = buildSoftDeleteQuery(this.name, id, now);
|
|
669
|
-
const opRow = serializeOperation(operation);
|
|
670
|
-
const opInsert = buildInsertQuery(
|
|
671
|
-
`_kora_ops_${this.name}`,
|
|
672
|
-
opRow
|
|
673
|
-
);
|
|
674
|
-
const cascadedOps = [];
|
|
675
|
-
await this.adapter.transaction(async (tx) => {
|
|
676
|
-
if (this.relationEnforcer) {
|
|
677
|
-
const enforcementResult = await this.relationEnforcer.enforceDelete(
|
|
678
|
-
this.name,
|
|
679
|
-
id,
|
|
680
|
-
tx,
|
|
681
|
-
[operation.id]
|
|
682
|
-
);
|
|
683
|
-
cascadedOps.push(...enforcementResult.operations);
|
|
684
|
-
}
|
|
685
|
-
await tx.execute(deleteQuery.sql, deleteQuery.params);
|
|
686
|
-
await tx.execute(opInsert.sql, opInsert.params);
|
|
687
|
-
await tx.execute(
|
|
688
|
-
"INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
|
|
689
|
-
[this.nodeId, sequenceNumber]
|
|
690
|
-
);
|
|
691
|
-
});
|
|
692
|
-
this.onMutation(this.name, operation);
|
|
693
|
-
for (const cascadedOp of cascadedOps) {
|
|
694
|
-
this.onMutation(cascadedOp.collection, cascadedOp);
|
|
267
|
+
if (this.mutationHandler) {
|
|
268
|
+
await this.mutationHandler.delete(this.name, id);
|
|
269
|
+
return;
|
|
695
270
|
}
|
|
271
|
+
await executeDelete(this.mutationContext(), id);
|
|
696
272
|
}
|
|
697
|
-
/** Get the collection name */
|
|
698
273
|
getName() {
|
|
699
274
|
return this.name;
|
|
700
275
|
}
|
|
701
|
-
/** Get the collection definition */
|
|
702
276
|
getDefinition() {
|
|
703
277
|
return this.definition;
|
|
704
278
|
}
|
|
279
|
+
/** Replace the mutation handler (e.g. after ApplyPipeline is wired in createApp). */
|
|
280
|
+
setMutationHandler(handler) {
|
|
281
|
+
this.mutationHandler = handler;
|
|
282
|
+
}
|
|
705
283
|
};
|
|
706
284
|
|
|
707
285
|
// src/query/pluralize.ts
|
|
@@ -998,7 +576,7 @@ var QueryError2 = class extends Error {
|
|
|
998
576
|
};
|
|
999
577
|
|
|
1000
578
|
// src/relations/relation-enforcer.ts
|
|
1001
|
-
import { KoraError
|
|
579
|
+
import { KoraError, createOperation } from "@korajs/core";
|
|
1002
580
|
|
|
1003
581
|
// src/relations/relation-lookup.ts
|
|
1004
582
|
function buildRelationLookup(schema) {
|
|
@@ -1022,7 +600,7 @@ function getIncomingRelations(lookup, collection) {
|
|
|
1022
600
|
}
|
|
1023
601
|
|
|
1024
602
|
// src/relations/relation-enforcer.ts
|
|
1025
|
-
var ReferentialIntegrityError = class extends
|
|
603
|
+
var ReferentialIntegrityError = class extends KoraError {
|
|
1026
604
|
constructor(collection, recordId, referencingCollection, relationName, referencingCount) {
|
|
1027
605
|
super(
|
|
1028
606
|
`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.`,
|
|
@@ -1044,13 +622,11 @@ var RelationEnforcer = class {
|
|
|
1044
622
|
adapter;
|
|
1045
623
|
clock;
|
|
1046
624
|
nodeId;
|
|
1047
|
-
getSequenceNumber;
|
|
1048
625
|
constructor(config) {
|
|
1049
626
|
this.schema = config.schema;
|
|
1050
627
|
this.adapter = config.adapter;
|
|
1051
628
|
this.clock = config.clock;
|
|
1052
629
|
this.nodeId = config.nodeId;
|
|
1053
|
-
this.getSequenceNumber = config.getSequenceNumber;
|
|
1054
630
|
this.lookup = buildRelationLookup(config.schema);
|
|
1055
631
|
}
|
|
1056
632
|
/**
|
|
@@ -1112,8 +688,8 @@ var RelationEnforcer = class {
|
|
|
1112
688
|
const operations = [];
|
|
1113
689
|
for (const row of referencingRows) {
|
|
1114
690
|
const now = Date.now();
|
|
1115
|
-
const sequenceNumber = this.
|
|
1116
|
-
const operation = await
|
|
691
|
+
const sequenceNumber = await allocateNextSequenceInTransaction(tx, this.nodeId);
|
|
692
|
+
const operation = await createOperation(
|
|
1117
693
|
{
|
|
1118
694
|
nodeId: this.nodeId,
|
|
1119
695
|
type: "delete",
|
|
@@ -1129,16 +705,11 @@ var RelationEnforcer = class {
|
|
|
1129
705
|
);
|
|
1130
706
|
const deleteQuery = buildSoftDeleteQuery(sourceCollection, row.id, now);
|
|
1131
707
|
await tx.execute(deleteQuery.sql, deleteQuery.params);
|
|
1132
|
-
const opRow = serializeOperation(operation);
|
|
1133
708
|
const opInsert = buildInsertQuery(
|
|
1134
709
|
`_kora_ops_${sourceCollection}`,
|
|
1135
|
-
|
|
710
|
+
serializeOperation(operation)
|
|
1136
711
|
);
|
|
1137
712
|
await tx.execute(opInsert.sql, opInsert.params);
|
|
1138
|
-
await tx.execute(
|
|
1139
|
-
"INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
|
|
1140
|
-
[this.nodeId, sequenceNumber]
|
|
1141
|
-
);
|
|
1142
713
|
operations.push(operation);
|
|
1143
714
|
const cascadeResult = await this.enforceDelete(sourceCollection, row.id, tx, [operation.id]);
|
|
1144
715
|
operations.push(...cascadeResult.operations);
|
|
@@ -1164,10 +735,10 @@ var RelationEnforcer = class {
|
|
|
1164
735
|
const operations = [];
|
|
1165
736
|
for (const row of referencingRows) {
|
|
1166
737
|
const now = Date.now();
|
|
1167
|
-
const sequenceNumber = this.
|
|
738
|
+
const sequenceNumber = await allocateNextSequenceInTransaction(tx, this.nodeId);
|
|
1168
739
|
const updateData = { [foreignKeyField]: null };
|
|
1169
740
|
const previousData = { [foreignKeyField]: deletedRecordId };
|
|
1170
|
-
const operation = await
|
|
741
|
+
const operation = await createOperation(
|
|
1171
742
|
{
|
|
1172
743
|
nodeId: this.nodeId,
|
|
1173
744
|
type: "update",
|
|
@@ -1193,10 +764,6 @@ var RelationEnforcer = class {
|
|
|
1193
764
|
opRow
|
|
1194
765
|
);
|
|
1195
766
|
await tx.execute(opInsert.sql, opInsert.params);
|
|
1196
|
-
await tx.execute(
|
|
1197
|
-
"INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
|
|
1198
|
-
[this.nodeId, sequenceNumber]
|
|
1199
|
-
);
|
|
1200
767
|
operations.push(operation);
|
|
1201
768
|
}
|
|
1202
769
|
return operations;
|
|
@@ -1231,20 +798,184 @@ var RelationEnforcer = class {
|
|
|
1231
798
|
}
|
|
1232
799
|
};
|
|
1233
800
|
|
|
1234
|
-
// src/
|
|
1235
|
-
import {
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
801
|
+
// src/replay/replay-to.ts
|
|
802
|
+
import { OperationError } from "@korajs/core";
|
|
803
|
+
import { topologicalSort } from "@korajs/core/internal";
|
|
804
|
+
function collectCausalClosure(allOps, targetOperationId) {
|
|
805
|
+
const opMap = /* @__PURE__ */ new Map();
|
|
806
|
+
for (const op of allOps) {
|
|
807
|
+
opMap.set(op.id, op);
|
|
808
|
+
}
|
|
809
|
+
const target = opMap.get(targetOperationId);
|
|
810
|
+
if (!target) {
|
|
811
|
+
throw new OperationError(
|
|
812
|
+
`Operation "${targetOperationId}" not found in the local operation log`,
|
|
813
|
+
{
|
|
814
|
+
operationId: targetOperationId
|
|
815
|
+
}
|
|
816
|
+
);
|
|
1242
817
|
}
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
818
|
+
const included = /* @__PURE__ */ new Set();
|
|
819
|
+
const stack = [targetOperationId];
|
|
820
|
+
while (stack.length > 0) {
|
|
821
|
+
const id = stack.pop();
|
|
822
|
+
if (id === void 0 || included.has(id)) {
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
included.add(id);
|
|
826
|
+
const op = opMap.get(id);
|
|
827
|
+
if (!op) {
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
for (const depId of op.causalDeps) {
|
|
831
|
+
if (opMap.has(depId)) {
|
|
832
|
+
stack.push(depId);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
const subset = allOps.filter((op) => included.has(op.id));
|
|
837
|
+
return topologicalSort(subset);
|
|
838
|
+
}
|
|
839
|
+
function buildReplaySnapshot(schema, allOps, targetOperationId) {
|
|
840
|
+
const operationsApplied = collectCausalClosure(allOps, targetOperationId);
|
|
841
|
+
const targetOperation = operationsApplied.find((op) => op.id === targetOperationId);
|
|
842
|
+
if (!targetOperation) {
|
|
843
|
+
throw new OperationError(`Operation "${targetOperationId}" not found after causal sort`, {
|
|
844
|
+
operationId: targetOperationId
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
const memory = /* @__PURE__ */ new Map();
|
|
848
|
+
for (const op of operationsApplied) {
|
|
849
|
+
applyOperationToMemory(memory, op, schema);
|
|
850
|
+
}
|
|
851
|
+
const collections = materializeCollections(schema, memory);
|
|
852
|
+
return {
|
|
853
|
+
targetOperation,
|
|
854
|
+
operationsApplied,
|
|
855
|
+
collections,
|
|
856
|
+
findRecord(collection, recordId) {
|
|
857
|
+
const colMap = memory.get(collection);
|
|
858
|
+
const record = colMap?.get(recordId);
|
|
859
|
+
if (!record || record.deleted) {
|
|
860
|
+
return null;
|
|
861
|
+
}
|
|
862
|
+
const definition = schema.collections[collection];
|
|
863
|
+
if (!definition) {
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
return toCollectionRecord(record, definition);
|
|
867
|
+
}
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
function applyOperationToMemory(state, op, schema) {
|
|
871
|
+
const definition = schema.collections[op.collection];
|
|
872
|
+
if (!definition) {
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
let colMap = state.get(op.collection);
|
|
876
|
+
if (!colMap) {
|
|
877
|
+
colMap = /* @__PURE__ */ new Map();
|
|
878
|
+
state.set(op.collection, colMap);
|
|
879
|
+
}
|
|
880
|
+
const wallTime = op.timestamp.wallTime;
|
|
881
|
+
switch (op.type) {
|
|
882
|
+
case "insert": {
|
|
883
|
+
if (!op.data) {
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
colMap.set(op.recordId, {
|
|
887
|
+
id: op.recordId,
|
|
888
|
+
fields: { ...op.data },
|
|
889
|
+
deleted: false,
|
|
890
|
+
createdAt: wallTime,
|
|
891
|
+
updatedAt: wallTime
|
|
892
|
+
});
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
case "update": {
|
|
896
|
+
if (!op.data) {
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
const existing = colMap.get(op.recordId);
|
|
900
|
+
if (existing && !existing.deleted) {
|
|
901
|
+
existing.fields = { ...existing.fields, ...op.data };
|
|
902
|
+
existing.updatedAt = wallTime;
|
|
903
|
+
break;
|
|
904
|
+
}
|
|
905
|
+
if (existing?.deleted) {
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
colMap.set(op.recordId, {
|
|
909
|
+
id: op.recordId,
|
|
910
|
+
fields: { ...op.data },
|
|
911
|
+
deleted: false,
|
|
912
|
+
createdAt: wallTime,
|
|
913
|
+
updatedAt: wallTime
|
|
914
|
+
});
|
|
915
|
+
break;
|
|
916
|
+
}
|
|
917
|
+
case "delete": {
|
|
918
|
+
const existing = colMap.get(op.recordId);
|
|
919
|
+
if (existing) {
|
|
920
|
+
existing.deleted = true;
|
|
921
|
+
existing.updatedAt = wallTime;
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
colMap.set(op.recordId, {
|
|
925
|
+
id: op.recordId,
|
|
926
|
+
fields: {},
|
|
927
|
+
deleted: true,
|
|
928
|
+
createdAt: wallTime,
|
|
929
|
+
updatedAt: wallTime
|
|
930
|
+
});
|
|
931
|
+
break;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
function materializeCollections(schema, memory) {
|
|
936
|
+
const collections = {};
|
|
937
|
+
for (const [collectionName, definition] of Object.entries(schema.collections)) {
|
|
938
|
+
const colMap = memory.get(collectionName);
|
|
939
|
+
const records = [];
|
|
940
|
+
if (colMap) {
|
|
941
|
+
for (const record of colMap.values()) {
|
|
942
|
+
if (!record.deleted) {
|
|
943
|
+
records.push(toCollectionRecord(record, definition));
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
collections[collectionName] = records;
|
|
948
|
+
}
|
|
949
|
+
return collections;
|
|
950
|
+
}
|
|
951
|
+
function toCollectionRecord(record, definition) {
|
|
952
|
+
const result = {
|
|
953
|
+
id: record.id,
|
|
954
|
+
createdAt: record.createdAt,
|
|
955
|
+
updatedAt: record.updatedAt
|
|
956
|
+
};
|
|
957
|
+
for (const [fieldName] of Object.entries(definition.fields)) {
|
|
958
|
+
if (fieldName in record.fields) {
|
|
959
|
+
result[fieldName] = record.fields[fieldName];
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
return result;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// src/sequences/sequence-manager.ts
|
|
966
|
+
import { defaultSequenceFormat, formatSequenceValue } from "@korajs/core";
|
|
967
|
+
var SequenceManager = class {
|
|
968
|
+
adapter;
|
|
969
|
+
nodeId;
|
|
970
|
+
constructor(adapter, nodeId) {
|
|
971
|
+
this.adapter = adapter;
|
|
972
|
+
this.nodeId = nodeId;
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Get the next value in a sequence, atomically incrementing the counter.
|
|
976
|
+
*
|
|
977
|
+
* @param name - The sequence name (e.g., 'receipt', 'invoice')
|
|
978
|
+
* @param config - Optional configuration for scope, format, and starting value
|
|
1248
979
|
* @returns The formatted sequence value
|
|
1249
980
|
*/
|
|
1250
981
|
async next(name, config) {
|
|
@@ -1474,6 +1205,7 @@ var SubscriptionManager = class {
|
|
|
1474
1205
|
subscriptions = /* @__PURE__ */ new Map();
|
|
1475
1206
|
pendingCollections = /* @__PURE__ */ new Set();
|
|
1476
1207
|
flushScheduled = false;
|
|
1208
|
+
onQuerySubscribed;
|
|
1477
1209
|
// Bloom filter state
|
|
1478
1210
|
bloomFilter = null;
|
|
1479
1211
|
bloomDirty = false;
|
|
@@ -1490,6 +1222,7 @@ var SubscriptionManager = class {
|
|
|
1490
1222
|
this.bloomThreshold = options?.bloomThreshold ?? DEFAULT_BLOOM_THRESHOLD;
|
|
1491
1223
|
this.bloomExpectedItems = options?.bloomExpectedItems ?? DEFAULT_BLOOM_EXPECTED_ITEMS;
|
|
1492
1224
|
this.bloomFalsePositiveRate = options?.bloomFalsePositiveRate ?? DEFAULT_BLOOM_FALSE_POSITIVE_RATE;
|
|
1225
|
+
this.onQuerySubscribed = options?.onQuerySubscribed;
|
|
1493
1226
|
}
|
|
1494
1227
|
/**
|
|
1495
1228
|
* Register a new subscription.
|
|
@@ -1510,9 +1243,11 @@ var SubscriptionManager = class {
|
|
|
1510
1243
|
};
|
|
1511
1244
|
this.subscriptions.set(id, subscription);
|
|
1512
1245
|
this.bloomDirty = true;
|
|
1246
|
+
const externalCleanup = this.onQuerySubscribed?.(descriptor);
|
|
1513
1247
|
return () => {
|
|
1514
1248
|
this.subscriptions.delete(id);
|
|
1515
1249
|
this.bloomDirty = true;
|
|
1250
|
+
externalCleanup?.();
|
|
1516
1251
|
};
|
|
1517
1252
|
}
|
|
1518
1253
|
/**
|
|
@@ -1533,6 +1268,7 @@ var SubscriptionManager = class {
|
|
|
1533
1268
|
};
|
|
1534
1269
|
this.subscriptions.set(id, subscription);
|
|
1535
1270
|
this.bloomDirty = true;
|
|
1271
|
+
const externalCleanup = this.onQuerySubscribed?.(descriptor);
|
|
1536
1272
|
executeFn().then((results) => {
|
|
1537
1273
|
if (this.subscriptions.has(id)) {
|
|
1538
1274
|
subscription.lastResults = results;
|
|
@@ -1542,6 +1278,7 @@ var SubscriptionManager = class {
|
|
|
1542
1278
|
return () => {
|
|
1543
1279
|
this.subscriptions.delete(id);
|
|
1544
1280
|
this.bloomDirty = true;
|
|
1281
|
+
externalCleanup?.();
|
|
1545
1282
|
};
|
|
1546
1283
|
}
|
|
1547
1284
|
/**
|
|
@@ -1677,10 +1414,7 @@ var SubscriptionManager = class {
|
|
|
1677
1414
|
* any included collection dependencies.
|
|
1678
1415
|
*/
|
|
1679
1416
|
rebuildBloomFilter() {
|
|
1680
|
-
const filter = new SubscriptionBloomFilter(
|
|
1681
|
-
this.bloomExpectedItems,
|
|
1682
|
-
this.bloomFalsePositiveRate
|
|
1683
|
-
);
|
|
1417
|
+
const filter = new SubscriptionBloomFilter(this.bloomExpectedItems, this.bloomFalsePositiveRate);
|
|
1684
1418
|
for (const sub of this.subscriptions.values()) {
|
|
1685
1419
|
filter.add(sub.descriptor.collection);
|
|
1686
1420
|
if (sub.descriptor.includeCollections) {
|
|
@@ -1700,24 +1434,38 @@ var SubscriptionManager = class {
|
|
|
1700
1434
|
});
|
|
1701
1435
|
}
|
|
1702
1436
|
/**
|
|
1703
|
-
* Compare two result sets. Uses
|
|
1704
|
-
*
|
|
1437
|
+
* Compare two result sets. Uses ID-based comparison first (same length, same IDs),
|
|
1438
|
+
* then deep comparison of remaining fields when IDs match.
|
|
1439
|
+
* Avoids JSON.stringify which is O(n) and fragile to field ordering differences.
|
|
1705
1440
|
*/
|
|
1706
1441
|
resultsEqual(prev, next) {
|
|
1707
1442
|
if (prev.length !== next.length) return false;
|
|
1708
1443
|
if (prev.length === 0) return true;
|
|
1709
|
-
|
|
1444
|
+
for (let i = 0; i < prev.length; i++) {
|
|
1445
|
+
if (prev[i]?.id !== next[i]?.id) return false;
|
|
1446
|
+
}
|
|
1447
|
+
for (let i = 0; i < prev.length; i++) {
|
|
1448
|
+
const a = prev[i];
|
|
1449
|
+
const b = next[i];
|
|
1450
|
+
const keysA = Object.keys(a);
|
|
1451
|
+
const keysB = Object.keys(b);
|
|
1452
|
+
if (keysA.length !== keysB.length) return false;
|
|
1453
|
+
for (const key of keysA) {
|
|
1454
|
+
if (a[key] !== b[key]) return false;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
return true;
|
|
1710
1458
|
}
|
|
1711
1459
|
};
|
|
1712
1460
|
|
|
1713
1461
|
// src/transaction/transaction-context.ts
|
|
1714
1462
|
import {
|
|
1715
|
-
createOperation as
|
|
1716
|
-
generateUUIDv7
|
|
1717
|
-
isAtomicOp
|
|
1718
|
-
resolveAtomicOp
|
|
1719
|
-
toAtomicOp
|
|
1720
|
-
validateRecord
|
|
1463
|
+
createOperation as createOperation2,
|
|
1464
|
+
generateUUIDv7,
|
|
1465
|
+
isAtomicOp,
|
|
1466
|
+
resolveAtomicOp,
|
|
1467
|
+
toAtomicOp,
|
|
1468
|
+
validateRecord
|
|
1721
1469
|
} from "@korajs/core";
|
|
1722
1470
|
var TransactionContext = class {
|
|
1723
1471
|
transactionId;
|
|
@@ -1728,7 +1476,7 @@ var TransactionContext = class {
|
|
|
1728
1476
|
config;
|
|
1729
1477
|
constructor(config) {
|
|
1730
1478
|
this.config = config;
|
|
1731
|
-
this.transactionId =
|
|
1479
|
+
this.transactionId = generateUUIDv7();
|
|
1732
1480
|
}
|
|
1733
1481
|
/**
|
|
1734
1482
|
* Set a human-readable mutation name for this transaction.
|
|
@@ -1775,16 +1523,48 @@ var TransactionContext = class {
|
|
|
1775
1523
|
if (this.buffer.length === 0) {
|
|
1776
1524
|
return { operations: [], affectedCollections: /* @__PURE__ */ new Set() };
|
|
1777
1525
|
}
|
|
1526
|
+
const handler = this.config.localMutationHandler;
|
|
1527
|
+
if (handler?.commitTransaction) {
|
|
1528
|
+
return handler.commitTransaction({
|
|
1529
|
+
entries: this.buffer.map((entry) => ({
|
|
1530
|
+
operation: entry.operation,
|
|
1531
|
+
commands: entry.commands,
|
|
1532
|
+
collection: entry.collection
|
|
1533
|
+
})),
|
|
1534
|
+
transactionId: this.transactionId,
|
|
1535
|
+
...this.mutationName !== void 0 ? { mutationName: this.mutationName } : {}
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1778
1538
|
const operations = [];
|
|
1779
1539
|
const affectedCollections = /* @__PURE__ */ new Set();
|
|
1780
1540
|
await this.config.adapter.transaction(async (tx) => {
|
|
1781
1541
|
for (const entry of this.buffer) {
|
|
1542
|
+
if (entry.operation.type === "delete" && this.config.relationEnforcer) {
|
|
1543
|
+
const cascadeResult = await this.config.relationEnforcer.enforceDelete(
|
|
1544
|
+
entry.collection,
|
|
1545
|
+
entry.operation.recordId,
|
|
1546
|
+
tx,
|
|
1547
|
+
[entry.operation.id]
|
|
1548
|
+
);
|
|
1549
|
+
for (const cascadedOp of cascadeResult.operations) {
|
|
1550
|
+
operations.push(cascadedOp);
|
|
1551
|
+
affectedCollections.add(cascadedOp.collection);
|
|
1552
|
+
this.config.causalTracker?.afterOperation(cascadedOp.collection, cascadedOp.id, true);
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1782
1555
|
for (const cmd of entry.commands) {
|
|
1783
1556
|
await tx.execute(cmd.sql, cmd.params);
|
|
1784
1557
|
}
|
|
1785
1558
|
operations.push(entry.operation);
|
|
1786
1559
|
affectedCollections.add(entry.collection);
|
|
1787
1560
|
}
|
|
1561
|
+
const finalSeq = this.config.sequenceAllocator.getHighWaterMark();
|
|
1562
|
+
if (finalSeq > 0) {
|
|
1563
|
+
await tx.execute(
|
|
1564
|
+
"INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
|
|
1565
|
+
[this.config.nodeId, finalSeq]
|
|
1566
|
+
);
|
|
1567
|
+
}
|
|
1788
1568
|
});
|
|
1789
1569
|
return { operations, affectedCollections };
|
|
1790
1570
|
}
|
|
@@ -1811,16 +1591,17 @@ var TransactionContext = class {
|
|
|
1811
1591
|
}
|
|
1812
1592
|
async insert(collectionName, definition, data) {
|
|
1813
1593
|
this.ensureActive();
|
|
1814
|
-
const validated =
|
|
1815
|
-
const recordId =
|
|
1594
|
+
const validated = validateRecord(collectionName, definition, data, "insert");
|
|
1595
|
+
const recordId = generateUUIDv7();
|
|
1816
1596
|
const now = Date.now();
|
|
1817
1597
|
for (const [fieldName, descriptor] of Object.entries(definition.fields)) {
|
|
1818
1598
|
if (descriptor.auto && descriptor.kind === "timestamp") {
|
|
1819
1599
|
validated[fieldName] = now;
|
|
1820
1600
|
}
|
|
1821
1601
|
}
|
|
1822
|
-
const sequenceNumber = this.config.
|
|
1823
|
-
const
|
|
1602
|
+
const sequenceNumber = await this.config.sequenceAllocator.allocate();
|
|
1603
|
+
const causalDeps = this.config.causalTracker?.nextCausalDeps(collectionName, true) ?? [];
|
|
1604
|
+
const operation = await createOperation2(
|
|
1824
1605
|
{
|
|
1825
1606
|
nodeId: this.config.nodeId,
|
|
1826
1607
|
type: "insert",
|
|
@@ -1829,19 +1610,22 @@ var TransactionContext = class {
|
|
|
1829
1610
|
data: { ...validated },
|
|
1830
1611
|
previousData: null,
|
|
1831
1612
|
sequenceNumber,
|
|
1832
|
-
causalDeps
|
|
1613
|
+
causalDeps,
|
|
1833
1614
|
schemaVersion: this.config.schema.version,
|
|
1834
1615
|
transactionId: this.transactionId,
|
|
1835
1616
|
...this.mutationName !== void 0 ? { mutationName: this.mutationName } : {}
|
|
1836
1617
|
},
|
|
1837
1618
|
this.config.clock
|
|
1838
1619
|
);
|
|
1620
|
+
this.config.causalTracker?.afterOperation(collectionName, operation.id, true);
|
|
1839
1621
|
const serializedData = serializeRecord(validated, definition.fields);
|
|
1622
|
+
const version = serializeRowVersion(operation.timestamp);
|
|
1840
1623
|
const record = {
|
|
1841
1624
|
id: recordId,
|
|
1842
1625
|
...serializedData,
|
|
1843
|
-
_created_at:
|
|
1844
|
-
_updated_at:
|
|
1626
|
+
_created_at: operation.timestamp.wallTime,
|
|
1627
|
+
_updated_at: operation.timestamp.wallTime,
|
|
1628
|
+
_version: version
|
|
1845
1629
|
};
|
|
1846
1630
|
const insertQuery = buildInsertQuery(collectionName, record);
|
|
1847
1631
|
const opRow = serializeOperation(operation);
|
|
@@ -1854,11 +1638,7 @@ var TransactionContext = class {
|
|
|
1854
1638
|
collection: collectionName,
|
|
1855
1639
|
commands: [
|
|
1856
1640
|
{ sql: insertQuery.sql, params: insertQuery.params },
|
|
1857
|
-
{ sql: opInsert.sql, params: opInsert.params }
|
|
1858
|
-
{
|
|
1859
|
-
sql: "INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
|
|
1860
|
-
params: [this.config.nodeId, sequenceNumber]
|
|
1861
|
-
}
|
|
1641
|
+
{ sql: opInsert.sql, params: opInsert.params }
|
|
1862
1642
|
]
|
|
1863
1643
|
});
|
|
1864
1644
|
return {
|
|
@@ -1874,7 +1654,7 @@ var TransactionContext = class {
|
|
|
1874
1654
|
if (!currentRecord) {
|
|
1875
1655
|
throw new RecordNotFoundError(collectionName, id);
|
|
1876
1656
|
}
|
|
1877
|
-
const validated =
|
|
1657
|
+
const validated = validateRecord(collectionName, definition, data, "update");
|
|
1878
1658
|
const now = Date.now();
|
|
1879
1659
|
const previousData = {};
|
|
1880
1660
|
const resolvedData = {};
|
|
@@ -1882,16 +1662,17 @@ var TransactionContext = class {
|
|
|
1882
1662
|
for (const key of Object.keys(validated)) {
|
|
1883
1663
|
const value = validated[key];
|
|
1884
1664
|
previousData[key] = currentRecord[key];
|
|
1885
|
-
if (
|
|
1886
|
-
resolvedData[key] =
|
|
1887
|
-
atomicOps[key] =
|
|
1665
|
+
if (isAtomicOp(value)) {
|
|
1666
|
+
resolvedData[key] = resolveAtomicOp(currentRecord[key], value);
|
|
1667
|
+
atomicOps[key] = toAtomicOp(value);
|
|
1888
1668
|
} else {
|
|
1889
1669
|
resolvedData[key] = value;
|
|
1890
1670
|
}
|
|
1891
1671
|
}
|
|
1892
1672
|
const hasAtomicOps = Object.keys(atomicOps).length > 0;
|
|
1893
|
-
const sequenceNumber = this.config.
|
|
1894
|
-
const
|
|
1673
|
+
const sequenceNumber = await this.config.sequenceAllocator.allocate();
|
|
1674
|
+
const causalDeps = this.config.causalTracker?.nextCausalDeps(collectionName, true) ?? [];
|
|
1675
|
+
const operation = await createOperation2(
|
|
1895
1676
|
{
|
|
1896
1677
|
nodeId: this.config.nodeId,
|
|
1897
1678
|
type: "update",
|
|
@@ -1900,7 +1681,7 @@ var TransactionContext = class {
|
|
|
1900
1681
|
data: { ...resolvedData },
|
|
1901
1682
|
previousData,
|
|
1902
1683
|
sequenceNumber,
|
|
1903
|
-
causalDeps
|
|
1684
|
+
causalDeps,
|
|
1904
1685
|
schemaVersion: this.config.schema.version,
|
|
1905
1686
|
transactionId: this.transactionId,
|
|
1906
1687
|
...this.mutationName !== void 0 ? { mutationName: this.mutationName } : {},
|
|
@@ -1908,10 +1689,13 @@ var TransactionContext = class {
|
|
|
1908
1689
|
},
|
|
1909
1690
|
this.config.clock
|
|
1910
1691
|
);
|
|
1692
|
+
this.config.causalTracker?.afterOperation(collectionName, operation.id, true);
|
|
1911
1693
|
const serializedChanges = serializeRecord(resolvedData, definition.fields);
|
|
1694
|
+
const version = serializeRowVersion(operation.timestamp);
|
|
1912
1695
|
const updateQuery = buildUpdateQuery(collectionName, id, {
|
|
1913
1696
|
...serializedChanges,
|
|
1914
|
-
_updated_at:
|
|
1697
|
+
_updated_at: operation.timestamp.wallTime,
|
|
1698
|
+
_version: version
|
|
1915
1699
|
});
|
|
1916
1700
|
const opRow = serializeOperation(operation);
|
|
1917
1701
|
const opInsert = buildInsertQuery(
|
|
@@ -1923,11 +1707,7 @@ var TransactionContext = class {
|
|
|
1923
1707
|
collection: collectionName,
|
|
1924
1708
|
commands: [
|
|
1925
1709
|
{ sql: updateQuery.sql, params: updateQuery.params },
|
|
1926
|
-
{ sql: opInsert.sql, params: opInsert.params }
|
|
1927
|
-
{
|
|
1928
|
-
sql: "INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
|
|
1929
|
-
params: [this.config.nodeId, sequenceNumber]
|
|
1930
|
-
}
|
|
1710
|
+
{ sql: opInsert.sql, params: opInsert.params }
|
|
1931
1711
|
]
|
|
1932
1712
|
});
|
|
1933
1713
|
return {
|
|
@@ -1943,8 +1723,9 @@ var TransactionContext = class {
|
|
|
1943
1723
|
throw new RecordNotFoundError(collectionName, id);
|
|
1944
1724
|
}
|
|
1945
1725
|
const now = Date.now();
|
|
1946
|
-
const sequenceNumber = this.config.
|
|
1947
|
-
const
|
|
1726
|
+
const sequenceNumber = await this.config.sequenceAllocator.allocate();
|
|
1727
|
+
const causalDeps = this.config.causalTracker?.nextCausalDeps(collectionName, true) ?? [];
|
|
1728
|
+
const operation = await createOperation2(
|
|
1948
1729
|
{
|
|
1949
1730
|
nodeId: this.config.nodeId,
|
|
1950
1731
|
type: "delete",
|
|
@@ -1953,13 +1734,14 @@ var TransactionContext = class {
|
|
|
1953
1734
|
data: null,
|
|
1954
1735
|
previousData: null,
|
|
1955
1736
|
sequenceNumber,
|
|
1956
|
-
causalDeps
|
|
1737
|
+
causalDeps,
|
|
1957
1738
|
schemaVersion: this.config.schema.version,
|
|
1958
1739
|
transactionId: this.transactionId,
|
|
1959
1740
|
...this.mutationName !== void 0 ? { mutationName: this.mutationName } : {}
|
|
1960
1741
|
},
|
|
1961
1742
|
this.config.clock
|
|
1962
1743
|
);
|
|
1744
|
+
this.config.causalTracker?.afterOperation(collectionName, operation.id, true);
|
|
1963
1745
|
const deleteQuery = buildSoftDeleteQuery(collectionName, id, now);
|
|
1964
1746
|
const opRow = serializeOperation(operation);
|
|
1965
1747
|
const opInsert = buildInsertQuery(
|
|
@@ -1971,11 +1753,7 @@ var TransactionContext = class {
|
|
|
1971
1753
|
collection: collectionName,
|
|
1972
1754
|
commands: [
|
|
1973
1755
|
{ sql: deleteQuery.sql, params: deleteQuery.params },
|
|
1974
|
-
{ sql: opInsert.sql, params: opInsert.params }
|
|
1975
|
-
{
|
|
1976
|
-
sql: "INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
|
|
1977
|
-
params: [this.config.nodeId, sequenceNumber]
|
|
1978
|
-
}
|
|
1756
|
+
{ sql: opInsert.sql, params: opInsert.params }
|
|
1979
1757
|
]
|
|
1980
1758
|
});
|
|
1981
1759
|
}
|
|
@@ -2026,25 +1804,86 @@ var TransactionContext = class {
|
|
|
2026
1804
|
}
|
|
2027
1805
|
};
|
|
2028
1806
|
|
|
1807
|
+
// src/transaction/transaction-sequence.ts
|
|
1808
|
+
var TransactionSequenceAllocator = class {
|
|
1809
|
+
constructor(adapter, nodeId) {
|
|
1810
|
+
this.adapter = adapter;
|
|
1811
|
+
this.nodeId = nodeId;
|
|
1812
|
+
}
|
|
1813
|
+
adapter;
|
|
1814
|
+
nodeId;
|
|
1815
|
+
loaded = false;
|
|
1816
|
+
watermark = 0;
|
|
1817
|
+
async allocate() {
|
|
1818
|
+
if (!this.loaded) {
|
|
1819
|
+
this.watermark = await readSequenceNumber(this.adapter, this.nodeId);
|
|
1820
|
+
this.loaded = true;
|
|
1821
|
+
}
|
|
1822
|
+
this.watermark++;
|
|
1823
|
+
return this.watermark;
|
|
1824
|
+
}
|
|
1825
|
+
getHighWaterMark() {
|
|
1826
|
+
return this.watermark;
|
|
1827
|
+
}
|
|
1828
|
+
};
|
|
1829
|
+
|
|
1830
|
+
// src/store/tab-node-id.ts
|
|
1831
|
+
import { generateUUIDv7 as generateUUIDv72 } from "@korajs/core";
|
|
1832
|
+
function perTabStorageKey(dbName) {
|
|
1833
|
+
return `kora-tab-node-${dbName}`;
|
|
1834
|
+
}
|
|
1835
|
+
function loadPerTabNodeId(dbName) {
|
|
1836
|
+
if (typeof sessionStorage === "undefined") {
|
|
1837
|
+
return null;
|
|
1838
|
+
}
|
|
1839
|
+
return sessionStorage.getItem(perTabStorageKey(dbName));
|
|
1840
|
+
}
|
|
1841
|
+
function savePerTabNodeId(dbName, nodeId) {
|
|
1842
|
+
if (typeof sessionStorage === "undefined") {
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
sessionStorage.setItem(perTabStorageKey(dbName), nodeId);
|
|
1846
|
+
}
|
|
1847
|
+
function resolvePerTabNodeId(dbName) {
|
|
1848
|
+
const existing = loadPerTabNodeId(dbName);
|
|
1849
|
+
if (existing) {
|
|
1850
|
+
return existing;
|
|
1851
|
+
}
|
|
1852
|
+
const nodeId = generateUUIDv72();
|
|
1853
|
+
savePerTabNodeId(dbName, nodeId);
|
|
1854
|
+
return nodeId;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
2029
1857
|
// src/store/store.ts
|
|
2030
1858
|
var Store = class {
|
|
2031
1859
|
opened = false;
|
|
2032
1860
|
nodeId = "";
|
|
2033
1861
|
sequenceNumber = 0;
|
|
2034
|
-
versionVector =
|
|
1862
|
+
versionVector = createVersionVector3();
|
|
2035
1863
|
clock = null;
|
|
2036
1864
|
collections = /* @__PURE__ */ new Map();
|
|
2037
|
-
subscriptionManager
|
|
1865
|
+
subscriptionManager;
|
|
2038
1866
|
sequenceManager = null;
|
|
2039
1867
|
schema;
|
|
2040
1868
|
adapter;
|
|
2041
1869
|
configNodeId;
|
|
1870
|
+
dbName;
|
|
1871
|
+
isolation;
|
|
2042
1872
|
emitter;
|
|
1873
|
+
localMutationHandler;
|
|
1874
|
+
relationEnforcer = null;
|
|
1875
|
+
causalTracker = null;
|
|
2043
1876
|
constructor(config) {
|
|
2044
1877
|
this.schema = config.schema;
|
|
2045
1878
|
this.adapter = config.adapter;
|
|
2046
1879
|
this.configNodeId = config.nodeId;
|
|
1880
|
+
this.dbName = config.dbName ?? "kora-db";
|
|
1881
|
+
this.isolation = config.isolation ?? "shared";
|
|
2047
1882
|
this.emitter = config.emitter ?? null;
|
|
1883
|
+
this.localMutationHandler = config.localMutationHandler ?? null;
|
|
1884
|
+
this.subscriptionManager = new SubscriptionManager({
|
|
1885
|
+
onQuerySubscribed: config.onQuerySubscribed
|
|
1886
|
+
});
|
|
2048
1887
|
}
|
|
2049
1888
|
/**
|
|
2050
1889
|
* Open the store: initialize the database, load or generate a node ID,
|
|
@@ -2055,17 +1894,17 @@ var Store = class {
|
|
|
2055
1894
|
await this.runMigrationsIfNeeded();
|
|
2056
1895
|
this.nodeId = await this.loadOrGenerateNodeId();
|
|
2057
1896
|
this.clock = new HybridLogicalClock2(this.nodeId);
|
|
1897
|
+
this.causalTracker = new CausalTracker();
|
|
2058
1898
|
this.sequenceManager = new SequenceManager(this.adapter, this.nodeId);
|
|
2059
1899
|
this.sequenceNumber = await this.loadSequenceNumber();
|
|
2060
1900
|
this.versionVector = await this.loadVersionVector();
|
|
2061
1901
|
const hasRelations = Object.keys(this.schema.relations).length > 0;
|
|
2062
|
-
|
|
1902
|
+
this.relationEnforcer = hasRelations ? new RelationEnforcer({
|
|
2063
1903
|
schema: this.schema,
|
|
2064
1904
|
adapter: this.adapter,
|
|
2065
1905
|
clock: this.clock,
|
|
2066
|
-
nodeId: this.nodeId
|
|
2067
|
-
|
|
2068
|
-
}) : void 0;
|
|
1906
|
+
nodeId: this.nodeId
|
|
1907
|
+
}) : null;
|
|
2069
1908
|
for (const [name, definition] of Object.entries(this.schema.collections)) {
|
|
2070
1909
|
const col = new Collection(
|
|
2071
1910
|
name,
|
|
@@ -2074,14 +1913,17 @@ var Store = class {
|
|
|
2074
1913
|
this.adapter,
|
|
2075
1914
|
this.clock,
|
|
2076
1915
|
this.nodeId,
|
|
2077
|
-
() => this.
|
|
1916
|
+
() => this.allocateSequenceNumber(),
|
|
2078
1917
|
(collectionName, operation) => {
|
|
1918
|
+
this.recordOperationSequence(operation);
|
|
2079
1919
|
this.subscriptionManager.notify(collectionName, operation);
|
|
2080
1920
|
if (this.emitter) {
|
|
2081
1921
|
this.emitter.emit({ type: "operation:created", operation });
|
|
2082
1922
|
}
|
|
2083
1923
|
},
|
|
2084
|
-
relationEnforcer
|
|
1924
|
+
this.relationEnforcer,
|
|
1925
|
+
this.localMutationHandler,
|
|
1926
|
+
this.causalTracker
|
|
2085
1927
|
);
|
|
2086
1928
|
this.collections.set(name, col);
|
|
2087
1929
|
}
|
|
@@ -2147,7 +1989,7 @@ var Store = class {
|
|
|
2147
1989
|
* Checks for duplicates, applies to the data table, persists the operation,
|
|
2148
1990
|
* and updates the version vector.
|
|
2149
1991
|
*/
|
|
2150
|
-
async applyRemoteOperation(op) {
|
|
1992
|
+
async applyRemoteOperation(op, options) {
|
|
2151
1993
|
this.ensureOpen();
|
|
2152
1994
|
const collection = op.collection;
|
|
2153
1995
|
const definition = this.schema.collections[collection];
|
|
@@ -2164,27 +2006,52 @@ var Store = class {
|
|
|
2164
2006
|
if (this.clock) {
|
|
2165
2007
|
this.clock.receive(op.timestamp);
|
|
2166
2008
|
}
|
|
2009
|
+
const remoteVersion = serializeRowVersion(op.timestamp);
|
|
2010
|
+
const wallTime = op.timestamp.wallTime;
|
|
2167
2011
|
await this.adapter.transaction(async (tx) => {
|
|
2168
2012
|
if (op.type === "insert" && op.data) {
|
|
2169
|
-
const
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2013
|
+
const existing2 = await tx.query(
|
|
2014
|
+
`SELECT _updated_at, _version FROM ${collection} WHERE id = ?`,
|
|
2015
|
+
[op.recordId]
|
|
2016
|
+
);
|
|
2017
|
+
const row = existing2[0];
|
|
2018
|
+
if (row && !isIncomingNewerThanRow(op.timestamp, row)) {
|
|
2019
|
+
} else {
|
|
2020
|
+
const serializedData = serializeRecord(op.data, definition.fields);
|
|
2021
|
+
const record = {
|
|
2022
|
+
id: op.recordId,
|
|
2023
|
+
...serializedData,
|
|
2024
|
+
_created_at: wallTime,
|
|
2025
|
+
_updated_at: wallTime,
|
|
2026
|
+
_version: remoteVersion
|
|
2027
|
+
};
|
|
2028
|
+
const insertQuery = buildInsertQuery(collection, record);
|
|
2029
|
+
await tx.execute(insertQuery.sql, insertQuery.params);
|
|
2030
|
+
}
|
|
2179
2031
|
} else if (op.type === "update" && op.data) {
|
|
2180
2032
|
const serializedChanges = serializeRecord(op.data, definition.fields);
|
|
2181
|
-
const
|
|
2033
|
+
const updatePayload = {
|
|
2182
2034
|
...serializedChanges,
|
|
2183
|
-
_updated_at:
|
|
2184
|
-
|
|
2035
|
+
_updated_at: wallTime,
|
|
2036
|
+
_version: remoteVersion
|
|
2037
|
+
};
|
|
2038
|
+
if (options?.reactivateIfDeleted) {
|
|
2039
|
+
updatePayload._deleted = 0;
|
|
2040
|
+
}
|
|
2041
|
+
const updateQuery = buildLwwUpdateQuery(
|
|
2042
|
+
collection,
|
|
2043
|
+
op.recordId,
|
|
2044
|
+
updatePayload,
|
|
2045
|
+
remoteVersion
|
|
2046
|
+
);
|
|
2185
2047
|
await tx.execute(updateQuery.sql, updateQuery.params);
|
|
2186
2048
|
} else if (op.type === "delete") {
|
|
2187
|
-
const deleteQuery =
|
|
2049
|
+
const deleteQuery = buildLwwSoftDeleteQuery(
|
|
2050
|
+
collection,
|
|
2051
|
+
op.recordId,
|
|
2052
|
+
wallTime,
|
|
2053
|
+
remoteVersion
|
|
2054
|
+
);
|
|
2188
2055
|
await tx.execute(deleteQuery.sql, deleteQuery.params);
|
|
2189
2056
|
}
|
|
2190
2057
|
const opRow = serializeOperation(op);
|
|
@@ -2209,11 +2076,11 @@ var Store = class {
|
|
|
2209
2076
|
* Get operations from a node within a sequence number range.
|
|
2210
2077
|
* Implements the OperationLog interface for computeDelta.
|
|
2211
2078
|
*/
|
|
2212
|
-
getRange(nodeId, fromSeq, toSeq) {
|
|
2213
|
-
return
|
|
2079
|
+
async getRange(nodeId, fromSeq, toSeq) {
|
|
2080
|
+
return this.getOperationRange(nodeId, fromSeq, toSeq);
|
|
2214
2081
|
}
|
|
2215
2082
|
/**
|
|
2216
|
-
*
|
|
2083
|
+
* Get operations from a node within a sequence number range.
|
|
2217
2084
|
*/
|
|
2218
2085
|
async getOperationRange(nodeId, fromSeq, toSeq) {
|
|
2219
2086
|
this.ensureOpen();
|
|
@@ -2230,12 +2097,233 @@ var Store = class {
|
|
|
2230
2097
|
allOps.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
|
2231
2098
|
return allOps;
|
|
2232
2099
|
}
|
|
2100
|
+
/**
|
|
2101
|
+
* Load every operation from the local append-only log across all collections.
|
|
2102
|
+
* Used by sync delta computation, backup export, and time-travel replay.
|
|
2103
|
+
*/
|
|
2104
|
+
async getAllOperations() {
|
|
2105
|
+
this.ensureOpen();
|
|
2106
|
+
const allOps = [];
|
|
2107
|
+
for (const collectionName of Object.keys(this.schema.collections)) {
|
|
2108
|
+
const rows = await this.adapter.query(
|
|
2109
|
+
`SELECT * FROM _kora_ops_${collectionName} ORDER BY sequence_number ASC`
|
|
2110
|
+
);
|
|
2111
|
+
for (const row of rows) {
|
|
2112
|
+
allOps.push(deserializeOperationWithCollection(row, collectionName));
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
return allOps;
|
|
2116
|
+
}
|
|
2117
|
+
/**
|
|
2118
|
+
* Rebuild an in-memory snapshot of materialized state at a causal cut in the op log.
|
|
2119
|
+
* Does not mutate the live store — intended for DevTools time-travel inspection.
|
|
2120
|
+
*
|
|
2121
|
+
* @param operationId - Content-addressed id of the operation to replay through (inclusive)
|
|
2122
|
+
* @throws {OperationError} When the operation id is not present in the local log
|
|
2123
|
+
*/
|
|
2124
|
+
async replayTo(operationId) {
|
|
2125
|
+
this.ensureOpen();
|
|
2126
|
+
const start = Date.now();
|
|
2127
|
+
const allOps = await this.getAllOperations();
|
|
2128
|
+
const snapshot = buildReplaySnapshot(this.schema, allOps, operationId);
|
|
2129
|
+
if (this.emitter) {
|
|
2130
|
+
this.emitter.emit({
|
|
2131
|
+
type: "replay:completed",
|
|
2132
|
+
targetOperationId: operationId,
|
|
2133
|
+
operationsApplied: snapshot.operationsApplied.length,
|
|
2134
|
+
duration: Date.now() - start
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
return snapshot;
|
|
2138
|
+
}
|
|
2139
|
+
/**
|
|
2140
|
+
* Persist a merge trace to the durable audit log.
|
|
2141
|
+
*/
|
|
2142
|
+
async appendAuditTrace(trace) {
|
|
2143
|
+
this.ensureOpen();
|
|
2144
|
+
const { appendAuditTrace: append } = await import("./audit-trace-store-AM4EWQNZ.js");
|
|
2145
|
+
await append(this.adapter, trace);
|
|
2146
|
+
}
|
|
2147
|
+
/**
|
|
2148
|
+
* Read persisted audit traces with optional filters.
|
|
2149
|
+
*/
|
|
2150
|
+
async getAuditTraces(query) {
|
|
2151
|
+
this.ensureOpen();
|
|
2152
|
+
const { readAuditTraces: readAuditTraces2 } = await import("./audit-trace-store-AM4EWQNZ.js");
|
|
2153
|
+
return readAuditTraces2(this.adapter, query);
|
|
2154
|
+
}
|
|
2155
|
+
/**
|
|
2156
|
+
* Export operations and merge traces as a portable audit bundle.
|
|
2157
|
+
*/
|
|
2158
|
+
async exportAudit(options) {
|
|
2159
|
+
this.ensureOpen();
|
|
2160
|
+
const { exportAudit: doExport } = await import("./export-audit-5GV6VRDZ.js");
|
|
2161
|
+
return doExport(this.adapter, this.schema, this.nodeId, this.schema.version, options);
|
|
2162
|
+
}
|
|
2233
2163
|
/**
|
|
2234
2164
|
* Get the schema definition.
|
|
2235
2165
|
*/
|
|
2236
2166
|
getSchema() {
|
|
2237
2167
|
return this.schema;
|
|
2238
2168
|
}
|
|
2169
|
+
/**
|
|
2170
|
+
* Route local CRUD through the unified apply pipeline (korajs ApplyPipeline).
|
|
2171
|
+
*/
|
|
2172
|
+
setLocalMutationHandler(handler) {
|
|
2173
|
+
this.localMutationHandler = handler;
|
|
2174
|
+
for (const col of this.collections.values()) {
|
|
2175
|
+
col.setMutationHandler(handler);
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* Build mutation context for a collection (used by ApplyPipeline side effects).
|
|
2180
|
+
*/
|
|
2181
|
+
createMutationContext(collection, options) {
|
|
2182
|
+
this.ensureOpen();
|
|
2183
|
+
const definition = this.schema.collections[collection];
|
|
2184
|
+
if (!definition || !this.clock) {
|
|
2185
|
+
throw new StoreNotOpenError();
|
|
2186
|
+
}
|
|
2187
|
+
return {
|
|
2188
|
+
collection,
|
|
2189
|
+
definition,
|
|
2190
|
+
schema: this.schema,
|
|
2191
|
+
adapter: this.adapter,
|
|
2192
|
+
clock: this.clock,
|
|
2193
|
+
nodeId: this.nodeId,
|
|
2194
|
+
allocateSequenceNumber: () => this.allocateSequenceNumber(),
|
|
2195
|
+
onMutation: (collectionName, operation) => {
|
|
2196
|
+
this.recordOperationSequence(operation);
|
|
2197
|
+
this.subscriptionManager.notify(collectionName, operation);
|
|
2198
|
+
if (this.emitter) {
|
|
2199
|
+
this.emitter.emit({ type: "operation:created", operation });
|
|
2200
|
+
}
|
|
2201
|
+
},
|
|
2202
|
+
relationEnforcer: this.relationEnforcer,
|
|
2203
|
+
causalTracker: this.causalTracker,
|
|
2204
|
+
inTransaction: options?.inTransaction ?? false,
|
|
2205
|
+
extraCausalDeps: options?.extraCausalDeps
|
|
2206
|
+
};
|
|
2207
|
+
}
|
|
2208
|
+
/**
|
|
2209
|
+
* Load a materialized row by ID, including soft-deleted tombstones.
|
|
2210
|
+
*/
|
|
2211
|
+
async findMaterializedRow(collection, recordId) {
|
|
2212
|
+
this.ensureOpen();
|
|
2213
|
+
const definition = this.schema.collections[collection];
|
|
2214
|
+
if (!definition) {
|
|
2215
|
+
return null;
|
|
2216
|
+
}
|
|
2217
|
+
const rows = await this.adapter.query(
|
|
2218
|
+
`SELECT * FROM ${collection} WHERE id = ?`,
|
|
2219
|
+
[recordId]
|
|
2220
|
+
);
|
|
2221
|
+
const row = rows[0];
|
|
2222
|
+
if (!row) {
|
|
2223
|
+
return null;
|
|
2224
|
+
}
|
|
2225
|
+
return {
|
|
2226
|
+
record: deserializeRecord(row, definition.fields),
|
|
2227
|
+
deleted: row._deleted === 1
|
|
2228
|
+
};
|
|
2229
|
+
}
|
|
2230
|
+
/**
|
|
2231
|
+
* Latest operation from this device for a record (used for delete-vs-update merge on sync).
|
|
2232
|
+
*/
|
|
2233
|
+
/**
|
|
2234
|
+
* Load the last server version vector acknowledged by this client (persisted in `_kora_meta`).
|
|
2235
|
+
*/
|
|
2236
|
+
async loadLastAckedServerVector() {
|
|
2237
|
+
this.ensureOpen();
|
|
2238
|
+
return loadLastAckedServerVector(this.adapter);
|
|
2239
|
+
}
|
|
2240
|
+
/**
|
|
2241
|
+
* Persist the last server version vector this client believes the server has applied.
|
|
2242
|
+
*/
|
|
2243
|
+
async saveLastAckedServerVector(vector) {
|
|
2244
|
+
this.ensureOpen();
|
|
2245
|
+
await saveLastAckedServerVector(this.adapter, vector);
|
|
2246
|
+
}
|
|
2247
|
+
/**
|
|
2248
|
+
* Load persisted delta cursor for resuming paginated initial sync.
|
|
2249
|
+
*/
|
|
2250
|
+
async loadDeltaCursor() {
|
|
2251
|
+
this.ensureOpen();
|
|
2252
|
+
return loadDeltaCursor(this.adapter);
|
|
2253
|
+
}
|
|
2254
|
+
/**
|
|
2255
|
+
* Persist or clear the delta cursor for paginated initial sync resume.
|
|
2256
|
+
*/
|
|
2257
|
+
async saveDeltaCursor(cursor) {
|
|
2258
|
+
this.ensureOpen();
|
|
2259
|
+
await saveDeltaCursor(this.adapter, cursor);
|
|
2260
|
+
}
|
|
2261
|
+
/**
|
|
2262
|
+
* Local operations not yet reflected on the server version vector.
|
|
2263
|
+
*/
|
|
2264
|
+
async getUnsyncedOperations(serverVector) {
|
|
2265
|
+
this.ensureOpen();
|
|
2266
|
+
return collectOperationsAheadOfServer(
|
|
2267
|
+
this.getVersionVector(),
|
|
2268
|
+
serverVector,
|
|
2269
|
+
(nodeId, fromSeq, toSeq) => this.getOperationRange(nodeId, fromSeq, toSeq)
|
|
2270
|
+
);
|
|
2271
|
+
}
|
|
2272
|
+
/**
|
|
2273
|
+
* Count of local operations ahead of the server version vector.
|
|
2274
|
+
*/
|
|
2275
|
+
async countUnsyncedOperations(serverVector) {
|
|
2276
|
+
const ops = await this.getUnsyncedOperations(serverVector);
|
|
2277
|
+
return ops.length;
|
|
2278
|
+
}
|
|
2279
|
+
/**
|
|
2280
|
+
* Compact the local operation log using materialized rows as the baseline.
|
|
2281
|
+
* Only removes ops the server has acknowledged (per {@link CompactionStrategy}).
|
|
2282
|
+
*/
|
|
2283
|
+
async compact(strategy) {
|
|
2284
|
+
this.ensureOpen();
|
|
2285
|
+
if (strategy.mode === "never") {
|
|
2286
|
+
return compactOperationLog(this.adapter, this.schema, strategy, createVersionVector3());
|
|
2287
|
+
}
|
|
2288
|
+
const serverVector = strategy.serverVector ?? await loadLastAckedServerVector(this.adapter);
|
|
2289
|
+
return compactOperationLog(this.adapter, this.schema, strategy, serverVector);
|
|
2290
|
+
}
|
|
2291
|
+
/**
|
|
2292
|
+
* Merge session remote vector with persisted last-acked vector (max per node).
|
|
2293
|
+
*/
|
|
2294
|
+
mergeServerVectors(sessionVector, persistedVector) {
|
|
2295
|
+
return mergeVersionVectors(persistedVector, sessionVector);
|
|
2296
|
+
}
|
|
2297
|
+
async getLatestLocalOperationForRecord(collection, recordId) {
|
|
2298
|
+
this.ensureOpen();
|
|
2299
|
+
const rows = await this.adapter.query(
|
|
2300
|
+
`SELECT * FROM _kora_ops_${collection} WHERE node_id = ? AND record_id = ? ORDER BY sequence_number DESC LIMIT 1`,
|
|
2301
|
+
[this.nodeId, recordId]
|
|
2302
|
+
);
|
|
2303
|
+
const row = rows[0];
|
|
2304
|
+
if (!row) {
|
|
2305
|
+
return null;
|
|
2306
|
+
}
|
|
2307
|
+
return deserializeOperationWithCollection(row, collection);
|
|
2308
|
+
}
|
|
2309
|
+
/**
|
|
2310
|
+
* Latest operation for a record from any node (for 3-way merge when local op log is empty).
|
|
2311
|
+
*/
|
|
2312
|
+
async getLatestOperationForRecord(collection, recordId) {
|
|
2313
|
+
this.ensureOpen();
|
|
2314
|
+
const rows = await this.adapter.query(
|
|
2315
|
+
`SELECT * FROM _kora_ops_${collection} WHERE record_id = ?`,
|
|
2316
|
+
[recordId]
|
|
2317
|
+
);
|
|
2318
|
+
let latest = null;
|
|
2319
|
+
for (const row of rows) {
|
|
2320
|
+
const op = deserializeOperationWithCollection(row, collection);
|
|
2321
|
+
if (!latest || HybridLogicalClock2.compare(op.timestamp, latest.timestamp) > 0) {
|
|
2322
|
+
latest = op;
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
return latest;
|
|
2326
|
+
}
|
|
2239
2327
|
/** Expose the subscription manager for direct access (e.g., by QueryBuilder) */
|
|
2240
2328
|
getSubscriptionManager() {
|
|
2241
2329
|
return this.subscriptionManager;
|
|
@@ -2268,7 +2356,10 @@ var Store = class {
|
|
|
2268
2356
|
adapter: this.adapter,
|
|
2269
2357
|
clock: this.clock,
|
|
2270
2358
|
nodeId: this.nodeId,
|
|
2271
|
-
|
|
2359
|
+
sequenceAllocator: new TransactionSequenceAllocator(this.adapter, this.nodeId),
|
|
2360
|
+
relationEnforcer: this.relationEnforcer,
|
|
2361
|
+
causalTracker: this.causalTracker,
|
|
2362
|
+
localMutationHandler: this.localMutationHandler
|
|
2272
2363
|
});
|
|
2273
2364
|
}
|
|
2274
2365
|
/**
|
|
@@ -2283,10 +2374,13 @@ var Store = class {
|
|
|
2283
2374
|
*/
|
|
2284
2375
|
async transaction(fn) {
|
|
2285
2376
|
const tx = this.createTransaction();
|
|
2377
|
+
this.causalTracker?.beginTransaction();
|
|
2286
2378
|
try {
|
|
2287
2379
|
await fn(tx);
|
|
2288
|
-
const { operations
|
|
2380
|
+
const { operations } = await tx.commit();
|
|
2381
|
+
this.causalTracker?.clearTransaction();
|
|
2289
2382
|
for (const op of operations) {
|
|
2383
|
+
this.recordOperationSequence(op);
|
|
2290
2384
|
this.subscriptionManager.notify(op.collection, op);
|
|
2291
2385
|
if (this.emitter) {
|
|
2292
2386
|
this.emitter.emit({ type: "operation:created", operation: op });
|
|
@@ -2295,13 +2389,57 @@ var Store = class {
|
|
|
2295
2389
|
return operations;
|
|
2296
2390
|
} catch (error) {
|
|
2297
2391
|
tx.rollback();
|
|
2392
|
+
this.causalTracker?.clearTransaction();
|
|
2298
2393
|
throw error;
|
|
2299
2394
|
}
|
|
2300
2395
|
}
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2396
|
+
/**
|
|
2397
|
+
* Export all data as a portable backup binary.
|
|
2398
|
+
* Includes operations, version vector, metadata, and optionally materialized records.
|
|
2399
|
+
*
|
|
2400
|
+
* @param options - Backup options (includeRecords, collections, onProgress)
|
|
2401
|
+
* @returns Backup as a Uint8Array
|
|
2402
|
+
*/
|
|
2403
|
+
async exportBackup(options) {
|
|
2404
|
+
this.ensureOpen();
|
|
2405
|
+
const { exportBackup: doExport } = await import("./backup-JN3LB4YT.js");
|
|
2406
|
+
return doExport(this.adapter, this.schema, this.nodeId, this.schema.version, options);
|
|
2407
|
+
}
|
|
2408
|
+
/**
|
|
2409
|
+
* Restore data from a backup binary.
|
|
2410
|
+
*
|
|
2411
|
+
* @param data - The backup data
|
|
2412
|
+
* @param options - Restore options (merge, collections, onProgress)
|
|
2413
|
+
* @returns Result of the restore operation
|
|
2414
|
+
*/
|
|
2415
|
+
async importBackup(data, options) {
|
|
2416
|
+
this.ensureOpen();
|
|
2417
|
+
const { restoreBackup: doRestore } = await import("./backup-JN3LB4YT.js");
|
|
2418
|
+
return doRestore(this.adapter, this.schema, data, options);
|
|
2419
|
+
}
|
|
2420
|
+
/**
|
|
2421
|
+
* Read backup manifest without loading the entire backup.
|
|
2422
|
+
*
|
|
2423
|
+
* @param data - The raw backup data
|
|
2424
|
+
* @returns The backup manifest
|
|
2425
|
+
*/
|
|
2426
|
+
static readBackupManifest(data) {
|
|
2427
|
+
return readBackupManifest(data);
|
|
2428
|
+
}
|
|
2429
|
+
recordOperationSequence(operation) {
|
|
2430
|
+
const prev = this.versionVector.get(operation.nodeId) ?? 0;
|
|
2431
|
+
if (operation.sequenceNumber > prev) {
|
|
2432
|
+
this.versionVector.set(operation.nodeId, operation.sequenceNumber);
|
|
2433
|
+
}
|
|
2434
|
+
if (operation.nodeId === this.nodeId) {
|
|
2435
|
+
this.sequenceNumber = Math.max(this.sequenceNumber, operation.sequenceNumber);
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
async allocateSequenceNumber() {
|
|
2439
|
+
const seq = await allocateNextSequenceNumber(this.adapter, this.nodeId);
|
|
2440
|
+
this.sequenceNumber = seq;
|
|
2441
|
+
this.versionVector.set(this.nodeId, seq);
|
|
2442
|
+
return seq;
|
|
2305
2443
|
}
|
|
2306
2444
|
/**
|
|
2307
2445
|
* Check the stored schema version and run any pending migrations.
|
|
@@ -2384,12 +2522,17 @@ var Store = class {
|
|
|
2384
2522
|
}
|
|
2385
2523
|
async loadOrGenerateNodeId() {
|
|
2386
2524
|
if (this.configNodeId) {
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2525
|
+
if (this.isolation !== "per-tab") {
|
|
2526
|
+
await this.adapter.execute(
|
|
2527
|
+
"INSERT OR REPLACE INTO _kora_meta (key, value) VALUES ('node_id', ?)",
|
|
2528
|
+
[this.configNodeId]
|
|
2529
|
+
);
|
|
2530
|
+
}
|
|
2391
2531
|
return this.configNodeId;
|
|
2392
2532
|
}
|
|
2533
|
+
if (this.isolation === "per-tab") {
|
|
2534
|
+
return resolvePerTabNodeId(this.dbName);
|
|
2535
|
+
}
|
|
2393
2536
|
const rows = await this.adapter.query(
|
|
2394
2537
|
"SELECT value FROM _kora_meta WHERE key = 'node_id'"
|
|
2395
2538
|
);
|
|
@@ -2413,7 +2556,7 @@ var Store = class {
|
|
|
2413
2556
|
const rows = await this.adapter.query(
|
|
2414
2557
|
"SELECT node_id, sequence_number FROM _kora_version_vector"
|
|
2415
2558
|
);
|
|
2416
|
-
const vector =
|
|
2559
|
+
const vector = createVersionVector3();
|
|
2417
2560
|
for (const row of rows) {
|
|
2418
2561
|
vector.set(row.node_id, row.sequence_number);
|
|
2419
2562
|
}
|
|
@@ -2425,13 +2568,493 @@ var Store = class {
|
|
|
2425
2568
|
}
|
|
2426
2569
|
}
|
|
2427
2570
|
};
|
|
2571
|
+
|
|
2572
|
+
// src/reactivity/query-store.ts
|
|
2573
|
+
var EMPTY_ARRAY = Object.freeze([]);
|
|
2574
|
+
var QueryStore = class {
|
|
2575
|
+
snapshot = EMPTY_ARRAY;
|
|
2576
|
+
listeners = /* @__PURE__ */ new Set();
|
|
2577
|
+
unsubscribeQuery = null;
|
|
2578
|
+
active = false;
|
|
2579
|
+
queryBuilder;
|
|
2580
|
+
constructor(queryBuilder) {
|
|
2581
|
+
this.queryBuilder = queryBuilder;
|
|
2582
|
+
}
|
|
2583
|
+
/**
|
|
2584
|
+
* Subscribe to snapshot changes.
|
|
2585
|
+
*
|
|
2586
|
+
* @returns Unsubscribe function
|
|
2587
|
+
*/
|
|
2588
|
+
subscribe = (onStoreChange) => {
|
|
2589
|
+
this.listeners.add(onStoreChange);
|
|
2590
|
+
if (!this.active) {
|
|
2591
|
+
this.startSubscription();
|
|
2592
|
+
}
|
|
2593
|
+
return () => {
|
|
2594
|
+
this.listeners.delete(onStoreChange);
|
|
2595
|
+
if (this.listeners.size === 0) {
|
|
2596
|
+
this.stopSubscription();
|
|
2597
|
+
}
|
|
2598
|
+
};
|
|
2599
|
+
};
|
|
2600
|
+
/** Synchronous read of the latest query results. */
|
|
2601
|
+
getSnapshot = () => {
|
|
2602
|
+
return this.snapshot;
|
|
2603
|
+
};
|
|
2604
|
+
/** Tear down listeners and the underlying query subscription. */
|
|
2605
|
+
destroy() {
|
|
2606
|
+
this.stopSubscription();
|
|
2607
|
+
this.listeners.clear();
|
|
2608
|
+
this.snapshot = EMPTY_ARRAY;
|
|
2609
|
+
}
|
|
2610
|
+
startSubscription() {
|
|
2611
|
+
this.active = true;
|
|
2612
|
+
this.unsubscribeQuery = this.queryBuilder.subscribe((results) => {
|
|
2613
|
+
if (!this.active) return;
|
|
2614
|
+
this.snapshot = Object.freeze([...results]);
|
|
2615
|
+
this.notifyListeners();
|
|
2616
|
+
});
|
|
2617
|
+
}
|
|
2618
|
+
stopSubscription() {
|
|
2619
|
+
this.active = false;
|
|
2620
|
+
if (this.unsubscribeQuery) {
|
|
2621
|
+
this.unsubscribeQuery();
|
|
2622
|
+
this.unsubscribeQuery = null;
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
notifyListeners() {
|
|
2626
|
+
for (const listener of this.listeners) {
|
|
2627
|
+
listener();
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
};
|
|
2631
|
+
|
|
2632
|
+
// src/reactivity/assert-query-ready.ts
|
|
2633
|
+
import { AppNotReadyError } from "@korajs/core";
|
|
2634
|
+
var LEGACY_PENDING_COLLECTION = "__pending__";
|
|
2635
|
+
function assertQueryReady(query) {
|
|
2636
|
+
const descriptor = query.getDescriptor();
|
|
2637
|
+
if (descriptor.collection === LEGACY_PENDING_COLLECTION) {
|
|
2638
|
+
throw new AppNotReadyError(
|
|
2639
|
+
"Cannot use useQuery() before app.ready. Await app.ready or wrap your UI in <KoraProvider app={app}>."
|
|
2640
|
+
);
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
// src/reactivity/query-store-cache.ts
|
|
2645
|
+
var QueryStoreCache = class {
|
|
2646
|
+
constructor(scopeKey = "default") {
|
|
2647
|
+
this.scopeKey = scopeKey;
|
|
2648
|
+
}
|
|
2649
|
+
scopeKey;
|
|
2650
|
+
entries = /* @__PURE__ */ new Map();
|
|
2651
|
+
getOrCreate(queryBuilder) {
|
|
2652
|
+
const key = this.getKey(queryBuilder);
|
|
2653
|
+
const existing = this.entries.get(key);
|
|
2654
|
+
if (existing) {
|
|
2655
|
+
existing.refCount++;
|
|
2656
|
+
return existing.queryStore;
|
|
2657
|
+
}
|
|
2658
|
+
const queryStore = new QueryStore(queryBuilder);
|
|
2659
|
+
this.entries.set(key, {
|
|
2660
|
+
queryStore,
|
|
2661
|
+
refCount: 1
|
|
2662
|
+
});
|
|
2663
|
+
return queryStore;
|
|
2664
|
+
}
|
|
2665
|
+
release(queryBuilder) {
|
|
2666
|
+
const key = this.getKey(queryBuilder);
|
|
2667
|
+
const entry = this.entries.get(key);
|
|
2668
|
+
if (!entry) {
|
|
2669
|
+
return;
|
|
2670
|
+
}
|
|
2671
|
+
entry.refCount--;
|
|
2672
|
+
if (entry.refCount <= 0) {
|
|
2673
|
+
entry.queryStore.destroy();
|
|
2674
|
+
this.entries.delete(key);
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
clear() {
|
|
2678
|
+
for (const entry of this.entries.values()) {
|
|
2679
|
+
entry.queryStore.destroy();
|
|
2680
|
+
}
|
|
2681
|
+
this.entries.clear();
|
|
2682
|
+
}
|
|
2683
|
+
get size() {
|
|
2684
|
+
return this.entries.size;
|
|
2685
|
+
}
|
|
2686
|
+
getKey(queryBuilder) {
|
|
2687
|
+
return `${this.scopeKey}:${JSON.stringify(queryBuilder.getDescriptor())}`;
|
|
2688
|
+
}
|
|
2689
|
+
};
|
|
2690
|
+
var sharedCache = null;
|
|
2691
|
+
function getSharedQueryStoreCache() {
|
|
2692
|
+
if (!sharedCache) {
|
|
2693
|
+
sharedCache = new QueryStoreCache();
|
|
2694
|
+
}
|
|
2695
|
+
return sharedCache;
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
// src/richtext/create-richtext-controller.ts
|
|
2699
|
+
import * as Y from "yjs";
|
|
2700
|
+
var LOAD_ORIGIN = "kora-load";
|
|
2701
|
+
var REMOTE_ORIGIN = "kora-remote";
|
|
2702
|
+
var DOC_CHANNEL_ORIGIN = "kora-doc-channel";
|
|
2703
|
+
var TEXT_KEY = "content";
|
|
2704
|
+
var COMPACT_AFTER_DELTAS = 20;
|
|
2705
|
+
var PERSIST_DEBOUNCE_MS = 400;
|
|
2706
|
+
function createRichTextController(options) {
|
|
2707
|
+
const {
|
|
2708
|
+
collection,
|
|
2709
|
+
collectionName,
|
|
2710
|
+
recordId,
|
|
2711
|
+
fieldName,
|
|
2712
|
+
store,
|
|
2713
|
+
syncEngine = null,
|
|
2714
|
+
useDocChannel
|
|
2715
|
+
} = options;
|
|
2716
|
+
const doc = new Y.Doc();
|
|
2717
|
+
const text = doc.getText(TEXT_KEY);
|
|
2718
|
+
const undoManager = new Y.UndoManager(text);
|
|
2719
|
+
let user = options.user;
|
|
2720
|
+
let disposed = false;
|
|
2721
|
+
let ready = false;
|
|
2722
|
+
let error = null;
|
|
2723
|
+
let canUndo = false;
|
|
2724
|
+
let canRedo = false;
|
|
2725
|
+
let cursors = [];
|
|
2726
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
2727
|
+
let snapshot = {
|
|
2728
|
+
ready: false,
|
|
2729
|
+
error: null,
|
|
2730
|
+
canUndo: false,
|
|
2731
|
+
canRedo: false,
|
|
2732
|
+
cursors: []
|
|
2733
|
+
};
|
|
2734
|
+
const baseUpdateRef = { current: null };
|
|
2735
|
+
const pendingDeltasRef = { current: [] };
|
|
2736
|
+
const docChannelActiveRef = { current: false };
|
|
2737
|
+
let persistTimer = null;
|
|
2738
|
+
let recordUnsubscribe = null;
|
|
2739
|
+
let docChannelUnsubscribe = null;
|
|
2740
|
+
let awarenessUnsubscribe = null;
|
|
2741
|
+
const refreshSnapshot = () => {
|
|
2742
|
+
snapshot = {
|
|
2743
|
+
ready,
|
|
2744
|
+
error,
|
|
2745
|
+
canUndo,
|
|
2746
|
+
canRedo,
|
|
2747
|
+
cursors: [...cursors]
|
|
2748
|
+
};
|
|
2749
|
+
};
|
|
2750
|
+
const notify = () => {
|
|
2751
|
+
refreshSnapshot();
|
|
2752
|
+
for (const listener of listeners) {
|
|
2753
|
+
listener();
|
|
2754
|
+
}
|
|
2755
|
+
};
|
|
2756
|
+
const syncHistoryState = () => {
|
|
2757
|
+
canUndo = undoManager.undoStack.length > 0;
|
|
2758
|
+
canRedo = undoManager.redoStack.length > 0;
|
|
2759
|
+
notify();
|
|
2760
|
+
};
|
|
2761
|
+
const getSnapshot = () => snapshot;
|
|
2762
|
+
const resolveAwarenessUser = () => {
|
|
2763
|
+
if (user) {
|
|
2764
|
+
return user;
|
|
2765
|
+
}
|
|
2766
|
+
if (syncEngine) {
|
|
2767
|
+
const existing = syncEngine.getAwarenessManager().getLocalState()?.user;
|
|
2768
|
+
if (existing) {
|
|
2769
|
+
return existing;
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
return {
|
|
2773
|
+
name: "Anonymous",
|
|
2774
|
+
color: "#6366f1"
|
|
2775
|
+
};
|
|
2776
|
+
};
|
|
2777
|
+
const setCursor = (anchor, head) => {
|
|
2778
|
+
if (!syncEngine) {
|
|
2779
|
+
return;
|
|
2780
|
+
}
|
|
2781
|
+
const awareness = syncEngine.getAwarenessManager();
|
|
2782
|
+
const state = {
|
|
2783
|
+
user: resolveAwarenessUser(),
|
|
2784
|
+
cursor: {
|
|
2785
|
+
collection: collectionName,
|
|
2786
|
+
recordId,
|
|
2787
|
+
field: fieldName,
|
|
2788
|
+
anchor,
|
|
2789
|
+
head
|
|
2790
|
+
}
|
|
2791
|
+
};
|
|
2792
|
+
awareness.setLocalState(state);
|
|
2793
|
+
};
|
|
2794
|
+
const clearCursor = () => {
|
|
2795
|
+
if (!syncEngine) {
|
|
2796
|
+
return;
|
|
2797
|
+
}
|
|
2798
|
+
const awareness = syncEngine.getAwarenessManager();
|
|
2799
|
+
const current = awareness.getLocalState();
|
|
2800
|
+
if (!current?.cursor) {
|
|
2801
|
+
return;
|
|
2802
|
+
}
|
|
2803
|
+
if (current.cursor.collection !== collectionName || current.cursor.recordId !== recordId || current.cursor.field !== fieldName) {
|
|
2804
|
+
return;
|
|
2805
|
+
}
|
|
2806
|
+
awareness.setLocalState({ user: current.user });
|
|
2807
|
+
};
|
|
2808
|
+
const applyRemoteSnapshot = (value) => {
|
|
2809
|
+
const encoded = encodeRichtextInput(value);
|
|
2810
|
+
if (!encoded) {
|
|
2811
|
+
return;
|
|
2812
|
+
}
|
|
2813
|
+
const currentSnapshot = Y.encodeStateAsUpdate(doc);
|
|
2814
|
+
if (updatesEqual(currentSnapshot, encoded)) {
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
Y.applyUpdate(doc, encoded, REMOTE_ORIGIN);
|
|
2818
|
+
baseUpdateRef.current = encoded;
|
|
2819
|
+
pendingDeltasRef.current = [];
|
|
2820
|
+
syncHistoryState();
|
|
2821
|
+
};
|
|
2822
|
+
const flushPersist = async () => {
|
|
2823
|
+
const snapshot2 = composeRichtextSnapshot(baseUpdateRef.current, pendingDeltasRef.current);
|
|
2824
|
+
if (pendingDeltasRef.current.length >= COMPACT_AFTER_DELTAS) {
|
|
2825
|
+
baseUpdateRef.current = snapshot2;
|
|
2826
|
+
pendingDeltasRef.current = [];
|
|
2827
|
+
}
|
|
2828
|
+
try {
|
|
2829
|
+
await collection.update(recordId, {
|
|
2830
|
+
[fieldName]: snapshot2
|
|
2831
|
+
});
|
|
2832
|
+
} catch (cause) {
|
|
2833
|
+
if (!disposed) {
|
|
2834
|
+
error = cause instanceof Error ? cause : new Error(String(cause));
|
|
2835
|
+
notify();
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
};
|
|
2839
|
+
const schedulePersist = () => {
|
|
2840
|
+
if (persistTimer) {
|
|
2841
|
+
clearTimeout(persistTimer);
|
|
2842
|
+
}
|
|
2843
|
+
persistTimer = setTimeout(() => {
|
|
2844
|
+
persistTimer = null;
|
|
2845
|
+
void flushPersist();
|
|
2846
|
+
}, PERSIST_DEBOUNCE_MS);
|
|
2847
|
+
};
|
|
2848
|
+
const onDocUpdate = (update, origin) => {
|
|
2849
|
+
syncHistoryState();
|
|
2850
|
+
if (origin === LOAD_ORIGIN || origin === REMOTE_ORIGIN || origin === DOC_CHANNEL_ORIGIN) {
|
|
2851
|
+
return;
|
|
2852
|
+
}
|
|
2853
|
+
pendingDeltasRef.current.push(update);
|
|
2854
|
+
if (docChannelActiveRef.current && syncEngine) {
|
|
2855
|
+
syncEngine.getRichtextDocChannel?.().send(collectionName, recordId, fieldName, update);
|
|
2856
|
+
schedulePersist();
|
|
2857
|
+
return;
|
|
2858
|
+
}
|
|
2859
|
+
void flushPersist();
|
|
2860
|
+
};
|
|
2861
|
+
const subscribeRecordChanges = () => {
|
|
2862
|
+
recordUnsubscribe?.();
|
|
2863
|
+
recordUnsubscribe = store.collection(collectionName).where({ id: recordId }).subscribe((results) => {
|
|
2864
|
+
const record = results[0];
|
|
2865
|
+
if (!record) {
|
|
2866
|
+
return;
|
|
2867
|
+
}
|
|
2868
|
+
applyRemoteSnapshot(record[fieldName]);
|
|
2869
|
+
});
|
|
2870
|
+
};
|
|
2871
|
+
const subscribeDocChannel = () => {
|
|
2872
|
+
docChannelUnsubscribe?.();
|
|
2873
|
+
if (!ready || !syncEngine || !docChannelActiveRef.current) {
|
|
2874
|
+
return;
|
|
2875
|
+
}
|
|
2876
|
+
const channel = syncEngine.getRichtextDocChannel?.();
|
|
2877
|
+
if (!channel) {
|
|
2878
|
+
return;
|
|
2879
|
+
}
|
|
2880
|
+
docChannelUnsubscribe = channel.subscribe(collectionName, recordId, fieldName, (update) => {
|
|
2881
|
+
Y.applyUpdate(doc, update, DOC_CHANNEL_ORIGIN);
|
|
2882
|
+
syncHistoryState();
|
|
2883
|
+
});
|
|
2884
|
+
};
|
|
2885
|
+
const updateCursors = () => {
|
|
2886
|
+
if (!syncEngine) {
|
|
2887
|
+
cursors = [];
|
|
2888
|
+
notify();
|
|
2889
|
+
return;
|
|
2890
|
+
}
|
|
2891
|
+
const awareness = syncEngine.getAwarenessManager();
|
|
2892
|
+
const localClientId = awareness.clientId;
|
|
2893
|
+
const states = awareness.getStates();
|
|
2894
|
+
const fieldCursors = [];
|
|
2895
|
+
for (const [clientId, state] of states) {
|
|
2896
|
+
if (clientId === localClientId) continue;
|
|
2897
|
+
if (!state.cursor) continue;
|
|
2898
|
+
if (state.cursor.collection !== collectionName || state.cursor.recordId !== recordId || state.cursor.field !== fieldName) {
|
|
2899
|
+
continue;
|
|
2900
|
+
}
|
|
2901
|
+
fieldCursors.push({
|
|
2902
|
+
clientId,
|
|
2903
|
+
userName: state.user.name,
|
|
2904
|
+
color: state.user.color,
|
|
2905
|
+
anchor: state.cursor.anchor,
|
|
2906
|
+
head: state.cursor.head
|
|
2907
|
+
});
|
|
2908
|
+
}
|
|
2909
|
+
cursors = fieldCursors;
|
|
2910
|
+
notify();
|
|
2911
|
+
};
|
|
2912
|
+
const subscribeAwareness = () => {
|
|
2913
|
+
awarenessUnsubscribe?.();
|
|
2914
|
+
if (!syncEngine) {
|
|
2915
|
+
cursors = [];
|
|
2916
|
+
notify();
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2919
|
+
const awareness = syncEngine.getAwarenessManager();
|
|
2920
|
+
awarenessUnsubscribe = awareness.on("change", () => {
|
|
2921
|
+
updateCursors();
|
|
2922
|
+
});
|
|
2923
|
+
updateCursors();
|
|
2924
|
+
};
|
|
2925
|
+
const initialize = async () => {
|
|
2926
|
+
ready = false;
|
|
2927
|
+
error = null;
|
|
2928
|
+
notify();
|
|
2929
|
+
try {
|
|
2930
|
+
const record = await collection.findById(recordId);
|
|
2931
|
+
if (disposed) return;
|
|
2932
|
+
doc.transact(() => {
|
|
2933
|
+
const target = doc.getText(TEXT_KEY);
|
|
2934
|
+
target.delete(0, target.length);
|
|
2935
|
+
}, LOAD_ORIGIN);
|
|
2936
|
+
const encoded = encodeRichtextInput(record?.[fieldName]);
|
|
2937
|
+
baseUpdateRef.current = encoded;
|
|
2938
|
+
pendingDeltasRef.current = [];
|
|
2939
|
+
if (encoded) {
|
|
2940
|
+
Y.applyUpdate(doc, encoded, LOAD_ORIGIN);
|
|
2941
|
+
}
|
|
2942
|
+
const channel = syncEngine?.getRichtextDocChannel?.();
|
|
2943
|
+
docChannelActiveRef.current = channel?.shouldUseChannel(encoded?.length ?? 0, useDocChannel) ?? false;
|
|
2944
|
+
ready = true;
|
|
2945
|
+
syncHistoryState();
|
|
2946
|
+
subscribeRecordChanges();
|
|
2947
|
+
subscribeDocChannel();
|
|
2948
|
+
subscribeAwareness();
|
|
2949
|
+
notify();
|
|
2950
|
+
} catch (cause) {
|
|
2951
|
+
if (disposed) return;
|
|
2952
|
+
error = cause instanceof Error ? cause : new Error(String(cause));
|
|
2953
|
+
notify();
|
|
2954
|
+
}
|
|
2955
|
+
};
|
|
2956
|
+
doc.on("update", onDocUpdate);
|
|
2957
|
+
void initialize();
|
|
2958
|
+
return {
|
|
2959
|
+
doc,
|
|
2960
|
+
text,
|
|
2961
|
+
getSnapshot,
|
|
2962
|
+
subscribe(listener) {
|
|
2963
|
+
listeners.add(listener);
|
|
2964
|
+
return () => {
|
|
2965
|
+
listeners.delete(listener);
|
|
2966
|
+
};
|
|
2967
|
+
},
|
|
2968
|
+
undo() {
|
|
2969
|
+
undoManager.undo();
|
|
2970
|
+
syncHistoryState();
|
|
2971
|
+
},
|
|
2972
|
+
redo() {
|
|
2973
|
+
undoManager.redo();
|
|
2974
|
+
syncHistoryState();
|
|
2975
|
+
},
|
|
2976
|
+
setCursor,
|
|
2977
|
+
clearCursor,
|
|
2978
|
+
setUser(nextUser) {
|
|
2979
|
+
user = nextUser;
|
|
2980
|
+
},
|
|
2981
|
+
destroy() {
|
|
2982
|
+
if (disposed) {
|
|
2983
|
+
return;
|
|
2984
|
+
}
|
|
2985
|
+
disposed = true;
|
|
2986
|
+
if (persistTimer) {
|
|
2987
|
+
clearTimeout(persistTimer);
|
|
2988
|
+
persistTimer = null;
|
|
2989
|
+
}
|
|
2990
|
+
doc.off("update", onDocUpdate);
|
|
2991
|
+
recordUnsubscribe?.();
|
|
2992
|
+
docChannelUnsubscribe?.();
|
|
2993
|
+
awarenessUnsubscribe?.();
|
|
2994
|
+
clearCursor();
|
|
2995
|
+
undoManager.destroy();
|
|
2996
|
+
baseUpdateRef.current = null;
|
|
2997
|
+
pendingDeltasRef.current = [];
|
|
2998
|
+
docChannelActiveRef.current = false;
|
|
2999
|
+
listeners.clear();
|
|
3000
|
+
}
|
|
3001
|
+
};
|
|
3002
|
+
}
|
|
3003
|
+
function encodeRichtextInput(value) {
|
|
3004
|
+
if (value === null || value === void 0) {
|
|
3005
|
+
return null;
|
|
3006
|
+
}
|
|
3007
|
+
try {
|
|
3008
|
+
return encodeRichtext(value);
|
|
3009
|
+
} catch {
|
|
3010
|
+
if (typeof value === "string") {
|
|
3011
|
+
const fallbackDoc = new Y.Doc();
|
|
3012
|
+
fallbackDoc.getText(TEXT_KEY).insert(0, value);
|
|
3013
|
+
return Y.encodeStateAsUpdate(fallbackDoc);
|
|
3014
|
+
}
|
|
3015
|
+
throw new Error("Richtext record value must be a string, Uint8Array, ArrayBuffer, or null.");
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
function composeRichtextSnapshot(base, deltas) {
|
|
3019
|
+
const mergedDoc = new Y.Doc();
|
|
3020
|
+
if (base) {
|
|
3021
|
+
Y.applyUpdate(mergedDoc, base);
|
|
3022
|
+
}
|
|
3023
|
+
for (const delta of deltas) {
|
|
3024
|
+
Y.applyUpdate(mergedDoc, delta);
|
|
3025
|
+
}
|
|
3026
|
+
return Y.encodeStateAsUpdate(mergedDoc);
|
|
3027
|
+
}
|
|
3028
|
+
function updatesEqual(left, right) {
|
|
3029
|
+
if (left.length !== right.length) {
|
|
3030
|
+
return false;
|
|
3031
|
+
}
|
|
3032
|
+
for (let index = 0; index < left.length; index++) {
|
|
3033
|
+
if (left[index] !== right[index]) {
|
|
3034
|
+
return false;
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
return true;
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
// src/richtext/as-rich-text-sync-engine.ts
|
|
3041
|
+
function asRichTextSyncEngine(engine) {
|
|
3042
|
+
if (engine == null) {
|
|
3043
|
+
return null;
|
|
3044
|
+
}
|
|
3045
|
+
return engine;
|
|
3046
|
+
}
|
|
2428
3047
|
export {
|
|
2429
3048
|
AdapterError,
|
|
3049
|
+
COMPACTION_BASELINE_META_KEY,
|
|
2430
3050
|
Collection,
|
|
2431
3051
|
InvalidStateTransitionError,
|
|
3052
|
+
LAST_ACKED_SERVER_VECTOR_META_KEY,
|
|
2432
3053
|
PersistenceError,
|
|
2433
3054
|
QueryBuilder,
|
|
2434
3055
|
QueryError,
|
|
3056
|
+
QueryStore,
|
|
3057
|
+
QueryStoreCache,
|
|
2435
3058
|
RecordNotFoundError,
|
|
2436
3059
|
SequenceManager,
|
|
2437
3060
|
Store,
|
|
@@ -2441,12 +3064,31 @@ export {
|
|
|
2441
3064
|
TransactionContext,
|
|
2442
3065
|
WorkerInitError,
|
|
2443
3066
|
WorkerTimeoutError,
|
|
3067
|
+
asRichTextSyncEngine,
|
|
3068
|
+
assertQueryReady,
|
|
3069
|
+
collectOperationsAheadOfServer,
|
|
3070
|
+
compactOperationLog,
|
|
3071
|
+
computeAckCompactionWatermark,
|
|
3072
|
+
createRichTextController,
|
|
3073
|
+
decodeAuditExport,
|
|
2444
3074
|
decodeRichtext,
|
|
3075
|
+
deserializeVersionVectorFromMeta,
|
|
2445
3076
|
encodeRichtext,
|
|
3077
|
+
exportBackup,
|
|
3078
|
+
getSharedQueryStoreCache,
|
|
3079
|
+
mergeVersionVectors,
|
|
3080
|
+
persistedAuditTraceFromEvent,
|
|
2446
3081
|
pluralize,
|
|
3082
|
+
readAuditExportManifest,
|
|
3083
|
+
readBackupManifest,
|
|
3084
|
+
restoreBackup,
|
|
3085
|
+
richtextStatesEqual,
|
|
2447
3086
|
richtextToPlainText,
|
|
3087
|
+
serializeVersionVectorToMeta,
|
|
2448
3088
|
singularize,
|
|
2449
3089
|
validateStateTransition,
|
|
2450
|
-
validateUpdateStateMachine
|
|
3090
|
+
validateUpdateStateMachine,
|
|
3091
|
+
verifyAuditExportChecksum,
|
|
3092
|
+
verifyBackupChecksum
|
|
2451
3093
|
};
|
|
2452
3094
|
//# sourceMappingURL=index.js.map
|