@korajs/store 1.0.0-beta.11 → 1.0.0-beta.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -37
- package/dist/adapters/better-sqlite3.cjs +90 -4
- package/dist/adapters/better-sqlite3.cjs.map +1 -1
- package/dist/adapters/better-sqlite3.d.cts +21 -12
- package/dist/adapters/better-sqlite3.d.ts +21 -12
- package/dist/adapters/better-sqlite3.js +69 -5
- package/dist/adapters/better-sqlite3.js.map +1 -1
- package/dist/adapters/indexeddb.cjs +1316 -610
- package/dist/adapters/indexeddb.cjs.map +1 -1
- package/dist/adapters/indexeddb.d.cts +29 -3
- package/dist/adapters/indexeddb.d.ts +29 -3
- package/dist/adapters/indexeddb.js +247 -221
- package/dist/adapters/indexeddb.js.map +1 -1
- package/dist/adapters/sqlite-wasm-worker.cjs +803 -62
- package/dist/adapters/sqlite-wasm-worker.cjs.map +1 -1
- package/dist/adapters/sqlite-wasm-worker.js +480 -62
- package/dist/adapters/sqlite-wasm-worker.js.map +1 -1
- package/dist/adapters/sqlite-wasm.cjs +1032 -344
- package/dist/adapters/sqlite-wasm.cjs.map +1 -1
- package/dist/adapters/sqlite-wasm.d.cts +239 -18
- package/dist/adapters/sqlite-wasm.d.ts +239 -18
- package/dist/adapters/sqlite-wasm.js +111 -5
- package/dist/adapters/sqlite-wasm.js.map +1 -1
- package/dist/backup-4NUPDEMB.js +25 -0
- package/dist/chunk-32EDKWMI.js +876 -0
- package/dist/chunk-32EDKWMI.js.map +1 -0
- package/dist/{chunk-CYO3YKFL.js → chunk-5ZRRF6R2.js} +2 -2
- package/dist/chunk-A6GFH5IG.js +36 -0
- package/dist/chunk-A6GFH5IG.js.map +1 -0
- package/dist/chunk-DUKTWVZW.js +175 -0
- package/dist/chunk-DUKTWVZW.js.map +1 -0
- package/dist/chunk-HZTDBLDB.js +607 -0
- package/dist/chunk-HZTDBLDB.js.map +1 -0
- package/dist/{chunk-GSJ7EEUC.js → chunk-J5AGXF3D.js} +58 -9
- package/dist/chunk-J5AGXF3D.js.map +1 -0
- package/dist/chunk-MCQKEHOD.js +112 -0
- package/dist/chunk-MCQKEHOD.js.map +1 -0
- package/dist/chunk-P4V375FY.js +810 -0
- package/dist/chunk-P4V375FY.js.map +1 -0
- package/dist/chunk-QDUSJMFV.js +1095 -0
- package/dist/chunk-QDUSJMFV.js.map +1 -0
- package/dist/chunk-SJP2HTYZ.js +1003 -0
- package/dist/chunk-SJP2HTYZ.js.map +1 -0
- package/dist/chunk-W5F2LWG6.js +591 -0
- package/dist/chunk-W5F2LWG6.js.map +1 -0
- package/dist/chunk-Y5LRH7KI.js +5341 -0
- package/dist/chunk-Y5LRH7KI.js.map +1 -0
- package/dist/{export-audit-ODGQTQIK.js → export-audit-J2QXRHD2.js} +3 -3
- package/dist/index.cjs +9354 -4157
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1064 -108
- package/dist/index.d.ts +1064 -108
- package/dist/index.js +249 -3092
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +580 -264
- package/dist/internal.cjs.map +1 -1
- package/dist/internal.d.cts +15 -85
- package/dist/internal.d.ts +15 -85
- package/dist/internal.js +10 -8
- package/dist/restore-FR7XIKT2.js +299 -0
- package/dist/restore-FR7XIKT2.js.map +1 -0
- package/dist/sql-builder-C4u55jKW.d.cts +608 -0
- package/dist/sql-builder-CGckdBDL.d.ts +608 -0
- package/dist/sqlite-wasm-channel-DVX93Uiq.d.cts +141 -0
- package/dist/sqlite-wasm-channel-DVX93Uiq.d.ts +141 -0
- package/dist/sqlite-wasm-channel-MLHLKEED.js +12 -0
- package/dist/store-IMWKZLWB.js +16 -0
- package/dist/store-IMWKZLWB.js.map +1 -0
- package/dist/{types-BslDOTk0.d.ts → types-Dh-xyUjP.d.cts} +99 -26
- package/dist/{types-BslDOTk0.d.cts → types-Dh-xyUjP.d.ts} +99 -26
- package/package.json +3 -2
- package/dist/backup-5S7K2Z35.js +0 -14
- package/dist/chunk-GSJ7EEUC.js.map +0 -1
- package/dist/chunk-HS6DBMST.js +0 -383
- package/dist/chunk-HS6DBMST.js.map +0 -1
- package/dist/chunk-RJIEKEN4.js +0 -751
- package/dist/chunk-RJIEKEN4.js.map +0 -1
- package/dist/chunk-TUR7RGEP.js +0 -84
- package/dist/chunk-TUR7RGEP.js.map +0 -1
- package/dist/chunk-VDBJQ75K.js +0 -78
- package/dist/chunk-VDBJQ75K.js.map +0 -1
- package/dist/chunk-YT7MRFCP.js +0 -687
- package/dist/chunk-YT7MRFCP.js.map +0 -1
- package/dist/sqlite-wasm-channel-GGHA2BXU.js +0 -12
- package/dist/sqlite-wasm-channel-L6o3bWQK.d.cts +0 -92
- package/dist/sqlite-wasm-channel-L6o3bWQK.d.ts +0 -92
- package/dist/types-BOO9EPt1.d.ts +0 -170
- package/dist/types-C_GDavG-.d.cts +0 -170
- /package/dist/{backup-5S7K2Z35.js.map → backup-4NUPDEMB.js.map} +0 -0
- /package/dist/{chunk-CYO3YKFL.js.map → chunk-5ZRRF6R2.js.map} +0 -0
- /package/dist/{export-audit-ODGQTQIK.js.map → export-audit-J2QXRHD2.js.map} +0 -0
- /package/dist/{sqlite-wasm-channel-GGHA2BXU.js.map → sqlite-wasm-channel-MLHLKEED.js.map} +0 -0
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TERMINAL_REJECTIONS_TABLE,
|
|
3
|
+
buildInsertQuery
|
|
4
|
+
} from "./chunk-SJP2HTYZ.js";
|
|
5
|
+
import {
|
|
6
|
+
deserializeOperationWithCollection,
|
|
7
|
+
serializeOperation,
|
|
8
|
+
serializeRecord
|
|
9
|
+
} from "./chunk-J5AGXF3D.js";
|
|
10
|
+
|
|
11
|
+
// src/sync/rehash-operation.ts
|
|
12
|
+
import { quoteIdent } from "@korajs/core";
|
|
13
|
+
import { computeOperationId } from "@korajs/core/internal";
|
|
14
|
+
async function rehashOperation(op) {
|
|
15
|
+
return computeOperationId(
|
|
16
|
+
{
|
|
17
|
+
nodeId: op.nodeId,
|
|
18
|
+
type: op.type,
|
|
19
|
+
collection: op.collection,
|
|
20
|
+
recordId: op.recordId,
|
|
21
|
+
data: op.data,
|
|
22
|
+
previousData: op.previousData,
|
|
23
|
+
timestamp: op.timestamp,
|
|
24
|
+
sequenceNumber: op.sequenceNumber,
|
|
25
|
+
causalDeps: op.causalDeps,
|
|
26
|
+
schemaVersion: op.schemaVersion,
|
|
27
|
+
...op.atomicOps !== void 0 ? { atomicOps: op.atomicOps } : {}
|
|
28
|
+
},
|
|
29
|
+
op.hashVersion === 2 ? 2 : 1
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
async function renumberOperationRow(tx, collection, row, sequenceNumber) {
|
|
33
|
+
const table = quoteIdent(`_kora_ops_${collection}`);
|
|
34
|
+
const current = deserializeOperationWithCollection(row, collection);
|
|
35
|
+
const renumbered = { ...current, sequenceNumber };
|
|
36
|
+
if (current.hashVersion !== 2) {
|
|
37
|
+
await tx.execute(`UPDATE ${table} SET sequence_number = ? WHERE id = ?`, [
|
|
38
|
+
sequenceNumber,
|
|
39
|
+
row.id
|
|
40
|
+
]);
|
|
41
|
+
return renumbered;
|
|
42
|
+
}
|
|
43
|
+
const id = await rehashOperation(renumbered);
|
|
44
|
+
const moved = { ...renumbered, id };
|
|
45
|
+
await replaceOperationRow(tx, collection, row.id, moved);
|
|
46
|
+
return moved;
|
|
47
|
+
}
|
|
48
|
+
async function replaceOperationRow(tx, collection, oldId, next) {
|
|
49
|
+
await tx.execute(`DELETE FROM ${quoteIdent(`_kora_ops_${collection}`)} WHERE id = ?`, [oldId]);
|
|
50
|
+
const insert = buildInsertQuery(
|
|
51
|
+
`_kora_ops_${collection}`,
|
|
52
|
+
serializeOperation(next)
|
|
53
|
+
);
|
|
54
|
+
await tx.execute(insert.sql, insert.params);
|
|
55
|
+
}
|
|
56
|
+
async function rewriteDependentsInTx(tx, collections, nodeId, idMapping, rewritable) {
|
|
57
|
+
if (rewritable.size === 0) return [];
|
|
58
|
+
const candidates = [];
|
|
59
|
+
for (const collection of collections) {
|
|
60
|
+
const rows = await tx.query(
|
|
61
|
+
`SELECT * FROM ${quoteIdent(`_kora_ops_${collection}`)} WHERE node_id = ?`,
|
|
62
|
+
[nodeId]
|
|
63
|
+
);
|
|
64
|
+
for (const row of rows) {
|
|
65
|
+
if (!rewritable.has(row.id)) continue;
|
|
66
|
+
candidates.push({ collection, row, op: deserializeOperationWithCollection(row, collection) });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
candidates.sort((a, b) => a.op.sequenceNumber - b.op.sequenceNumber);
|
|
70
|
+
const rewritten = [];
|
|
71
|
+
for (const { collection, row, op } of candidates) {
|
|
72
|
+
if (!op.causalDeps.some((dep) => idMapping[dep] !== void 0)) continue;
|
|
73
|
+
const causalDeps = op.causalDeps.map((dep) => idMapping[dep] ?? dep);
|
|
74
|
+
const updated = { ...op, causalDeps };
|
|
75
|
+
const id = op.hashVersion === 2 ? await rehashOperation(updated) : op.id;
|
|
76
|
+
const next = { ...updated, id };
|
|
77
|
+
await replaceOperationRow(tx, collection, row.id, next);
|
|
78
|
+
if (id !== op.id) idMapping[op.id] = id;
|
|
79
|
+
rewritten.push(next);
|
|
80
|
+
}
|
|
81
|
+
return rewritten;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/store/sequence-allocator.ts
|
|
85
|
+
async function allocateNextSequenceInTransaction(tx, nodeId) {
|
|
86
|
+
const rows = await tx.query(
|
|
87
|
+
`INSERT INTO _kora_version_vector (node_id, sequence_number) VALUES (?, 1)
|
|
88
|
+
ON CONFLICT(node_id) DO UPDATE SET sequence_number = sequence_number + 1
|
|
89
|
+
RETURNING sequence_number`,
|
|
90
|
+
[nodeId]
|
|
91
|
+
);
|
|
92
|
+
const seq = rows[0]?.sequence_number;
|
|
93
|
+
if (seq === void 0) {
|
|
94
|
+
throw new Error(`Failed to allocate sequence number for node "${nodeId}" in transaction`);
|
|
95
|
+
}
|
|
96
|
+
return seq;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// src/store/sequence-repair.ts
|
|
100
|
+
import { quoteIdent as quoteIdent2 } from "@korajs/core";
|
|
101
|
+
var SEQ_CONFLICTS_TABLE = "_kora_seq_conflicts";
|
|
102
|
+
var REPAIR_DONE_META_KEY = "seq_unique_repair_v1";
|
|
103
|
+
var CREATE_SEQ_CONFLICTS_SQL = `CREATE TABLE IF NOT EXISTS ${SEQ_CONFLICTS_TABLE} (
|
|
104
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
105
|
+
collection TEXT NOT NULL,
|
|
106
|
+
node_id TEXT NOT NULL,
|
|
107
|
+
type TEXT NOT NULL,
|
|
108
|
+
record_id TEXT NOT NULL,
|
|
109
|
+
data TEXT,
|
|
110
|
+
previous_data TEXT,
|
|
111
|
+
timestamp TEXT NOT NULL,
|
|
112
|
+
sequence_number INTEGER NOT NULL,
|
|
113
|
+
causal_deps TEXT NOT NULL,
|
|
114
|
+
schema_version INTEGER NOT NULL,
|
|
115
|
+
reason TEXT NOT NULL,
|
|
116
|
+
reemitted_as TEXT,
|
|
117
|
+
new_sequence_number INTEGER,
|
|
118
|
+
recorded_at INTEGER NOT NULL
|
|
119
|
+
)`;
|
|
120
|
+
function uniqueSequenceIndexName(collection) {
|
|
121
|
+
return `uidx_kora_ops_${collection.length}_${collection}_node_seq`;
|
|
122
|
+
}
|
|
123
|
+
async function repairSequenceUniqueness(adapter, schema, nodeId) {
|
|
124
|
+
await adapter.execute(CREATE_SEQ_CONFLICTS_SQL);
|
|
125
|
+
const collections = Object.keys(schema.collections);
|
|
126
|
+
const result = { resequenced: 0, retained: 0 };
|
|
127
|
+
const done = await adapter.query(
|
|
128
|
+
"SELECT value FROM _kora_meta WHERE key = ?",
|
|
129
|
+
[REPAIR_DONE_META_KEY]
|
|
130
|
+
);
|
|
131
|
+
if (done.length === 0 && collections.length > 0) {
|
|
132
|
+
await runRepair(adapter, collections, nodeId, result);
|
|
133
|
+
}
|
|
134
|
+
for (const collection of collections) {
|
|
135
|
+
const createIndex = `CREATE UNIQUE INDEX IF NOT EXISTS ${quoteIdent2(uniqueSequenceIndexName(collection))} ON ${quoteIdent2(`_kora_ops_${collection}`)} (node_id, sequence_number)`;
|
|
136
|
+
try {
|
|
137
|
+
await adapter.execute(createIndex);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (!/UNIQUE/i.test(error instanceof Error ? error.message : String(error))) throw error;
|
|
140
|
+
await runRepair(adapter, collections, nodeId, result);
|
|
141
|
+
await adapter.execute(createIndex);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
async function runRepair(adapter, collections, nodeId, result) {
|
|
147
|
+
let resequenced = 0;
|
|
148
|
+
let retained = 0;
|
|
149
|
+
await adapter.transaction(async (tx) => {
|
|
150
|
+
await raiseOwnCounterToLog(tx, collections, nodeId);
|
|
151
|
+
const repaired = await repairDuplicates(tx, collections, nodeId);
|
|
152
|
+
resequenced = repaired.resequenced;
|
|
153
|
+
retained = repaired.retained;
|
|
154
|
+
await tx.execute("INSERT OR REPLACE INTO _kora_meta (key, value) VALUES (?, ?)", [
|
|
155
|
+
REPAIR_DONE_META_KEY,
|
|
156
|
+
String(Date.now())
|
|
157
|
+
]);
|
|
158
|
+
});
|
|
159
|
+
result.resequenced += resequenced;
|
|
160
|
+
result.retained += retained;
|
|
161
|
+
if (resequenced > 0 || retained > 0) {
|
|
162
|
+
console.warn(
|
|
163
|
+
`[kora] Repaired ${resequenced + retained} operation(s) that shared a sequence number (written by an earlier Kora version). ${resequenced} were given fresh sequence numbers; ${retained} from other devices were kept in ${SEQ_CONFLICTS_TABLE}. No data was removed.`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async function raiseOwnCounterToLog(tx, collections, nodeId) {
|
|
168
|
+
let maxSeq = 0;
|
|
169
|
+
for (const collection of collections) {
|
|
170
|
+
const rows = await tx.query(
|
|
171
|
+
`SELECT MAX(sequence_number) AS max_seq FROM ${quoteIdent2(`_kora_ops_${collection}`)} WHERE node_id = ?`,
|
|
172
|
+
[nodeId]
|
|
173
|
+
);
|
|
174
|
+
maxSeq = Math.max(maxSeq, rows[0]?.max_seq ?? 0);
|
|
175
|
+
}
|
|
176
|
+
if (maxSeq > 0) {
|
|
177
|
+
await tx.execute(
|
|
178
|
+
`INSERT INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)
|
|
179
|
+
ON CONFLICT(node_id) DO UPDATE SET sequence_number = MAX(sequence_number, excluded.sequence_number)`,
|
|
180
|
+
[nodeId, maxSeq]
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async function repairDuplicates(tx, collections, nodeId) {
|
|
185
|
+
const all = [];
|
|
186
|
+
for (const collection of collections) {
|
|
187
|
+
const rows = await tx.query(
|
|
188
|
+
`SELECT id, node_id, sequence_number FROM ${quoteIdent2(`_kora_ops_${collection}`)}`
|
|
189
|
+
);
|
|
190
|
+
for (const row of rows) all.push({ collection, ...row });
|
|
191
|
+
}
|
|
192
|
+
const groups = /* @__PURE__ */ new Map();
|
|
193
|
+
for (const row of all) {
|
|
194
|
+
const key = `${row.node_id}\0${row.sequence_number}`;
|
|
195
|
+
const group = groups.get(key);
|
|
196
|
+
if (group) group.push(row);
|
|
197
|
+
else groups.set(key, [row]);
|
|
198
|
+
}
|
|
199
|
+
const losers = [];
|
|
200
|
+
for (const group of groups.values()) {
|
|
201
|
+
if (group.length < 2) continue;
|
|
202
|
+
const version2 = /* @__PURE__ */ new Set();
|
|
203
|
+
for (const member of group) {
|
|
204
|
+
if (await isVersion2Row(tx, member)) version2.add(member.id);
|
|
205
|
+
}
|
|
206
|
+
group.sort((a, b) => {
|
|
207
|
+
const va = version2.has(a.id) ? 0 : 1;
|
|
208
|
+
const vb = version2.has(b.id) ? 0 : 1;
|
|
209
|
+
if (va !== vb) return va - vb;
|
|
210
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
211
|
+
});
|
|
212
|
+
losers.push(...group.slice(1));
|
|
213
|
+
}
|
|
214
|
+
losers.sort(
|
|
215
|
+
(a, b) => a.sequence_number !== b.sequence_number ? a.sequence_number - b.sequence_number : a.id < b.id ? -1 : a.id > b.id ? 1 : 0
|
|
216
|
+
);
|
|
217
|
+
const result = { resequenced: 0, retained: 0 };
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
for (const loser of losers) {
|
|
220
|
+
const table = quoteIdent2(`_kora_ops_${loser.collection}`);
|
|
221
|
+
const rows = await tx.query(`SELECT * FROM ${table} WHERE id = ?`, [loser.id]);
|
|
222
|
+
const row = rows[0];
|
|
223
|
+
if (!row) continue;
|
|
224
|
+
if (loser.node_id === nodeId) {
|
|
225
|
+
const newSeq = await allocateNextSequenceInTransaction(tx, nodeId);
|
|
226
|
+
const moved = await renumberOperationRow(tx, loser.collection, row, newSeq);
|
|
227
|
+
await insertConflictRow(
|
|
228
|
+
tx,
|
|
229
|
+
loser.collection,
|
|
230
|
+
row,
|
|
231
|
+
"duplicate-sequence",
|
|
232
|
+
moved.id,
|
|
233
|
+
newSeq,
|
|
234
|
+
now
|
|
235
|
+
);
|
|
236
|
+
result.resequenced++;
|
|
237
|
+
} else {
|
|
238
|
+
await insertConflictRow(tx, loser.collection, row, "duplicate-sequence", null, null, now);
|
|
239
|
+
await tx.execute(`DELETE FROM ${table} WHERE id = ?`, [loser.id]);
|
|
240
|
+
result.retained++;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return result;
|
|
244
|
+
}
|
|
245
|
+
async function isVersion2Row(tx, identity) {
|
|
246
|
+
const rows = await tx.query(
|
|
247
|
+
`SELECT * FROM ${quoteIdent2(`_kora_ops_${identity.collection}`)} WHERE id = ?`,
|
|
248
|
+
[identity.id]
|
|
249
|
+
);
|
|
250
|
+
const row = rows[0];
|
|
251
|
+
if (!row) return false;
|
|
252
|
+
return deserializeOperationWithCollection(row, identity.collection).hashVersion === 2;
|
|
253
|
+
}
|
|
254
|
+
async function insertConflictRow(tx, collection, row, reason, reemittedAs, newSequenceNumber, recordedAt) {
|
|
255
|
+
await tx.execute(
|
|
256
|
+
`INSERT OR IGNORE INTO ${SEQ_CONFLICTS_TABLE} (id, collection, node_id, type, record_id, data, previous_data, timestamp, sequence_number, causal_deps, schema_version, reason, reemitted_as, new_sequence_number, recorded_at)
|
|
257
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
258
|
+
[
|
|
259
|
+
row.id,
|
|
260
|
+
collection,
|
|
261
|
+
row.node_id,
|
|
262
|
+
row.type,
|
|
263
|
+
row.record_id,
|
|
264
|
+
row.data,
|
|
265
|
+
row.previous_data,
|
|
266
|
+
row.timestamp,
|
|
267
|
+
row.sequence_number,
|
|
268
|
+
row.causal_deps,
|
|
269
|
+
row.schema_version,
|
|
270
|
+
reason,
|
|
271
|
+
reemittedAs,
|
|
272
|
+
newSequenceNumber,
|
|
273
|
+
recordedAt
|
|
274
|
+
]
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
async function loadRetainedConflictRows(query, collection, recordId, types) {
|
|
278
|
+
const typeFilter = types && types.length > 0 ? ` AND type IN (${types.map(() => "?").join(", ")})` : "";
|
|
279
|
+
return query(
|
|
280
|
+
`SELECT id, node_id, type, record_id, data, previous_data, timestamp, sequence_number, causal_deps, schema_version FROM ${SEQ_CONFLICTS_TABLE} WHERE collection = ? AND record_id = ? AND reemitted_as IS NULL${typeFilter}`,
|
|
281
|
+
[collection, recordId, ...types ?? []]
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
async function isOperationLogged(tx, collection, opId) {
|
|
285
|
+
const inLog = await tx.query(
|
|
286
|
+
`SELECT id FROM ${quoteIdent2(`_kora_ops_${collection}`)} WHERE id = ?`,
|
|
287
|
+
[opId]
|
|
288
|
+
);
|
|
289
|
+
if (inLog.length > 0) return true;
|
|
290
|
+
const retained = await tx.query(
|
|
291
|
+
`SELECT id FROM ${SEQ_CONFLICTS_TABLE} WHERE id = ? AND reemitted_as IS NULL`,
|
|
292
|
+
[opId]
|
|
293
|
+
);
|
|
294
|
+
return retained.length > 0;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// src/fold/record-folder.ts
|
|
298
|
+
import {
|
|
299
|
+
FOLD_STATE_VERSION,
|
|
300
|
+
FoldStateError,
|
|
301
|
+
HybridLogicalClock,
|
|
302
|
+
adaptFoldState,
|
|
303
|
+
base64ToBytes,
|
|
304
|
+
createFoldState,
|
|
305
|
+
deserializeFoldState,
|
|
306
|
+
getFoldFieldVersionStrings,
|
|
307
|
+
isFoldStateLive,
|
|
308
|
+
joinStates,
|
|
309
|
+
materialize,
|
|
310
|
+
mergeOp,
|
|
311
|
+
mismatchedFoldFields,
|
|
312
|
+
quoteIdent as quoteIdent3,
|
|
313
|
+
toMergeTrace
|
|
314
|
+
} from "@korajs/core";
|
|
315
|
+
import * as Y from "yjs";
|
|
316
|
+
var FOLD_STATE_TABLE = "_kora_fold_state";
|
|
317
|
+
var FOLD_BASE_TABLE = "_kora_fold_base";
|
|
318
|
+
var COMPACTED_THROUGH_TABLE = "_kora_compacted_through";
|
|
319
|
+
var FOLD_SNAPSHOT_TABLE = "_kora_fold_snapshot";
|
|
320
|
+
var PROVISIONAL_OPS_TABLE = "_kora_provisional_ops";
|
|
321
|
+
var KEPT_REJECTION_CODES = ["HELD_DISCARDED"];
|
|
322
|
+
var FOLD_MATERIALIZATION_META_KEY = "fold_materialization";
|
|
323
|
+
var FOLD_MATERIALIZATION_CURRENT = `fold-v${FOLD_STATE_VERSION}`;
|
|
324
|
+
var FOLD_MATERIALIZATION_LEGACY = "legacy";
|
|
325
|
+
var FOLD_TABLES_DDL = [
|
|
326
|
+
`CREATE TABLE IF NOT EXISTS ${FOLD_STATE_TABLE} (collection TEXT NOT NULL, record_id TEXT NOT NULL, state TEXT NOT NULL, PRIMARY KEY (collection, record_id))`,
|
|
327
|
+
`CREATE TABLE IF NOT EXISTS ${FOLD_BASE_TABLE} (collection TEXT NOT NULL, record_id TEXT NOT NULL, state TEXT NOT NULL, PRIMARY KEY (collection, record_id))`,
|
|
328
|
+
`CREATE TABLE IF NOT EXISTS ${COMPACTED_THROUGH_TABLE} (node_id TEXT PRIMARY KEY, sequence_number INTEGER NOT NULL)`,
|
|
329
|
+
`CREATE TABLE IF NOT EXISTS ${FOLD_SNAPSHOT_TABLE} (collection TEXT NOT NULL, record_id TEXT NOT NULL, state TEXT NOT NULL, PRIMARY KEY (collection, record_id))`,
|
|
330
|
+
`CREATE TABLE IF NOT EXISTS ${PROVISIONAL_OPS_TABLE} (id TEXT PRIMARY KEY, collection TEXT NOT NULL, record_id TEXT NOT NULL, parent_id TEXT NOT NULL, operation TEXT NOT NULL)`,
|
|
331
|
+
`CREATE INDEX IF NOT EXISTS ${PROVISIONAL_OPS_TABLE}_record ON ${PROVISIONAL_OPS_TABLE} (collection, record_id)`
|
|
332
|
+
];
|
|
333
|
+
function mergeYjsUpdates(updates) {
|
|
334
|
+
const doc = new Y.Doc();
|
|
335
|
+
for (const update of updates) Y.applyUpdate(doc, update);
|
|
336
|
+
return Y.encodeStateAsUpdate(doc);
|
|
337
|
+
}
|
|
338
|
+
function sameBytes(a, b) {
|
|
339
|
+
if (a.length !== b.length) return false;
|
|
340
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
function yjsSubsumes(a, b) {
|
|
344
|
+
return sameBytes(mergeYjsUpdates([b]), mergeYjsUpdates([a, b]));
|
|
345
|
+
}
|
|
346
|
+
async function isCompacted(tx, op) {
|
|
347
|
+
const rows = await tx.query(
|
|
348
|
+
`SELECT sequence_number FROM ${COMPACTED_THROUGH_TABLE} WHERE node_id = ?`,
|
|
349
|
+
[op.nodeId]
|
|
350
|
+
);
|
|
351
|
+
const through = rows[0]?.sequence_number;
|
|
352
|
+
return through !== void 0 && op.sequenceNumber <= through;
|
|
353
|
+
}
|
|
354
|
+
var RecordFolder = class {
|
|
355
|
+
constructor(schema, transforms = []) {
|
|
356
|
+
this.schema = schema;
|
|
357
|
+
this.transforms = transforms;
|
|
358
|
+
}
|
|
359
|
+
schema;
|
|
360
|
+
transforms;
|
|
361
|
+
authoritative = /* @__PURE__ */ new Set();
|
|
362
|
+
/** The schema transforms every merge applies (transforms at fold time, RT-84). */
|
|
363
|
+
getOperationTransforms() {
|
|
364
|
+
return this.transforms;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* The fields an operation's merge can change: its own data's keys, or every field
|
|
368
|
+
* when the fold reads it through a schema transform (whose view may name others).
|
|
369
|
+
*/
|
|
370
|
+
touchedFields(op) {
|
|
371
|
+
if (this.transforms.length > 0 && op.schemaVersion !== this.schema.version) return "all";
|
|
372
|
+
return new Set(Object.keys(op.data ?? {}));
|
|
373
|
+
}
|
|
374
|
+
/** Node ids whose writes win `merge('server-authoritative')` fields. */
|
|
375
|
+
getAuthoritativeNodeIds() {
|
|
376
|
+
return this.authoritative;
|
|
377
|
+
}
|
|
378
|
+
/** @internal Set by the store from persisted meta / the sync handshake. */
|
|
379
|
+
setAuthoritativeNodeIds(ids) {
|
|
380
|
+
this.authoritative = new Set(ids);
|
|
381
|
+
}
|
|
382
|
+
/** Fold options every merge of this database uses. */
|
|
383
|
+
options(traces = "none") {
|
|
384
|
+
return {
|
|
385
|
+
richtext: mergeYjsUpdates,
|
|
386
|
+
richtextSubsumes: yjsSubsumes,
|
|
387
|
+
traces,
|
|
388
|
+
...this.authoritative.size > 0 ? { authoritativeNodeIds: this.authoritative } : {},
|
|
389
|
+
...this.transforms.length > 0 ? { transforms: this.transforms } : {}
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
/** Whether the schema has a `merge('server-authoritative')` field in `collection`. */
|
|
393
|
+
hasAuthoritativeFields(collection) {
|
|
394
|
+
const definition = this.schema.collections[collection];
|
|
395
|
+
if (!definition) return false;
|
|
396
|
+
return Object.values(definition.fields).some(
|
|
397
|
+
(field) => field.mergeStrategy === "server-authoritative"
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Merge one operation (already appended to the log) into its record and
|
|
402
|
+
* re-materialize the row.
|
|
403
|
+
*
|
|
404
|
+
* @param tx - The write transaction that appended the operation
|
|
405
|
+
* @param op - The operation
|
|
406
|
+
* @param mode - 'remote' emits merge traces; 'local' does not (a local write is
|
|
407
|
+
* computed from the row it updates, so it is never concurrent with it)
|
|
408
|
+
*/
|
|
409
|
+
async applyInTx(tx, op, mode) {
|
|
410
|
+
const scopeEntry = op.type === "insert";
|
|
411
|
+
if (mode === "remote") {
|
|
412
|
+
const retired = await this.deleteProvisional(tx, op.collection, op.recordId, op.causalDeps);
|
|
413
|
+
const replaced = await this.dropSnapshotForCarriedState(tx, op);
|
|
414
|
+
if (retired > 0 || replaced) {
|
|
415
|
+
await this.refoldInTx(tx, op.collection, op.recordId, { clearRetraction: scopeEntry });
|
|
416
|
+
return { changed: true, traces: [] };
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const fresh = mode === "local" && op.type === "insert";
|
|
420
|
+
const prior = fresh ? { state: createFoldState(op.collection, op.recordId), rebuilt: false } : await this.loadOrRebuild(tx, op.collection, op.recordId, op.id);
|
|
421
|
+
let result;
|
|
422
|
+
try {
|
|
423
|
+
result = mergeOp(
|
|
424
|
+
prior.state,
|
|
425
|
+
op,
|
|
426
|
+
this.schema,
|
|
427
|
+
this.options(mode === "remote" ? "conflicts" : "none")
|
|
428
|
+
);
|
|
429
|
+
} catch (error) {
|
|
430
|
+
if (!(error instanceof FoldStateError)) throw error;
|
|
431
|
+
await this.refoldInTx(tx, op.collection, op.recordId, { clearRetraction: scopeEntry });
|
|
432
|
+
return { changed: true, traces: [] };
|
|
433
|
+
}
|
|
434
|
+
if (!result.changed && !prior.rebuilt && !scopeEntry) {
|
|
435
|
+
return { changed: false, traces: await this.toMergeTraces(tx, result.traces) };
|
|
436
|
+
}
|
|
437
|
+
await this.saveState(tx, result.state);
|
|
438
|
+
const revived = isDeadState(prior.state) && !isDeadState(result.state);
|
|
439
|
+
const fields = prior.rebuilt || revived || op.foldState !== void 0 ? "all" : this.touchedFields(op);
|
|
440
|
+
await this.materializeRow(tx, result.state, fields, {
|
|
441
|
+
clearRetraction: scopeEntry,
|
|
442
|
+
...fresh ? { absent: true } : {}
|
|
443
|
+
});
|
|
444
|
+
return { changed: result.changed, traces: await this.toMergeTraces(tx, result.traces) };
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Fold a record from its base state and its log, leaving out operations the
|
|
448
|
+
* server terminally rejected, and re-materialize its row (all fields).
|
|
449
|
+
*
|
|
450
|
+
* @returns The new state (null when nothing of the record remains)
|
|
451
|
+
*/
|
|
452
|
+
async refoldInTx(tx, collection, recordId, options = {}) {
|
|
453
|
+
const state = await this.foldFromLog(tx, collection, recordId);
|
|
454
|
+
if (state === null) {
|
|
455
|
+
await tx.execute(`DELETE FROM ${FOLD_STATE_TABLE} WHERE collection = ? AND record_id = ?`, [
|
|
456
|
+
collection,
|
|
457
|
+
recordId
|
|
458
|
+
]);
|
|
459
|
+
await tx.execute(`UPDATE ${quoteIdent3(collection)} SET _deleted = 1 WHERE id = ?`, [recordId]);
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
await this.saveState(tx, state);
|
|
463
|
+
await this.materializeRow(tx, state, "all", {
|
|
464
|
+
clearRetraction: options.clearRetraction ?? false
|
|
465
|
+
});
|
|
466
|
+
return state;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* `join(base, snapshot, fold(log - terminally rejected, provisional effects))`, or
|
|
470
|
+
* null when none exists. Base and snapshot are adapted to the current fold plan
|
|
471
|
+
* (RT-63): a field whose kind changed is rebuilt from its materialized value.
|
|
472
|
+
*/
|
|
473
|
+
async foldFromLog(tx, collection, recordId) {
|
|
474
|
+
const ops = [
|
|
475
|
+
...await this.loadRecordOperations(tx, collection, recordId),
|
|
476
|
+
...await this.loadProvisional(tx, collection, recordId)
|
|
477
|
+
];
|
|
478
|
+
const rejected = await this.loadTerminalRejections(
|
|
479
|
+
tx,
|
|
480
|
+
ops.map((op) => op.id)
|
|
481
|
+
);
|
|
482
|
+
const base = await this.loadBase(tx, collection, recordId);
|
|
483
|
+
const snapshot = await this.loadSnapshot(tx, collection, recordId);
|
|
484
|
+
let state = base !== null && snapshot !== null ? joinStates(base, snapshot, this.schema) : base ?? snapshot;
|
|
485
|
+
for (const op of ops) {
|
|
486
|
+
if (rejected.has(op.id)) continue;
|
|
487
|
+
state = mergeOp(
|
|
488
|
+
state ?? createFoldState(collection, recordId),
|
|
489
|
+
op,
|
|
490
|
+
this.schema,
|
|
491
|
+
this.options()
|
|
492
|
+
).state;
|
|
493
|
+
}
|
|
494
|
+
return state;
|
|
495
|
+
}
|
|
496
|
+
/** The record's operations (log + retained sequence-conflict rows). */
|
|
497
|
+
async loadRecordOperations(tx, collection, recordId) {
|
|
498
|
+
const rows = [
|
|
499
|
+
...await tx.query(
|
|
500
|
+
`SELECT * FROM ${quoteIdent3(`_kora_ops_${collection}`)} WHERE record_id = ?`,
|
|
501
|
+
[recordId]
|
|
502
|
+
),
|
|
503
|
+
...await loadRetainedConflictRows(
|
|
504
|
+
(sql, params) => tx.query(sql, params),
|
|
505
|
+
collection,
|
|
506
|
+
recordId
|
|
507
|
+
)
|
|
508
|
+
];
|
|
509
|
+
return rows.map((row) => deserializeOperationWithCollection(row, collection));
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Which of `ids` the fold leaves out: operations with a terminal-rejection marker
|
|
513
|
+
* from the server (or quarantined by a scope retraction, which the server never
|
|
514
|
+
* receives). Writes the user chose to discard from a held node are NOT left out:
|
|
515
|
+
* discarding stops the upload, it is not a rollback.
|
|
516
|
+
*/
|
|
517
|
+
async loadTerminalRejections(tx, ids) {
|
|
518
|
+
const found = /* @__PURE__ */ new Set();
|
|
519
|
+
const CHUNK = 500;
|
|
520
|
+
for (let i = 0; i < ids.length; i += CHUNK) {
|
|
521
|
+
const chunk = ids.slice(i, i + CHUNK);
|
|
522
|
+
const rows = await tx.query(
|
|
523
|
+
`SELECT operation_id FROM ${TERMINAL_REJECTIONS_TABLE} WHERE code NOT IN (${KEPT_REJECTION_CODES.map(() => "?").join(", ")}) AND operation_id IN (${chunk.map(() => "?").join(", ")})`,
|
|
524
|
+
[...KEPT_REJECTION_CODES, ...chunk]
|
|
525
|
+
);
|
|
526
|
+
for (const row of rows) found.add(row.operation_id);
|
|
527
|
+
}
|
|
528
|
+
return found;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* The record's stored state, or null (none, another format version, or a field
|
|
532
|
+
* whose fold kind the schema has changed since: the caller re-folds, RT-63).
|
|
533
|
+
*/
|
|
534
|
+
async loadState(tx, collection, recordId) {
|
|
535
|
+
const state = await this.readStateRow(tx, FOLD_STATE_TABLE, collection, recordId);
|
|
536
|
+
if (state === null || mismatchedFoldFields(state, this.schema).length > 0) return null;
|
|
537
|
+
return state;
|
|
538
|
+
}
|
|
539
|
+
/** The stored state as it is, even when the plan changed (a snapshot seed). */
|
|
540
|
+
async loadStoredState(tx, collection, recordId) {
|
|
541
|
+
return this.readStateRow(tx, FOLD_STATE_TABLE, collection, recordId);
|
|
542
|
+
}
|
|
543
|
+
/** The record's compacted base, adapted to the current fold plan (RT-63). */
|
|
544
|
+
async loadBase(tx, collection, recordId) {
|
|
545
|
+
const base = await this.readStateRow(tx, FOLD_BASE_TABLE, collection, recordId);
|
|
546
|
+
return base === null ? null : adaptFoldState(base, this.schema, this.options()).state;
|
|
547
|
+
}
|
|
548
|
+
/** The record's row snapshot (RT-68), adapted to the current fold plan. */
|
|
549
|
+
async loadSnapshot(tx, collection, recordId) {
|
|
550
|
+
const snapshot = await this.readStateRow(tx, FOLD_SNAPSHOT_TABLE, collection, recordId);
|
|
551
|
+
return snapshot === null ? null : adaptFoldState(snapshot, this.schema, this.options()).state;
|
|
552
|
+
}
|
|
553
|
+
/** Store `snapshot` as the record's row snapshot (joined with an existing one). */
|
|
554
|
+
async saveSnapshot(tx, snapshot) {
|
|
555
|
+
const existing = await this.loadSnapshot(tx, snapshot.c, snapshot.r);
|
|
556
|
+
const state = existing === null ? snapshot : joinStates(existing, snapshot, this.schema);
|
|
557
|
+
await tx.execute(
|
|
558
|
+
`INSERT OR REPLACE INTO ${FOLD_SNAPSHOT_TABLE} (collection, record_id, state) VALUES (?, ?, ?)`,
|
|
559
|
+
[state.c, state.r, persistedForm(state)]
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* A scope entry carrying the server's fold state (RT-29) replaces the record's row
|
|
564
|
+
* snapshot (RT-68): the server's state is built from every operation of the record
|
|
565
|
+
* the device was missing, so with the device's own log it is the exact state.
|
|
566
|
+
* Only a carried state this schema can read and join counts (otherwise the entry
|
|
567
|
+
* merges by its data and the snapshot stays).
|
|
568
|
+
*
|
|
569
|
+
* @returns Whether a snapshot was dropped
|
|
570
|
+
*/
|
|
571
|
+
async dropSnapshotForCarriedState(tx, op) {
|
|
572
|
+
if (op.type !== "insert" || op.foldState === void 0) return false;
|
|
573
|
+
const rows = await tx.query(
|
|
574
|
+
`SELECT COUNT(*) AS n FROM ${FOLD_SNAPSHOT_TABLE} WHERE collection = ? AND record_id = ?`,
|
|
575
|
+
[op.collection, op.recordId]
|
|
576
|
+
);
|
|
577
|
+
if ((rows[0]?.n ?? 0) === 0) return false;
|
|
578
|
+
let carried;
|
|
579
|
+
try {
|
|
580
|
+
carried = deserializeFoldState(op.foldState);
|
|
581
|
+
} catch (error) {
|
|
582
|
+
if (error instanceof FoldStateError) return false;
|
|
583
|
+
throw error;
|
|
584
|
+
}
|
|
585
|
+
if (carried.c !== op.collection || carried.r !== op.recordId) return false;
|
|
586
|
+
if (mismatchedFoldFields(carried, this.schema).length > 0) return false;
|
|
587
|
+
await tx.execute(`DELETE FROM ${FOLD_SNAPSHOT_TABLE} WHERE collection = ? AND record_id = ?`, [
|
|
588
|
+
op.collection,
|
|
589
|
+
op.recordId
|
|
590
|
+
]);
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
/** Records that still fold against a row snapshot (approximate, RT-68). */
|
|
594
|
+
async listSnapshotRecords(tx) {
|
|
595
|
+
const rows = await tx.query(
|
|
596
|
+
`SELECT collection, record_id FROM ${FOLD_SNAPSHOT_TABLE} ORDER BY collection, record_id`
|
|
597
|
+
);
|
|
598
|
+
return rows.map((row) => ({ collection: row.collection, recordId: row.record_id }));
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* After a full resync, a record whose log holds its insert again no longer needs
|
|
602
|
+
* its row snapshot: drop it and re-fold the record from base and log (RT-68).
|
|
603
|
+
*
|
|
604
|
+
* @returns Whether the snapshot was dropped
|
|
605
|
+
*/
|
|
606
|
+
async settleSnapshotInTx(tx, collection, recordId) {
|
|
607
|
+
const ops = await this.loadRecordOperations(tx, collection, recordId);
|
|
608
|
+
const rejected = await this.loadTerminalRejections(
|
|
609
|
+
tx,
|
|
610
|
+
ops.map((op) => op.id)
|
|
611
|
+
);
|
|
612
|
+
const hasInsert = ops.some((op) => op.type === "insert" && !rejected.has(op.id));
|
|
613
|
+
const base = await this.loadBase(tx, collection, recordId);
|
|
614
|
+
if (!hasInsert && (base?.cr ?? null) === null) return false;
|
|
615
|
+
await tx.execute(`DELETE FROM ${FOLD_SNAPSHOT_TABLE} WHERE collection = ? AND record_id = ?`, [
|
|
616
|
+
collection,
|
|
617
|
+
recordId
|
|
618
|
+
]);
|
|
619
|
+
await this.refoldInTx(tx, collection, recordId);
|
|
620
|
+
return true;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Fold a local-only provisional side effect (RT-69) into its record: stored apart
|
|
624
|
+
* from the log (never sequenced, never uploaded) and kept until the real copy of
|
|
625
|
+
* the effect arrives or the delivery stream catches up.
|
|
626
|
+
*
|
|
627
|
+
* @param parentId - The remote operation that caused it (the parent delete)
|
|
628
|
+
*/
|
|
629
|
+
async applyProvisionalInTx(tx, op, parentId) {
|
|
630
|
+
const existing = await tx.query(
|
|
631
|
+
`SELECT id FROM ${PROVISIONAL_OPS_TABLE} WHERE id = ?`,
|
|
632
|
+
[op.id]
|
|
633
|
+
);
|
|
634
|
+
if (existing.length > 0) return;
|
|
635
|
+
await tx.execute(
|
|
636
|
+
`INSERT INTO ${PROVISIONAL_OPS_TABLE} (id, collection, record_id, parent_id, operation) VALUES (?, ?, ?, ?, ?)`,
|
|
637
|
+
[op.id, op.collection, op.recordId, parentId, JSON.stringify(op)]
|
|
638
|
+
);
|
|
639
|
+
const prior = await this.loadOrRebuild(tx, op.collection, op.recordId, op.id);
|
|
640
|
+
const result = mergeOp(prior.state, op, this.schema, this.options());
|
|
641
|
+
if (!result.changed && !prior.rebuilt) return;
|
|
642
|
+
await this.saveState(tx, result.state);
|
|
643
|
+
await this.materializeRow(tx, result.state, prior.rebuilt ? "all" : this.touchedFields(op), {
|
|
644
|
+
clearRetraction: false
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Remove provisional effects of a record (those caused by one of `parentIds`, or
|
|
649
|
+
* all of them when null). The caller re-folds the record.
|
|
650
|
+
*
|
|
651
|
+
* @returns How many were removed
|
|
652
|
+
*/
|
|
653
|
+
async deleteProvisional(tx, collection, recordId, parentIds) {
|
|
654
|
+
if (parentIds !== null && parentIds.length === 0) return 0;
|
|
655
|
+
const filter = parentIds === null ? "" : ` AND parent_id IN (${parentIds.map(() => "?").join(", ")})`;
|
|
656
|
+
const rows = await tx.query(
|
|
657
|
+
`SELECT id FROM ${PROVISIONAL_OPS_TABLE} WHERE collection = ? AND record_id = ?${filter}`,
|
|
658
|
+
[collection, recordId, ...parentIds ?? []]
|
|
659
|
+
);
|
|
660
|
+
if (rows.length === 0) return 0;
|
|
661
|
+
await tx.execute(
|
|
662
|
+
`DELETE FROM ${PROVISIONAL_OPS_TABLE} WHERE id IN (${rows.map(() => "?").join(", ")})`,
|
|
663
|
+
rows.map((row) => row.id)
|
|
664
|
+
);
|
|
665
|
+
return rows.length;
|
|
666
|
+
}
|
|
667
|
+
/** Records with provisional effects. */
|
|
668
|
+
async listProvisionalRecords(tx) {
|
|
669
|
+
const rows = await tx.query(
|
|
670
|
+
`SELECT DISTINCT collection, record_id FROM ${PROVISIONAL_OPS_TABLE} ORDER BY collection, record_id`
|
|
671
|
+
);
|
|
672
|
+
return rows.map((row) => ({ collection: row.collection, recordId: row.record_id }));
|
|
673
|
+
}
|
|
674
|
+
async loadProvisional(tx, collection, recordId) {
|
|
675
|
+
const rows = await tx.query(
|
|
676
|
+
`SELECT operation FROM ${PROVISIONAL_OPS_TABLE} WHERE collection = ? AND record_id = ?`,
|
|
677
|
+
[collection, recordId]
|
|
678
|
+
);
|
|
679
|
+
return rows.map((row) => JSON.parse(row.operation));
|
|
680
|
+
}
|
|
681
|
+
async saveState(tx, state) {
|
|
682
|
+
await tx.execute(
|
|
683
|
+
`INSERT OR REPLACE INTO ${FOLD_STATE_TABLE} (collection, record_id, state) VALUES (?, ?, ?)`,
|
|
684
|
+
[state.c, state.r, persistedForm(state)]
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
async saveBase(tx, state) {
|
|
688
|
+
await tx.execute(
|
|
689
|
+
`INSERT OR REPLACE INTO ${FOLD_BASE_TABLE} (collection, record_id, state) VALUES (?, ?, ?)`,
|
|
690
|
+
[state.c, state.r, persistedForm(state)]
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
/** Join `addition` into the record's base state. */
|
|
694
|
+
async joinIntoBase(tx, addition) {
|
|
695
|
+
const base = await this.loadBase(tx, addition.c, addition.r);
|
|
696
|
+
await this.saveBase(tx, base === null ? addition : joinStates(base, addition, this.schema));
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Write the row of a record from its fold state.
|
|
700
|
+
*
|
|
701
|
+
* - No insert merged: no row (an existing one is hidden).
|
|
702
|
+
* - Otherwise the row holds the fold's values (also while deleted: a tombstone
|
|
703
|
+
* keeps its last values), `_deleted` from the record's liveness, and versions
|
|
704
|
+
* from the state: `_version` / `_updated_at` = newest operation, `_created_at` =
|
|
705
|
+
* creation, `_field_versions` = each field's newest write.
|
|
706
|
+
* - Only `fields` are written to an existing row; columns the fold never wrote
|
|
707
|
+
* (a column a schema migration added with a default, a local-only backfill)
|
|
708
|
+
* keep their stored value.
|
|
709
|
+
* - A row hidden by a scope retraction stays hidden unless `clearRetraction`
|
|
710
|
+
* (a scope-entry insert) is set.
|
|
711
|
+
*/
|
|
712
|
+
async materializeRow(tx, state, fields, options) {
|
|
713
|
+
const collection = state.c;
|
|
714
|
+
const definition = this.schema.collections[collection];
|
|
715
|
+
if (!definition) return;
|
|
716
|
+
const table = quoteIdent3(collection);
|
|
717
|
+
const rows = options.absent ? [] : await tx.query(`SELECT id, _deleted FROM ${table} WHERE id = ?`, [
|
|
718
|
+
state.r
|
|
719
|
+
]);
|
|
720
|
+
const existing = rows[0];
|
|
721
|
+
if (state.cr === null || state.u === null) {
|
|
722
|
+
if (existing && existing._deleted !== 1) {
|
|
723
|
+
await tx.execute(`UPDATE ${table} SET _deleted = 1 WHERE id = ?`, [state.r]);
|
|
724
|
+
}
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
const asLive = { ...state, d: null };
|
|
728
|
+
const values = materialize(asLive, { richtext: mergeYjsUpdates }) ?? {};
|
|
729
|
+
const latest = HybridLogicalClock.deserialize(state.u.t);
|
|
730
|
+
const created = HybridLogicalClock.deserialize(state.cr.t);
|
|
731
|
+
const fieldVersions = getFoldFieldVersionStrings(state);
|
|
732
|
+
let deleted = isDeadState(state) ? 1 : 0;
|
|
733
|
+
if (existing && deleted === 0) {
|
|
734
|
+
const retracted = await tx.query(
|
|
735
|
+
"SELECT record_id FROM _kora_scope_retractions WHERE collection = ? AND record_id = ?",
|
|
736
|
+
[collection, state.r]
|
|
737
|
+
);
|
|
738
|
+
if (retracted.length > 0) {
|
|
739
|
+
if (options.clearRetraction) {
|
|
740
|
+
await tx.execute(
|
|
741
|
+
"DELETE FROM _kora_scope_retractions WHERE collection = ? AND record_id = ?",
|
|
742
|
+
[collection, state.r]
|
|
743
|
+
);
|
|
744
|
+
} else {
|
|
745
|
+
deleted = 1;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
const known = Object.keys(definition.fields);
|
|
750
|
+
const writable = existing && deleted === 1 ? [] : known.filter((field) => field in state.f && (fields === "all" || fields.has(field)));
|
|
751
|
+
const fieldValues = {};
|
|
752
|
+
for (const field of writable) {
|
|
753
|
+
const value = values[field] ?? null;
|
|
754
|
+
fieldValues[field] = definition.fields[field]?.kind === "richtext" && value !== null && typeof value === "object" && "$koraBytes" in value ? mergeYjsUpdates([base64ToBytes(String(value.$koraBytes))]) : value;
|
|
755
|
+
}
|
|
756
|
+
const serialized = serializeRecord(fieldValues, definition.fields);
|
|
757
|
+
const meta = {
|
|
758
|
+
_updated_at: latest.wallTime,
|
|
759
|
+
_version: state.u.t,
|
|
760
|
+
_field_versions: JSON.stringify(fieldVersions),
|
|
761
|
+
_deleted: deleted
|
|
762
|
+
};
|
|
763
|
+
if (!existing) {
|
|
764
|
+
const insert = buildInsertQuery(collection, {
|
|
765
|
+
id: state.r,
|
|
766
|
+
...serialized,
|
|
767
|
+
...meta,
|
|
768
|
+
_created_at: created.wallTime
|
|
769
|
+
});
|
|
770
|
+
await tx.execute(insert.sql, insert.params);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
const changes = {
|
|
774
|
+
...serialized,
|
|
775
|
+
...meta,
|
|
776
|
+
_created_at: created.wallTime
|
|
777
|
+
};
|
|
778
|
+
const columns = Object.keys(changes);
|
|
779
|
+
await tx.execute(
|
|
780
|
+
`UPDATE ${table} SET ${columns.map((c) => `${quoteIdent3(c)} = ?`).join(", ")} WHERE id = ?`,
|
|
781
|
+
[...columns.map((c) => changes[c]), state.r]
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Load the record's state; rebuild it from base + log when it is missing or in
|
|
786
|
+
* another format version. `rebuilt` tells the caller to rewrite the whole row.
|
|
787
|
+
*/
|
|
788
|
+
async loadOrRebuild(tx, collection, recordId, appendedId) {
|
|
789
|
+
const stored = await this.loadState(tx, collection, recordId);
|
|
790
|
+
if (stored !== null) return { state: stored, rebuilt: false };
|
|
791
|
+
const hasRow = await this.hasStateRow(tx, collection, recordId);
|
|
792
|
+
const others = await tx.query(
|
|
793
|
+
`SELECT COUNT(*) AS n FROM ${quoteIdent3(`_kora_ops_${collection}`)} WHERE record_id = ? AND id != ?`,
|
|
794
|
+
[recordId, appendedId]
|
|
795
|
+
);
|
|
796
|
+
const retained = await loadRetainedConflictRows(
|
|
797
|
+
(sql, params) => tx.query(sql, params),
|
|
798
|
+
collection,
|
|
799
|
+
recordId
|
|
800
|
+
);
|
|
801
|
+
const hasBase = await this.loadBase(tx, collection, recordId) !== null || await this.loadSnapshot(tx, collection, recordId) !== null || (await this.loadProvisional(tx, collection, recordId)).some((p) => p.id !== appendedId);
|
|
802
|
+
if (!hasRow && !hasBase && (others[0]?.n ?? 0) === 0 && retained.every((row) => row.id === appendedId)) {
|
|
803
|
+
return { state: createFoldState(collection, recordId), rebuilt: false };
|
|
804
|
+
}
|
|
805
|
+
const rebuilt = await this.foldFromLog(tx, collection, recordId);
|
|
806
|
+
return { state: rebuilt ?? createFoldState(collection, recordId), rebuilt: true };
|
|
807
|
+
}
|
|
808
|
+
async hasStateRow(tx, collection, recordId) {
|
|
809
|
+
const rows = await tx.query(
|
|
810
|
+
`SELECT COUNT(*) AS n FROM ${FOLD_STATE_TABLE} WHERE collection = ? AND record_id = ?`,
|
|
811
|
+
[collection, recordId]
|
|
812
|
+
);
|
|
813
|
+
return (rows[0]?.n ?? 0) > 0;
|
|
814
|
+
}
|
|
815
|
+
async readStateRow(tx, table, collection, recordId) {
|
|
816
|
+
const rows = await tx.query(
|
|
817
|
+
`SELECT state FROM ${table} WHERE collection = ? AND record_id = ?`,
|
|
818
|
+
[collection, recordId]
|
|
819
|
+
);
|
|
820
|
+
const raw = rows[0]?.state;
|
|
821
|
+
if (raw === void 0) return null;
|
|
822
|
+
try {
|
|
823
|
+
return deserializeFoldState(raw);
|
|
824
|
+
} catch (error) {
|
|
825
|
+
if (error instanceof FoldStateError) return null;
|
|
826
|
+
throw error;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
async toMergeTraces(tx, traces) {
|
|
830
|
+
const out = [];
|
|
831
|
+
for (const trace of traces) {
|
|
832
|
+
let prior = null;
|
|
833
|
+
if (trace.priorOperationId !== null) {
|
|
834
|
+
const rows = await tx.query(
|
|
835
|
+
`SELECT * FROM ${quoteIdent3(`_kora_ops_${trace.operation.collection}`)} WHERE id = ?`,
|
|
836
|
+
[trace.priorOperationId]
|
|
837
|
+
);
|
|
838
|
+
const row = rows[0];
|
|
839
|
+
if (row) prior = deserializeOperationWithCollection(row, trace.operation.collection);
|
|
840
|
+
}
|
|
841
|
+
out.push(toMergeTrace(trace, prior));
|
|
842
|
+
}
|
|
843
|
+
return out;
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
function persistedForm(state) {
|
|
847
|
+
return JSON.stringify(state);
|
|
848
|
+
}
|
|
849
|
+
function isDeadState(state) {
|
|
850
|
+
return !isFoldStateLive(state);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
export {
|
|
854
|
+
rehashOperation,
|
|
855
|
+
renumberOperationRow,
|
|
856
|
+
rewriteDependentsInTx,
|
|
857
|
+
allocateNextSequenceInTransaction,
|
|
858
|
+
SEQ_CONFLICTS_TABLE,
|
|
859
|
+
repairSequenceUniqueness,
|
|
860
|
+
insertConflictRow,
|
|
861
|
+
loadRetainedConflictRows,
|
|
862
|
+
isOperationLogged,
|
|
863
|
+
FOLD_STATE_TABLE,
|
|
864
|
+
FOLD_BASE_TABLE,
|
|
865
|
+
COMPACTED_THROUGH_TABLE,
|
|
866
|
+
FOLD_SNAPSHOT_TABLE,
|
|
867
|
+
PROVISIONAL_OPS_TABLE,
|
|
868
|
+
FOLD_MATERIALIZATION_META_KEY,
|
|
869
|
+
FOLD_MATERIALIZATION_CURRENT,
|
|
870
|
+
FOLD_MATERIALIZATION_LEGACY,
|
|
871
|
+
FOLD_TABLES_DDL,
|
|
872
|
+
mergeYjsUpdates,
|
|
873
|
+
isCompacted,
|
|
874
|
+
RecordFolder
|
|
875
|
+
};
|
|
876
|
+
//# sourceMappingURL=chunk-32EDKWMI.js.map
|