@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.
Files changed (73) hide show
  1. package/dist/adapters/better-sqlite3.cjs +1 -1
  2. package/dist/adapters/better-sqlite3.cjs.map +1 -1
  3. package/dist/adapters/better-sqlite3.d.cts +1 -1
  4. package/dist/adapters/better-sqlite3.d.ts +1 -1
  5. package/dist/adapters/better-sqlite3.js +1 -1
  6. package/dist/adapters/better-sqlite3.js.map +1 -1
  7. package/dist/adapters/indexeddb.cjs +413 -18
  8. package/dist/adapters/indexeddb.cjs.map +1 -1
  9. package/dist/adapters/indexeddb.d.cts +23 -5
  10. package/dist/adapters/indexeddb.d.ts +23 -5
  11. package/dist/adapters/indexeddb.js +166 -19
  12. package/dist/adapters/indexeddb.js.map +1 -1
  13. package/dist/adapters/sqlite-wasm-shared-host.cjs +68 -0
  14. package/dist/adapters/sqlite-wasm-shared-host.cjs.map +1 -0
  15. package/dist/adapters/sqlite-wasm-shared-host.d.cts +2 -0
  16. package/dist/adapters/sqlite-wasm-shared-host.d.ts +2 -0
  17. package/dist/adapters/sqlite-wasm-shared-host.js +66 -0
  18. package/dist/adapters/sqlite-wasm-shared-host.js.map +1 -0
  19. package/dist/adapters/sqlite-wasm-worker.cjs +34 -5
  20. package/dist/adapters/sqlite-wasm-worker.cjs.map +1 -1
  21. package/dist/adapters/sqlite-wasm-worker.js +34 -5
  22. package/dist/adapters/sqlite-wasm-worker.js.map +1 -1
  23. package/dist/adapters/sqlite-wasm.cjs +249 -2
  24. package/dist/adapters/sqlite-wasm.cjs.map +1 -1
  25. package/dist/adapters/sqlite-wasm.d.cts +15 -3
  26. package/dist/adapters/sqlite-wasm.d.ts +15 -3
  27. package/dist/adapters/sqlite-wasm.js +2 -2
  28. package/dist/audit-trace-store-AM4EWQNZ.js +15 -0
  29. package/dist/backup-JN3LB4YT.js +14 -0
  30. package/dist/backup-JN3LB4YT.js.map +1 -0
  31. package/dist/chunk-56LL4XP6.js +205 -0
  32. package/dist/chunk-56LL4XP6.js.map +1 -0
  33. package/dist/chunk-6AHYGQQT.js +425 -0
  34. package/dist/chunk-6AHYGQQT.js.map +1 -0
  35. package/dist/chunk-7BPETY5Y.js +570 -0
  36. package/dist/chunk-7BPETY5Y.js.map +1 -0
  37. package/dist/chunk-AGN3CIPB.js +188 -0
  38. package/dist/chunk-AGN3CIPB.js.map +1 -0
  39. package/dist/chunk-EZSTD2XC.js +380 -0
  40. package/dist/chunk-EZSTD2XC.js.map +1 -0
  41. package/dist/chunk-MYSJN3DF.js +117 -0
  42. package/dist/chunk-MYSJN3DF.js.map +1 -0
  43. package/dist/{chunk-DXKLAQ6P.js → chunk-XO4LMPAH.js} +1 -1
  44. package/dist/chunk-XO4LMPAH.js.map +1 -0
  45. package/dist/export-audit-5GV6VRDZ.js +15 -0
  46. package/dist/export-audit-5GV6VRDZ.js.map +1 -0
  47. package/dist/index.cjs +3351 -510
  48. package/dist/index.cjs.map +1 -1
  49. package/dist/index.d.cts +822 -50
  50. package/dist/index.d.ts +822 -50
  51. package/dist/index.js +1976 -558
  52. package/dist/index.js.map +1 -1
  53. package/dist/internal.cjs +789 -0
  54. package/dist/internal.cjs.map +1 -0
  55. package/dist/internal.d.cts +154 -0
  56. package/dist/internal.d.ts +154 -0
  57. package/dist/internal.js +48 -0
  58. package/dist/internal.js.map +1 -0
  59. package/dist/{sqlite-wasm-channel-46AOWNPM.js → sqlite-wasm-channel-HDDW6UBH.js} +2 -2
  60. package/dist/sqlite-wasm-channel-HDDW6UBH.js.map +1 -0
  61. package/dist/{sqlite-wasm-channel-Lakjuk2E.d.cts → sqlite-wasm-channel-I9y_jObg.d.cts} +1 -0
  62. package/dist/{sqlite-wasm-channel-Lakjuk2E.d.ts → sqlite-wasm-channel-I9y_jObg.d.ts} +1 -0
  63. package/dist/types-BMyHmwvn.d.ts +163 -0
  64. package/dist/types-CTr00g_d.d.cts +163 -0
  65. package/dist/types-DepnRDk_.d.cts +212 -0
  66. package/dist/types-DepnRDk_.d.ts +212 -0
  67. package/package.json +23 -2
  68. package/dist/chunk-DXKLAQ6P.js.map +0 -1
  69. package/dist/chunk-ZP5AXQ3Z.js +0 -179
  70. package/dist/chunk-ZP5AXQ3Z.js.map +0 -1
  71. package/dist/types-DF-KDSK1.d.cts +0 -106
  72. package/dist/types-DF-KDSK1.d.ts +0 -106
  73. /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/store/store.ts
