@korajs/store 1.0.0-beta.12 → 1.0.0-beta.14
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 +856 -63
- package/dist/adapters/sqlite-wasm-worker.cjs.map +1 -1
- package/dist/adapters/sqlite-wasm-worker.js +529 -63
- 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-IHKVMUAM.js +25 -0
- package/dist/chunk-5AQSGMJV.js +5471 -0
- package/dist/chunk-5AQSGMJV.js.map +1 -0
- package/dist/chunk-5JMGMPUE.js +591 -0
- package/dist/chunk-5JMGMPUE.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-BILSA6XZ.js +608 -0
- package/dist/chunk-BILSA6XZ.js.map +1 -0
- package/dist/chunk-BQSIL6TT.js +1095 -0
- package/dist/chunk-BQSIL6TT.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-KK3GBONL.js +112 -0
- package/dist/chunk-KK3GBONL.js.map +1 -0
- package/dist/chunk-KTVNANUH.js +876 -0
- package/dist/chunk-KTVNANUH.js.map +1 -0
- package/dist/chunk-QW4UU7NG.js +1003 -0
- package/dist/chunk-QW4UU7NG.js.map +1 -0
- package/dist/chunk-SRHDJF5B.js +186 -0
- package/dist/chunk-SRHDJF5B.js.map +1 -0
- package/dist/chunk-UC2LGFPV.js +810 -0
- package/dist/chunk-UC2LGFPV.js.map +1 -0
- package/dist/{export-audit-ODGQTQIK.js → export-audit-J2QXRHD2.js} +3 -3
- package/dist/index.cjs +9533 -4246
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1143 -110
- package/dist/index.d.ts +1143 -110
- package/dist/index.js +298 -3191
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +581 -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-MNDPR557.js +299 -0
- package/dist/restore-MNDPR557.js.map +1 -0
- package/dist/sql-builder-D0tC3sv9.d.cts +615 -0
- package/dist/sql-builder-D5UMzHEo.d.ts +615 -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-QCCNBYCU.js +12 -0
- package/dist/store-HNGX4KTT.js +16 -0
- package/dist/store-HNGX4KTT.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-IHKVMUAM.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-QCCNBYCU.js.map} +0 -0
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
import {
|
|
2
|
+
COMPACTED_THROUGH_TABLE,
|
|
3
|
+
FOLD_BASE_TABLE,
|
|
4
|
+
FOLD_SNAPSHOT_TABLE,
|
|
5
|
+
SEQ_CONFLICTS_TABLE
|
|
6
|
+
} from "./chunk-KTVNANUH.js";
|
|
7
|
+
import {
|
|
8
|
+
TERMINAL_REJECTIONS_TABLE
|
|
9
|
+
} from "./chunk-QW4UU7NG.js";
|
|
10
|
+
import {
|
|
11
|
+
deserializeOperation,
|
|
12
|
+
deserializeOperationWithCollection,
|
|
13
|
+
serializeOperation
|
|
14
|
+
} from "./chunk-J5AGXF3D.js";
|
|
15
|
+
|
|
16
|
+
// src/backup/backup.ts
|
|
17
|
+
import { HybridLogicalClock as HybridLogicalClock2, KoraError, quoteIdent as quoteIdent2 } from "@korajs/core";
|
|
18
|
+
|
|
19
|
+
// src/compaction/types.ts
|
|
20
|
+
var COMPACTION_BASELINE_META_KEY = "compaction_baseline_at";
|
|
21
|
+
|
|
22
|
+
// src/log-integrity/log-integrity.ts
|
|
23
|
+
import { HybridLogicalClock, quoteIdent } from "@korajs/core";
|
|
24
|
+
var LOG_QUARANTINE_TABLE = "_kora_log_quarantine";
|
|
25
|
+
var QUARANTINE_DDL = `CREATE TABLE IF NOT EXISTS ${LOG_QUARANTINE_TABLE} (
|
|
26
|
+
collection TEXT NOT NULL,
|
|
27
|
+
operation_id TEXT NOT NULL,
|
|
28
|
+
node_id TEXT,
|
|
29
|
+
sequence_number INTEGER,
|
|
30
|
+
problem TEXT NOT NULL,
|
|
31
|
+
detail TEXT NOT NULL,
|
|
32
|
+
row_json TEXT NOT NULL,
|
|
33
|
+
quarantined_at INTEGER NOT NULL,
|
|
34
|
+
PRIMARY KEY (collection, operation_id)
|
|
35
|
+
)`;
|
|
36
|
+
var OPERATION_TYPES = /* @__PURE__ */ new Set(["insert", "update", "delete"]);
|
|
37
|
+
var CANONICAL_TIMESTAMP = /^\d{15}:\d{5}:.+$/;
|
|
38
|
+
var PAGE_SIZE = 500;
|
|
39
|
+
var SUSPECT_WHERE = `id IS NULL OR node_id IS NULL OR record_id IS NULL
|
|
40
|
+
OR type NOT IN ('insert', 'update', 'delete')
|
|
41
|
+
OR typeof(sequence_number) != 'integer' OR sequence_number < 1
|
|
42
|
+
OR typeof(timestamp) != 'text' OR length(timestamp) < 23
|
|
43
|
+
OR substr(timestamp, 16, 1) != ':' OR substr(timestamp, 22, 1) != ':'
|
|
44
|
+
OR substr(timestamp, 1, 15) GLOB '*[^0-9]*' OR substr(timestamp, 17, 5) GLOB '*[^0-9]*'
|
|
45
|
+
OR causal_deps IS NULL
|
|
46
|
+
OR (CASE WHEN json_valid(causal_deps) THEN json_type(causal_deps) != 'array' ELSE 1 END)
|
|
47
|
+
OR (data IS NOT NULL AND (CASE WHEN json_valid(data) THEN json_type(data) != 'object' ELSE 1 END))
|
|
48
|
+
OR (previous_data IS NOT NULL AND (CASE WHEN json_valid(previous_data) THEN json_type(previous_data) != 'object' ELSE 1 END))`;
|
|
49
|
+
async function scanLogIntegrity(adapter, schema, options) {
|
|
50
|
+
const repair = options.repair ?? true;
|
|
51
|
+
let mode = options.mode ?? "full";
|
|
52
|
+
await adapter.execute(QUARANTINE_DDL);
|
|
53
|
+
const actions = [];
|
|
54
|
+
let checkedRows = 0;
|
|
55
|
+
for (const collection of Object.keys(schema.collections)) {
|
|
56
|
+
const table = quoteIdent(`_kora_ops_${collection}`);
|
|
57
|
+
let rows = null;
|
|
58
|
+
if (mode === "quick") {
|
|
59
|
+
try {
|
|
60
|
+
rows = await adapter.query(
|
|
61
|
+
`SELECT rowid AS __rowid, * FROM ${table} WHERE ${SUSPECT_WHERE}`
|
|
62
|
+
);
|
|
63
|
+
} catch {
|
|
64
|
+
mode = "full";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (rows !== null) {
|
|
68
|
+
for (const row of rows) {
|
|
69
|
+
checkedRows++;
|
|
70
|
+
const verdict = checkOperationRow(row);
|
|
71
|
+
if (verdict.kind !== "ok") actions.push({ collection, row, verdict });
|
|
72
|
+
}
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
let after = 0;
|
|
76
|
+
for (; ; ) {
|
|
77
|
+
const page = await adapter.query(
|
|
78
|
+
`SELECT rowid AS __rowid, * FROM ${table} WHERE rowid > ? ORDER BY rowid LIMIT ${PAGE_SIZE}`,
|
|
79
|
+
[after]
|
|
80
|
+
);
|
|
81
|
+
for (const row of page) {
|
|
82
|
+
checkedRows++;
|
|
83
|
+
const verdict = checkOperationRow(row);
|
|
84
|
+
if (verdict.kind !== "ok") actions.push({ collection, row, verdict });
|
|
85
|
+
}
|
|
86
|
+
if (page.length < PAGE_SIZE) break;
|
|
87
|
+
after = page[page.length - 1]?.__rowid ?? after;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const repaired = [];
|
|
91
|
+
const newlyQuarantined = [];
|
|
92
|
+
for (const { collection, row, verdict } of actions) {
|
|
93
|
+
if (verdict.kind === "ok") continue;
|
|
94
|
+
const entry = describeRow(collection, row, verdict.problem, verdict.detail);
|
|
95
|
+
if (verdict.kind === "repair") repaired.push(entry);
|
|
96
|
+
else newlyQuarantined.push(entry);
|
|
97
|
+
}
|
|
98
|
+
if (repair && actions.length > 0) {
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
await adapter.transaction(async (tx) => {
|
|
101
|
+
for (const { collection, row, verdict } of actions) {
|
|
102
|
+
const table = quoteIdent(`_kora_ops_${collection}`);
|
|
103
|
+
if (verdict.kind === "repair") {
|
|
104
|
+
await tx.execute(`UPDATE ${table} SET timestamp = ? WHERE rowid = ?`, [
|
|
105
|
+
verdict.timestamp,
|
|
106
|
+
row.__rowid
|
|
107
|
+
]);
|
|
108
|
+
} else if (verdict.kind === "quarantine") {
|
|
109
|
+
await quarantineRow(tx, collection, row, verdict.problem, verdict.detail, now);
|
|
110
|
+
await tx.execute(`DELETE FROM ${table} WHERE rowid = ?`, [row.__rowid]);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const quarantined = await loadQuarantine(adapter);
|
|
116
|
+
const gaps = await findOwnSequenceGaps(adapter, schema, options.localNodeIds);
|
|
117
|
+
const compactedAt = await loadCompactedAt(adapter);
|
|
118
|
+
const pendingQuarantine = repair ? 0 : newlyQuarantined.length;
|
|
119
|
+
return {
|
|
120
|
+
clean: quarantined.length === 0 && pendingQuarantine === 0 && gaps.length === 0,
|
|
121
|
+
mode,
|
|
122
|
+
checkedRows,
|
|
123
|
+
repaired,
|
|
124
|
+
newlyQuarantined,
|
|
125
|
+
quarantined,
|
|
126
|
+
gaps,
|
|
127
|
+
compactedAt,
|
|
128
|
+
repairApplied: repair
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function checkOperationRow(row) {
|
|
132
|
+
if (!isNonEmptyString(row.id) || !isNonEmptyString(row.node_id) || !isNonEmptyString(row.record_id)) {
|
|
133
|
+
return {
|
|
134
|
+
kind: "quarantine",
|
|
135
|
+
problem: "identity-malformed",
|
|
136
|
+
detail: "id, node_id or record_id is missing"
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (!OPERATION_TYPES.has(row.type)) {
|
|
140
|
+
return {
|
|
141
|
+
kind: "quarantine",
|
|
142
|
+
problem: "type-invalid",
|
|
143
|
+
detail: `type ${JSON.stringify(row.type)}`
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (!Number.isInteger(row.sequence_number) || row.sequence_number < 1) {
|
|
147
|
+
return {
|
|
148
|
+
kind: "quarantine",
|
|
149
|
+
problem: "sequence-invalid",
|
|
150
|
+
detail: `sequence_number ${JSON.stringify(row.sequence_number)}`
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (!isJsonObjectOrNull(row.data) || !isJsonObjectOrNull(row.previous_data)) {
|
|
154
|
+
return {
|
|
155
|
+
kind: "quarantine",
|
|
156
|
+
problem: "data-malformed",
|
|
157
|
+
detail: "data or previous_data is not a JSON object"
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (!isJsonStringArray(row.causal_deps)) {
|
|
161
|
+
return {
|
|
162
|
+
kind: "quarantine",
|
|
163
|
+
problem: "causal-deps-malformed",
|
|
164
|
+
detail: `causal_deps ${truncate(String(row.causal_deps))}`
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
let timestamp = row.timestamp;
|
|
168
|
+
let repairedFrom = null;
|
|
169
|
+
if (!isCanonicalTimestamp(timestamp)) {
|
|
170
|
+
const recovered = recoverTimestamp(timestamp);
|
|
171
|
+
if (recovered === null) {
|
|
172
|
+
return {
|
|
173
|
+
kind: "quarantine",
|
|
174
|
+
problem: "timestamp-malformed",
|
|
175
|
+
detail: `timestamp ${truncate(String(timestamp))}`
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
repairedFrom = String(timestamp);
|
|
179
|
+
timestamp = recovered;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const op = deserializeOperation({ ...row, timestamp });
|
|
183
|
+
const again = serializeOperation({ ...op, collection: "" });
|
|
184
|
+
if (again.timestamp !== timestamp) {
|
|
185
|
+
throw new Error(`timestamp ${again.timestamp} != ${timestamp}`);
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
return {
|
|
189
|
+
kind: "quarantine",
|
|
190
|
+
problem: "round-trip-failed",
|
|
191
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
if (repairedFrom !== null) {
|
|
195
|
+
return {
|
|
196
|
+
kind: "repair",
|
|
197
|
+
problem: "timestamp-json",
|
|
198
|
+
detail: `timestamp ${truncate(repairedFrom)} -> ${timestamp}`,
|
|
199
|
+
timestamp
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
return { kind: "ok" };
|
|
203
|
+
}
|
|
204
|
+
function isCanonicalTimestamp(value) {
|
|
205
|
+
if (typeof value !== "string" || !CANONICAL_TIMESTAMP.test(value)) return false;
|
|
206
|
+
try {
|
|
207
|
+
return HybridLogicalClock.serialize(HybridLogicalClock.deserialize(value)) === value;
|
|
208
|
+
} catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function recoverTimestamp(value) {
|
|
213
|
+
if (typeof value !== "string" || !value.startsWith("{")) return null;
|
|
214
|
+
let parsed;
|
|
215
|
+
try {
|
|
216
|
+
parsed = JSON.parse(value);
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
let candidate = parsed;
|
|
221
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
222
|
+
if (candidate === null || typeof candidate !== "object") return null;
|
|
223
|
+
const { wallTime, logical, nodeId } = candidate;
|
|
224
|
+
if (Number.isInteger(wallTime) && Number.isInteger(logical) && isNonEmptyString(nodeId)) {
|
|
225
|
+
try {
|
|
226
|
+
return HybridLogicalClock.serialize({
|
|
227
|
+
wallTime,
|
|
228
|
+
logical,
|
|
229
|
+
nodeId
|
|
230
|
+
});
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (wallTime !== null || !Number.isInteger(logical) || typeof nodeId !== "string") return null;
|
|
236
|
+
const match = /^(\d+),"nodeId":(".*")\}$/.exec(nodeId);
|
|
237
|
+
if (!match?.[1] || !match[2]) return null;
|
|
238
|
+
let innerNode;
|
|
239
|
+
try {
|
|
240
|
+
innerNode = JSON.parse(match[2]);
|
|
241
|
+
} catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
candidate = { wallTime: logical, logical: Number(match[1]), nodeId: innerNode };
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
async function quarantineRow(tx, collection, row, problem, detail, now) {
|
|
249
|
+
const { __rowid: _rowid, ...original } = row;
|
|
250
|
+
await tx.execute(
|
|
251
|
+
`INSERT OR REPLACE INTO ${LOG_QUARANTINE_TABLE} (collection, operation_id, node_id, sequence_number, problem, detail, row_json, quarantined_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
252
|
+
[
|
|
253
|
+
collection,
|
|
254
|
+
typeof row.id === "string" ? row.id : `rowid:${row.__rowid}`,
|
|
255
|
+
typeof row.node_id === "string" ? row.node_id : null,
|
|
256
|
+
Number.isInteger(row.sequence_number) ? row.sequence_number : null,
|
|
257
|
+
problem,
|
|
258
|
+
detail,
|
|
259
|
+
JSON.stringify(original, bytesReplacer),
|
|
260
|
+
now
|
|
261
|
+
]
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
async function loadQuarantine(adapter) {
|
|
265
|
+
const rows = await adapter.query(
|
|
266
|
+
`SELECT collection, operation_id, node_id, sequence_number, problem, detail FROM ${LOG_QUARANTINE_TABLE} ORDER BY quarantined_at ASC, collection ASC, operation_id ASC`
|
|
267
|
+
);
|
|
268
|
+
return rows.map((row) => ({
|
|
269
|
+
collection: row.collection,
|
|
270
|
+
operationId: row.operation_id,
|
|
271
|
+
nodeId: row.node_id,
|
|
272
|
+
sequenceNumber: row.sequence_number,
|
|
273
|
+
problem: row.problem,
|
|
274
|
+
detail: row.detail
|
|
275
|
+
}));
|
|
276
|
+
}
|
|
277
|
+
async function findOwnSequenceGaps(adapter, schema, nodeIds) {
|
|
278
|
+
const collections = Object.keys(schema.collections);
|
|
279
|
+
if (collections.length === 0) return [];
|
|
280
|
+
const gaps = [];
|
|
281
|
+
const union = collections.map((c) => `SELECT sequence_number FROM ${quoteIdent(`_kora_ops_${c}`)} WHERE node_id = ?`).join(" UNION ALL ");
|
|
282
|
+
for (const nodeId of [...new Set(nodeIds)]) {
|
|
283
|
+
const params = collections.map(() => nodeId);
|
|
284
|
+
const summary = await adapter.query(
|
|
285
|
+
`SELECT COUNT(DISTINCT sequence_number) AS n, MAX(sequence_number) AS m FROM (${union})`,
|
|
286
|
+
params
|
|
287
|
+
);
|
|
288
|
+
const count = summary[0]?.n ?? 0;
|
|
289
|
+
const max = summary[0]?.m ?? 0;
|
|
290
|
+
if (max === null || max === 0 || count === max) continue;
|
|
291
|
+
const present = await adapter.query(
|
|
292
|
+
`SELECT DISTINCT sequence_number FROM (${union}) ORDER BY sequence_number ASC`,
|
|
293
|
+
params
|
|
294
|
+
);
|
|
295
|
+
let expected = 1;
|
|
296
|
+
for (const { sequence_number: seq } of present) {
|
|
297
|
+
if (seq > expected) gaps.push({ nodeId, from: expected, to: seq - 1 });
|
|
298
|
+
expected = Math.max(expected, seq + 1);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return gaps;
|
|
302
|
+
}
|
|
303
|
+
async function loadCompactedAt(adapter) {
|
|
304
|
+
const rows = await adapter.query("SELECT value FROM _kora_meta WHERE key = ?", [
|
|
305
|
+
COMPACTION_BASELINE_META_KEY
|
|
306
|
+
]);
|
|
307
|
+
const value = Number(rows[0]?.value);
|
|
308
|
+
return rows[0] && Number.isFinite(value) ? value : null;
|
|
309
|
+
}
|
|
310
|
+
function describeRow(collection, row, problem, detail) {
|
|
311
|
+
return {
|
|
312
|
+
collection,
|
|
313
|
+
operationId: typeof row.id === "string" ? row.id : "",
|
|
314
|
+
nodeId: typeof row.node_id === "string" ? row.node_id : null,
|
|
315
|
+
sequenceNumber: Number.isInteger(row.sequence_number) ? row.sequence_number : null,
|
|
316
|
+
problem,
|
|
317
|
+
detail
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
function isNonEmptyString(value) {
|
|
321
|
+
return typeof value === "string" && value.length > 0;
|
|
322
|
+
}
|
|
323
|
+
function isJsonObjectOrNull(value) {
|
|
324
|
+
if (value === null || value === void 0) return true;
|
|
325
|
+
if (typeof value !== "string") return false;
|
|
326
|
+
try {
|
|
327
|
+
const parsed = JSON.parse(value);
|
|
328
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed);
|
|
329
|
+
} catch {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function isJsonStringArray(value) {
|
|
334
|
+
if (typeof value !== "string") return false;
|
|
335
|
+
try {
|
|
336
|
+
const parsed = JSON.parse(value);
|
|
337
|
+
return Array.isArray(parsed) && parsed.every((id) => typeof id === "string");
|
|
338
|
+
} catch {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function truncate(value) {
|
|
343
|
+
return value.length > 120 ? `${value.slice(0, 117)}...` : value;
|
|
344
|
+
}
|
|
345
|
+
function bytesReplacer(_key, value) {
|
|
346
|
+
if (value instanceof Uint8Array) return { $bytes: Array.from(value) };
|
|
347
|
+
return value;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// src/backup/backup.ts
|
|
351
|
+
var BACKUP_VERSION = 2;
|
|
352
|
+
var BackupFormatError = class extends KoraError {
|
|
353
|
+
constructor(message, code, context) {
|
|
354
|
+
super(message, code, context);
|
|
355
|
+
this.name = "BackupFormatError";
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
function encodeSection(name, content) {
|
|
359
|
+
const nameBytes = new TextEncoder().encode(name);
|
|
360
|
+
const result = new Uint8Array(8 + nameBytes.length + content.length);
|
|
361
|
+
const dv = new DataView(result.buffer);
|
|
362
|
+
dv.setUint32(0, nameBytes.length, true);
|
|
363
|
+
dv.setUint32(4, content.length, true);
|
|
364
|
+
result.set(nameBytes, 8);
|
|
365
|
+
result.set(content, 8 + nameBytes.length);
|
|
366
|
+
return result;
|
|
367
|
+
}
|
|
368
|
+
function encodeJsonSection(name, data) {
|
|
369
|
+
return encodeSection(name, new TextEncoder().encode(JSON.stringify(data, bytesReplacer2)));
|
|
370
|
+
}
|
|
371
|
+
function encodeNdjsonSection(name, items) {
|
|
372
|
+
const lines = items.map((item) => JSON.stringify(item, bytesReplacer2)).join("\n");
|
|
373
|
+
return encodeSection(name, new TextEncoder().encode(items.length > 0 ? `${lines}
|
|
374
|
+
` : ""));
|
|
375
|
+
}
|
|
376
|
+
function parseSections(data) {
|
|
377
|
+
const sections = [];
|
|
378
|
+
const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
379
|
+
let offset = 0;
|
|
380
|
+
while (offset + 8 <= data.byteLength) {
|
|
381
|
+
const nameLen = dv.getUint32(offset, true);
|
|
382
|
+
const contentLen = dv.getUint32(offset + 4, true);
|
|
383
|
+
offset += 8;
|
|
384
|
+
if (offset + nameLen + contentLen > data.byteLength) break;
|
|
385
|
+
const name = new TextDecoder().decode(data.slice(offset, offset + nameLen));
|
|
386
|
+
offset += nameLen;
|
|
387
|
+
sections.push({ name, content: data.slice(offset, offset + contentLen) });
|
|
388
|
+
offset += contentLen;
|
|
389
|
+
}
|
|
390
|
+
return sections;
|
|
391
|
+
}
|
|
392
|
+
function findSection(sections, name) {
|
|
393
|
+
return sections.find((section) => section.name === name)?.content ?? null;
|
|
394
|
+
}
|
|
395
|
+
function parseJsonSection(sections, name) {
|
|
396
|
+
const content = findSection(sections, name);
|
|
397
|
+
if (!content) return null;
|
|
398
|
+
return JSON.parse(new TextDecoder().decode(content), bytesReviver);
|
|
399
|
+
}
|
|
400
|
+
function parseNdjson(content) {
|
|
401
|
+
if (!content) return [];
|
|
402
|
+
return new TextDecoder().decode(content).split("\n").filter((line) => line.trim().length > 0).map((line) => JSON.parse(line, bytesReviver));
|
|
403
|
+
}
|
|
404
|
+
function concat(parts) {
|
|
405
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
406
|
+
const result = new Uint8Array(total);
|
|
407
|
+
let pos = 0;
|
|
408
|
+
for (const part of parts) {
|
|
409
|
+
result.set(part, pos);
|
|
410
|
+
pos += part.length;
|
|
411
|
+
}
|
|
412
|
+
return result;
|
|
413
|
+
}
|
|
414
|
+
async function exportBackup(adapter, schema, nodeId, schemaVersion, options) {
|
|
415
|
+
const onProgress = options?.onProgress ?? (() => {
|
|
416
|
+
});
|
|
417
|
+
const includeRecords = options?.includeRecords ?? true;
|
|
418
|
+
const filter = options?.collections;
|
|
419
|
+
const collections = Object.keys(schema.collections).filter(
|
|
420
|
+
(name) => !filter || filter.includes(name)
|
|
421
|
+
);
|
|
422
|
+
const snapshot = {
|
|
423
|
+
vector: {},
|
|
424
|
+
operations: [],
|
|
425
|
+
terminal: [],
|
|
426
|
+
records: /* @__PURE__ */ new Map(),
|
|
427
|
+
bases: [],
|
|
428
|
+
snapshots: [],
|
|
429
|
+
compactedThrough: {},
|
|
430
|
+
compacted: false
|
|
431
|
+
};
|
|
432
|
+
onProgress({ phase: "reading", progress: 0, message: "Reading the local database" });
|
|
433
|
+
await adapter.transaction(async (tx) => {
|
|
434
|
+
const vector = await tx.query(
|
|
435
|
+
"SELECT node_id, sequence_number FROM _kora_version_vector"
|
|
436
|
+
);
|
|
437
|
+
for (const row of vector) snapshot.vector[row.node_id] = row.sequence_number;
|
|
438
|
+
snapshot.operations = await readOperations(tx, collections);
|
|
439
|
+
snapshot.terminal = await readTerminalRejections(tx);
|
|
440
|
+
snapshot.bases = await readFoldStateRows(tx, FOLD_BASE_TABLE, collections);
|
|
441
|
+
snapshot.snapshots = await readFoldStateRows(tx, FOLD_SNAPSHOT_TABLE, collections);
|
|
442
|
+
if (await hasTable(tx, COMPACTED_THROUGH_TABLE)) {
|
|
443
|
+
for (const row of await tx.query(
|
|
444
|
+
`SELECT node_id, sequence_number FROM ${COMPACTED_THROUGH_TABLE}`
|
|
445
|
+
)) {
|
|
446
|
+
snapshot.compactedThrough[row.node_id] = row.sequence_number;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
snapshot.compacted = snapshot.bases.length > 0 || Object.keys(snapshot.compactedThrough).length > 0 || (await tx.query("SELECT value FROM _kora_meta WHERE key = ?", [
|
|
450
|
+
COMPACTION_BASELINE_META_KEY
|
|
451
|
+
])).length > 0;
|
|
452
|
+
if (includeRecords) {
|
|
453
|
+
for (const collection of collections) {
|
|
454
|
+
snapshot.records.set(
|
|
455
|
+
collection,
|
|
456
|
+
await tx.query(`SELECT * FROM ${quoteIdent2(collection)}`)
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
onProgress({ phase: "reading", progress: 0.7, message: "Encoding" });
|
|
462
|
+
const content = [
|
|
463
|
+
encodeJsonSection("version_vector", snapshot.vector),
|
|
464
|
+
encodeNdjsonSection("operations", snapshot.operations),
|
|
465
|
+
encodeJsonSection("terminal_rejections", snapshot.terminal),
|
|
466
|
+
encodeNdjsonSection("fold_base", snapshot.bases),
|
|
467
|
+
encodeNdjsonSection("fold_snapshot", snapshot.snapshots),
|
|
468
|
+
encodeJsonSection("compacted_through", snapshot.compactedThrough)
|
|
469
|
+
];
|
|
470
|
+
for (const [collection, rows] of snapshot.records) {
|
|
471
|
+
if (rows.length > 0) content.push(encodeNdjsonSection(`records:${collection}`, rows));
|
|
472
|
+
}
|
|
473
|
+
const body = concat(content);
|
|
474
|
+
const checksum = await computeSha256(body);
|
|
475
|
+
onProgress({ phase: "writing", progress: 1, message: "Finalizing" });
|
|
476
|
+
const manifest = {
|
|
477
|
+
version: BACKUP_VERSION,
|
|
478
|
+
createdAt: Date.now(),
|
|
479
|
+
nodeId,
|
|
480
|
+
schemaVersion,
|
|
481
|
+
operationCount: snapshot.operations.length,
|
|
482
|
+
collections,
|
|
483
|
+
includesRecords: includeRecords,
|
|
484
|
+
includesTombstones: true,
|
|
485
|
+
includesFoldState: true,
|
|
486
|
+
compacted: snapshot.compacted,
|
|
487
|
+
checksum
|
|
488
|
+
};
|
|
489
|
+
return concat([
|
|
490
|
+
encodeJsonSection("manifest", manifest),
|
|
491
|
+
body,
|
|
492
|
+
encodeSection("checksum", new TextEncoder().encode(checksum))
|
|
493
|
+
]);
|
|
494
|
+
}
|
|
495
|
+
async function readOperations(tx, collections) {
|
|
496
|
+
const operations = [];
|
|
497
|
+
const seen = /* @__PURE__ */ new Set();
|
|
498
|
+
const retained = await hasTable(tx, SEQ_CONFLICTS_TABLE);
|
|
499
|
+
for (const collection of collections) {
|
|
500
|
+
const rows = await tx.query(
|
|
501
|
+
`SELECT * FROM ${quoteIdent2(`_kora_ops_${collection}`)} ORDER BY sequence_number ASC`
|
|
502
|
+
);
|
|
503
|
+
if (retained) {
|
|
504
|
+
rows.push(
|
|
505
|
+
...await tx.query(
|
|
506
|
+
`SELECT id, node_id, type, record_id, data, previous_data, timestamp, sequence_number, causal_deps, schema_version FROM ${SEQ_CONFLICTS_TABLE} WHERE collection = ? AND reemitted_as IS NULL`,
|
|
507
|
+
[collection]
|
|
508
|
+
)
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
for (const row of rows) {
|
|
512
|
+
if (seen.has(row.id)) continue;
|
|
513
|
+
seen.add(row.id);
|
|
514
|
+
operations.push(deserializeOperationWithCollection(row, collection));
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
operations.sort((a, b) => HybridLogicalClock2.compare(a.timestamp, b.timestamp));
|
|
518
|
+
return operations;
|
|
519
|
+
}
|
|
520
|
+
async function readFoldStateRows(tx, table, collections) {
|
|
521
|
+
if (!await hasTable(tx, table)) return [];
|
|
522
|
+
const out = [];
|
|
523
|
+
for (const collection of collections) {
|
|
524
|
+
const rows = await tx.query(
|
|
525
|
+
`SELECT record_id, state FROM ${table} WHERE collection = ? ORDER BY record_id`,
|
|
526
|
+
[collection]
|
|
527
|
+
);
|
|
528
|
+
for (const row of rows) out.push({ collection, recordId: row.record_id, state: row.state });
|
|
529
|
+
}
|
|
530
|
+
return out;
|
|
531
|
+
}
|
|
532
|
+
async function readTerminalRejections(tx) {
|
|
533
|
+
if (!await hasTable(tx, TERMINAL_REJECTIONS_TABLE)) return [];
|
|
534
|
+
const rows = await tx.query(
|
|
535
|
+
`SELECT operation_id, node_id, sequence_number, code, rejected_at FROM ${TERMINAL_REJECTIONS_TABLE} ORDER BY operation_id`
|
|
536
|
+
);
|
|
537
|
+
return rows.map((row) => ({
|
|
538
|
+
operationId: row.operation_id,
|
|
539
|
+
nodeId: row.node_id,
|
|
540
|
+
sequenceNumber: row.sequence_number,
|
|
541
|
+
code: row.code,
|
|
542
|
+
rejectedAt: row.rejected_at
|
|
543
|
+
}));
|
|
544
|
+
}
|
|
545
|
+
async function hasTable(tx, name) {
|
|
546
|
+
const rows = await tx.query(
|
|
547
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
|
|
548
|
+
[name]
|
|
549
|
+
);
|
|
550
|
+
return rows.length > 0;
|
|
551
|
+
}
|
|
552
|
+
function readBackupManifest(data) {
|
|
553
|
+
const manifest = parseJsonSection(parseSections(data), "manifest");
|
|
554
|
+
if (!manifest) {
|
|
555
|
+
throw new BackupFormatError("Invalid backup: manifest section not found", "BACKUP_INVALID");
|
|
556
|
+
}
|
|
557
|
+
return manifest;
|
|
558
|
+
}
|
|
559
|
+
async function verifyBackupChecksum(data) {
|
|
560
|
+
const sections = parseSections(data);
|
|
561
|
+
const manifest = parseJsonSection(sections, "manifest");
|
|
562
|
+
if (!manifest?.checksum) return false;
|
|
563
|
+
const body = concat(
|
|
564
|
+
sections.filter((s) => s.name !== "manifest" && s.name !== "checksum").map((s) => encodeSection(s.name, s.content))
|
|
565
|
+
);
|
|
566
|
+
return await computeSha256(body) === manifest.checksum;
|
|
567
|
+
}
|
|
568
|
+
async function parseBackup(data, options) {
|
|
569
|
+
const sections = parseSections(data);
|
|
570
|
+
const manifest = parseJsonSection(sections, "manifest");
|
|
571
|
+
if (!manifest) {
|
|
572
|
+
throw new BackupFormatError("Invalid backup: manifest section not found", "BACKUP_INVALID");
|
|
573
|
+
}
|
|
574
|
+
if (manifest.version === 1) {
|
|
575
|
+
throw new BackupFormatError(
|
|
576
|
+
"This backup was written by Kora 1.0.0-beta.12 or earlier (format version 1), whose restore corrupted operation timestamps and copied the exporting device identity. Convert it first: `restoreBackup`/`importBackup(await convertBackupV1(data))`.",
|
|
577
|
+
"BACKUP_FORMAT_OUTDATED",
|
|
578
|
+
{ version: 1, fix: "Call convertBackupV1(data) and import the result." }
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
if (manifest.version !== BACKUP_VERSION) {
|
|
582
|
+
throw new BackupFormatError(
|
|
583
|
+
`Unsupported backup version: ${String(manifest.version)} (this release reads version ${BACKUP_VERSION}).`,
|
|
584
|
+
"BACKUP_VERSION_UNSUPPORTED",
|
|
585
|
+
{ version: manifest.version }
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
if (!await verifyBackupChecksum(data)) {
|
|
589
|
+
throw new BackupFormatError(
|
|
590
|
+
"Backup checksum mismatch: data may be corrupted",
|
|
591
|
+
"BACKUP_CHECKSUM_MISMATCH"
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
const keep = (collection) => !options?.collections || options.collections.includes(collection);
|
|
595
|
+
const operations = [];
|
|
596
|
+
for (const raw of parseNdjson(findSection(sections, "operations"))) {
|
|
597
|
+
if (!keep(raw.collection)) continue;
|
|
598
|
+
operations.push(canonicalOperation(raw));
|
|
599
|
+
}
|
|
600
|
+
operations.sort((a, b) => HybridLogicalClock2.compare(a.timestamp, b.timestamp));
|
|
601
|
+
const vector = /* @__PURE__ */ new Map();
|
|
602
|
+
for (const [nodeId, seq] of Object.entries(
|
|
603
|
+
parseJsonSection(sections, "version_vector") ?? {}
|
|
604
|
+
)) {
|
|
605
|
+
if (Number.isInteger(seq) && seq > 0) vector.set(nodeId, seq);
|
|
606
|
+
}
|
|
607
|
+
const records = /* @__PURE__ */ new Map();
|
|
608
|
+
if (manifest.includesRecords) {
|
|
609
|
+
for (const collection of manifest.collections) {
|
|
610
|
+
if (!keep(collection)) continue;
|
|
611
|
+
records.set(
|
|
612
|
+
collection,
|
|
613
|
+
parseNdjson(findSection(sections, `records:${collection}`))
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
const foldRows = (name) => parseNdjson(findSection(sections, name)).filter(
|
|
618
|
+
(row) => typeof row?.collection === "string" && typeof row.recordId === "string" && typeof row.state === "string" && keep(row.collection)
|
|
619
|
+
);
|
|
620
|
+
const compactedThrough = /* @__PURE__ */ new Map();
|
|
621
|
+
for (const [nodeId, seq] of Object.entries(
|
|
622
|
+
parseJsonSection(sections, "compacted_through") ?? {}
|
|
623
|
+
)) {
|
|
624
|
+
if (Number.isInteger(seq) && seq > 0) compactedThrough.set(nodeId, seq);
|
|
625
|
+
}
|
|
626
|
+
return {
|
|
627
|
+
manifest,
|
|
628
|
+
operations,
|
|
629
|
+
versionVector: vector,
|
|
630
|
+
terminalRejections: parseJsonSection(sections, "terminal_rejections") ?? [],
|
|
631
|
+
records,
|
|
632
|
+
foldBases: foldRows("fold_base"),
|
|
633
|
+
foldSnapshots: foldRows("fold_snapshot"),
|
|
634
|
+
compactedThrough
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
function canonicalOperation(raw) {
|
|
638
|
+
let row;
|
|
639
|
+
try {
|
|
640
|
+
row = serializeOperation(raw);
|
|
641
|
+
} catch (error) {
|
|
642
|
+
throw new BackupFormatError(
|
|
643
|
+
`Backup operation ${String(raw?.id)} is not a valid operation: ${error instanceof Error ? error.message : String(error)}`,
|
|
644
|
+
"BACKUP_OPERATION_INVALID",
|
|
645
|
+
{ operationId: raw?.id }
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
const verdict = checkOperationRow(row);
|
|
649
|
+
if (verdict.kind !== "ok" || typeof raw.collection !== "string" || raw.collection === "") {
|
|
650
|
+
throw new BackupFormatError(
|
|
651
|
+
`Backup operation ${String(raw.id)} is not canonical (${verdict.kind === "ok" ? "no collection" : verdict.problem}).`,
|
|
652
|
+
"BACKUP_OPERATION_INVALID",
|
|
653
|
+
{ operationId: raw.id }
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
return deserializeOperationWithCollection(row, raw.collection);
|
|
657
|
+
}
|
|
658
|
+
async function convertBackupV1(data, options) {
|
|
659
|
+
const sections = parseSections(data);
|
|
660
|
+
const manifest = parseJsonSection(sections, "manifest");
|
|
661
|
+
if (!manifest || manifest.version !== 1) {
|
|
662
|
+
throw new BackupFormatError("convertBackupV1 expects a version-1 backup.", "BACKUP_INVALID", {
|
|
663
|
+
version: manifest?.version
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
if (!await verifyBackupChecksum(data)) {
|
|
667
|
+
throw new BackupFormatError(
|
|
668
|
+
"Backup checksum mismatch: data may be corrupted",
|
|
669
|
+
"BACKUP_CHECKSUM_MISMATCH"
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
const operations = [];
|
|
673
|
+
const dropped = [];
|
|
674
|
+
for (const raw of parseNdjson(findSection(sections, "operations"))) {
|
|
675
|
+
const fixed = recoverV1Operation(raw);
|
|
676
|
+
if (fixed) operations.push(fixed);
|
|
677
|
+
else dropped.push(String(raw?.id));
|
|
678
|
+
}
|
|
679
|
+
if (dropped.length > 0 && !options?.dropUnrecoverable) {
|
|
680
|
+
throw new BackupFormatError(
|
|
681
|
+
`${dropped.length} operation(s) in this version-1 backup have unrecoverable timestamps (first: ${dropped[0]}). Pass { dropUnrecoverable: true } to convert without them.`,
|
|
682
|
+
"BACKUP_OPERATION_INVALID",
|
|
683
|
+
{ operationIds: dropped.slice(0, 20) }
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
operations.sort((a, b) => HybridLogicalClock2.compare(a.timestamp, b.timestamp));
|
|
687
|
+
const content = [
|
|
688
|
+
encodeJsonSection(
|
|
689
|
+
"version_vector",
|
|
690
|
+
parseJsonSection(sections, "version_vector") ?? {}
|
|
691
|
+
),
|
|
692
|
+
encodeNdjsonSection("operations", operations),
|
|
693
|
+
encodeJsonSection("terminal_rejections", [])
|
|
694
|
+
];
|
|
695
|
+
for (const section of sections) {
|
|
696
|
+
if (section.name.startsWith("records:")) {
|
|
697
|
+
const rows = parseNdjson(section.content).map(decodeV1Bytes);
|
|
698
|
+
content.push(encodeNdjsonSection(section.name, rows));
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
const body = concat(content);
|
|
702
|
+
const checksum = await computeSha256(body);
|
|
703
|
+
const converted = {
|
|
704
|
+
version: BACKUP_VERSION,
|
|
705
|
+
createdAt: manifest.createdAt,
|
|
706
|
+
nodeId: manifest.nodeId,
|
|
707
|
+
schemaVersion: manifest.schemaVersion,
|
|
708
|
+
operationCount: operations.length,
|
|
709
|
+
collections: manifest.collections,
|
|
710
|
+
includesRecords: manifest.includesRecords,
|
|
711
|
+
includesTombstones: false,
|
|
712
|
+
checksum,
|
|
713
|
+
convertedFrom: 1
|
|
714
|
+
};
|
|
715
|
+
return concat([
|
|
716
|
+
encodeJsonSection("manifest", converted),
|
|
717
|
+
body,
|
|
718
|
+
encodeSection("checksum", new TextEncoder().encode(checksum))
|
|
719
|
+
]);
|
|
720
|
+
}
|
|
721
|
+
function recoverV1Operation(raw) {
|
|
722
|
+
if (!raw || typeof raw !== "object" || typeof raw.id !== "string") return null;
|
|
723
|
+
const ts = raw.timestamp;
|
|
724
|
+
let timestamp = ts;
|
|
725
|
+
const valid = ts !== null && typeof ts === "object" && Number.isInteger(ts.wallTime) && Number.isInteger(ts.logical);
|
|
726
|
+
if (!valid) {
|
|
727
|
+
const recovered = recoverTimestamp(JSON.stringify(ts));
|
|
728
|
+
if (!recovered) return null;
|
|
729
|
+
timestamp = HybridLogicalClock2.deserialize(recovered);
|
|
730
|
+
}
|
|
731
|
+
try {
|
|
732
|
+
return canonicalOperation({ ...raw, timestamp });
|
|
733
|
+
} catch {
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
function decodeV1Bytes(row) {
|
|
738
|
+
const out = {};
|
|
739
|
+
for (const [key, value] of Object.entries(row)) {
|
|
740
|
+
out[key] = looksLikeSerializedBytes(value) ? toBytes(value) : value;
|
|
741
|
+
}
|
|
742
|
+
return out;
|
|
743
|
+
}
|
|
744
|
+
function looksLikeSerializedBytes(value) {
|
|
745
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
746
|
+
const entries = Object.entries(value);
|
|
747
|
+
if (entries.length === 0) return false;
|
|
748
|
+
return entries.every(
|
|
749
|
+
([key, byte], index) => key === String(index) && Number.isInteger(byte) && byte >= 0 && byte < 256
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
function toBytes(value) {
|
|
753
|
+
const length = Object.keys(value).length;
|
|
754
|
+
const bytes = new Uint8Array(length);
|
|
755
|
+
for (let i = 0; i < length; i++) bytes[i] = value[String(i)] ?? 0;
|
|
756
|
+
return bytes;
|
|
757
|
+
}
|
|
758
|
+
async function restoreBackup(adapter, schema, data, options) {
|
|
759
|
+
const { Store } = await import("./store-HNGX4KTT.js");
|
|
760
|
+
const store = new Store({ schema, adapter });
|
|
761
|
+
await store.open();
|
|
762
|
+
try {
|
|
763
|
+
return await store.importBackup(data, options);
|
|
764
|
+
} finally {
|
|
765
|
+
await store.close();
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
function bytesReplacer2(_key, value) {
|
|
769
|
+
if (value instanceof Uint8Array) return { $bytes: toBase64(value) };
|
|
770
|
+
return value;
|
|
771
|
+
}
|
|
772
|
+
function bytesReviver(_key, value) {
|
|
773
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 1 && typeof value.$bytes === "string") {
|
|
774
|
+
return fromBase64(value.$bytes);
|
|
775
|
+
}
|
|
776
|
+
return value;
|
|
777
|
+
}
|
|
778
|
+
function toBase64(bytes) {
|
|
779
|
+
let binary = "";
|
|
780
|
+
const CHUNK = 32768;
|
|
781
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
782
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
783
|
+
}
|
|
784
|
+
return btoa(binary);
|
|
785
|
+
}
|
|
786
|
+
function fromBase64(text) {
|
|
787
|
+
const binary = atob(text);
|
|
788
|
+
const bytes = new Uint8Array(binary.length);
|
|
789
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
790
|
+
return bytes;
|
|
791
|
+
}
|
|
792
|
+
async function computeSha256(data) {
|
|
793
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", new Uint8Array(data));
|
|
794
|
+
return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
export {
|
|
798
|
+
COMPACTION_BASELINE_META_KEY,
|
|
799
|
+
LOG_QUARANTINE_TABLE,
|
|
800
|
+
scanLogIntegrity,
|
|
801
|
+
BACKUP_VERSION,
|
|
802
|
+
BackupFormatError,
|
|
803
|
+
exportBackup,
|
|
804
|
+
readBackupManifest,
|
|
805
|
+
verifyBackupChecksum,
|
|
806
|
+
parseBackup,
|
|
807
|
+
convertBackupV1,
|
|
808
|
+
restoreBackup
|
|
809
|
+
};
|
|
810
|
+
//# sourceMappingURL=chunk-UC2LGFPV.js.map
|