@korajs/store 0.3.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/better-sqlite3.cjs +1 -1
- package/dist/adapters/better-sqlite3.cjs.map +1 -1
- package/dist/adapters/better-sqlite3.d.cts +1 -1
- package/dist/adapters/better-sqlite3.d.ts +1 -1
- package/dist/adapters/better-sqlite3.js +1 -1
- package/dist/adapters/better-sqlite3.js.map +1 -1
- package/dist/adapters/indexeddb.cjs +413 -18
- package/dist/adapters/indexeddb.cjs.map +1 -1
- package/dist/adapters/indexeddb.d.cts +23 -5
- package/dist/adapters/indexeddb.d.ts +23 -5
- package/dist/adapters/indexeddb.js +166 -19
- package/dist/adapters/indexeddb.js.map +1 -1
- package/dist/adapters/sqlite-wasm-shared-host.cjs +68 -0
- package/dist/adapters/sqlite-wasm-shared-host.cjs.map +1 -0
- package/dist/adapters/sqlite-wasm-shared-host.d.cts +2 -0
- package/dist/adapters/sqlite-wasm-shared-host.d.ts +2 -0
- package/dist/adapters/sqlite-wasm-shared-host.js +66 -0
- package/dist/adapters/sqlite-wasm-shared-host.js.map +1 -0
- package/dist/adapters/sqlite-wasm-worker.cjs +34 -5
- package/dist/adapters/sqlite-wasm-worker.cjs.map +1 -1
- package/dist/adapters/sqlite-wasm-worker.js +34 -5
- package/dist/adapters/sqlite-wasm-worker.js.map +1 -1
- package/dist/adapters/sqlite-wasm.cjs +249 -2
- package/dist/adapters/sqlite-wasm.cjs.map +1 -1
- package/dist/adapters/sqlite-wasm.d.cts +15 -3
- package/dist/adapters/sqlite-wasm.d.ts +15 -3
- package/dist/adapters/sqlite-wasm.js +2 -2
- package/dist/audit-trace-store-AM4EWQNZ.js +15 -0
- package/dist/backup-JN3LB4YT.js +14 -0
- package/dist/backup-JN3LB4YT.js.map +1 -0
- package/dist/chunk-56LL4XP6.js +205 -0
- package/dist/chunk-56LL4XP6.js.map +1 -0
- package/dist/chunk-6AHYGQQT.js +425 -0
- package/dist/chunk-6AHYGQQT.js.map +1 -0
- package/dist/chunk-7BPETY5Y.js +570 -0
- package/dist/chunk-7BPETY5Y.js.map +1 -0
- package/dist/chunk-AGN3CIPB.js +188 -0
- package/dist/chunk-AGN3CIPB.js.map +1 -0
- package/dist/chunk-EZSTD2XC.js +380 -0
- package/dist/chunk-EZSTD2XC.js.map +1 -0
- package/dist/chunk-MYSJN3DF.js +117 -0
- package/dist/chunk-MYSJN3DF.js.map +1 -0
- package/dist/{chunk-DXKLAQ6P.js → chunk-XO4LMPAH.js} +1 -1
- package/dist/chunk-XO4LMPAH.js.map +1 -0
- package/dist/export-audit-5GV6VRDZ.js +15 -0
- package/dist/export-audit-5GV6VRDZ.js.map +1 -0
- package/dist/index.cjs +3351 -510
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +822 -50
- package/dist/index.d.ts +822 -50
- package/dist/index.js +1976 -558
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +789 -0
- package/dist/internal.cjs.map +1 -0
- package/dist/internal.d.cts +154 -0
- package/dist/internal.d.ts +154 -0
- package/dist/internal.js +48 -0
- package/dist/internal.js.map +1 -0
- package/dist/{sqlite-wasm-channel-46AOWNPM.js → sqlite-wasm-channel-HDDW6UBH.js} +2 -2
- package/dist/sqlite-wasm-channel-HDDW6UBH.js.map +1 -0
- package/dist/{sqlite-wasm-channel-Lakjuk2E.d.cts → sqlite-wasm-channel-I9y_jObg.d.cts} +1 -0
- package/dist/{sqlite-wasm-channel-Lakjuk2E.d.ts → sqlite-wasm-channel-I9y_jObg.d.ts} +1 -0
- package/dist/types-BMyHmwvn.d.ts +163 -0
- package/dist/types-CTr00g_d.d.cts +163 -0
- package/dist/types-DepnRDk_.d.cts +212 -0
- package/dist/types-DepnRDk_.d.ts +212 -0
- package/package.json +23 -2
- package/dist/chunk-DXKLAQ6P.js.map +0 -1
- package/dist/chunk-ZP5AXQ3Z.js +0 -179
- package/dist/chunk-ZP5AXQ3Z.js.map +0 -1
- package/dist/types-DF-KDSK1.d.cts +0 -106
- package/dist/types-DF-KDSK1.d.ts +0 -106
- /package/dist/{sqlite-wasm-channel-46AOWNPM.js.map → audit-trace-store-AM4EWQNZ.js.map} +0 -0
package/dist/index.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,309 +35,186 @@ import {
|
|
|
7
35
|
WorkerInitError,
|
|
8
36
|
WorkerTimeoutError
|
|
9
37
|
} from "./chunk-LAWV6CFH.js";
|
|
38
|
+
import {
|
|
39
|
+
exportBackup,
|
|
40
|
+
readBackupManifest,
|
|
41
|
+
restoreBackup,
|
|
42
|
+
verifyBackupChecksum
|
|
43
|
+
} from "./chunk-EZSTD2XC.js";
|
|
44
|
+
import {
|
|
45
|
+
decodeRichtext,
|
|
46
|
+
deserializeOperationWithCollection,
|
|
47
|
+
deserializeRecord,
|
|
48
|
+
encodeRichtext,
|
|
49
|
+
richtextStatesEqual,
|
|
50
|
+
richtextToPlainText,
|
|
51
|
+
serializeOperation,
|
|
52
|
+
serializeRecord
|
|
53
|
+
} from "./chunk-56LL4XP6.js";
|
|
10
54
|
|
|
11
|
-
// src/
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
// src/collection/collection.ts
|
|
15
|
-
import { createOperation, generateUUIDv7, validateRecord } from "@korajs/core";
|
|
55
|
+
// src/compaction/types.ts
|
|
56
|
+
var COMPACTION_BASELINE_META_KEY = "compaction_baseline_at";
|
|
16
57
|
|
|
17
|
-
// src/
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
parts.push(`WHERE ${deletedFilter} AND ${whereClause}`);
|
|
25
|
-
} else {
|
|
26
|
-
parts.push(`WHERE ${deletedFilter}`);
|
|
27
|
-
}
|
|
28
|
-
if (descriptor.orderBy.length > 0) {
|
|
29
|
-
const orderParts = descriptor.orderBy.map((o) => {
|
|
30
|
-
validateFieldName(o.field, fields);
|
|
31
|
-
return `${o.field} ${o.direction.toUpperCase()}`;
|
|
32
|
-
});
|
|
33
|
-
parts.push(`ORDER BY ${orderParts.join(", ")}`);
|
|
34
|
-
}
|
|
35
|
-
if (descriptor.limit !== void 0) {
|
|
36
|
-
parts.push(`LIMIT ${descriptor.limit}`);
|
|
37
|
-
}
|
|
38
|
-
if (descriptor.offset !== void 0) {
|
|
39
|
-
parts.push(`OFFSET ${descriptor.offset}`);
|
|
40
|
-
}
|
|
41
|
-
return { sql: parts.join(" "), params };
|
|
42
|
-
}
|
|
43
|
-
function buildCountQuery(descriptor, fields) {
|
|
44
|
-
const params = [];
|
|
45
|
-
const parts = [`SELECT COUNT(*) as count FROM ${descriptor.collection}`];
|
|
46
|
-
const whereClause = buildWhereClauseParts(descriptor.where, fields, params);
|
|
47
|
-
const deletedFilter = "_deleted = 0";
|
|
48
|
-
if (whereClause) {
|
|
49
|
-
parts.push(`WHERE ${deletedFilter} AND ${whereClause}`);
|
|
50
|
-
} else {
|
|
51
|
-
parts.push(`WHERE ${deletedFilter}`);
|
|
52
|
-
}
|
|
53
|
-
return { sql: parts.join(" "), params };
|
|
54
|
-
}
|
|
55
|
-
function buildInsertQuery(collection, record) {
|
|
56
|
-
const columns = Object.keys(record);
|
|
57
|
-
const placeholders = columns.map(() => "?");
|
|
58
|
-
const params = Object.values(record);
|
|
59
|
-
const sql = `INSERT INTO ${collection} (${columns.join(", ")}) VALUES (${placeholders.join(", ")})`;
|
|
60
|
-
return { sql, params };
|
|
61
|
-
}
|
|
62
|
-
function buildUpdateQuery(collection, id, changes) {
|
|
63
|
-
const setClauses = Object.keys(changes).map((col) => `${col} = ?`);
|
|
64
|
-
const params = [...Object.values(changes), id];
|
|
65
|
-
const sql = `UPDATE ${collection} SET ${setClauses.join(", ")} WHERE id = ?`;
|
|
66
|
-
return { sql, params };
|
|
67
|
-
}
|
|
68
|
-
function buildSoftDeleteQuery(collection, id, updatedAt) {
|
|
69
|
-
return {
|
|
70
|
-
sql: `UPDATE ${collection} SET _deleted = 1, _updated_at = ? WHERE id = ?`,
|
|
71
|
-
params: [updatedAt, id]
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
var VALID_OPERATORS = /* @__PURE__ */ new Set(["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in"]);
|
|
75
|
-
function buildWhereClauseParts(where, fields, params) {
|
|
76
|
-
const conditions = [];
|
|
77
|
-
for (const [fieldName, value] of Object.entries(where)) {
|
|
78
|
-
validateFieldName(fieldName, fields);
|
|
79
|
-
const descriptor = fields[fieldName];
|
|
80
|
-
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
81
|
-
const ops = value;
|
|
82
|
-
for (const [op, opValue] of Object.entries(ops)) {
|
|
83
|
-
if (!VALID_OPERATORS.has(op)) {
|
|
84
|
-
throw new QueryError(`Unknown operator "${op}" on field "${fieldName}"`, {
|
|
85
|
-
field: fieldName,
|
|
86
|
-
operator: op,
|
|
87
|
-
validOperators: [...VALID_OPERATORS]
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
conditions.push(buildOperatorCondition(fieldName, op, opValue, descriptor, params));
|
|
91
|
-
}
|
|
92
|
-
} else {
|
|
93
|
-
conditions.push(buildOperatorCondition(fieldName, "$eq", value, descriptor, params));
|
|
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);
|
|
94
65
|
}
|
|
95
66
|
}
|
|
96
|
-
|
|
97
|
-
return conditions.join(" AND ");
|
|
67
|
+
return watermark;
|
|
98
68
|
}
|
|
99
|
-
function
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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);
|
|
105
96
|
}
|
|
106
|
-
params.push(sqlValue);
|
|
107
|
-
return `${fieldName} = ?`;
|
|
108
|
-
case "$ne":
|
|
109
|
-
if (sqlValue === null) {
|
|
110
|
-
return `${fieldName} IS NOT NULL`;
|
|
111
|
-
}
|
|
112
|
-
params.push(sqlValue);
|
|
113
|
-
return `${fieldName} != ?`;
|
|
114
|
-
case "$gt":
|
|
115
|
-
params.push(sqlValue);
|
|
116
|
-
return `${fieldName} > ?`;
|
|
117
|
-
case "$gte":
|
|
118
|
-
params.push(sqlValue);
|
|
119
|
-
return `${fieldName} >= ?`;
|
|
120
|
-
case "$lt":
|
|
121
|
-
params.push(sqlValue);
|
|
122
|
-
return `${fieldName} < ?`;
|
|
123
|
-
case "$lte":
|
|
124
|
-
params.push(sqlValue);
|
|
125
|
-
return `${fieldName} <= ?`;
|
|
126
|
-
case "$in": {
|
|
127
|
-
if (!Array.isArray(sqlValue)) {
|
|
128
|
-
throw new QueryError(`$in operator requires an array value for field "${fieldName}"`, {
|
|
129
|
-
field: fieldName,
|
|
130
|
-
received: typeof sqlValue
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
const placeholders = sqlValue.map(() => "?");
|
|
134
|
-
for (const item of sqlValue) {
|
|
135
|
-
params.push(
|
|
136
|
-
descriptor?.kind === "boolean" && typeof item === "boolean" ? item ? 1 : 0 : item
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
return `${fieldName} IN (${placeholders.join(", ")})`;
|
|
140
97
|
}
|
|
141
|
-
|
|
142
|
-
|
|
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 };
|
|
104
|
+
}
|
|
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) < ?`;
|
|
143
108
|
}
|
|
109
|
+
return `SELECT COUNT(*) as count FROM ${table} WHERE node_id = ? AND sequence_number <= ?`;
|
|
144
110
|
}
|
|
145
|
-
function
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
"id",
|
|
149
|
-
"createdAt",
|
|
150
|
-
"updatedAt",
|
|
151
|
-
"_created_at",
|
|
152
|
-
"_updated_at"
|
|
153
|
-
]);
|
|
154
|
-
if (!allowedFields.has(fieldName)) {
|
|
155
|
-
throw new QueryError(
|
|
156
|
-
`Unknown field "${fieldName}" in query. Available fields: ${[...allowedFields].join(", ")}`,
|
|
157
|
-
{ field: fieldName }
|
|
158
|
-
);
|
|
111
|
+
function buildDeleteSql(table, withAge) {
|
|
112
|
+
if (withAge) {
|
|
113
|
+
return `DELETE FROM ${table} WHERE node_id = ? AND sequence_number <= ? AND SUBSTR(timestamp, 1, 15) < ?`;
|
|
159
114
|
}
|
|
115
|
+
return `DELETE FROM ${table} WHERE node_id = ? AND sequence_number <= ?`;
|
|
160
116
|
}
|
|
161
117
|
|
|
162
|
-
// src/
|
|
163
|
-
import {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (typeof value === "string") {
|
|
173
|
-
const doc = new Y.Doc();
|
|
174
|
-
doc.getText(TEXT_KEY).insert(0, value);
|
|
175
|
-
return Y.encodeStateAsUpdate(doc);
|
|
176
|
-
}
|
|
177
|
-
if (value instanceof Uint8Array) {
|
|
178
|
-
return value;
|
|
179
|
-
}
|
|
180
|
-
if (value instanceof ArrayBuffer) {
|
|
181
|
-
return new Uint8Array(value);
|
|
182
|
-
}
|
|
183
|
-
throw new Error("Richtext value must be a string, Uint8Array, ArrayBuffer, null, or undefined.");
|
|
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);
|
|
184
128
|
}
|
|
185
|
-
function
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (value instanceof Uint8Array) {
|
|
193
|
-
return value;
|
|
194
|
-
}
|
|
195
|
-
if (value instanceof ArrayBuffer) {
|
|
196
|
-
return new Uint8Array(value);
|
|
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);
|
|
135
|
+
}
|
|
197
136
|
}
|
|
198
|
-
|
|
137
|
+
return vector;
|
|
199
138
|
}
|
|
200
|
-
function
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
Y.applyUpdate(doc, encoded);
|
|
205
|
-
return doc.getText(TEXT_KEY).toString();
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// src/serialization/serializer.ts
|
|
209
|
-
function serializeRecord(data, fields) {
|
|
210
|
-
const result = {};
|
|
211
|
-
for (const [key, value] of Object.entries(data)) {
|
|
212
|
-
const descriptor = fields[key];
|
|
213
|
-
if (!descriptor) {
|
|
214
|
-
result[key] = value;
|
|
215
|
-
continue;
|
|
216
|
-
}
|
|
217
|
-
result[key] = serializeValue(value, descriptor);
|
|
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));
|
|
218
143
|
}
|
|
219
|
-
return
|
|
144
|
+
return merged;
|
|
220
145
|
}
|
|
221
|
-
function
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const rawValue = row[key];
|
|
229
|
-
if (rawValue === void 0 || rawValue === null) {
|
|
230
|
-
result[key] = rawValue ?? null;
|
|
231
|
-
continue;
|
|
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);
|
|
232
153
|
}
|
|
233
|
-
result[key] = deserializeValue(rawValue, descriptor);
|
|
234
154
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
function serializeOperation(op) {
|
|
238
|
-
return {
|
|
239
|
-
id: op.id,
|
|
240
|
-
node_id: op.nodeId,
|
|
241
|
-
type: op.type,
|
|
242
|
-
record_id: op.recordId,
|
|
243
|
-
data: op.data ? JSON.stringify(op.data) : null,
|
|
244
|
-
previous_data: op.previousData ? JSON.stringify(op.previousData) : null,
|
|
245
|
-
timestamp: HybridLogicalClock.serialize(op.timestamp),
|
|
246
|
-
sequence_number: op.sequenceNumber,
|
|
247
|
-
causal_deps: JSON.stringify(op.causalDeps),
|
|
248
|
-
schema_version: op.schemaVersion
|
|
249
|
-
};
|
|
155
|
+
missing.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
|
156
|
+
return missing;
|
|
250
157
|
}
|
|
251
|
-
function
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
recordId: row.record_id,
|
|
259
|
-
data: row.data ? JSON.parse(row.data) : null,
|
|
260
|
-
previousData: row.previous_data ? JSON.parse(row.previous_data) : null,
|
|
261
|
-
timestamp: HybridLogicalClock.deserialize(row.timestamp),
|
|
262
|
-
sequenceNumber: row.sequence_number,
|
|
263
|
-
causalDeps: JSON.parse(row.causal_deps),
|
|
264
|
-
schemaVersion: row.schema_version
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
function deserializeOperationWithCollection(row, collection) {
|
|
268
|
-
const op = deserializeOperation(row);
|
|
269
|
-
return { ...op, collection };
|
|
270
|
-
}
|
|
271
|
-
function serializeValue(value, descriptor) {
|
|
272
|
-
if (value === null || value === void 0) {
|
|
273
|
-
return null;
|
|
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();
|
|
274
165
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
return JSON.stringify(value);
|
|
280
|
-
case "richtext":
|
|
281
|
-
return encodeRichtext(value);
|
|
282
|
-
default:
|
|
283
|
-
return value;
|
|
166
|
+
try {
|
|
167
|
+
return deserializeVersionVectorFromMeta(row.value);
|
|
168
|
+
} catch {
|
|
169
|
+
return createVersionVector2();
|
|
284
170
|
}
|
|
285
171
|
}
|
|
286
|
-
function
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
+
]);
|
|
177
|
+
}
|
|
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;
|
|
183
|
+
}
|
|
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
|
+
]);
|
|
300
193
|
}
|
|
301
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
|
+
|
|
302
204
|
// src/collection/collection.ts
|
|
303
205
|
var Collection = class {
|
|
304
|
-
constructor(name, definition, schema, adapter, clock, nodeId,
|
|
206
|
+
constructor(name, definition, schema, adapter, clock, nodeId, allocateSequenceNumber, onMutation, relationEnforcer, mutationHandler, causalTracker) {
|
|
305
207
|
this.name = name;
|
|
306
208
|
this.definition = definition;
|
|
307
209
|
this.schema = schema;
|
|
308
210
|
this.adapter = adapter;
|
|
309
211
|
this.clock = clock;
|
|
310
212
|
this.nodeId = nodeId;
|
|
311
|
-
this.
|
|
213
|
+
this.allocateSequenceNumber = allocateSequenceNumber;
|
|
312
214
|
this.onMutation = onMutation;
|
|
215
|
+
this.relationEnforcer = relationEnforcer;
|
|
216
|
+
this.mutationHandler = mutationHandler;
|
|
217
|
+
this.causalTracker = causalTracker;
|
|
313
218
|
}
|
|
314
219
|
name;
|
|
315
220
|
definition;
|
|
@@ -317,71 +222,32 @@ var Collection = class {
|
|
|
317
222
|
adapter;
|
|
318
223
|
clock;
|
|
319
224
|
nodeId;
|
|
320
|
-
|
|
225
|
+
allocateSequenceNumber;
|
|
321
226
|
onMutation;
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
* @param data - The record data (auto fields and defaults are applied automatically)
|
|
327
|
-
* @returns The inserted record with id, createdAt, updatedAt
|
|
328
|
-
*/
|
|
329
|
-
async insert(data) {
|
|
330
|
-
const validated = validateRecord(this.name, this.definition, data, "insert");
|
|
331
|
-
const recordId = generateUUIDv7();
|
|
332
|
-
const now = Date.now();
|
|
333
|
-
for (const [fieldName, descriptor] of Object.entries(this.definition.fields)) {
|
|
334
|
-
if (descriptor.auto && descriptor.kind === "timestamp") {
|
|
335
|
-
validated[fieldName] = now;
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
const sequenceNumber = this.getSequenceNumber();
|
|
339
|
-
const operation = await createOperation(
|
|
340
|
-
{
|
|
341
|
-
nodeId: this.nodeId,
|
|
342
|
-
type: "insert",
|
|
343
|
-
collection: this.name,
|
|
344
|
-
recordId,
|
|
345
|
-
data: { ...validated },
|
|
346
|
-
previousData: null,
|
|
347
|
-
sequenceNumber,
|
|
348
|
-
causalDeps: [],
|
|
349
|
-
schemaVersion: this.schema.version
|
|
350
|
-
},
|
|
351
|
-
this.clock
|
|
352
|
-
);
|
|
353
|
-
const serializedData = serializeRecord(validated, this.definition.fields);
|
|
354
|
-
const record = {
|
|
355
|
-
id: recordId,
|
|
356
|
-
...serializedData,
|
|
357
|
-
_created_at: now,
|
|
358
|
-
_updated_at: now
|
|
359
|
-
};
|
|
360
|
-
const insertQuery = buildInsertQuery(this.name, record);
|
|
361
|
-
const opRow = serializeOperation(operation);
|
|
362
|
-
const opInsert = buildInsertQuery(
|
|
363
|
-
`_kora_ops_${this.name}`,
|
|
364
|
-
opRow
|
|
365
|
-
);
|
|
366
|
-
await this.adapter.transaction(async (tx) => {
|
|
367
|
-
await tx.execute(insertQuery.sql, insertQuery.params);
|
|
368
|
-
await tx.execute(opInsert.sql, opInsert.params);
|
|
369
|
-
await tx.execute(
|
|
370
|
-
"INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
|
|
371
|
-
[this.nodeId, sequenceNumber]
|
|
372
|
-
);
|
|
373
|
-
});
|
|
374
|
-
this.onMutation(this.name, operation);
|
|
227
|
+
relationEnforcer;
|
|
228
|
+
mutationHandler;
|
|
229
|
+
causalTracker;
|
|
230
|
+
mutationContext() {
|
|
375
231
|
return {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
|
380
243
|
};
|
|
381
244
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
245
|
+
async insert(data) {
|
|
246
|
+
if (this.mutationHandler) {
|
|
247
|
+
return this.mutationHandler.insert(this.name, data);
|
|
248
|
+
}
|
|
249
|
+
return executeInsert(this.mutationContext(), data);
|
|
250
|
+
}
|
|
385
251
|
async findById(id) {
|
|
386
252
|
const rows = await this.adapter.query(
|
|
387
253
|
`SELECT * FROM ${this.name} WHERE id = ? AND _deleted = 0`,
|
|
@@ -391,124 +257,29 @@ var Collection = class {
|
|
|
391
257
|
if (!row) return null;
|
|
392
258
|
return deserializeRecord(row, this.definition.fields);
|
|
393
259
|
}
|
|
394
|
-
/**
|
|
395
|
-
* Update an existing record. Only the provided fields are changed.
|
|
396
|
-
*
|
|
397
|
-
* @param id - The record ID to update
|
|
398
|
-
* @param data - Partial data with only the fields to change
|
|
399
|
-
* @returns The updated record
|
|
400
|
-
* @throws {RecordNotFoundError} If the record doesn't exist or is deleted
|
|
401
|
-
*/
|
|
402
260
|
async update(id, data) {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
[id]
|
|
406
|
-
);
|
|
407
|
-
const currentRow = currentRows[0];
|
|
408
|
-
if (!currentRow) {
|
|
409
|
-
throw new RecordNotFoundError(this.name, id);
|
|
410
|
-
}
|
|
411
|
-
const validated = validateRecord(this.name, this.definition, data, "update");
|
|
412
|
-
const now = Date.now();
|
|
413
|
-
const previousData = {};
|
|
414
|
-
const currentRecord = deserializeRecord(currentRow, this.definition.fields);
|
|
415
|
-
for (const key of Object.keys(validated)) {
|
|
416
|
-
previousData[key] = currentRecord[key];
|
|
417
|
-
}
|
|
418
|
-
const sequenceNumber = this.getSequenceNumber();
|
|
419
|
-
const operation = await createOperation(
|
|
420
|
-
{
|
|
421
|
-
nodeId: this.nodeId,
|
|
422
|
-
type: "update",
|
|
423
|
-
collection: this.name,
|
|
424
|
-
recordId: id,
|
|
425
|
-
data: { ...validated },
|
|
426
|
-
previousData,
|
|
427
|
-
sequenceNumber,
|
|
428
|
-
causalDeps: [],
|
|
429
|
-
schemaVersion: this.schema.version
|
|
430
|
-
},
|
|
431
|
-
this.clock
|
|
432
|
-
);
|
|
433
|
-
const serializedChanges = serializeRecord(validated, this.definition.fields);
|
|
434
|
-
const updateQuery = buildUpdateQuery(this.name, id, {
|
|
435
|
-
...serializedChanges,
|
|
436
|
-
_updated_at: now
|
|
437
|
-
});
|
|
438
|
-
const opRow = serializeOperation(operation);
|
|
439
|
-
const opInsert = buildInsertQuery(
|
|
440
|
-
`_kora_ops_${this.name}`,
|
|
441
|
-
opRow
|
|
442
|
-
);
|
|
443
|
-
await this.adapter.transaction(async (tx) => {
|
|
444
|
-
await tx.execute(updateQuery.sql, updateQuery.params);
|
|
445
|
-
await tx.execute(opInsert.sql, opInsert.params);
|
|
446
|
-
await tx.execute(
|
|
447
|
-
"INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
|
|
448
|
-
[this.nodeId, sequenceNumber]
|
|
449
|
-
);
|
|
450
|
-
});
|
|
451
|
-
this.onMutation(this.name, operation);
|
|
452
|
-
const updatedRow = await this.findById(id);
|
|
453
|
-
if (!updatedRow) {
|
|
454
|
-
throw new RecordNotFoundError(this.name, id);
|
|
261
|
+
if (this.mutationHandler) {
|
|
262
|
+
return this.mutationHandler.update(this.name, id, data);
|
|
455
263
|
}
|
|
456
|
-
return
|
|
264
|
+
return executeUpdate(this.mutationContext(), id, data);
|
|
457
265
|
}
|
|
458
|
-
/**
|
|
459
|
-
* Soft-delete a record by its ID.
|
|
460
|
-
*
|
|
461
|
-
* @param id - The record ID to delete
|
|
462
|
-
* @throws {RecordNotFoundError} If the record doesn't exist or is already deleted
|
|
463
|
-
*/
|
|
464
266
|
async delete(id) {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
);
|
|
469
|
-
if (!currentRows[0]) {
|
|
470
|
-
throw new RecordNotFoundError(this.name, id);
|
|
267
|
+
if (this.mutationHandler) {
|
|
268
|
+
await this.mutationHandler.delete(this.name, id);
|
|
269
|
+
return;
|
|
471
270
|
}
|
|
472
|
-
|
|
473
|
-
const sequenceNumber = this.getSequenceNumber();
|
|
474
|
-
const operation = await createOperation(
|
|
475
|
-
{
|
|
476
|
-
nodeId: this.nodeId,
|
|
477
|
-
type: "delete",
|
|
478
|
-
collection: this.name,
|
|
479
|
-
recordId: id,
|
|
480
|
-
data: null,
|
|
481
|
-
previousData: null,
|
|
482
|
-
sequenceNumber,
|
|
483
|
-
causalDeps: [],
|
|
484
|
-
schemaVersion: this.schema.version
|
|
485
|
-
},
|
|
486
|
-
this.clock
|
|
487
|
-
);
|
|
488
|
-
const deleteQuery = buildSoftDeleteQuery(this.name, id, now);
|
|
489
|
-
const opRow = serializeOperation(operation);
|
|
490
|
-
const opInsert = buildInsertQuery(
|
|
491
|
-
`_kora_ops_${this.name}`,
|
|
492
|
-
opRow
|
|
493
|
-
);
|
|
494
|
-
await this.adapter.transaction(async (tx) => {
|
|
495
|
-
await tx.execute(deleteQuery.sql, deleteQuery.params);
|
|
496
|
-
await tx.execute(opInsert.sql, opInsert.params);
|
|
497
|
-
await tx.execute(
|
|
498
|
-
"INSERT OR REPLACE INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)",
|
|
499
|
-
[this.nodeId, sequenceNumber]
|
|
500
|
-
);
|
|
501
|
-
});
|
|
502
|
-
this.onMutation(this.name, operation);
|
|
271
|
+
await executeDelete(this.mutationContext(), id);
|
|
503
272
|
}
|
|
504
|
-
/** Get the collection name */
|
|
505
273
|
getName() {
|
|
506
274
|
return this.name;
|
|
507
275
|
}
|
|
508
|
-
/** Get the collection definition */
|
|
509
276
|
getDefinition() {
|
|
510
277
|
return this.definition;
|
|
511
278
|
}
|
|
279
|
+
/** Replace the mutation handler (e.g. after ApplyPipeline is wired in createApp). */
|
|
280
|
+
setMutationHandler(handler) {
|
|
281
|
+
this.mutationHandler = handler;
|
|
282
|
+
}
|
|
512
283
|
};
|
|
513
284
|
|
|
514
285
|
// src/query/pluralize.ts
|
|
@@ -804,150 +575,1315 @@ var QueryError2 = class extends Error {
|
|
|
804
575
|
}
|
|
805
576
|
};
|
|
806
577
|
|
|
807
|
-
// src/
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
578
|
+
// src/relations/relation-enforcer.ts
|
|
579
|
+
import { KoraError, createOperation } from "@korajs/core";
|
|
580
|
+
|
|
581
|
+
// src/relations/relation-lookup.ts
|
|
582
|
+
function buildRelationLookup(schema) {
|
|
583
|
+
const lookup = /* @__PURE__ */ new Map();
|
|
584
|
+
for (const [relationName, relation] of Object.entries(schema.relations)) {
|
|
585
|
+
const targetCollection = relation.to;
|
|
586
|
+
const existing = lookup.get(targetCollection) ?? [];
|
|
587
|
+
existing.push({
|
|
588
|
+
relationName,
|
|
589
|
+
sourceCollection: relation.from,
|
|
590
|
+
foreignKeyField: relation.field,
|
|
591
|
+
onDelete: relation.onDelete,
|
|
592
|
+
relation
|
|
593
|
+
});
|
|
594
|
+
lookup.set(targetCollection, existing);
|
|
595
|
+
}
|
|
596
|
+
return lookup;
|
|
597
|
+
}
|
|
598
|
+
function getIncomingRelations(lookup, collection) {
|
|
599
|
+
return lookup.get(collection) ?? [];
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// src/relations/relation-enforcer.ts
|
|
603
|
+
var ReferentialIntegrityError = class extends KoraError {
|
|
604
|
+
constructor(collection, recordId, referencingCollection, relationName, referencingCount) {
|
|
605
|
+
super(
|
|
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.`,
|
|
607
|
+
"REFERENTIAL_INTEGRITY",
|
|
608
|
+
{
|
|
609
|
+
collection,
|
|
610
|
+
recordId,
|
|
611
|
+
referencingCollection,
|
|
612
|
+
relationName,
|
|
613
|
+
referencingCount
|
|
614
|
+
}
|
|
615
|
+
);
|
|
616
|
+
this.name = "ReferentialIntegrityError";
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
var RelationEnforcer = class {
|
|
620
|
+
lookup;
|
|
621
|
+
schema;
|
|
622
|
+
adapter;
|
|
623
|
+
clock;
|
|
624
|
+
nodeId;
|
|
625
|
+
constructor(config) {
|
|
626
|
+
this.schema = config.schema;
|
|
627
|
+
this.adapter = config.adapter;
|
|
628
|
+
this.clock = config.clock;
|
|
629
|
+
this.nodeId = config.nodeId;
|
|
630
|
+
this.lookup = buildRelationLookup(config.schema);
|
|
631
|
+
}
|
|
813
632
|
/**
|
|
814
|
-
*
|
|
633
|
+
* Enforce referential integrity after deleting a record.
|
|
815
634
|
*
|
|
816
|
-
*
|
|
817
|
-
*
|
|
818
|
-
*
|
|
819
|
-
* @
|
|
635
|
+
* Must be called within a transaction. The transaction handle is used
|
|
636
|
+
* for all cascaded writes to ensure atomicity.
|
|
637
|
+
*
|
|
638
|
+
* @param collection - The collection the deleted record belongs to
|
|
639
|
+
* @param recordId - The ID of the deleted record
|
|
640
|
+
* @param tx - The active transaction handle
|
|
641
|
+
* @param causalDeps - Causal dependencies for generated operations
|
|
642
|
+
* @returns All additional operations created as side effects
|
|
643
|
+
* @throws {ReferentialIntegrityError} If a 'restrict' policy is violated
|
|
820
644
|
*/
|
|
821
|
-
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
}
|
|
645
|
+
async enforceDelete(collection, recordId, tx, causalDeps) {
|
|
646
|
+
const incomingRelations = getIncomingRelations(this.lookup, collection);
|
|
647
|
+
if (incomingRelations.length === 0) {
|
|
648
|
+
return { operations: [] };
|
|
649
|
+
}
|
|
650
|
+
const allOperations = [];
|
|
651
|
+
const sortedRelations = [...incomingRelations].sort(
|
|
652
|
+
(a, b) => a.relationName.localeCompare(b.relationName)
|
|
653
|
+
);
|
|
654
|
+
for (const incoming of sortedRelations) {
|
|
655
|
+
const ops = await this.enforceRelation(incoming, recordId, tx, causalDeps);
|
|
656
|
+
allOperations.push(...ops);
|
|
657
|
+
}
|
|
658
|
+
return { operations: allOperations };
|
|
834
659
|
}
|
|
835
660
|
/**
|
|
836
|
-
*
|
|
837
|
-
* The initial results are stored as lastResults so subsequent flushes
|
|
838
|
-
* correctly diff against the initial state.
|
|
839
|
-
*
|
|
840
|
-
* @returns An unsubscribe function
|
|
661
|
+
* Enforce a single relation's onDelete policy.
|
|
841
662
|
*/
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
if (this.subscriptions.has(id)) {
|
|
854
|
-
subscription.lastResults = results;
|
|
855
|
-
callback(results);
|
|
856
|
-
}
|
|
857
|
-
});
|
|
858
|
-
return () => {
|
|
859
|
-
this.subscriptions.delete(id);
|
|
860
|
-
};
|
|
663
|
+
async enforceRelation(incoming, deletedRecordId, tx, causalDeps) {
|
|
664
|
+
switch (incoming.onDelete) {
|
|
665
|
+
case "cascade":
|
|
666
|
+
return this.enforceCascade(incoming, deletedRecordId, tx, causalDeps);
|
|
667
|
+
case "set-null":
|
|
668
|
+
return this.enforceSetNull(incoming, deletedRecordId, tx, causalDeps);
|
|
669
|
+
case "restrict":
|
|
670
|
+
return this.enforceRestrict(incoming, deletedRecordId, tx);
|
|
671
|
+
case "no-action":
|
|
672
|
+
return [];
|
|
673
|
+
}
|
|
861
674
|
}
|
|
862
675
|
/**
|
|
863
|
-
*
|
|
864
|
-
*
|
|
676
|
+
* Cascade: delete all records in the source collection that reference
|
|
677
|
+
* the deleted record, then recursively cascade those deletes.
|
|
865
678
|
*/
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
679
|
+
async enforceCascade(incoming, deletedRecordId, tx, causalDeps) {
|
|
680
|
+
const { sourceCollection, foreignKeyField } = incoming;
|
|
681
|
+
const referencingRows = await tx.query(
|
|
682
|
+
`SELECT id FROM ${sourceCollection} WHERE ${foreignKeyField} = ? AND _deleted = 0`,
|
|
683
|
+
[deletedRecordId]
|
|
684
|
+
);
|
|
685
|
+
if (referencingRows.length === 0) {
|
|
686
|
+
return [];
|
|
687
|
+
}
|
|
688
|
+
const operations = [];
|
|
689
|
+
for (const row of referencingRows) {
|
|
690
|
+
const now = Date.now();
|
|
691
|
+
const sequenceNumber = await allocateNextSequenceInTransaction(tx, this.nodeId);
|
|
692
|
+
const operation = await createOperation(
|
|
693
|
+
{
|
|
694
|
+
nodeId: this.nodeId,
|
|
695
|
+
type: "delete",
|
|
696
|
+
collection: sourceCollection,
|
|
697
|
+
recordId: row.id,
|
|
698
|
+
data: null,
|
|
699
|
+
previousData: null,
|
|
700
|
+
sequenceNumber,
|
|
701
|
+
causalDeps: [...causalDeps],
|
|
702
|
+
schemaVersion: this.schema.version
|
|
703
|
+
},
|
|
704
|
+
this.clock
|
|
705
|
+
);
|
|
706
|
+
const deleteQuery = buildSoftDeleteQuery(sourceCollection, row.id, now);
|
|
707
|
+
await tx.execute(deleteQuery.sql, deleteQuery.params);
|
|
708
|
+
const opInsert = buildInsertQuery(
|
|
709
|
+
`_kora_ops_${sourceCollection}`,
|
|
710
|
+
serializeOperation(operation)
|
|
711
|
+
);
|
|
712
|
+
await tx.execute(opInsert.sql, opInsert.params);
|
|
713
|
+
operations.push(operation);
|
|
714
|
+
const cascadeResult = await this.enforceDelete(sourceCollection, row.id, tx, [operation.id]);
|
|
715
|
+
operations.push(...cascadeResult.operations);
|
|
716
|
+
}
|
|
717
|
+
return operations;
|
|
869
718
|
}
|
|
870
719
|
/**
|
|
871
|
-
*
|
|
872
|
-
|
|
720
|
+
* Set-null: update all referencing records to set the foreign key to null.
|
|
721
|
+
*/
|
|
722
|
+
async enforceSetNull(incoming, deletedRecordId, tx, causalDeps) {
|
|
723
|
+
const { sourceCollection, foreignKeyField } = incoming;
|
|
724
|
+
const collectionDef = this.schema.collections[sourceCollection];
|
|
725
|
+
if (!collectionDef) {
|
|
726
|
+
return [];
|
|
727
|
+
}
|
|
728
|
+
const referencingRows = await tx.query(
|
|
729
|
+
`SELECT id FROM ${sourceCollection} WHERE ${foreignKeyField} = ? AND _deleted = 0`,
|
|
730
|
+
[deletedRecordId]
|
|
731
|
+
);
|
|
732
|
+
if (referencingRows.length === 0) {
|
|
733
|
+
return [];
|
|
734
|
+
}
|
|
735
|
+
const operations = [];
|
|
736
|
+
for (const row of referencingRows) {
|
|
737
|
+
const now = Date.now();
|
|
738
|
+
const sequenceNumber = await allocateNextSequenceInTransaction(tx, this.nodeId);
|
|
739
|
+
const updateData = { [foreignKeyField]: null };
|
|
740
|
+
const previousData = { [foreignKeyField]: deletedRecordId };
|
|
741
|
+
const operation = await createOperation(
|
|
742
|
+
{
|
|
743
|
+
nodeId: this.nodeId,
|
|
744
|
+
type: "update",
|
|
745
|
+
collection: sourceCollection,
|
|
746
|
+
recordId: row.id,
|
|
747
|
+
data: { ...updateData },
|
|
748
|
+
previousData,
|
|
749
|
+
sequenceNumber,
|
|
750
|
+
causalDeps: [...causalDeps],
|
|
751
|
+
schemaVersion: this.schema.version
|
|
752
|
+
},
|
|
753
|
+
this.clock
|
|
754
|
+
);
|
|
755
|
+
const serializedChanges = serializeRecord(updateData, collectionDef.fields);
|
|
756
|
+
const updateQuery = buildUpdateQuery(sourceCollection, row.id, {
|
|
757
|
+
...serializedChanges,
|
|
758
|
+
_updated_at: now
|
|
759
|
+
});
|
|
760
|
+
await tx.execute(updateQuery.sql, updateQuery.params);
|
|
761
|
+
const opRow = serializeOperation(operation);
|
|
762
|
+
const opInsert = buildInsertQuery(
|
|
763
|
+
`_kora_ops_${sourceCollection}`,
|
|
764
|
+
opRow
|
|
765
|
+
);
|
|
766
|
+
await tx.execute(opInsert.sql, opInsert.params);
|
|
767
|
+
operations.push(operation);
|
|
768
|
+
}
|
|
769
|
+
return operations;
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Restrict: refuse the delete if any referencing records exist.
|
|
773
|
+
*/
|
|
774
|
+
async enforceRestrict(incoming, deletedRecordId, tx) {
|
|
775
|
+
const { sourceCollection, foreignKeyField, relationName } = incoming;
|
|
776
|
+
const countRows = await tx.query(
|
|
777
|
+
`SELECT COUNT(*) as cnt FROM ${sourceCollection} WHERE ${foreignKeyField} = ? AND _deleted = 0`,
|
|
778
|
+
[deletedRecordId]
|
|
779
|
+
);
|
|
780
|
+
const count = countRows[0]?.cnt ?? 0;
|
|
781
|
+
if (count > 0) {
|
|
782
|
+
const targetCollection = incoming.relation.to;
|
|
783
|
+
throw new ReferentialIntegrityError(
|
|
784
|
+
targetCollection,
|
|
785
|
+
deletedRecordId,
|
|
786
|
+
sourceCollection,
|
|
787
|
+
relationName,
|
|
788
|
+
count
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
return [];
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Get the relation lookup map for external use (e.g., by the merge engine).
|
|
795
|
+
*/
|
|
796
|
+
getRelationLookup() {
|
|
797
|
+
return this.lookup;
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
|
|
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
|
+
);
|
|
817
|
+
}
|
|
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
|
|
979
|
+
* @returns The formatted sequence value
|
|
980
|
+
*/
|
|
981
|
+
async next(name, config) {
|
|
982
|
+
const scope = config?.scope ?? "";
|
|
983
|
+
const startAt = config?.startAt ?? 1;
|
|
984
|
+
const format = config?.format ?? defaultSequenceFormat(name);
|
|
985
|
+
let counter = 0;
|
|
986
|
+
await this.adapter.transaction(async (tx) => {
|
|
987
|
+
const rows = await tx.query(
|
|
988
|
+
"SELECT counter FROM _kora_sequences WHERE name = ? AND scope = ? AND node_id = ?",
|
|
989
|
+
[name, scope, this.nodeId]
|
|
990
|
+
);
|
|
991
|
+
if (rows.length > 0) {
|
|
992
|
+
const row = rows[0];
|
|
993
|
+
counter = row.counter + 1;
|
|
994
|
+
await tx.execute(
|
|
995
|
+
"UPDATE _kora_sequences SET counter = ? WHERE name = ? AND scope = ? AND node_id = ?",
|
|
996
|
+
[counter, name, scope, this.nodeId]
|
|
997
|
+
);
|
|
998
|
+
} else {
|
|
999
|
+
counter = startAt;
|
|
1000
|
+
await tx.execute(
|
|
1001
|
+
"INSERT INTO _kora_sequences (name, scope, node_id, counter) VALUES (?, ?, ?, ?)",
|
|
1002
|
+
[name, scope, this.nodeId, counter]
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
});
|
|
1006
|
+
return formatSequenceValue(format, counter, this.nodeId);
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Get the current counter value without incrementing.
|
|
1010
|
+
*
|
|
1011
|
+
* @param name - The sequence name
|
|
1012
|
+
* @param config - Optional scope
|
|
1013
|
+
* @returns The current counter value, or 0 if the sequence has never been used
|
|
1014
|
+
*/
|
|
1015
|
+
async current(name, config) {
|
|
1016
|
+
const scope = config?.scope ?? "";
|
|
1017
|
+
const rows = await this.adapter.query(
|
|
1018
|
+
"SELECT counter FROM _kora_sequences WHERE name = ? AND scope = ? AND node_id = ?",
|
|
1019
|
+
[name, scope, this.nodeId]
|
|
1020
|
+
);
|
|
1021
|
+
if (rows.length > 0) {
|
|
1022
|
+
return rows[0].counter;
|
|
1023
|
+
}
|
|
1024
|
+
return 0;
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Reset a sequence counter.
|
|
1028
|
+
*
|
|
1029
|
+
* @param name - The sequence name
|
|
1030
|
+
* @param config - Optional scope and target value
|
|
1031
|
+
*/
|
|
1032
|
+
async reset(name, config) {
|
|
1033
|
+
const scope = config?.scope ?? "";
|
|
1034
|
+
const to = config?.to ?? 0;
|
|
1035
|
+
await this.adapter.execute(
|
|
1036
|
+
"DELETE FROM _kora_sequences WHERE name = ? AND scope = ? AND node_id = ?",
|
|
1037
|
+
[name, scope, this.nodeId]
|
|
1038
|
+
);
|
|
1039
|
+
if (to > 0) {
|
|
1040
|
+
await this.adapter.execute(
|
|
1041
|
+
"INSERT INTO _kora_sequences (name, scope, node_id, counter) VALUES (?, ?, ?, ?)",
|
|
1042
|
+
[name, scope, this.nodeId, to]
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
// src/subscription/bloom-filter.ts
|
|
1049
|
+
var FNV_OFFSET_BASIS = 2166136261;
|
|
1050
|
+
var FNV_PRIME = 16777619;
|
|
1051
|
+
function fnv1a32(input) {
|
|
1052
|
+
let hash = FNV_OFFSET_BASIS;
|
|
1053
|
+
for (let i = 0; i < input.length; i++) {
|
|
1054
|
+
hash ^= input.charCodeAt(i);
|
|
1055
|
+
hash = Math.imul(hash, FNV_PRIME);
|
|
1056
|
+
}
|
|
1057
|
+
return hash >>> 0;
|
|
1058
|
+
}
|
|
1059
|
+
function optimalBitCount(expectedItems, falsePositiveRate) {
|
|
1060
|
+
if (expectedItems <= 0) return 32;
|
|
1061
|
+
if (falsePositiveRate <= 0 || falsePositiveRate >= 1) return 32;
|
|
1062
|
+
const ln2Squared = Math.LN2 * Math.LN2;
|
|
1063
|
+
const rawBits = -(expectedItems * Math.log(falsePositiveRate)) / ln2Squared;
|
|
1064
|
+
const aligned = Math.ceil(rawBits / 32) * 32;
|
|
1065
|
+
return Math.max(32, aligned);
|
|
1066
|
+
}
|
|
1067
|
+
function optimalHashCount(bitCount, expectedItems) {
|
|
1068
|
+
if (expectedItems <= 0) return 1;
|
|
1069
|
+
const raw = bitCount / expectedItems * Math.LN2;
|
|
1070
|
+
return Math.max(1, Math.min(30, Math.round(raw)));
|
|
1071
|
+
}
|
|
1072
|
+
var SubscriptionBloomFilter = class {
|
|
1073
|
+
bits;
|
|
1074
|
+
bitCount;
|
|
1075
|
+
hashCount;
|
|
1076
|
+
itemCount = 0;
|
|
1077
|
+
constructor(expectedItems, falsePositiveRate = 0.01) {
|
|
1078
|
+
this.bitCount = optimalBitCount(expectedItems, falsePositiveRate);
|
|
1079
|
+
this.hashCount = optimalHashCount(this.bitCount, expectedItems);
|
|
1080
|
+
this.bits = new Uint32Array(this.bitCount / 32);
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Add a collection (and optional field) to the bloom filter.
|
|
1084
|
+
*
|
|
1085
|
+
* @param collection - Collection name (e.g., "todos")
|
|
1086
|
+
* @param field - Optional field name (e.g., "completed")
|
|
1087
|
+
*/
|
|
1088
|
+
add(collection, field) {
|
|
1089
|
+
const key = field !== void 0 ? `${collection}:${field}` : collection;
|
|
1090
|
+
const positions = this.getPositions(key);
|
|
1091
|
+
for (const pos of positions) {
|
|
1092
|
+
const wordIndex = pos >>> 5;
|
|
1093
|
+
const bitIndex = pos & 31;
|
|
1094
|
+
const current = this.bits[wordIndex] ?? 0;
|
|
1095
|
+
this.bits[wordIndex] = current | 1 << bitIndex;
|
|
1096
|
+
}
|
|
1097
|
+
this.itemCount++;
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Check if a collection (and optional field) might be in the filter.
|
|
1101
|
+
*
|
|
1102
|
+
* A return value of `false` means the item is DEFINITELY NOT in the filter.
|
|
1103
|
+
* A return value of `true` means the item MIGHT be in the filter (possible false positive).
|
|
1104
|
+
*
|
|
1105
|
+
* @param collection - Collection name to check
|
|
1106
|
+
* @param field - Optional field name to check
|
|
1107
|
+
* @returns false = definitely absent, true = possibly present
|
|
1108
|
+
*/
|
|
1109
|
+
mightContain(collection, field) {
|
|
1110
|
+
const key = field !== void 0 ? `${collection}:${field}` : collection;
|
|
1111
|
+
const positions = this.getPositions(key);
|
|
1112
|
+
for (const pos of positions) {
|
|
1113
|
+
const wordIndex = pos >>> 5;
|
|
1114
|
+
const bitIndex = pos & 31;
|
|
1115
|
+
if (((this.bits[wordIndex] ?? 0) & 1 << bitIndex) === 0) {
|
|
1116
|
+
return false;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return true;
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* Reset the bloom filter, clearing all bits and the item count.
|
|
1123
|
+
*/
|
|
1124
|
+
clear() {
|
|
1125
|
+
this.bits.fill(0);
|
|
1126
|
+
this.itemCount = 0;
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Estimate the current false positive rate based on the number of items inserted.
|
|
1130
|
+
*
|
|
1131
|
+
* Formula: (1 - e^(-kn/m))^k
|
|
1132
|
+
* where k = hash count, n = item count, m = bit count.
|
|
1133
|
+
*
|
|
1134
|
+
* @returns Estimated false positive rate as a number between 0 and 1
|
|
1135
|
+
*/
|
|
1136
|
+
estimatedFalsePositiveRate() {
|
|
1137
|
+
if (this.itemCount === 0) return 0;
|
|
1138
|
+
const exponent = -(this.hashCount * this.itemCount) / this.bitCount;
|
|
1139
|
+
return (1 - Math.exp(exponent)) ** this.hashCount;
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* @returns The number of items that have been added to the filter
|
|
1143
|
+
*/
|
|
1144
|
+
getItemCount() {
|
|
1145
|
+
return this.itemCount;
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* @returns The total number of bits in the filter
|
|
1149
|
+
*/
|
|
1150
|
+
getBitCount() {
|
|
1151
|
+
return this.bitCount;
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* @returns The number of hash functions used
|
|
1155
|
+
*/
|
|
1156
|
+
getHashCount() {
|
|
1157
|
+
return this.hashCount;
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Count the number of bits currently set to 1.
|
|
1161
|
+
* Uses Brian Kernighan's algorithm: each iteration clears the lowest set bit,
|
|
1162
|
+
* so the loop runs exactly as many times as there are set bits.
|
|
1163
|
+
*
|
|
1164
|
+
* @returns Number of bits set to 1
|
|
1165
|
+
*/
|
|
1166
|
+
getSetBitCount() {
|
|
1167
|
+
let count = 0;
|
|
1168
|
+
for (let i = 0; i < this.bits.length; i++) {
|
|
1169
|
+
let word = this.bits[i] ?? 0;
|
|
1170
|
+
while (word !== 0) {
|
|
1171
|
+
word &= word - 1;
|
|
1172
|
+
count++;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
return count;
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Compute k bit positions for a given key using Kirsch-Mitzenmacker double hashing.
|
|
1179
|
+
*
|
|
1180
|
+
* Instead of computing k independent hash functions, we compute two base hashes
|
|
1181
|
+
* (h1 and h2) and derive the rest as: h_i = (h1 + i * h2) mod m.
|
|
1182
|
+
* This technique is proven to have the same asymptotic false positive rate as
|
|
1183
|
+
* k independent hash functions.
|
|
1184
|
+
*
|
|
1185
|
+
* We derive h1 and h2 from a single FNV-1a hash by splitting and mixing:
|
|
1186
|
+
* h1 = fnv1a(key), h2 = fnv1a(key + "\0salt") to ensure independence.
|
|
1187
|
+
*/
|
|
1188
|
+
getPositions(key) {
|
|
1189
|
+
const h1 = fnv1a32(key);
|
|
1190
|
+
const h2 = fnv1a32(`${key}\0bloom`);
|
|
1191
|
+
const positions = new Array(this.hashCount);
|
|
1192
|
+
for (let i = 0; i < this.hashCount; i++) {
|
|
1193
|
+
positions[i] = (h1 + Math.imul(i, h2) >>> 0) % this.bitCount;
|
|
1194
|
+
}
|
|
1195
|
+
return positions;
|
|
1196
|
+
}
|
|
1197
|
+
};
|
|
1198
|
+
|
|
1199
|
+
// src/subscription/subscription-manager.ts
|
|
1200
|
+
var nextSubId = 0;
|
|
1201
|
+
var DEFAULT_BLOOM_THRESHOLD = 100;
|
|
1202
|
+
var DEFAULT_BLOOM_EXPECTED_ITEMS = 500;
|
|
1203
|
+
var DEFAULT_BLOOM_FALSE_POSITIVE_RATE = 0.01;
|
|
1204
|
+
var SubscriptionManager = class {
|
|
1205
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
1206
|
+
pendingCollections = /* @__PURE__ */ new Set();
|
|
1207
|
+
flushScheduled = false;
|
|
1208
|
+
onQuerySubscribed;
|
|
1209
|
+
// Bloom filter state
|
|
1210
|
+
bloomFilter = null;
|
|
1211
|
+
bloomDirty = false;
|
|
1212
|
+
bloomThreshold;
|
|
1213
|
+
bloomExpectedItems;
|
|
1214
|
+
bloomFalsePositiveRate;
|
|
1215
|
+
// Performance stats
|
|
1216
|
+
totalChecks = 0;
|
|
1217
|
+
bloomFilterHits = 0;
|
|
1218
|
+
bloomFilterMisses = 0;
|
|
1219
|
+
falsePositives = 0;
|
|
1220
|
+
totalCheckTimeMs = 0;
|
|
1221
|
+
constructor(options) {
|
|
1222
|
+
this.bloomThreshold = options?.bloomThreshold ?? DEFAULT_BLOOM_THRESHOLD;
|
|
1223
|
+
this.bloomExpectedItems = options?.bloomExpectedItems ?? DEFAULT_BLOOM_EXPECTED_ITEMS;
|
|
1224
|
+
this.bloomFalsePositiveRate = options?.bloomFalsePositiveRate ?? DEFAULT_BLOOM_FALSE_POSITIVE_RATE;
|
|
1225
|
+
this.onQuerySubscribed = options?.onQuerySubscribed;
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Register a new subscription.
|
|
1229
|
+
*
|
|
1230
|
+
* @param descriptor - The query descriptor defining what this subscription watches
|
|
1231
|
+
* @param callback - Called with results whenever they change
|
|
1232
|
+
* @param executeFn - Function to re-execute the query and get current results
|
|
1233
|
+
* @returns An unsubscribe function
|
|
1234
|
+
*/
|
|
1235
|
+
register(descriptor, callback, executeFn) {
|
|
1236
|
+
const id = `sub_${++nextSubId}`;
|
|
1237
|
+
const subscription = {
|
|
1238
|
+
id,
|
|
1239
|
+
descriptor,
|
|
1240
|
+
callback,
|
|
1241
|
+
executeFn,
|
|
1242
|
+
lastResults: []
|
|
1243
|
+
};
|
|
1244
|
+
this.subscriptions.set(id, subscription);
|
|
1245
|
+
this.bloomDirty = true;
|
|
1246
|
+
const externalCleanup = this.onQuerySubscribed?.(descriptor);
|
|
1247
|
+
return () => {
|
|
1248
|
+
this.subscriptions.delete(id);
|
|
1249
|
+
this.bloomDirty = true;
|
|
1250
|
+
externalCleanup?.();
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* Register a subscription and immediately execute the query.
|
|
1255
|
+
* The initial results are stored as lastResults so subsequent flushes
|
|
1256
|
+
* correctly diff against the initial state.
|
|
1257
|
+
*
|
|
1258
|
+
* @returns An unsubscribe function
|
|
1259
|
+
*/
|
|
1260
|
+
registerAndFetch(descriptor, callback, executeFn) {
|
|
1261
|
+
const id = `sub_${++nextSubId}`;
|
|
1262
|
+
const subscription = {
|
|
1263
|
+
id,
|
|
1264
|
+
descriptor,
|
|
1265
|
+
callback,
|
|
1266
|
+
executeFn,
|
|
1267
|
+
lastResults: []
|
|
1268
|
+
};
|
|
1269
|
+
this.subscriptions.set(id, subscription);
|
|
1270
|
+
this.bloomDirty = true;
|
|
1271
|
+
const externalCleanup = this.onQuerySubscribed?.(descriptor);
|
|
1272
|
+
executeFn().then((results) => {
|
|
1273
|
+
if (this.subscriptions.has(id)) {
|
|
1274
|
+
subscription.lastResults = results;
|
|
1275
|
+
callback(results);
|
|
1276
|
+
}
|
|
1277
|
+
});
|
|
1278
|
+
return () => {
|
|
1279
|
+
this.subscriptions.delete(id);
|
|
1280
|
+
this.bloomDirty = true;
|
|
1281
|
+
externalCleanup?.();
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
/**
|
|
1285
|
+
* Notify the manager that a mutation occurred on a collection.
|
|
1286
|
+
* Schedules a microtask flush to batch multiple mutations in the same tick.
|
|
1287
|
+
*/
|
|
1288
|
+
notify(collection, _operation) {
|
|
1289
|
+
this.pendingCollections.add(collection);
|
|
1290
|
+
this.scheduleFlush();
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Immediately flush all pending notifications.
|
|
1294
|
+
* Useful for testing. In production, flushing happens via microtask.
|
|
873
1295
|
*/
|
|
874
1296
|
async flush() {
|
|
875
1297
|
if (this.pendingCollections.size === 0) return;
|
|
876
1298
|
const collections = new Set(this.pendingCollections);
|
|
877
1299
|
this.pendingCollections.clear();
|
|
878
1300
|
this.flushScheduled = false;
|
|
1301
|
+
const affected = this.findAffectedSubscriptions(collections);
|
|
1302
|
+
for (const sub of affected) {
|
|
1303
|
+
try {
|
|
1304
|
+
const newResults = await sub.executeFn();
|
|
1305
|
+
if (!this.resultsEqual(sub.lastResults, newResults)) {
|
|
1306
|
+
sub.lastResults = newResults;
|
|
1307
|
+
sub.callback(newResults);
|
|
1308
|
+
}
|
|
1309
|
+
} catch {
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* Remove all subscriptions. Called on store close.
|
|
1315
|
+
*/
|
|
1316
|
+
clear() {
|
|
1317
|
+
this.subscriptions.clear();
|
|
1318
|
+
this.pendingCollections.clear();
|
|
1319
|
+
this.flushScheduled = false;
|
|
1320
|
+
this.bloomFilter = null;
|
|
1321
|
+
this.bloomDirty = false;
|
|
1322
|
+
this.totalChecks = 0;
|
|
1323
|
+
this.bloomFilterHits = 0;
|
|
1324
|
+
this.bloomFilterMisses = 0;
|
|
1325
|
+
this.falsePositives = 0;
|
|
1326
|
+
this.totalCheckTimeMs = 0;
|
|
1327
|
+
}
|
|
1328
|
+
/** Number of active subscriptions (for testing/debugging) */
|
|
1329
|
+
get size() {
|
|
1330
|
+
return this.subscriptions.size;
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Get performance statistics for monitoring subscription checking efficiency.
|
|
1334
|
+
* Useful for DevTools integration and performance tuning.
|
|
1335
|
+
*/
|
|
1336
|
+
getStats() {
|
|
1337
|
+
return {
|
|
1338
|
+
totalChecks: this.totalChecks,
|
|
1339
|
+
bloomFilterHits: this.bloomFilterHits,
|
|
1340
|
+
bloomFilterMisses: this.bloomFilterMisses,
|
|
1341
|
+
falsePositives: this.falsePositives,
|
|
1342
|
+
averageCheckTimeMs: this.totalChecks > 0 ? this.totalCheckTimeMs / this.totalChecks : 0,
|
|
1343
|
+
bloomFilterActive: this.isBloomActive(),
|
|
1344
|
+
subscriptionCount: this.subscriptions.size
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Check if bloom filter is currently active.
|
|
1349
|
+
* Active when subscription count meets or exceeds the threshold.
|
|
1350
|
+
*/
|
|
1351
|
+
isBloomActive() {
|
|
1352
|
+
return this.subscriptions.size >= this.bloomThreshold;
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* Find subscriptions affected by mutations to the given collections.
|
|
1356
|
+
* Uses two-level checking when bloom filter is active:
|
|
1357
|
+
*
|
|
1358
|
+
* Level 1: Bloom filter pre-check -- if no subscription depends on any
|
|
1359
|
+
* of the mutated collections, skip everything (O(k) per collection).
|
|
1360
|
+
*
|
|
1361
|
+
* Level 2: Precise check -- linear scan of subscriptions, matching
|
|
1362
|
+
* against the mutated collections.
|
|
1363
|
+
*/
|
|
1364
|
+
findAffectedSubscriptions(collections) {
|
|
1365
|
+
const startTime = performance.now();
|
|
1366
|
+
this.totalChecks++;
|
|
1367
|
+
const useBloom = this.isBloomActive();
|
|
1368
|
+
if (useBloom) {
|
|
1369
|
+
if (this.bloomDirty || this.bloomFilter === null) {
|
|
1370
|
+
this.rebuildBloomFilter();
|
|
1371
|
+
}
|
|
1372
|
+
const filter = this.bloomFilter;
|
|
1373
|
+
if (filter !== null) {
|
|
1374
|
+
let anyPossibleMatch = false;
|
|
1375
|
+
for (const col of collections) {
|
|
1376
|
+
if (filter.mightContain(col)) {
|
|
1377
|
+
anyPossibleMatch = true;
|
|
1378
|
+
break;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
if (!anyPossibleMatch) {
|
|
1382
|
+
this.bloomFilterMisses++;
|
|
1383
|
+
this.totalCheckTimeMs += performance.now() - startTime;
|
|
1384
|
+
return [];
|
|
1385
|
+
}
|
|
1386
|
+
this.bloomFilterHits++;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
879
1389
|
const affected = [];
|
|
1390
|
+
let anyPreciseMatch = false;
|
|
880
1391
|
for (const sub of this.subscriptions.values()) {
|
|
881
1392
|
if (collections.has(sub.descriptor.collection)) {
|
|
882
1393
|
affected.push(sub);
|
|
1394
|
+
anyPreciseMatch = true;
|
|
883
1395
|
} else if (sub.descriptor.includeCollections) {
|
|
884
1396
|
for (const incCol of sub.descriptor.includeCollections) {
|
|
885
1397
|
if (collections.has(incCol)) {
|
|
886
1398
|
affected.push(sub);
|
|
1399
|
+
anyPreciseMatch = true;
|
|
887
1400
|
break;
|
|
888
1401
|
}
|
|
889
1402
|
}
|
|
890
1403
|
}
|
|
891
1404
|
}
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1405
|
+
if (useBloom && !anyPreciseMatch) {
|
|
1406
|
+
this.falsePositives++;
|
|
1407
|
+
}
|
|
1408
|
+
this.totalCheckTimeMs += performance.now() - startTime;
|
|
1409
|
+
return affected;
|
|
1410
|
+
}
|
|
1411
|
+
/**
|
|
1412
|
+
* Rebuild the bloom filter from all current subscriptions.
|
|
1413
|
+
* Adds collection-level dependencies for every subscription, plus
|
|
1414
|
+
* any included collection dependencies.
|
|
1415
|
+
*/
|
|
1416
|
+
rebuildBloomFilter() {
|
|
1417
|
+
const filter = new SubscriptionBloomFilter(this.bloomExpectedItems, this.bloomFalsePositiveRate);
|
|
1418
|
+
for (const sub of this.subscriptions.values()) {
|
|
1419
|
+
filter.add(sub.descriptor.collection);
|
|
1420
|
+
if (sub.descriptor.includeCollections) {
|
|
1421
|
+
for (const incCol of sub.descriptor.includeCollections) {
|
|
1422
|
+
filter.add(incCol);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
this.bloomFilter = filter;
|
|
1427
|
+
this.bloomDirty = false;
|
|
1428
|
+
}
|
|
1429
|
+
scheduleFlush() {
|
|
1430
|
+
if (this.flushScheduled) return;
|
|
1431
|
+
this.flushScheduled = true;
|
|
1432
|
+
queueMicrotask(() => {
|
|
1433
|
+
this.flush();
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
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.
|
|
1440
|
+
*/
|
|
1441
|
+
resultsEqual(prev, next) {
|
|
1442
|
+
if (prev.length !== next.length) return false;
|
|
1443
|
+
if (prev.length === 0) return true;
|
|
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;
|
|
1458
|
+
}
|
|
1459
|
+
};
|
|
1460
|
+
|
|
1461
|
+
// src/transaction/transaction-context.ts
|
|
1462
|
+
import {
|
|
1463
|
+
createOperation as createOperation2,
|
|
1464
|
+
generateUUIDv7,
|
|
1465
|
+
isAtomicOp,
|
|
1466
|
+
resolveAtomicOp,
|
|
1467
|
+
toAtomicOp,
|
|
1468
|
+
validateRecord
|
|
1469
|
+
} from "@korajs/core";
|
|
1470
|
+
var TransactionContext = class {
|
|
1471
|
+
transactionId;
|
|
1472
|
+
mutationName;
|
|
1473
|
+
buffer = [];
|
|
1474
|
+
committed = false;
|
|
1475
|
+
rolledBack = false;
|
|
1476
|
+
config;
|
|
1477
|
+
constructor(config) {
|
|
1478
|
+
this.config = config;
|
|
1479
|
+
this.transactionId = generateUUIDv7();
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* Set a human-readable mutation name for this transaction.
|
|
1483
|
+
* Propagated to all operations for DevTools display.
|
|
1484
|
+
*/
|
|
1485
|
+
setMutationName(name) {
|
|
1486
|
+
this.mutationName = name;
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* Get the mutation name, if set.
|
|
1490
|
+
*/
|
|
1491
|
+
getMutationName() {
|
|
1492
|
+
return this.mutationName;
|
|
1493
|
+
}
|
|
1494
|
+
/**
|
|
1495
|
+
* Get a collection accessor for buffered operations within this transaction.
|
|
1496
|
+
*/
|
|
1497
|
+
collection(name) {
|
|
1498
|
+
const definition = this.config.schema.collections[name];
|
|
1499
|
+
if (!definition) {
|
|
1500
|
+
throw new Error(
|
|
1501
|
+
`Unknown collection "${name}". Available: ${Object.keys(this.config.schema.collections).join(", ")}`
|
|
1502
|
+
);
|
|
1503
|
+
}
|
|
1504
|
+
return {
|
|
1505
|
+
insert: (data) => this.insert(name, definition, data),
|
|
1506
|
+
update: (id, data) => this.update(name, definition, id, data),
|
|
1507
|
+
delete: (id) => this.deleteRecord(name, definition, id),
|
|
1508
|
+
findById: (id) => this.findById(name, definition, id)
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* Commit all buffered operations atomically.
|
|
1513
|
+
* Returns the list of operations and affected collections for subscription notification.
|
|
1514
|
+
*/
|
|
1515
|
+
async commit() {
|
|
1516
|
+
if (this.committed) {
|
|
1517
|
+
throw new Error("Transaction already committed.");
|
|
1518
|
+
}
|
|
1519
|
+
if (this.rolledBack) {
|
|
1520
|
+
throw new Error("Transaction was rolled back and cannot be committed.");
|
|
1521
|
+
}
|
|
1522
|
+
this.committed = true;
|
|
1523
|
+
if (this.buffer.length === 0) {
|
|
1524
|
+
return { operations: [], affectedCollections: /* @__PURE__ */ new Set() };
|
|
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
|
+
}
|
|
1538
|
+
const operations = [];
|
|
1539
|
+
const affectedCollections = /* @__PURE__ */ new Set();
|
|
1540
|
+
await this.config.adapter.transaction(async (tx) => {
|
|
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
|
+
}
|
|
1555
|
+
for (const cmd of entry.commands) {
|
|
1556
|
+
await tx.execute(cmd.sql, cmd.params);
|
|
1557
|
+
}
|
|
1558
|
+
operations.push(entry.operation);
|
|
1559
|
+
affectedCollections.add(entry.collection);
|
|
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
|
+
}
|
|
1568
|
+
});
|
|
1569
|
+
return { operations, affectedCollections };
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* Mark the transaction as rolled back. No operations will be committed.
|
|
1573
|
+
*/
|
|
1574
|
+
rollback() {
|
|
1575
|
+
this.rolledBack = true;
|
|
1576
|
+
this.buffer.length = 0;
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* Get the transaction ID shared by all operations in this transaction.
|
|
1580
|
+
*/
|
|
1581
|
+
getTransactionId() {
|
|
1582
|
+
return this.transactionId;
|
|
1583
|
+
}
|
|
1584
|
+
ensureActive() {
|
|
1585
|
+
if (this.committed) {
|
|
1586
|
+
throw new Error("Cannot perform operations on a committed transaction.");
|
|
1587
|
+
}
|
|
1588
|
+
if (this.rolledBack) {
|
|
1589
|
+
throw new Error("Cannot perform operations on a rolled-back transaction.");
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
async insert(collectionName, definition, data) {
|
|
1593
|
+
this.ensureActive();
|
|
1594
|
+
const validated = validateRecord(collectionName, definition, data, "insert");
|
|
1595
|
+
const recordId = generateUUIDv7();
|
|
1596
|
+
const now = Date.now();
|
|
1597
|
+
for (const [fieldName, descriptor] of Object.entries(definition.fields)) {
|
|
1598
|
+
if (descriptor.auto && descriptor.kind === "timestamp") {
|
|
1599
|
+
validated[fieldName] = now;
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
const sequenceNumber = await this.config.sequenceAllocator.allocate();
|
|
1603
|
+
const causalDeps = this.config.causalTracker?.nextCausalDeps(collectionName, true) ?? [];
|
|
1604
|
+
const operation = await createOperation2(
|
|
1605
|
+
{
|
|
1606
|
+
nodeId: this.config.nodeId,
|
|
1607
|
+
type: "insert",
|
|
1608
|
+
collection: collectionName,
|
|
1609
|
+
recordId,
|
|
1610
|
+
data: { ...validated },
|
|
1611
|
+
previousData: null,
|
|
1612
|
+
sequenceNumber,
|
|
1613
|
+
causalDeps,
|
|
1614
|
+
schemaVersion: this.config.schema.version,
|
|
1615
|
+
transactionId: this.transactionId,
|
|
1616
|
+
...this.mutationName !== void 0 ? { mutationName: this.mutationName } : {}
|
|
1617
|
+
},
|
|
1618
|
+
this.config.clock
|
|
1619
|
+
);
|
|
1620
|
+
this.config.causalTracker?.afterOperation(collectionName, operation.id, true);
|
|
1621
|
+
const serializedData = serializeRecord(validated, definition.fields);
|
|
1622
|
+
const version = serializeRowVersion(operation.timestamp);
|
|
1623
|
+
const record = {
|
|
1624
|
+
id: recordId,
|
|
1625
|
+
...serializedData,
|
|
1626
|
+
_created_at: operation.timestamp.wallTime,
|
|
1627
|
+
_updated_at: operation.timestamp.wallTime,
|
|
1628
|
+
_version: version
|
|
1629
|
+
};
|
|
1630
|
+
const insertQuery = buildInsertQuery(collectionName, record);
|
|
1631
|
+
const opRow = serializeOperation(operation);
|
|
1632
|
+
const opInsert = buildInsertQuery(
|
|
1633
|
+
`_kora_ops_${collectionName}`,
|
|
1634
|
+
opRow
|
|
1635
|
+
);
|
|
1636
|
+
this.buffer.push({
|
|
1637
|
+
operation,
|
|
1638
|
+
collection: collectionName,
|
|
1639
|
+
commands: [
|
|
1640
|
+
{ sql: insertQuery.sql, params: insertQuery.params },
|
|
1641
|
+
{ sql: opInsert.sql, params: opInsert.params }
|
|
1642
|
+
]
|
|
1643
|
+
});
|
|
1644
|
+
return {
|
|
1645
|
+
id: recordId,
|
|
1646
|
+
...validated,
|
|
1647
|
+
createdAt: now,
|
|
1648
|
+
updatedAt: now
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
async update(collectionName, definition, id, data) {
|
|
1652
|
+
this.ensureActive();
|
|
1653
|
+
const currentRecord = await this.getEffectiveRecord(collectionName, definition, id);
|
|
1654
|
+
if (!currentRecord) {
|
|
1655
|
+
throw new RecordNotFoundError(collectionName, id);
|
|
1656
|
+
}
|
|
1657
|
+
const validated = validateRecord(collectionName, definition, data, "update");
|
|
1658
|
+
const now = Date.now();
|
|
1659
|
+
const previousData = {};
|
|
1660
|
+
const resolvedData = {};
|
|
1661
|
+
const atomicOps = {};
|
|
1662
|
+
for (const key of Object.keys(validated)) {
|
|
1663
|
+
const value = validated[key];
|
|
1664
|
+
previousData[key] = currentRecord[key];
|
|
1665
|
+
if (isAtomicOp(value)) {
|
|
1666
|
+
resolvedData[key] = resolveAtomicOp(currentRecord[key], value);
|
|
1667
|
+
atomicOps[key] = toAtomicOp(value);
|
|
1668
|
+
} else {
|
|
1669
|
+
resolvedData[key] = value;
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
const hasAtomicOps = Object.keys(atomicOps).length > 0;
|
|
1673
|
+
const sequenceNumber = await this.config.sequenceAllocator.allocate();
|
|
1674
|
+
const causalDeps = this.config.causalTracker?.nextCausalDeps(collectionName, true) ?? [];
|
|
1675
|
+
const operation = await createOperation2(
|
|
1676
|
+
{
|
|
1677
|
+
nodeId: this.config.nodeId,
|
|
1678
|
+
type: "update",
|
|
1679
|
+
collection: collectionName,
|
|
1680
|
+
recordId: id,
|
|
1681
|
+
data: { ...resolvedData },
|
|
1682
|
+
previousData,
|
|
1683
|
+
sequenceNumber,
|
|
1684
|
+
causalDeps,
|
|
1685
|
+
schemaVersion: this.config.schema.version,
|
|
1686
|
+
transactionId: this.transactionId,
|
|
1687
|
+
...this.mutationName !== void 0 ? { mutationName: this.mutationName } : {},
|
|
1688
|
+
...hasAtomicOps ? { atomicOps } : {}
|
|
1689
|
+
},
|
|
1690
|
+
this.config.clock
|
|
1691
|
+
);
|
|
1692
|
+
this.config.causalTracker?.afterOperation(collectionName, operation.id, true);
|
|
1693
|
+
const serializedChanges = serializeRecord(resolvedData, definition.fields);
|
|
1694
|
+
const version = serializeRowVersion(operation.timestamp);
|
|
1695
|
+
const updateQuery = buildUpdateQuery(collectionName, id, {
|
|
1696
|
+
...serializedChanges,
|
|
1697
|
+
_updated_at: operation.timestamp.wallTime,
|
|
1698
|
+
_version: version
|
|
1699
|
+
});
|
|
1700
|
+
const opRow = serializeOperation(operation);
|
|
1701
|
+
const opInsert = buildInsertQuery(
|
|
1702
|
+
`_kora_ops_${collectionName}`,
|
|
1703
|
+
opRow
|
|
1704
|
+
);
|
|
1705
|
+
this.buffer.push({
|
|
1706
|
+
operation,
|
|
1707
|
+
collection: collectionName,
|
|
1708
|
+
commands: [
|
|
1709
|
+
{ sql: updateQuery.sql, params: updateQuery.params },
|
|
1710
|
+
{ sql: opInsert.sql, params: opInsert.params }
|
|
1711
|
+
]
|
|
1712
|
+
});
|
|
1713
|
+
return {
|
|
1714
|
+
...currentRecord,
|
|
1715
|
+
...resolvedData,
|
|
1716
|
+
updatedAt: now
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
async deleteRecord(collectionName, definition, id) {
|
|
1720
|
+
this.ensureActive();
|
|
1721
|
+
const currentRecord = await this.getEffectiveRecord(collectionName, definition, id);
|
|
1722
|
+
if (!currentRecord) {
|
|
1723
|
+
throw new RecordNotFoundError(collectionName, id);
|
|
1724
|
+
}
|
|
1725
|
+
const now = Date.now();
|
|
1726
|
+
const sequenceNumber = await this.config.sequenceAllocator.allocate();
|
|
1727
|
+
const causalDeps = this.config.causalTracker?.nextCausalDeps(collectionName, true) ?? [];
|
|
1728
|
+
const operation = await createOperation2(
|
|
1729
|
+
{
|
|
1730
|
+
nodeId: this.config.nodeId,
|
|
1731
|
+
type: "delete",
|
|
1732
|
+
collection: collectionName,
|
|
1733
|
+
recordId: id,
|
|
1734
|
+
data: null,
|
|
1735
|
+
previousData: null,
|
|
1736
|
+
sequenceNumber,
|
|
1737
|
+
causalDeps,
|
|
1738
|
+
schemaVersion: this.config.schema.version,
|
|
1739
|
+
transactionId: this.transactionId,
|
|
1740
|
+
...this.mutationName !== void 0 ? { mutationName: this.mutationName } : {}
|
|
1741
|
+
},
|
|
1742
|
+
this.config.clock
|
|
1743
|
+
);
|
|
1744
|
+
this.config.causalTracker?.afterOperation(collectionName, operation.id, true);
|
|
1745
|
+
const deleteQuery = buildSoftDeleteQuery(collectionName, id, now);
|
|
1746
|
+
const opRow = serializeOperation(operation);
|
|
1747
|
+
const opInsert = buildInsertQuery(
|
|
1748
|
+
`_kora_ops_${collectionName}`,
|
|
1749
|
+
opRow
|
|
1750
|
+
);
|
|
1751
|
+
this.buffer.push({
|
|
1752
|
+
operation,
|
|
1753
|
+
collection: collectionName,
|
|
1754
|
+
commands: [
|
|
1755
|
+
{ sql: deleteQuery.sql, params: deleteQuery.params },
|
|
1756
|
+
{ sql: opInsert.sql, params: opInsert.params }
|
|
1757
|
+
]
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1760
|
+
async findById(collectionName, definition, id) {
|
|
1761
|
+
return this.getEffectiveRecord(collectionName, definition, id);
|
|
1762
|
+
}
|
|
1763
|
+
/**
|
|
1764
|
+
* Get the effective state of a record, considering both the database and buffered operations.
|
|
1765
|
+
* Buffered inserts/updates take precedence over the database state.
|
|
1766
|
+
*/
|
|
1767
|
+
async getEffectiveRecord(collectionName, definition, id) {
|
|
1768
|
+
for (let i = this.buffer.length - 1; i >= 0; i--) {
|
|
1769
|
+
const entry = this.buffer[i];
|
|
1770
|
+
if (entry.collection === collectionName && entry.operation.recordId === id) {
|
|
1771
|
+
if (entry.operation.type === "delete") {
|
|
1772
|
+
return null;
|
|
1773
|
+
}
|
|
1774
|
+
break;
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
const rows = await this.config.adapter.query(
|
|
1778
|
+
`SELECT * FROM ${collectionName} WHERE id = ? AND _deleted = 0`,
|
|
1779
|
+
[id]
|
|
1780
|
+
);
|
|
1781
|
+
let record = rows[0] ? deserializeRecord(rows[0], definition.fields) : null;
|
|
1782
|
+
for (const entry of this.buffer) {
|
|
1783
|
+
if (entry.collection !== collectionName || entry.operation.recordId !== id) {
|
|
1784
|
+
continue;
|
|
1785
|
+
}
|
|
1786
|
+
if (entry.operation.type === "insert" && entry.operation.data) {
|
|
1787
|
+
record = {
|
|
1788
|
+
id,
|
|
1789
|
+
...entry.operation.data,
|
|
1790
|
+
createdAt: entry.operation.timestamp.wallTime,
|
|
1791
|
+
updatedAt: entry.operation.timestamp.wallTime
|
|
1792
|
+
};
|
|
1793
|
+
} else if (entry.operation.type === "update" && entry.operation.data && record) {
|
|
1794
|
+
record = {
|
|
1795
|
+
...record,
|
|
1796
|
+
...entry.operation.data,
|
|
1797
|
+
updatedAt: entry.operation.timestamp.wallTime
|
|
1798
|
+
};
|
|
1799
|
+
} else if (entry.operation.type === "delete") {
|
|
1800
|
+
record = null;
|
|
900
1801
|
}
|
|
901
1802
|
}
|
|
1803
|
+
return record;
|
|
902
1804
|
}
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
this.
|
|
909
|
-
this.
|
|
910
|
-
}
|
|
911
|
-
/** Number of active subscriptions (for testing/debugging) */
|
|
912
|
-
get size() {
|
|
913
|
-
return this.subscriptions.size;
|
|
1805
|
+
};
|
|
1806
|
+
|
|
1807
|
+
// src/transaction/transaction-sequence.ts
|
|
1808
|
+
var TransactionSequenceAllocator = class {
|
|
1809
|
+
constructor(adapter, nodeId) {
|
|
1810
|
+
this.adapter = adapter;
|
|
1811
|
+
this.nodeId = nodeId;
|
|
914
1812
|
}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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;
|
|
921
1824
|
}
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
* Sufficient for typical query results. Can be optimized to id-based diffing if profiling shows need.
|
|
925
|
-
*/
|
|
926
|
-
resultsEqual(prev, next) {
|
|
927
|
-
if (prev.length !== next.length) return false;
|
|
928
|
-
if (prev.length === 0) return true;
|
|
929
|
-
return JSON.stringify(prev) === JSON.stringify(next);
|
|
1825
|
+
getHighWaterMark() {
|
|
1826
|
+
return this.watermark;
|
|
930
1827
|
}
|
|
931
1828
|
};
|
|
932
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
|
+
|
|
933
1857
|
// src/store/store.ts
|
|
934
1858
|
var Store = class {
|
|
935
1859
|
opened = false;
|
|
936
1860
|
nodeId = "";
|
|
937
1861
|
sequenceNumber = 0;
|
|
938
|
-
versionVector =
|
|
1862
|
+
versionVector = createVersionVector3();
|
|
939
1863
|
clock = null;
|
|
940
1864
|
collections = /* @__PURE__ */ new Map();
|
|
941
|
-
subscriptionManager
|
|
1865
|
+
subscriptionManager;
|
|
1866
|
+
sequenceManager = null;
|
|
942
1867
|
schema;
|
|
943
1868
|
adapter;
|
|
944
1869
|
configNodeId;
|
|
1870
|
+
dbName;
|
|
1871
|
+
isolation;
|
|
945
1872
|
emitter;
|
|
1873
|
+
localMutationHandler;
|
|
1874
|
+
relationEnforcer = null;
|
|
1875
|
+
causalTracker = null;
|
|
946
1876
|
constructor(config) {
|
|
947
1877
|
this.schema = config.schema;
|
|
948
1878
|
this.adapter = config.adapter;
|
|
949
1879
|
this.configNodeId = config.nodeId;
|
|
1880
|
+
this.dbName = config.dbName ?? "kora-db";
|
|
1881
|
+
this.isolation = config.isolation ?? "shared";
|
|
950
1882
|
this.emitter = config.emitter ?? null;
|
|
1883
|
+
this.localMutationHandler = config.localMutationHandler ?? null;
|
|
1884
|
+
this.subscriptionManager = new SubscriptionManager({
|
|
1885
|
+
onQuerySubscribed: config.onQuerySubscribed
|
|
1886
|
+
});
|
|
951
1887
|
}
|
|
952
1888
|
/**
|
|
953
1889
|
* Open the store: initialize the database, load or generate a node ID,
|
|
@@ -955,10 +1891,20 @@ var Store = class {
|
|
|
955
1891
|
*/
|
|
956
1892
|
async open() {
|
|
957
1893
|
await this.adapter.open(this.schema);
|
|
1894
|
+
await this.runMigrationsIfNeeded();
|
|
958
1895
|
this.nodeId = await this.loadOrGenerateNodeId();
|
|
959
1896
|
this.clock = new HybridLogicalClock2(this.nodeId);
|
|
1897
|
+
this.causalTracker = new CausalTracker();
|
|
1898
|
+
this.sequenceManager = new SequenceManager(this.adapter, this.nodeId);
|
|
960
1899
|
this.sequenceNumber = await this.loadSequenceNumber();
|
|
961
1900
|
this.versionVector = await this.loadVersionVector();
|
|
1901
|
+
const hasRelations = Object.keys(this.schema.relations).length > 0;
|
|
1902
|
+
this.relationEnforcer = hasRelations ? new RelationEnforcer({
|
|
1903
|
+
schema: this.schema,
|
|
1904
|
+
adapter: this.adapter,
|
|
1905
|
+
clock: this.clock,
|
|
1906
|
+
nodeId: this.nodeId
|
|
1907
|
+
}) : null;
|
|
962
1908
|
for (const [name, definition] of Object.entries(this.schema.collections)) {
|
|
963
1909
|
const col = new Collection(
|
|
964
1910
|
name,
|
|
@@ -967,13 +1913,17 @@ var Store = class {
|
|
|
967
1913
|
this.adapter,
|
|
968
1914
|
this.clock,
|
|
969
1915
|
this.nodeId,
|
|
970
|
-
() => this.
|
|
1916
|
+
() => this.allocateSequenceNumber(),
|
|
971
1917
|
(collectionName, operation) => {
|
|
1918
|
+
this.recordOperationSequence(operation);
|
|
972
1919
|
this.subscriptionManager.notify(collectionName, operation);
|
|
973
1920
|
if (this.emitter) {
|
|
974
1921
|
this.emitter.emit({ type: "operation:created", operation });
|
|
975
1922
|
}
|
|
976
|
-
}
|
|
1923
|
+
},
|
|
1924
|
+
this.relationEnforcer,
|
|
1925
|
+
this.localMutationHandler,
|
|
1926
|
+
this.causalTracker
|
|
977
1927
|
);
|
|
978
1928
|
this.collections.set(name, col);
|
|
979
1929
|
}
|
|
@@ -1010,7 +1960,14 @@ var Store = class {
|
|
|
1010
1960
|
findById: (id) => col.findById(id),
|
|
1011
1961
|
update: (id, data) => col.update(id, data),
|
|
1012
1962
|
delete: (id) => col.delete(id),
|
|
1013
|
-
where: (conditions) => new QueryBuilder(
|
|
1963
|
+
where: (conditions) => new QueryBuilder(
|
|
1964
|
+
name,
|
|
1965
|
+
definition,
|
|
1966
|
+
this.adapter,
|
|
1967
|
+
this.subscriptionManager,
|
|
1968
|
+
conditions,
|
|
1969
|
+
this.schema
|
|
1970
|
+
)
|
|
1014
1971
|
};
|
|
1015
1972
|
}
|
|
1016
1973
|
/**
|
|
@@ -1032,7 +1989,7 @@ var Store = class {
|
|
|
1032
1989
|
* Checks for duplicates, applies to the data table, persists the operation,
|
|
1033
1990
|
* and updates the version vector.
|
|
1034
1991
|
*/
|
|
1035
|
-
async applyRemoteOperation(op) {
|
|
1992
|
+
async applyRemoteOperation(op, options) {
|
|
1036
1993
|
this.ensureOpen();
|
|
1037
1994
|
const collection = op.collection;
|
|
1038
1995
|
const definition = this.schema.collections[collection];
|
|
@@ -1049,27 +2006,52 @@ var Store = class {
|
|
|
1049
2006
|
if (this.clock) {
|
|
1050
2007
|
this.clock.receive(op.timestamp);
|
|
1051
2008
|
}
|
|
2009
|
+
const remoteVersion = serializeRowVersion(op.timestamp);
|
|
2010
|
+
const wallTime = op.timestamp.wallTime;
|
|
1052
2011
|
await this.adapter.transaction(async (tx) => {
|
|
1053
2012
|
if (op.type === "insert" && op.data) {
|
|
1054
|
-
const
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
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
|
+
}
|
|
1064
2031
|
} else if (op.type === "update" && op.data) {
|
|
1065
2032
|
const serializedChanges = serializeRecord(op.data, definition.fields);
|
|
1066
|
-
const
|
|
2033
|
+
const updatePayload = {
|
|
1067
2034
|
...serializedChanges,
|
|
1068
|
-
_updated_at:
|
|
1069
|
-
|
|
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
|
+
);
|
|
1070
2047
|
await tx.execute(updateQuery.sql, updateQuery.params);
|
|
1071
2048
|
} else if (op.type === "delete") {
|
|
1072
|
-
const deleteQuery =
|
|
2049
|
+
const deleteQuery = buildLwwSoftDeleteQuery(
|
|
2050
|
+
collection,
|
|
2051
|
+
op.recordId,
|
|
2052
|
+
wallTime,
|
|
2053
|
+
remoteVersion
|
|
2054
|
+
);
|
|
1073
2055
|
await tx.execute(deleteQuery.sql, deleteQuery.params);
|
|
1074
2056
|
}
|
|
1075
2057
|
const opRow = serializeOperation(op);
|
|
@@ -1094,11 +2076,11 @@ var Store = class {
|
|
|
1094
2076
|
* Get operations from a node within a sequence number range.
|
|
1095
2077
|
* Implements the OperationLog interface for computeDelta.
|
|
1096
2078
|
*/
|
|
1097
|
-
getRange(nodeId, fromSeq, toSeq) {
|
|
1098
|
-
return
|
|
2079
|
+
async getRange(nodeId, fromSeq, toSeq) {
|
|
2080
|
+
return this.getOperationRange(nodeId, fromSeq, toSeq);
|
|
1099
2081
|
}
|
|
1100
2082
|
/**
|
|
1101
|
-
*
|
|
2083
|
+
* Get operations from a node within a sequence number range.
|
|
1102
2084
|
*/
|
|
1103
2085
|
async getOperationRange(nodeId, fromSeq, toSeq) {
|
|
1104
2086
|
this.ensureOpen();
|
|
@@ -1115,36 +2097,449 @@ var Store = class {
|
|
|
1115
2097
|
allOps.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
|
1116
2098
|
return allOps;
|
|
1117
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
|
+
}
|
|
1118
2163
|
/**
|
|
1119
2164
|
* Get the schema definition.
|
|
1120
2165
|
*/
|
|
1121
2166
|
getSchema() {
|
|
1122
2167
|
return this.schema;
|
|
1123
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
|
+
}
|
|
1124
2327
|
/** Expose the subscription manager for direct access (e.g., by QueryBuilder) */
|
|
1125
2328
|
getSubscriptionManager() {
|
|
1126
2329
|
return this.subscriptionManager;
|
|
1127
2330
|
}
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
2331
|
+
/**
|
|
2332
|
+
* Get the sequence manager for offline-safe sequence generation.
|
|
2333
|
+
* @throws {StoreNotOpenError} If the store is not open
|
|
2334
|
+
*/
|
|
2335
|
+
getSequenceManager() {
|
|
2336
|
+
this.ensureOpen();
|
|
2337
|
+
if (!this.sequenceManager) {
|
|
2338
|
+
throw new StoreNotOpenError();
|
|
2339
|
+
}
|
|
2340
|
+
return this.sequenceManager;
|
|
2341
|
+
}
|
|
2342
|
+
/**
|
|
2343
|
+
* Create a TransactionContext for atomic multi-collection operations.
|
|
2344
|
+
* The returned context buffers all mutations and commits them atomically.
|
|
2345
|
+
*
|
|
2346
|
+
* After commit, the caller is responsible for notifying subscriptions
|
|
2347
|
+
* and emitting events for each operation.
|
|
2348
|
+
*/
|
|
2349
|
+
createTransaction() {
|
|
2350
|
+
this.ensureOpen();
|
|
2351
|
+
if (!this.clock) {
|
|
2352
|
+
throw new StoreNotOpenError();
|
|
2353
|
+
}
|
|
2354
|
+
return new TransactionContext({
|
|
2355
|
+
schema: this.schema,
|
|
2356
|
+
adapter: this.adapter,
|
|
2357
|
+
clock: this.clock,
|
|
2358
|
+
nodeId: this.nodeId,
|
|
2359
|
+
sequenceAllocator: new TransactionSequenceAllocator(this.adapter, this.nodeId),
|
|
2360
|
+
relationEnforcer: this.relationEnforcer,
|
|
2361
|
+
causalTracker: this.causalTracker,
|
|
2362
|
+
localMutationHandler: this.localMutationHandler
|
|
2363
|
+
});
|
|
2364
|
+
}
|
|
2365
|
+
/**
|
|
2366
|
+
* Execute a function within a transaction. All mutations performed on the
|
|
2367
|
+
* TransactionContext are committed atomically. Subscription notifications
|
|
2368
|
+
* are batched and fired after the commit.
|
|
2369
|
+
*
|
|
2370
|
+
* If the function throws, the transaction is rolled back and the error is re-thrown.
|
|
2371
|
+
*
|
|
2372
|
+
* @param fn - Function receiving a TransactionContext for buffered operations
|
|
2373
|
+
* @returns The operations that were committed
|
|
2374
|
+
*/
|
|
2375
|
+
async transaction(fn) {
|
|
2376
|
+
const tx = this.createTransaction();
|
|
2377
|
+
this.causalTracker?.beginTransaction();
|
|
2378
|
+
try {
|
|
2379
|
+
await fn(tx);
|
|
2380
|
+
const { operations } = await tx.commit();
|
|
2381
|
+
this.causalTracker?.clearTransaction();
|
|
2382
|
+
for (const op of operations) {
|
|
2383
|
+
this.recordOperationSequence(op);
|
|
2384
|
+
this.subscriptionManager.notify(op.collection, op);
|
|
2385
|
+
if (this.emitter) {
|
|
2386
|
+
this.emitter.emit({ type: "operation:created", operation: op });
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
return operations;
|
|
2390
|
+
} catch (error) {
|
|
2391
|
+
tx.rollback();
|
|
2392
|
+
this.causalTracker?.clearTransaction();
|
|
2393
|
+
throw error;
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
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;
|
|
2443
|
+
}
|
|
2444
|
+
/**
|
|
2445
|
+
* Check the stored schema version and run any pending migrations.
|
|
2446
|
+
* Migrations are applied in version order within a transaction.
|
|
2447
|
+
*/
|
|
2448
|
+
async runMigrationsIfNeeded() {
|
|
2449
|
+
const storedVersion = await this.getStoredSchemaVersion();
|
|
2450
|
+
const targetVersion = this.schema.version;
|
|
2451
|
+
if (storedVersion >= targetVersion) {
|
|
2452
|
+
if (storedVersion === 0) {
|
|
2453
|
+
await this.adapter.execute(
|
|
2454
|
+
"INSERT OR REPLACE INTO _kora_meta (key, value) VALUES ('schema_version', ?)",
|
|
2455
|
+
[String(targetVersion)]
|
|
2456
|
+
);
|
|
2457
|
+
}
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
const migrations = this.schema.migrations ?? {};
|
|
2461
|
+
for (let v = storedVersion + 1; v <= targetVersion; v++) {
|
|
2462
|
+
const migration = migrations[v];
|
|
2463
|
+
if (!migration) continue;
|
|
2464
|
+
const sqlStatements = migrationStepsToSQL(migration.steps);
|
|
2465
|
+
for (const sql of sqlStatements) {
|
|
2466
|
+
try {
|
|
2467
|
+
await this.adapter.execute(sql);
|
|
2468
|
+
} catch (e) {
|
|
2469
|
+
const msg = e.message || "";
|
|
2470
|
+
if (!msg.includes("duplicate column name")) {
|
|
2471
|
+
throw e;
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
const backfillSteps = migration.steps.filter(
|
|
2476
|
+
(s) => s.type === "backfill"
|
|
2477
|
+
);
|
|
2478
|
+
for (const step of backfillSteps) {
|
|
2479
|
+
await this.runBackfill(step.collection, step.transform);
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
await this.adapter.execute(
|
|
2483
|
+
"INSERT OR REPLACE INTO _kora_meta (key, value) VALUES ('schema_version', ?)",
|
|
2484
|
+
[String(targetVersion)]
|
|
2485
|
+
);
|
|
2486
|
+
}
|
|
2487
|
+
/**
|
|
2488
|
+
* Get the stored schema version from _kora_meta. Returns 0 if not set.
|
|
2489
|
+
*/
|
|
2490
|
+
async getStoredSchemaVersion() {
|
|
2491
|
+
const rows = await this.adapter.query(
|
|
2492
|
+
"SELECT value FROM _kora_meta WHERE key = 'schema_version'"
|
|
2493
|
+
);
|
|
2494
|
+
return rows[0] ? Number(rows[0].value) : 0;
|
|
2495
|
+
}
|
|
2496
|
+
/**
|
|
2497
|
+
* Run a backfill transform on all records in a collection.
|
|
2498
|
+
* Reads all rows, applies the transform, and updates changed fields.
|
|
2499
|
+
*/
|
|
2500
|
+
async runBackfill(collection, transform) {
|
|
2501
|
+
const rows = await this.adapter.query(
|
|
2502
|
+
`SELECT * FROM ${collection} WHERE _deleted = 0`
|
|
2503
|
+
);
|
|
2504
|
+
await this.adapter.transaction(async (tx) => {
|
|
2505
|
+
for (const row of rows) {
|
|
2506
|
+
const updates = transform(row);
|
|
2507
|
+
const fields = Object.keys(updates);
|
|
2508
|
+
if (fields.length === 0) continue;
|
|
2509
|
+
const setClauses = fields.map((f) => `${f} = ?`).join(", ");
|
|
2510
|
+
const values = fields.map((f) => {
|
|
2511
|
+
const val = updates[f];
|
|
2512
|
+
if (typeof val === "boolean") return val ? 1 : 0;
|
|
2513
|
+
if (Array.isArray(val) || typeof val === "object" && val !== null) {
|
|
2514
|
+
return JSON.stringify(val);
|
|
2515
|
+
}
|
|
2516
|
+
return val;
|
|
2517
|
+
});
|
|
2518
|
+
values.push(row.id);
|
|
2519
|
+
await tx.execute(`UPDATE ${collection} SET ${setClauses} WHERE id = ?`, values);
|
|
2520
|
+
}
|
|
2521
|
+
});
|
|
1132
2522
|
}
|
|
1133
2523
|
async loadOrGenerateNodeId() {
|
|
1134
2524
|
if (this.configNodeId) {
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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
|
+
}
|
|
1139
2531
|
return this.configNodeId;
|
|
1140
2532
|
}
|
|
2533
|
+
if (this.isolation === "per-tab") {
|
|
2534
|
+
return resolvePerTabNodeId(this.dbName);
|
|
2535
|
+
}
|
|
1141
2536
|
const rows = await this.adapter.query(
|
|
1142
2537
|
"SELECT value FROM _kora_meta WHERE key = 'node_id'"
|
|
1143
2538
|
);
|
|
1144
2539
|
if (rows[0]) {
|
|
1145
2540
|
return rows[0].value;
|
|
1146
2541
|
}
|
|
1147
|
-
const newNodeId =
|
|
2542
|
+
const newNodeId = generateUUIDv73();
|
|
1148
2543
|
await this.adapter.execute("INSERT INTO _kora_meta (key, value) VALUES ('node_id', ?)", [
|
|
1149
2544
|
newNodeId
|
|
1150
2545
|
]);
|
|
@@ -1161,7 +2556,7 @@ var Store = class {
|
|
|
1161
2556
|
const rows = await this.adapter.query(
|
|
1162
2557
|
"SELECT node_id, sequence_number FROM _kora_version_vector"
|
|
1163
2558
|
);
|
|
1164
|
-
const vector =
|
|
2559
|
+
const vector = createVersionVector3();
|
|
1165
2560
|
for (const row of rows) {
|
|
1166
2561
|
vector.set(row.node_id, row.sequence_number);
|
|
1167
2562
|
}
|
|
@@ -1175,20 +2570,43 @@ var Store = class {
|
|
|
1175
2570
|
};
|
|
1176
2571
|
export {
|
|
1177
2572
|
AdapterError,
|
|
2573
|
+
COMPACTION_BASELINE_META_KEY,
|
|
1178
2574
|
Collection,
|
|
2575
|
+
InvalidStateTransitionError,
|
|
2576
|
+
LAST_ACKED_SERVER_VECTOR_META_KEY,
|
|
1179
2577
|
PersistenceError,
|
|
1180
2578
|
QueryBuilder,
|
|
1181
2579
|
QueryError,
|
|
1182
2580
|
RecordNotFoundError,
|
|
2581
|
+
SequenceManager,
|
|
1183
2582
|
Store,
|
|
1184
2583
|
StoreNotOpenError,
|
|
2584
|
+
SubscriptionBloomFilter,
|
|
1185
2585
|
SubscriptionManager,
|
|
2586
|
+
TransactionContext,
|
|
1186
2587
|
WorkerInitError,
|
|
1187
2588
|
WorkerTimeoutError,
|
|
2589
|
+
collectOperationsAheadOfServer,
|
|
2590
|
+
compactOperationLog,
|
|
2591
|
+
computeAckCompactionWatermark,
|
|
2592
|
+
decodeAuditExport,
|
|
1188
2593
|
decodeRichtext,
|
|
2594
|
+
deserializeVersionVectorFromMeta,
|
|
1189
2595
|
encodeRichtext,
|
|
2596
|
+
exportBackup,
|
|
2597
|
+
mergeVersionVectors,
|
|
2598
|
+
persistedAuditTraceFromEvent,
|
|
1190
2599
|
pluralize,
|
|
2600
|
+
readAuditExportManifest,
|
|
2601
|
+
readBackupManifest,
|
|
2602
|
+
restoreBackup,
|
|
2603
|
+
richtextStatesEqual,
|
|
1191
2604
|
richtextToPlainText,
|
|
1192
|
-
|
|
2605
|
+
serializeVersionVectorToMeta,
|
|
2606
|
+
singularize,
|
|
2607
|
+
validateStateTransition,
|
|
2608
|
+
validateUpdateStateMachine,
|
|
2609
|
+
verifyAuditExportChecksum,
|
|
2610
|
+
verifyBackupChecksum
|
|
1193
2611
|
};
|
|
1194
2612
|
//# sourceMappingURL=index.js.map
|