12
- import { HybridLogicalClock as HybridLogicalClock2, createVersionVector, generateUUIDv7 as generateUUIDv72 } from "@korajs/core";
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/query/sql-builder.ts
18
- function buildSelectQuery(descriptor, fields) {
19
- const params = [];
20
- const parts = [`SELECT * FROM ${descriptor.collection}`];
21
- const whereClause = buildWhereClauseParts(descriptor.where, fields, params);
22
- const deletedFilter = "_deleted = 0";
23
- if (whereClause) {
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
- if (conditions.length === 0) return null;
97
- return conditions.join(" AND ");
67
+ return watermark;
98
68
  }
99
- function buildOperatorCondition(fieldName, operator, value, descriptor, params) {
100
- const sqlValue = descriptor?.kind === "boolean" && typeof value === "boolean" ? value ? 1 : 0 : value;
101
- switch (operator) {
102
- case "$eq":
103
- if (sqlValue === null) {
104
- return `${fieldName} IS NULL`;
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
- default:
142
- throw new QueryError(`Unknown operator "${operator}"`, { operator });
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 validateFieldName(fieldName, fields) {
146
- const allowedFields = /* @__PURE__ */ new Set([
147
- ...Object.keys(fields),
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/serialization/serializer.ts
163
- import { HybridLogicalClock } from "@korajs/core";
164
-
165
- // src/serialization/richtext-serializer.ts
166
- import * as Y from "yjs";
167
- var TEXT_KEY = "content";
168
- function encodeRichtext(value) {
169
- if (value === null || value === void 0) {
170
- return null;
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 decodeRichtext(value) {
186
- if (value === null || value === void 0) {
187
- return null;
188
- }
189
- if (typeof Buffer !== "undefined" && Buffer.isBuffer(value)) {
190
- return new Uint8Array(value);
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
- throw new Error("Richtext storage value must be Uint8Array, ArrayBuffer, Buffer, null, or undefined.");
137
+ return vector;
199
138
  }
200
- function richtextToPlainText(value) {
201
- const encoded = encodeRichtext(value);
202
- if (!encoded) return "";
203
- const doc = new Y.Doc();
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 result;
144
+ return merged;
220
145
  }
221
- function deserializeRecord(row, fields) {
222
- const result = {
223
- id: row.id,
224
- createdAt: row._created_at,
225
- updatedAt: row._updated_at
226
- };
227
- for (const [key, descriptor] of Object.entries(fields)) {
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
- return result;
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 deserializeOperation(row) {
252
- return {
253
- id: row.id,
254
- nodeId: row.node_id,
255
- type: row.type,
256
- collection: "",
257
- // Collection name is derived from the table name by the caller
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
- switch (descriptor.kind) {
276
- case "boolean":
277
- return value ? 1 : 0;
278
- case "array":
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 deserializeValue(value, descriptor) {
287
- switch (descriptor.kind) {
288
- case "boolean":
289
- return value === 1 || value === true;
290
- case "array":
291
- if (typeof value === "string") {
292
- return JSON.parse(value);
293
- }
294
- return value;
295
- case "richtext":
296
- return decodeRichtext(value);
297
- default:
298
- return value;
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, getSequenceNumber, onMutation) {
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.getSequenceNumber = getSequenceNumber;
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
- getSequenceNumber;
225
+ allocateSequenceNumber;
321
226
  onMutation;
322
- /**
323
- * Insert a new record into the collection.
324
- * Generates a UUID v7 for the id, validates data, and persists atomically.
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
- id: recordId,
377
- ...validated,
378
- createdAt: now,
379
- updatedAt: now
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
- * Find a record by its ID. Returns null if not found or soft-deleted.
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
- const currentRows = await this.adapter.query(
404
- `SELECT * FROM ${this.name} WHERE id = ? AND _deleted = 0`,
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 updatedRow;
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
- const currentRows = await this.adapter.query(
466
- `SELECT * FROM ${this.name} WHERE id = ? AND _deleted = 0`,
467
- [id]
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
- const now = Date.now();
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/subscription/subscription-manager.ts
808
- var nextSubId = 0;
809
- var SubscriptionManager = class {
810
- subscriptions = /* @__PURE__ */ new Map();
811
- pendingCollections = /* @__PURE__ */ new Set();
812
- flushScheduled = false;
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
- * Register a new subscription.
633
+ * Enforce referential integrity after deleting a record.
815
634
  *
816
- * @param descriptor - The query descriptor defining what this subscription watches
817
- * @param callback - Called with results whenever they change
818
- * @param executeFn - Function to re-execute the query and get current results
819
- * @returns An unsubscribe function
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
- register(descriptor, callback, executeFn) {
822
- const id = `sub_${++nextSubId}`;
823
- const subscription = {
824
- id,
825
- descriptor,
826
- callback,
827
- executeFn,
828
- lastResults: []
829
- };
830
- this.subscriptions.set(id, subscription);
831
- return () => {
832
- this.subscriptions.delete(id);
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
- * Register a subscription and immediately execute the query.
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
- registerAndFetch(descriptor, callback, executeFn) {
843
- const id = `sub_${++nextSubId}`;
844
- const subscription = {
845
- id,
846
- descriptor,
847
- callback,
848
- executeFn,
849
- lastResults: []
850
- };
851
- this.subscriptions.set(id, subscription);
852
- executeFn().then((results) => {
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
- * Notify the manager that a mutation occurred on a collection.
864
- * Schedules a microtask flush to batch multiple mutations in the same tick.
676
+ * Cascade: delete all records in the source collection that reference
677
+ * the deleted record, then recursively cascade those deletes.
865
678
  */
866
- notify(collection, _operation) {
867
- this.pendingCollections.add(collection);
868
- this.scheduleFlush();
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
- * Immediately flush all pending notifications.
872
- * Useful for testing. In production, flushing happens via microtask.
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
- for (const sub of affected) {
893
- try {
894
- const newResults = await sub.executeFn();
895
- if (!this.resultsEqual(sub.lastResults, newResults)) {
896
- sub.lastResults = newResults;
897
- sub.callback(newResults);
898
- }
899
- } catch {
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
- * Remove all subscriptions. Called on store close.
905
- */
906
- clear() {
907
- this.subscriptions.clear();
908
- this.pendingCollections.clear();
909
- this.flushScheduled = false;
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
- scheduleFlush() {
916
- if (this.flushScheduled) return;
917
- this.flushScheduled = true;
918
- queueMicrotask(() => {
919
- this.flush();
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
- * Compare two result sets. Uses length check + JSON comparison as pragmatic approach.
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 = createVersionVector();
1862
+ versionVector = createVersionVector3();
939
1863
  clock = null;
940
1864
  collections = /* @__PURE__ */ new Map();
941
- subscriptionManager = new 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.nextSequenceNumber(),
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(name, definition, this.adapter, this.subscriptionManager, conditions, this.schema)
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 serializedData = serializeRecord(op.data, definition.fields);
1055
- const now = op.timestamp.wallTime;
1056
- const record = {
1057
- id: op.recordId,
1058
- ...serializedData,
1059
- _created_at: now,
1060
- _updated_at: now
1061
- };
1062
- const insertQuery = buildInsertQuery(collection, record);
1063
- await tx.execute(insertQuery.sql, insertQuery.params);
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 updateQuery = buildUpdateQuery(collection, op.recordId, {
2033
+ const updatePayload = {
1067
2034
  ...serializedChanges,
1068
- _updated_at: op.timestamp.wallTime
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 = buildSoftDeleteQuery(collection, op.recordId, op.timestamp.wallTime);
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
- * Async version of getRange for use by the sync layer.
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
- nextSequenceNumber() {
1129
- this.sequenceNumber++;
1130
- this.versionVector.set(this.nodeId, this.sequenceNumber);
1131
- return this.sequenceNumber;
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
- await this.adapter.execute(
1136
- "INSERT OR REPLACE INTO _kora_meta (key, value) VALUES ('node_id', ?)",
1137
- [this.configNodeId]
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 = generateUUIDv72();
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 = createVersionVector();
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
- singularize
2605
+ serializeVersionVectorToMeta,
2606
+ singularize,
2607
+ validateStateTransition,
2608
+ validateUpdateStateMachine,
2609
+ verifyAuditExportChecksum,
2610
+ verifyBackupChecksum
1193
2611
  };
1194
2612
  //# sourceMappingURL=index.js.map