@bitcobblers/wod-wiki-storage 0.39.2142 → 0.40.2144
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-IFLOX2TK.mjs +874 -0
- package/dist/chunk-IFLOX2TK.mjs.map +1 -0
- package/dist/{chunk-NUJEAUAK.mjs → chunk-IMNG2HY5.mjs} +4 -131
- package/dist/chunk-IMNG2HY5.mjs.map +1 -0
- package/dist/chunk-JFVXIJJI.mjs +134 -0
- package/dist/chunk-JFVXIJJI.mjs.map +1 -0
- package/dist/chunk-MOGQICXM.mjs +43 -0
- package/dist/chunk-MOGQICXM.mjs.map +1 -0
- package/dist/idb/index.cjs +1462 -0
- package/dist/idb/index.cjs.map +1 -0
- package/dist/idb/index.d.cts +156 -0
- package/dist/idb/index.d.ts +156 -0
- package/dist/idb/index.mjs +1079 -0
- package/dist/idb/index.mjs.map +1 -0
- package/dist/index.cjs +47 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.mjs +5 -2
- package/dist/index.mjs.map +1 -1
- package/dist/policy-CPscN81J.d.ts +49 -0
- package/dist/policy-DRYF6Y8i.d.cts +49 -0
- package/dist/sql/index.cjs +241 -38
- package/dist/sql/index.cjs.map +1 -1
- package/dist/sql/index.d.cts +64 -132
- package/dist/sql/index.d.ts +64 -132
- package/dist/sql/index.mjs +188 -852
- package/dist/sql/index.mjs.map +1 -1
- package/dist/sync-Bjj_c1ea.d.ts +141 -0
- package/dist/sync-DG4Z-szj.d.cts +141 -0
- package/dist/{wire-CKJDK_Nq.d.cts → wire-DGYxXt3I.d.cts} +2 -2
- package/dist/{wire-CKJDK_Nq.d.ts → wire-DGYxXt3I.d.ts} +2 -2
- package/package.json +14 -7
- package/dist/chunk-NUJEAUAK.mjs.map +0 -1
|
@@ -0,0 +1,1462 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var idb = require('idb');
|
|
4
|
+
var wodWikiCore = require('@bitcobblers/wod-wiki-core');
|
|
5
|
+
var wodWikiWql = require('@bitcobblers/wod-wiki-wql');
|
|
6
|
+
|
|
7
|
+
// src/idb/index.ts
|
|
8
|
+
|
|
9
|
+
// src/schema.ts
|
|
10
|
+
var DB_VERSION = 32;
|
|
11
|
+
var STORE_DEFS = [
|
|
12
|
+
// 1. notes
|
|
13
|
+
{ name: "notes", keyPath: ["id"], indexes: [
|
|
14
|
+
{ name: "by-date", keyPath: ["date"] },
|
|
15
|
+
{ name: "by-created", keyPath: ["createdAt"] },
|
|
16
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
17
|
+
] },
|
|
18
|
+
// 2. page
|
|
19
|
+
{ name: "page", keyPath: ["id"], indexes: [
|
|
20
|
+
{ name: "by-date", keyPath: ["date"], unique: true },
|
|
21
|
+
{ name: "by-slug", keyPath: ["slug"], unique: true },
|
|
22
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
23
|
+
] },
|
|
24
|
+
// 2b. page_notes (V22)
|
|
25
|
+
{ name: "page_notes", keyPath: ["id"], indexes: [
|
|
26
|
+
{ name: "by-page", keyPath: ["pageId"] },
|
|
27
|
+
{ name: "by-note", keyPath: ["noteId"] },
|
|
28
|
+
{ name: "by-page-note", keyPath: ["pageId", "noteId"], unique: true },
|
|
29
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
30
|
+
] },
|
|
31
|
+
// 3. tags
|
|
32
|
+
{ name: "tags", keyPath: ["id"], indexes: [
|
|
33
|
+
{ name: "by-label", keyPath: ["label"], unique: true },
|
|
34
|
+
{ name: "by-type", keyPath: ["type"] },
|
|
35
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
36
|
+
] },
|
|
37
|
+
// 3a. tag_types (V23)
|
|
38
|
+
{ name: "tag_types", keyPath: ["id"], indexes: [
|
|
39
|
+
{ name: "by-name", keyPath: ["name"], unique: true },
|
|
40
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
41
|
+
] },
|
|
42
|
+
// 4. note_tags
|
|
43
|
+
{ name: "note_tags", keyPath: ["id"], indexes: [
|
|
44
|
+
{ name: "by-note", keyPath: ["noteId"] },
|
|
45
|
+
{ name: "by-tag", keyPath: ["tagId"] },
|
|
46
|
+
{ name: "by-note-tag", keyPath: ["noteId", "tagId"], unique: true },
|
|
47
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
48
|
+
] },
|
|
49
|
+
{ name: "block_revisions", keyPath: ["noteId", "blockId", "version"], indexes: [
|
|
50
|
+
{ name: "by-note", keyPath: ["noteId"] },
|
|
51
|
+
{ name: "by-block", keyPath: ["noteId", "blockId"] }
|
|
52
|
+
] },
|
|
53
|
+
// 6. sessions
|
|
54
|
+
{ name: "sessions", keyPath: ["id"], indexes: [
|
|
55
|
+
{ name: "by-note", keyPath: ["noteId"] },
|
|
56
|
+
{ name: "by-completed", keyPath: ["createdAt"] },
|
|
57
|
+
{ name: "by-content", keyPath: ["blockContentId"] },
|
|
58
|
+
{ name: "by-block", keyPath: ["blockId"] },
|
|
59
|
+
{ name: "by-page", keyPath: ["pageId"] },
|
|
60
|
+
{ name: "by-origin", keyPath: ["origin"] },
|
|
61
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
62
|
+
] },
|
|
63
|
+
// 8. attachments
|
|
64
|
+
{ name: "attachments", keyPath: ["id"], indexes: [
|
|
65
|
+
{ name: "by-note", keyPath: ["noteId"] },
|
|
66
|
+
{ name: "by-time", keyPath: ["createdAt"] },
|
|
67
|
+
{ name: "by-page", keyPath: ["pageId"] },
|
|
68
|
+
{ name: "by-result", keyPath: ["resultId"] },
|
|
69
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
70
|
+
] },
|
|
71
|
+
// 9. events
|
|
72
|
+
{ name: "events", keyPath: ["id"], indexes: [
|
|
73
|
+
{ name: "by-timestamp", keyPath: ["timestamp"] },
|
|
74
|
+
{ name: "by-result-grain", keyPath: ["resultId", "grain"] },
|
|
75
|
+
{ name: "by-content-grain", keyPath: ["blockContentId", "grain"] },
|
|
76
|
+
{ name: "by-effort", keyPath: ["effortSlug"] },
|
|
77
|
+
{ name: "by-outputType", keyPath: ["outputType"] },
|
|
78
|
+
{ name: "by-grain", keyPath: ["grain"] },
|
|
79
|
+
{ name: "by-metric-date", keyPath: ["metricDateKeys"], multiEntry: true },
|
|
80
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
81
|
+
] },
|
|
82
|
+
// 10. field catalog
|
|
83
|
+
{ name: "field_catalog", keyPath: ["id"], indexes: [
|
|
84
|
+
{ name: "by-path", keyPath: ["path"] }
|
|
85
|
+
] },
|
|
86
|
+
{ name: "field_sources", keyPath: ["id"], indexes: [] },
|
|
87
|
+
{ name: "field_values", keyPath: ["key"], indexes: [
|
|
88
|
+
{ name: "by-field", keyPath: ["fieldId"] }
|
|
89
|
+
] },
|
|
90
|
+
{ name: "field_catalog_meta", keyPath: ["id"], indexes: [] },
|
|
91
|
+
// 11. efforts
|
|
92
|
+
{ name: "efforts", keyPath: ["slug"], indexes: [
|
|
93
|
+
{ name: "by-discipline", keyPath: ["baseAttributes.discipline"] },
|
|
94
|
+
{ name: "by-source", keyPath: ["registrySource"] },
|
|
95
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
96
|
+
] },
|
|
97
|
+
// 11b. equipment (V31) — canonical equipment definitions, keyed by slug
|
|
98
|
+
{ name: "equipment", keyPath: ["slug"], indexes: [
|
|
99
|
+
{ name: "by-source", keyPath: ["registrySource"] },
|
|
100
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
101
|
+
] },
|
|
102
|
+
// 12. block_index
|
|
103
|
+
{ name: "block_index", keyPath: ["id"], indexes: [
|
|
104
|
+
{ name: "by-note", keyPath: ["noteId"] },
|
|
105
|
+
{ name: "by-content", keyPath: ["blockContentId"] },
|
|
106
|
+
{ name: "by-type", keyPath: ["dataType"] },
|
|
107
|
+
{ name: "by-source", keyPath: ["sourceId"] },
|
|
108
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
109
|
+
] },
|
|
110
|
+
// 13. block_efforts
|
|
111
|
+
{ name: "block_efforts", keyPath: ["id"], indexes: [
|
|
112
|
+
{ name: "by-note", keyPath: ["noteId"] },
|
|
113
|
+
{ name: "by-effort", keyPath: ["effortSlug"] },
|
|
114
|
+
{ name: "by-block", keyPath: ["blockContentId"] },
|
|
115
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
116
|
+
] },
|
|
117
|
+
// 13b. block_equipment (V31) — containment links, mirroring block_efforts
|
|
118
|
+
{ name: "block_equipment", keyPath: ["id"], indexes: [
|
|
119
|
+
{ name: "by-note", keyPath: ["noteId"] },
|
|
120
|
+
{ name: "by-equipment", keyPath: ["equipmentSlug"] },
|
|
121
|
+
{ name: "by-block", keyPath: ["blockContentId"] },
|
|
122
|
+
{ name: "by-user", keyPath: ["userId"] }
|
|
123
|
+
] },
|
|
124
|
+
// 14. meta
|
|
125
|
+
{ name: "meta", keyPath: ["key"], indexes: [] },
|
|
126
|
+
// 15. memberships (V25, system — survives wipe; truth lives in
|
|
127
|
+
// localStorage locally, in the SQL table on the API)
|
|
128
|
+
{ name: "memberships", keyPath: ["id"], indexes: [], system: true }
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
// src/membership.ts
|
|
132
|
+
var USER_OWNED_STORES = [
|
|
133
|
+
"notes",
|
|
134
|
+
"page",
|
|
135
|
+
"page_notes",
|
|
136
|
+
"tags",
|
|
137
|
+
"tag_types",
|
|
138
|
+
"note_tags",
|
|
139
|
+
"sessions",
|
|
140
|
+
"attachments",
|
|
141
|
+
"events",
|
|
142
|
+
"efforts",
|
|
143
|
+
"block_index",
|
|
144
|
+
"block_efforts",
|
|
145
|
+
"equipment",
|
|
146
|
+
"block_equipment"
|
|
147
|
+
];
|
|
148
|
+
var STAMPABLE = Object.fromEntries(
|
|
149
|
+
USER_OWNED_STORES.map((s) => [s, true])
|
|
150
|
+
);
|
|
151
|
+
function stampUserOwned(store, value, userId) {
|
|
152
|
+
if (!STAMPABLE[store]) return value;
|
|
153
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
|
|
154
|
+
const row = value;
|
|
155
|
+
if (row.seedOrigin === "seed") return value;
|
|
156
|
+
if (row.userId === userId) return value;
|
|
157
|
+
return { ...row, userId };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// src/wire.ts
|
|
161
|
+
function extractKeyPart(value, dottedPath) {
|
|
162
|
+
let cur = value;
|
|
163
|
+
for (const part of dottedPath.split(".")) {
|
|
164
|
+
if (cur === null || typeof cur !== "object") return void 0;
|
|
165
|
+
const row = cur;
|
|
166
|
+
if (!(part in row)) return void 0;
|
|
167
|
+
cur = row[part];
|
|
168
|
+
}
|
|
169
|
+
if (cur === null || typeof cur === "object" || cur === void 0) return void 0;
|
|
170
|
+
return cur;
|
|
171
|
+
}
|
|
172
|
+
function keyFromValue(def, value) {
|
|
173
|
+
const parts = def.keyPath.map((p) => extractKeyPart(value, p));
|
|
174
|
+
for (const [i, part] of parts.entries()) {
|
|
175
|
+
if (part === void 0) {
|
|
176
|
+
throw new Error(`keyFromValue(${def.name}): keyPath part '${def.keyPath[i]}' missing on value`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const key = parts;
|
|
180
|
+
return key.length === 1 ? key[0] : key;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// src/sql/errors.ts
|
|
184
|
+
var StorageError = class extends Error {
|
|
185
|
+
constructor(status, message, code) {
|
|
186
|
+
super(message);
|
|
187
|
+
this.status = status;
|
|
188
|
+
this.code = code;
|
|
189
|
+
this.name = "StorageError";
|
|
190
|
+
}
|
|
191
|
+
status;
|
|
192
|
+
code;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// src/validation.ts
|
|
196
|
+
function rowsEqual(left, right) {
|
|
197
|
+
if (Object.is(left, right)) return true;
|
|
198
|
+
if (left instanceof ArrayBuffer && right instanceof ArrayBuffer) {
|
|
199
|
+
const a2 = new Uint8Array(left), b2 = new Uint8Array(right);
|
|
200
|
+
return a2.length === b2.length && a2.every((value, i) => value === b2[i]);
|
|
201
|
+
}
|
|
202
|
+
if (typeof left !== "object" || left === null || typeof right !== "object" || right === null) return false;
|
|
203
|
+
if (Array.isArray(left) || Array.isArray(right)) return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, i) => rowsEqual(value, right[i]));
|
|
204
|
+
const a = Object.keys(left).filter((key) => Reflect.get(left, key) !== void 0);
|
|
205
|
+
const b = Object.keys(right).filter((key) => Reflect.get(right, key) !== void 0);
|
|
206
|
+
return a.length === b.length && a.every((key) => Object.hasOwn(right, key) && rowsEqual(Reflect.get(left, key), Reflect.get(right, key)));
|
|
207
|
+
}
|
|
208
|
+
function record(value) {
|
|
209
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new StorageError(400, "storage row must be an object");
|
|
210
|
+
return value;
|
|
211
|
+
}
|
|
212
|
+
function validateStorageRow(store, value) {
|
|
213
|
+
if (store !== "notes" && store !== "block_revisions" && store !== "sessions" && store !== "block_index") return;
|
|
214
|
+
const row = record(value);
|
|
215
|
+
const positive = (field) => typeof row[field] === "number" && Number.isSafeInteger(row[field]) && row[field] > 0;
|
|
216
|
+
const required = (field) => typeof row[field] === "string" && row[field].length > 0;
|
|
217
|
+
if (store === "notes") {
|
|
218
|
+
if (!required("id") || typeof row.title !== "string" || typeof row.rawContent !== "string" || !positive("sourceRevision") || typeof row.createdAt !== "number" || !Number.isFinite(row.createdAt) || "segments" in row || "segmentIds" in row || row.date !== void 0 && (typeof row.date !== "number" || !Number.isFinite(row.date)) || row.seedOrigin !== void 0 && row.seedOrigin !== "seed" && row.seedOrigin !== "user" || row.seedVersion !== void 0 && !(typeof row.seedVersion === "number" && Number.isSafeInteger(row.seedVersion) && row.seedVersion >= 0) || row.tags !== void 0 && (!Array.isArray(row.tags) || !row.tags.every((tag) => typeof tag === "string")) || ["type", "sourceId", "sourcePath", "catalog", "notebookId", "seedChunkId"].some((field) => row[field] !== void 0 && typeof row[field] !== "string")) {
|
|
219
|
+
throw new StorageError(400, "invalid whole Note", "invalid_note_blocks");
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
wodWikiCore.validateNoteBlocks(row.rawContent, record(row.blocks), row.sourceRevision);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
throw new StorageError(400, error instanceof Error ? error.message : "invalid block map", "invalid_note_blocks");
|
|
225
|
+
}
|
|
226
|
+
} else if (store === "block_revisions") {
|
|
227
|
+
if (!required("noteId") || !required("blockId") || !positive("version") || typeof row.rawContent !== "string" || typeof row.savedAt !== "number" || !Number.isFinite(row.savedAt) || !["wod", "query", "widget", "embed"].includes(String(row.kind)) || (row.kind === "wod" ? row.dialect !== "time" && row.dialect !== "log" : row.dialect !== void 0 || row.sport !== void 0) || row.sport !== void 0 && typeof row.sport !== "string") throw new StorageError(400, "invalid block revision", "invalid_note_blocks");
|
|
228
|
+
try {
|
|
229
|
+
wodWikiCore.validateNoteBlocks(row.rawContent, { [String(row.blockId)]: {
|
|
230
|
+
id: String(row.blockId),
|
|
231
|
+
kind: row.kind,
|
|
232
|
+
version: row.version,
|
|
233
|
+
sourceRevision: 1,
|
|
234
|
+
from: 0,
|
|
235
|
+
to: row.rawContent.length,
|
|
236
|
+
...row.dialect === void 0 ? {} : { dialect: row.dialect },
|
|
237
|
+
...row.sport === void 0 ? {} : { sport: row.sport }
|
|
238
|
+
} }, 1);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
throw new StorageError(400, error instanceof Error ? error.message : "invalid revision source", "invalid_note_blocks");
|
|
241
|
+
}
|
|
242
|
+
} else if (store === "sessions") {
|
|
243
|
+
if (!required("noteId") || !required("blockId") || !positive("blockVersion") || ["segmentId", "segmentVersion", "version", "rawContent", "script", "serializedScript", "source", "data", "logs"].some((field) => field in row)) {
|
|
244
|
+
throw new StorageError(400, "Session requires immutable block pin", "invalid_session_pin");
|
|
245
|
+
}
|
|
246
|
+
} else if (!required("noteId") || !required("blockId") || !positive("blockVersion") || !positive("sourceRevision") || row.id !== `${row.noteId}:${row.blockId}` || !Number.isSafeInteger(row.from) || !Number.isSafeInteger(row.to) || "segmentId" in row || "segmentVersion" in row || !["wod", "query", "widget", "embed"].includes(String(row.dataType)) || typeof row.rawContent !== "string" || typeof row.noteTitle !== "string" || typeof row.from !== "number" || typeof row.to !== "number" || row.from < 0 || row.to <= row.from) {
|
|
247
|
+
throw new StorageError(400, "invalid actionable block projection", "invalid_note_blocks");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function validateNoteWrite(previous, value) {
|
|
251
|
+
if (previous === void 0 || rowsEqual(previous, value)) return;
|
|
252
|
+
const before = record(previous), after = record(value);
|
|
253
|
+
if (typeof before.sourceRevision !== "number" || typeof after.sourceRevision !== "number" || after.sourceRevision < before.sourceRevision || (!rowsEqual(before.blocks, after.blocks) || before.rawContent !== after.rawContent) && after.sourceRevision <= before.sourceRevision) throw new StorageError(409, "stale Note source revision", "transaction_conflict");
|
|
254
|
+
}
|
|
255
|
+
function validateSessionWrite(previous, value) {
|
|
256
|
+
if (previous === void 0) return;
|
|
257
|
+
const before = record(previous), after = record(value);
|
|
258
|
+
if (before.noteId !== after.noteId || before.blockId !== after.blockId || before.blockVersion !== after.blockVersion)
|
|
259
|
+
throw new StorageError(409, "Session pin is immutable", "invalid_session_pin");
|
|
260
|
+
}
|
|
261
|
+
async function validateStoredPins(notes, sessions, getRevisions) {
|
|
262
|
+
const keys = [];
|
|
263
|
+
for (const note of notes) for (const block of Object.values(note.blocks)) keys.push([note.id, block.id, block.version]);
|
|
264
|
+
for (const session of sessions) keys.push([session.noteId, session.blockId, session.blockVersion]);
|
|
265
|
+
const revisions = await getRevisions(keys);
|
|
266
|
+
let i = 0;
|
|
267
|
+
for (const note of notes) for (const block of Object.values(note.blocks)) {
|
|
268
|
+
const revision = revisions[i++];
|
|
269
|
+
if (!revision || revision.kind !== block.kind || revision.rawContent !== note.rawContent.slice(block.from, block.to) || revision.dialect !== block.dialect || revision.sport !== block.sport) throw new StorageError(409, "Note block has no matching immutable revision", "invalid_note_blocks");
|
|
270
|
+
}
|
|
271
|
+
for (let sessionIndex = 0; sessionIndex < sessions.length; sessionIndex++) {
|
|
272
|
+
const revision = revisions[i++];
|
|
273
|
+
if (!revision || revision.kind !== "wod") throw new StorageError(409, "Session pin must reference a saved workout revision", "invalid_session_pin");
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// src/policy.ts
|
|
278
|
+
var USER_DELETED_SEED_PREFIX = "user-deleted-seed:";
|
|
279
|
+
function isSeedMetaKey(key) {
|
|
280
|
+
return typeof key === "string" && key.startsWith("seed:");
|
|
281
|
+
}
|
|
282
|
+
function collectUserScopeContext(notes) {
|
|
283
|
+
const seedNoteIds = /* @__PURE__ */ new Set();
|
|
284
|
+
for (const row of notes) {
|
|
285
|
+
if (row !== null && typeof row === "object" && "seedOrigin" in row && row.seedOrigin === "seed" && "id" in row && typeof row.id === "string") {
|
|
286
|
+
seedNoteIds.add(row.id);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return { seedNoteIds };
|
|
290
|
+
}
|
|
291
|
+
function isUserOwnedRow(store, row, ctx) {
|
|
292
|
+
if (row === null || typeof row !== "object") return true;
|
|
293
|
+
switch (store) {
|
|
294
|
+
case "notes":
|
|
295
|
+
return !("seedOrigin" in row && row.seedOrigin === "seed");
|
|
296
|
+
case "block_index":
|
|
297
|
+
case "block_efforts":
|
|
298
|
+
case "block_equipment":
|
|
299
|
+
return !("isStatic" in row && row.isStatic === true && "noteId" in row && typeof row.noteId === "string" && ctx.seedNoteIds.has(row.noteId));
|
|
300
|
+
case "efforts":
|
|
301
|
+
case "equipment":
|
|
302
|
+
return !("registrySource" in row && row.registrySource === "bundled");
|
|
303
|
+
case "meta":
|
|
304
|
+
return !isSeedMetaKey("key" in row ? row.key : void 0);
|
|
305
|
+
default:
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// src/sql/codec.ts
|
|
311
|
+
var TAG = "$binary";
|
|
312
|
+
var LEN = "$bytes";
|
|
313
|
+
function base64ToBytes(base64) {
|
|
314
|
+
const binary = atob(base64);
|
|
315
|
+
const bytes = new Uint8Array(binary.length);
|
|
316
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
317
|
+
return bytes;
|
|
318
|
+
}
|
|
319
|
+
function reviveBinary(value) {
|
|
320
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
|
|
321
|
+
const keys = Object.keys(value);
|
|
322
|
+
if (keys.length !== 2 || !keys.includes(TAG) || !keys.includes(LEN)) return value;
|
|
323
|
+
const tag = value;
|
|
324
|
+
if (typeof tag[TAG] !== "string" || typeof tag[LEN] !== "number") return value;
|
|
325
|
+
const bytes = base64ToBytes(tag[TAG]);
|
|
326
|
+
if (bytes.length !== tag[LEN]) {
|
|
327
|
+
throw new Error(`corrupt binary cell: declared ${tag[LEN]} bytes, decoded ${bytes.length}`);
|
|
328
|
+
}
|
|
329
|
+
return bytes.buffer;
|
|
330
|
+
}
|
|
331
|
+
function decodeRowJson(text, binaryField) {
|
|
332
|
+
const row = JSON.parse(text);
|
|
333
|
+
if (binaryField && row !== null && typeof row === "object" && !Array.isArray(row)) {
|
|
334
|
+
const record2 = row;
|
|
335
|
+
const revived = reviveBinary(record2[binaryField]);
|
|
336
|
+
if (revived !== record2[binaryField]) record2[binaryField] = revived;
|
|
337
|
+
}
|
|
338
|
+
return row;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// src/sql/sync.ts
|
|
342
|
+
var SYNC_OPS_TABLE = "_wodwiki_sync_ops";
|
|
343
|
+
var BINARY_FIELD = { attachments: "data" };
|
|
344
|
+
Object.fromEntries(STORE_DEFS.map((d) => [d.name, true]));
|
|
345
|
+
async function tableExists(conn, dialect, name) {
|
|
346
|
+
const sql = dialect === "postgres" ? `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1` : `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`;
|
|
347
|
+
return (await conn.query(sql, [name])).length > 0;
|
|
348
|
+
}
|
|
349
|
+
async function readRawSyncOps(db) {
|
|
350
|
+
const rows = await db.query(`SELECT "rev", "idx", "data" FROM "${SYNC_OPS_TABLE}" ORDER BY "rev", "idx"`);
|
|
351
|
+
return rows.map((row) => {
|
|
352
|
+
const rev = Number(row.rev);
|
|
353
|
+
const idx = Number(row.idx);
|
|
354
|
+
if (!Number.isSafeInteger(rev) || rev < 0 || !Number.isSafeInteger(idx) || idx < 0 || typeof row.data !== "string") {
|
|
355
|
+
throw new StorageError(400, "sync op row has malformed shape", "bad_sync_row");
|
|
356
|
+
}
|
|
357
|
+
let parsed;
|
|
358
|
+
try {
|
|
359
|
+
parsed = JSON.parse(row.data);
|
|
360
|
+
} catch {
|
|
361
|
+
throw new StorageError(400, "sync op data is not valid JSON", "bad_sync_row");
|
|
362
|
+
}
|
|
363
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
364
|
+
throw new StorageError(400, "sync op data must be an object", "bad_sync_row");
|
|
365
|
+
}
|
|
366
|
+
const mutation = parsed;
|
|
367
|
+
if (mutation.op === "wipe") return { op: "wipe" };
|
|
368
|
+
if (typeof mutation.store !== "string") {
|
|
369
|
+
throw new StorageError(400, "sync op must carry a string store", "bad_sync_row");
|
|
370
|
+
}
|
|
371
|
+
if (mutation.op === "clear") return { op: "clear", store: mutation.store };
|
|
372
|
+
if (mutation.op === "delete") return { op: "delete", store: mutation.store, key: mutation.key };
|
|
373
|
+
if (mutation.op !== "put" || mutation.value === null || typeof mutation.value !== "object" || Array.isArray(mutation.value)) {
|
|
374
|
+
throw new StorageError(400, "sync op has an unknown operation or invalid row", "bad_sync_row");
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
op: "put",
|
|
378
|
+
store: mutation.store,
|
|
379
|
+
key: mutation.key,
|
|
380
|
+
value: decodeRowJson(JSON.stringify(mutation.value), BINARY_FIELD[mutation.store])
|
|
381
|
+
};
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
async function readLegacyRows(db, table) {
|
|
385
|
+
if (!await tableExists(db, db.dialect, table)) return [];
|
|
386
|
+
const rows = await db.query(`SELECT "data" FROM "${table}"`);
|
|
387
|
+
return rows.map((row) => decodeRowJson(String(row.data), BINARY_FIELD[table]));
|
|
388
|
+
}
|
|
389
|
+
var IDB_DB_NAME = "wodwiki-db";
|
|
390
|
+
var OUTBOX_STORE = "sync_outbox";
|
|
391
|
+
var SYNC_STATE_STORE = "sync_state";
|
|
392
|
+
var LOCAL_SYNC_STORES = [
|
|
393
|
+
{ name: OUTBOX_STORE, keyPath: "seq", autoIncrement: true },
|
|
394
|
+
{ name: SYNC_STATE_STORE, keyPath: "key" }
|
|
395
|
+
];
|
|
396
|
+
var BOOTSTRAP_RUN_PREFIX = "bootstrap-run:";
|
|
397
|
+
var NON_SYSTEM_STORES = STORE_DEFS.filter((def) => !def.system).map((def) => def.name);
|
|
398
|
+
var BOOTSTRAP_MAX_OPS = 500;
|
|
399
|
+
var BOOTSTRAP_MAX_BYTES = 1e6;
|
|
400
|
+
var BINARY_SYNC_FIELDS = { attachments: "data" };
|
|
401
|
+
var LEGACY_EXTRA_TABLES = [
|
|
402
|
+
{ name: "segments", keyPath: ["id", "version"] },
|
|
403
|
+
{ name: "results", keyPath: "id" }
|
|
404
|
+
];
|
|
405
|
+
var TxJournal = class {
|
|
406
|
+
ops = [];
|
|
407
|
+
ctx;
|
|
408
|
+
};
|
|
409
|
+
var outboxListeners = /* @__PURE__ */ new Set();
|
|
410
|
+
function subscribeOutboxWrites(listener) {
|
|
411
|
+
outboxListeners.add(listener);
|
|
412
|
+
return () => {
|
|
413
|
+
outboxListeners.delete(listener);
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
function notifyOutboxCommit() {
|
|
417
|
+
for (const listener of outboxListeners) listener();
|
|
418
|
+
}
|
|
419
|
+
function isKeyRange(key) {
|
|
420
|
+
return key !== null && typeof key === "object" && !Array.isArray(key) && ("lower" in key || "upper" in key || "lowerOpen" in key || "upperOpen" in key);
|
|
421
|
+
}
|
|
422
|
+
function mutationBytes(op) {
|
|
423
|
+
if (op.op !== "put") return 32;
|
|
424
|
+
let bytes = 32;
|
|
425
|
+
const field = BINARY_SYNC_FIELDS[op.store];
|
|
426
|
+
const value = op.value;
|
|
427
|
+
const data = field && value !== null && typeof value === "object" && !Array.isArray(value) && field in value ? Reflect.get(value, field) : void 0;
|
|
428
|
+
bytes += typeof data === "string" ? data.length : data instanceof ArrayBuffer ? Math.ceil(data.byteLength / 3) * 4 + 64 : JSON.stringify(op.value)?.length ?? 0;
|
|
429
|
+
return bytes;
|
|
430
|
+
}
|
|
431
|
+
var SNAPSHOT_OP_ORDER = [
|
|
432
|
+
"block_revisions",
|
|
433
|
+
"notes",
|
|
434
|
+
"sessions",
|
|
435
|
+
...NON_SYSTEM_STORES.filter(
|
|
436
|
+
(name) => name !== "block_revisions" && name !== "notes" && name !== "sessions"
|
|
437
|
+
)
|
|
438
|
+
];
|
|
439
|
+
async function policySnapshotOps(readAll) {
|
|
440
|
+
const ctx = collectUserScopeContext(await readAll("notes"));
|
|
441
|
+
const ops = [];
|
|
442
|
+
for (const store of SNAPSHOT_OP_ORDER) {
|
|
443
|
+
const def = STORE_DEFS.find((candidate) => candidate.name === store);
|
|
444
|
+
for (const row of await readAll(store)) {
|
|
445
|
+
if (!isUserOwnedRow(store, row, ctx)) continue;
|
|
446
|
+
ops.push({ op: "put", store, key: keyFromValue(def, row), value: row });
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return ops;
|
|
450
|
+
}
|
|
451
|
+
var IDBStore = class _IDBStore {
|
|
452
|
+
constructor(owner, name, tx, mutable = false, touched, journal) {
|
|
453
|
+
this.owner = owner;
|
|
454
|
+
this.name = name;
|
|
455
|
+
this.tx = tx;
|
|
456
|
+
this.mutable = mutable;
|
|
457
|
+
this.touched = touched;
|
|
458
|
+
this.journal = journal;
|
|
459
|
+
}
|
|
460
|
+
owner;
|
|
461
|
+
name;
|
|
462
|
+
tx;
|
|
463
|
+
mutable;
|
|
464
|
+
touched;
|
|
465
|
+
journal;
|
|
466
|
+
async get(key) {
|
|
467
|
+
if (this.tx) return this.tx.objectStore(this.name).get(key);
|
|
468
|
+
return (await this.owner.open()).get(this.name, key);
|
|
469
|
+
}
|
|
470
|
+
async getMany(keys) {
|
|
471
|
+
if (this.tx) return Promise.all(keys.map((key) => this.tx.objectStore(this.name).get(key)));
|
|
472
|
+
return this.owner.transaction([this.name], "readonly", (tx) => tx.readonly(this.name).getMany(keys));
|
|
473
|
+
}
|
|
474
|
+
async getAll(query, count) {
|
|
475
|
+
if (this.tx) return this.tx.objectStore(this.name).getAll(query, count);
|
|
476
|
+
return (await this.owner.open()).getAll(this.name, query, count);
|
|
477
|
+
}
|
|
478
|
+
async getAllFromIndex(index, query, count) {
|
|
479
|
+
if (this.tx) return this.tx.objectStore(this.name).index(index).getAll(query, count);
|
|
480
|
+
return (await this.owner.open()).getAllFromIndex(this.name, index, query, count);
|
|
481
|
+
}
|
|
482
|
+
async getAllFromIndexMany(index, keys) {
|
|
483
|
+
if (this.tx) return Promise.all(keys.map((key) => this.tx.objectStore(this.name).index(index).getAll(key)));
|
|
484
|
+
return this.owner.transaction([this.name], "readonly", (tx) => tx.readonly(this.name).getAllFromIndexMany(index, keys));
|
|
485
|
+
}
|
|
486
|
+
async count(query) {
|
|
487
|
+
if (this.tx) return this.tx.objectStore(this.name).count(query);
|
|
488
|
+
return (await this.owner.open()).count(this.name, query);
|
|
489
|
+
}
|
|
490
|
+
async countFromIndex(index, query) {
|
|
491
|
+
if (this.tx) return this.tx.objectStore(this.name).index(index).count(query);
|
|
492
|
+
return (await this.owner.open()).countFromIndex(this.name, index, query);
|
|
493
|
+
}
|
|
494
|
+
async put(value, _key) {
|
|
495
|
+
if (!this.mutable) throw new Error("read-only store");
|
|
496
|
+
if (!this.tx) {
|
|
497
|
+
const scope = this.name === "notes" ? [this.name, "block_revisions"] : [this.name, "block_revisions", "notes"];
|
|
498
|
+
return this.owner.transaction([...scope], "readwrite", (tx) => tx.readwrite(this.name).put(value));
|
|
499
|
+
}
|
|
500
|
+
const stored = this.owner.stamp(this.name, value);
|
|
501
|
+
validateStorageRow(this.name, stored);
|
|
502
|
+
const def = STORE_DEFS.find((def2) => def2.name === this.name);
|
|
503
|
+
const key = keyFromValue(def, stored);
|
|
504
|
+
const store = this.tx.objectStore(this.name);
|
|
505
|
+
const previous = await store.get(key);
|
|
506
|
+
if (this.name === "block_revisions" && previous !== void 0 && !rowsEqual(previous, stored)) throw new Error("immutable block revision collision");
|
|
507
|
+
if (this.name === "notes") validateNoteWrite(previous, stored);
|
|
508
|
+
if (this.name === "sessions") validateSessionWrite(previous, stored);
|
|
509
|
+
if (rowsEqual(previous, stored)) return key;
|
|
510
|
+
const result = await store.put(stored);
|
|
511
|
+
if (!def.system && this.journal) {
|
|
512
|
+
const ctx = this.name === "notes" ? void 0 : await (this.journal.ctx ??= this.owner.policyContext(this.tx));
|
|
513
|
+
const wasUser = previous !== void 0 && isUserOwnedRow(this.name, previous, ctx ?? collectUserScopeContext([]));
|
|
514
|
+
const isUser = isUserOwnedRow(this.name, stored, ctx ?? collectUserScopeContext([]));
|
|
515
|
+
if (isUser) {
|
|
516
|
+
if (this.name === "notes" && stored !== null && typeof stored === "object" && "id" in stored) {
|
|
517
|
+
for (const revision of await this.tx.objectStore("block_revisions").index("by-note").getAll(String(stored.id))) {
|
|
518
|
+
this.journal.ops.push({ op: "put", store: "block_revisions", key: [revision.noteId, revision.blockId, revision.version], value: revision });
|
|
519
|
+
}
|
|
520
|
+
} else if (this.name === "sessions" && stored !== null && typeof stored === "object" && "noteId" in stored && "blockId" in stored && "blockVersion" in stored) {
|
|
521
|
+
const revisionKey = [stored.noteId, stored.blockId, stored.blockVersion];
|
|
522
|
+
const revision = await this.tx.objectStore("block_revisions").get(revisionKey);
|
|
523
|
+
if (revision) this.journal.ops.push({ op: "put", store: "block_revisions", key: revisionKey, value: revision });
|
|
524
|
+
}
|
|
525
|
+
this.journal.ops.push({ op: "put", store: this.name, key, value: stored });
|
|
526
|
+
} else if (wasUser) this.journal.ops.push({ op: "delete", store: this.name, key });
|
|
527
|
+
if (this.name === "notes") this.journal.ctx = void 0;
|
|
528
|
+
}
|
|
529
|
+
if (this.name === "notes") this.touched?.notes.add(String(key));
|
|
530
|
+
if (this.name === "sessions") this.touched?.sessions.add(String(key));
|
|
531
|
+
return result;
|
|
532
|
+
}
|
|
533
|
+
async putMany(values, keys) {
|
|
534
|
+
if (keys && keys.length !== values.length) throw new Error("putMany key count mismatch");
|
|
535
|
+
if (!this.tx) {
|
|
536
|
+
const scope = this.name === "notes" ? [this.name, "block_revisions"] : [this.name, "block_revisions", "notes"];
|
|
537
|
+
return this.owner.transaction([...scope], "readwrite", (tx) => tx.readwrite(this.name).putMany(values, keys));
|
|
538
|
+
}
|
|
539
|
+
const out = [];
|
|
540
|
+
for (let i = 0; i < values.length; i++) out.push(await this.put(values[i], keys?.[i]));
|
|
541
|
+
return out;
|
|
542
|
+
}
|
|
543
|
+
async delete(key) {
|
|
544
|
+
if (!this.mutable) throw new Error("read-only store");
|
|
545
|
+
if (this.name === "block_revisions") throw new Error("block history requires explicit wipe");
|
|
546
|
+
if (!this.tx) {
|
|
547
|
+
const scope = this.name === "notes" ? [this.name] : [this.name, "notes"];
|
|
548
|
+
return this.owner.transaction([...scope], "readwrite", (tx) => tx.readwrite(this.name).delete(key));
|
|
549
|
+
}
|
|
550
|
+
if (this.name === "notes") {
|
|
551
|
+
for (const note of await this.tx.objectStore("notes").getAll(key)) {
|
|
552
|
+
for (const name of ["block_index", "block_efforts", "block_equipment"]) {
|
|
553
|
+
const store = this.tx.objectStore(name);
|
|
554
|
+
for (const id of await store.index("by-note").getAllKeys(note.id)) {
|
|
555
|
+
const projection = new _IDBStore(this.owner, name, this.tx, true, this.touched, this.journal);
|
|
556
|
+
await projection.delete(id);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
await this.recordDelete(key);
|
|
562
|
+
await this.tx.objectStore(this.name).delete(key);
|
|
563
|
+
}
|
|
564
|
+
/** Journal a delete as per-key tombstones (ranges expand to their keys).
|
|
565
|
+
* Journal-time ownership: only deletes of USER-scope rows ship — a seed
|
|
566
|
+
* row's removal is re-imported remotely, not a user mutation (deleting a
|
|
567
|
+
* seed NOTE carries intent via the user-deleted-seed meta tombstone the
|
|
568
|
+
* caller writes in the same transaction). */
|
|
569
|
+
async recordDelete(key) {
|
|
570
|
+
if (!this.journal) return;
|
|
571
|
+
const store = this.tx.objectStore(this.name);
|
|
572
|
+
const keys = isKeyRange(key) ? await store.getAllKeys(key) : [key];
|
|
573
|
+
const ctx = this.name === "notes" ? void 0 : await (this.journal.ctx ??= this.owner.policyContext(this.tx));
|
|
574
|
+
for (const k of keys) {
|
|
575
|
+
const previous = await store.get(k);
|
|
576
|
+
if (previous !== void 0 && !isUserOwnedRow(this.name, previous, ctx ?? collectUserScopeContext([]))) continue;
|
|
577
|
+
this.journal.ops.push({ op: "delete", store: this.name, key: k });
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* User-scope clear: expands to per-key deletes of user-owned rows,
|
|
582
|
+
* journaled the same way so replay preserves exactly what the local clear
|
|
583
|
+
* preserved — seed content (re-importable), pinned revision history
|
|
584
|
+
* (block_revisions requires explicit wipe) and seed-deletion tombstones
|
|
585
|
+
* survive a clear. A journaled `{op:'clear'}` would wipe seed rows on
|
|
586
|
+
* every replaying device.
|
|
587
|
+
*/
|
|
588
|
+
async clear() {
|
|
589
|
+
if (!this.mutable) throw new Error("read-only store");
|
|
590
|
+
if (this.name === "block_revisions") throw new Error("block history requires explicit wipe");
|
|
591
|
+
if (!this.tx) {
|
|
592
|
+
const scope = this.name === "notes" ? ["notes", "block_index", "block_efforts"] : [this.name, "notes"];
|
|
593
|
+
return this.owner.transaction([...scope], "readwrite", (tx) => tx.readwrite(this.name).clear());
|
|
594
|
+
}
|
|
595
|
+
const store = this.tx.objectStore(this.name);
|
|
596
|
+
const rows = await store.getAll();
|
|
597
|
+
const notes = this.name === "notes" ? rows : await this.tx.objectStore("notes").getAll();
|
|
598
|
+
const ctx = collectUserScopeContext(notes);
|
|
599
|
+
for (const row of rows) {
|
|
600
|
+
const key = keyFromValue(STORE_DEFS.find((def) => def.name === this.name), row);
|
|
601
|
+
if (!isUserOwnedRow(this.name, row, ctx)) continue;
|
|
602
|
+
if (this.name === "meta" && typeof key === "string" && key.startsWith(USER_DELETED_SEED_PREFIX)) continue;
|
|
603
|
+
if (this.name === "notes" && typeof key === "string") {
|
|
604
|
+
for (const name of ["block_index", "block_efforts", "block_equipment"]) {
|
|
605
|
+
const projections = this.tx.objectStore(name);
|
|
606
|
+
for (const id of await projections.index("by-note").getAllKeys(key)) {
|
|
607
|
+
this.journal?.ops.push({ op: "delete", store: name, key: id });
|
|
608
|
+
await projections.delete(id);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
await this.recordDelete(key);
|
|
613
|
+
await store.delete(key);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
var SEGMENT_TO_BLOCK_KIND = { script: "wod", wod: "wod" };
|
|
618
|
+
function legacyDialect(rawContent) {
|
|
619
|
+
const first = rawContent.slice(0, rawContent.indexOf("\n"));
|
|
620
|
+
return wodWikiCore.matchDialectFence(first.trim()) ?? {};
|
|
621
|
+
}
|
|
622
|
+
var IndexedDBStorage = class {
|
|
623
|
+
constructor(options = {}) {
|
|
624
|
+
this.options = options;
|
|
625
|
+
}
|
|
626
|
+
options;
|
|
627
|
+
opening;
|
|
628
|
+
open() {
|
|
629
|
+
if (typeof indexedDB === "undefined") return Promise.reject(new Error("IndexedDB is unavailable in this environment"));
|
|
630
|
+
if (!this.opening) {
|
|
631
|
+
let newer = false;
|
|
632
|
+
let pre20 = 0;
|
|
633
|
+
let migrationError;
|
|
634
|
+
this.opening = idb.openDB(this.options.dbName ?? IDB_DB_NAME, DB_VERSION, {
|
|
635
|
+
upgrade: async (db, oldVersion, _version, tx) => {
|
|
636
|
+
void tx.done.catch(() => {
|
|
637
|
+
});
|
|
638
|
+
if (oldVersion > DB_VERSION) {
|
|
639
|
+
newer = true;
|
|
640
|
+
tx.abort();
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
if (oldVersion !== 0 && oldVersion < 20) {
|
|
644
|
+
pre20 = oldVersion;
|
|
645
|
+
tx.abort();
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
try {
|
|
649
|
+
createMissingStores(db, tx);
|
|
650
|
+
if (oldVersion !== 0 && oldVersion < DB_VERSION) this.options.onUpgradeStart?.(oldVersion);
|
|
651
|
+
await migrateSchema(db, oldVersion, tx);
|
|
652
|
+
} catch (error) {
|
|
653
|
+
migrationError = error instanceof Error ? error : new Error(String(error));
|
|
654
|
+
try {
|
|
655
|
+
tx.abort();
|
|
656
|
+
} catch {
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
},
|
|
660
|
+
blocking: () => {
|
|
661
|
+
void this.close();
|
|
662
|
+
}
|
|
663
|
+
}).catch((error) => {
|
|
664
|
+
this.opening = void 0;
|
|
665
|
+
if (migrationError) throw migrationError;
|
|
666
|
+
if (pre20 !== 0) {
|
|
667
|
+
throw new Error(
|
|
668
|
+
`local database schema v${pre20} predates the supported migration chain (v20+). Open this browser profile in an intermediate app version to export your data, or clear site data to start fresh \u2014 the old database was left untouched.`
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
throw newer || error instanceof DOMException && error.name === "VersionError" ? new Error(`database schema is newer than this client supports (v${DB_VERSION}); refusing to downgrade`) : error;
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
return this.opening;
|
|
675
|
+
}
|
|
676
|
+
stamp(store, value) {
|
|
677
|
+
const userId = this.options.getUserId?.();
|
|
678
|
+
return userId ? stampUserOwned(store, value, userId) : value;
|
|
679
|
+
}
|
|
680
|
+
/** User-scope context (proven seed note ids) from the notes of `tx` —
|
|
681
|
+
* journal-time ownership classification reads it INSIDE the same
|
|
682
|
+
* transaction, so a seed import's note writes are visible to the link
|
|
683
|
+
* writes that follow in the same commit. Internal seam for IDBStore. */
|
|
684
|
+
async policyContext(tx) {
|
|
685
|
+
const notes = tx ? await tx.objectStore("notes").getAll() : await (await this.open()).getAll("notes");
|
|
686
|
+
return collectUserScopeContext(notes);
|
|
687
|
+
}
|
|
688
|
+
readonly(store) {
|
|
689
|
+
return new IDBStore(this, store);
|
|
690
|
+
}
|
|
691
|
+
readwrite(store) {
|
|
692
|
+
return new IDBStore(this, store, void 0, true);
|
|
693
|
+
}
|
|
694
|
+
async transaction(stores, mode, fn) {
|
|
695
|
+
const db = await this.open();
|
|
696
|
+
const journal = mode === "readwrite" ? new TxJournal() : void 0;
|
|
697
|
+
const scope = new Set(stores);
|
|
698
|
+
if (mode === "readwrite") {
|
|
699
|
+
scope.add("block_revisions");
|
|
700
|
+
scope.add(OUTBOX_STORE);
|
|
701
|
+
scope.add("notes");
|
|
702
|
+
if (scope.has("notes")) {
|
|
703
|
+
scope.add("block_index");
|
|
704
|
+
scope.add("block_efforts");
|
|
705
|
+
scope.add("block_equipment");
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
const native = db.transaction([...scope], mode);
|
|
709
|
+
const done = native.done;
|
|
710
|
+
void done.catch(() => {
|
|
711
|
+
});
|
|
712
|
+
const touched = { notes: /* @__PURE__ */ new Set(), sessions: /* @__PURE__ */ new Set() };
|
|
713
|
+
const adapter = {
|
|
714
|
+
readonly: (name) => {
|
|
715
|
+
if (!scope.has(name)) throw new Error("store outside transaction scope");
|
|
716
|
+
return new IDBStore(this, name, native);
|
|
717
|
+
},
|
|
718
|
+
readwrite: (name) => {
|
|
719
|
+
if (mode !== "readwrite") throw new Error("read-only transaction");
|
|
720
|
+
if (!scope.has(name)) throw new Error("store outside transaction scope");
|
|
721
|
+
return new IDBStore(this, name, native, true, touched, journal);
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
try {
|
|
725
|
+
const result = await fn(adapter);
|
|
726
|
+
const notes = (await Promise.all([...touched.notes].map((id) => native.objectStore("notes").get(id)))).filter(Boolean);
|
|
727
|
+
const sessions = (await Promise.all([...touched.sessions].map((id) => native.objectStore("sessions").get(id)))).filter(Boolean);
|
|
728
|
+
await validateStoredPins(notes, sessions, (keys) => Promise.all(keys.map((key) => native.objectStore("block_revisions").get(key))));
|
|
729
|
+
if (journal && journal.ops.length > 0) {
|
|
730
|
+
await native.objectStore(OUTBOX_STORE).put({ id: crypto.randomUUID(), at: Date.now(), ops: journal.ops });
|
|
731
|
+
}
|
|
732
|
+
await done;
|
|
733
|
+
if (journal && journal.ops.length > 0) notifyOutboxCommit();
|
|
734
|
+
return result;
|
|
735
|
+
} catch (error) {
|
|
736
|
+
try {
|
|
737
|
+
native.abort();
|
|
738
|
+
} catch {
|
|
739
|
+
}
|
|
740
|
+
await done.catch(() => {
|
|
741
|
+
});
|
|
742
|
+
throw error;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
async wipeUserData() {
|
|
746
|
+
const names = STORE_DEFS.filter((def) => !def.system && def.name !== "block_revisions").map((def) => def.name);
|
|
747
|
+
await this.transaction(names, "readwrite", async (tx) => {
|
|
748
|
+
for (const name of names) await tx.readwrite(name).clear();
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
async wipe() {
|
|
752
|
+
const db = await this.open();
|
|
753
|
+
const names = STORE_DEFS.filter((def) => !def.system).map((def) => def.name);
|
|
754
|
+
const tx = db.transaction([...names, OUTBOX_STORE], "readwrite");
|
|
755
|
+
await Promise.all(names.map((name) => tx.objectStore(name).clear()));
|
|
756
|
+
await tx.objectStore(OUTBOX_STORE).put({ id: crypto.randomUUID(), at: Date.now(), ops: [{ op: "wipe" }] });
|
|
757
|
+
await tx.done;
|
|
758
|
+
notifyOutboxCommit();
|
|
759
|
+
}
|
|
760
|
+
// -----------------------------------------------------------------------
|
|
761
|
+
// Sync seams (Turso backup engine only — not part of IStorage).
|
|
762
|
+
// -----------------------------------------------------------------------
|
|
763
|
+
/** Pending committed transaction groups, in commit order. */
|
|
764
|
+
async readOutbox(afterSeq, limit) {
|
|
765
|
+
const db = await this.open();
|
|
766
|
+
const range = IDBKeyRange.lowerBound(afterSeq, true);
|
|
767
|
+
return await db.getAll(OUTBOX_STORE, range, Math.min(limit, 4294967295));
|
|
768
|
+
}
|
|
769
|
+
/** Drop acknowledged groups. Callers pass only the contiguous acked
|
|
770
|
+
* watermark — a failed upload never truncates pending writes. */
|
|
771
|
+
async ackOutbox(upToSeq) {
|
|
772
|
+
const db = await this.open();
|
|
773
|
+
const keys = await db.getAllKeys(OUTBOX_STORE, IDBKeyRange.upperBound(upToSeq));
|
|
774
|
+
const tx = db.transaction(OUTBOX_STORE, "readwrite");
|
|
775
|
+
await Promise.all(keys.map((key) => tx.store.delete(key)));
|
|
776
|
+
await tx.done;
|
|
777
|
+
}
|
|
778
|
+
async getSyncState(key) {
|
|
779
|
+
const row = await (await this.open()).get(SYNC_STATE_STORE, key);
|
|
780
|
+
return row?.value;
|
|
781
|
+
}
|
|
782
|
+
async setSyncState(key, value) {
|
|
783
|
+
await (await this.open()).put(SYNC_STATE_STORE, { key, value });
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Freeze ONE durable bootstrap run for `key` (per backup target): a
|
|
787
|
+
* policy-compliant snapshot of the current rows, chunked into ≤500-op /
|
|
788
|
+
* ≤~1MB entries with a unique run UUID and stable chunk ids, persisted in
|
|
789
|
+
* `sync_state[key]` in the SAME readwrite transaction that reads the data
|
|
790
|
+
* — so a retry returns the byte-identical frozen entries (idempotent
|
|
791
|
+
* remote replays), and a crash before the commit freezes nothing. No
|
|
792
|
+
* network happens here.
|
|
793
|
+
*
|
|
794
|
+
* Pending outbox groups are read INSIDE the same transaction: their
|
|
795
|
+
* touched keys/stores are excluded from the snapshot so the journal (which
|
|
796
|
+
* ships after the bootstrap, in commit order) stays the last writer and
|
|
797
|
+
* nothing ships twice. A pending wipe freezes an empty run — the journal
|
|
798
|
+
* wipe itself still ships and decides the remote's end state.
|
|
799
|
+
*
|
|
800
|
+
* The engine deletes the frozen run (`db.delete('sync_state', key)`) only
|
|
801
|
+
* after the remote commit; until then every retry replays these entries.
|
|
802
|
+
*/
|
|
803
|
+
async captureSyncBootstrap(key) {
|
|
804
|
+
const db = await this.open();
|
|
805
|
+
const tx = db.transaction([...NON_SYSTEM_STORES, OUTBOX_STORE, SYNC_STATE_STORE], "readwrite");
|
|
806
|
+
const state = tx.objectStore(SYNC_STATE_STORE);
|
|
807
|
+
const frozen = await state.get(key);
|
|
808
|
+
if (frozen !== void 0 && Array.isArray(frozen.value)) {
|
|
809
|
+
await tx.done;
|
|
810
|
+
return frozen.value;
|
|
811
|
+
}
|
|
812
|
+
const pendingKeys = /* @__PURE__ */ new Set();
|
|
813
|
+
const clearedStores = /* @__PURE__ */ new Set();
|
|
814
|
+
let wiped = false;
|
|
815
|
+
for (const group of await tx.objectStore(OUTBOX_STORE).getAll()) {
|
|
816
|
+
for (const op of group.ops) {
|
|
817
|
+
if (op.op === "put" || op.op === "delete") pendingKeys.add(`${op.store}:${JSON.stringify(op.key)}`);
|
|
818
|
+
else if (op.op === "clear") clearedStores.add(op.store);
|
|
819
|
+
else wiped = true;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
const run = crypto.randomUUID();
|
|
823
|
+
const entries = [];
|
|
824
|
+
if (!wiped) {
|
|
825
|
+
let ops = [];
|
|
826
|
+
let bytes = 0;
|
|
827
|
+
const flush = () => {
|
|
828
|
+
if (ops.length === 0) return;
|
|
829
|
+
entries.push({ id: `bootstrap:${run}:${entries.length}`, at: Date.now(), ops });
|
|
830
|
+
ops = [];
|
|
831
|
+
bytes = 0;
|
|
832
|
+
};
|
|
833
|
+
for (const op of await policySnapshotOps((store) => tx.objectStore(store).getAll())) {
|
|
834
|
+
if (clearedStores.has(op.store)) continue;
|
|
835
|
+
if (op.store !== "block_revisions" && pendingKeys.has(`${op.store}:${JSON.stringify(op.key)}`)) continue;
|
|
836
|
+
const size = mutationBytes(op);
|
|
837
|
+
if (ops.length > 0 && (ops.length >= BOOTSTRAP_MAX_OPS || bytes + size > BOOTSTRAP_MAX_BYTES)) flush();
|
|
838
|
+
ops.push(op);
|
|
839
|
+
bytes += size;
|
|
840
|
+
}
|
|
841
|
+
flush();
|
|
842
|
+
}
|
|
843
|
+
await state.put({ key, value: entries });
|
|
844
|
+
await tx.done;
|
|
845
|
+
return entries;
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Validate every stored pin (note blocks + session pins against
|
|
849
|
+
* block_revisions) in ONE consistent readonly read — the post-restore
|
|
850
|
+
* gate: a replayed backup with missing history fails loudly here instead
|
|
851
|
+
* of surfacing later as broken sessions.
|
|
852
|
+
*/
|
|
853
|
+
async validateSyncState() {
|
|
854
|
+
const db = await this.open();
|
|
855
|
+
const tx = db.transaction(["notes", "sessions", "block_revisions"], "readonly");
|
|
856
|
+
const notes = await tx.objectStore("notes").getAll();
|
|
857
|
+
const sessions = await tx.objectStore("sessions").getAll();
|
|
858
|
+
const revisions = tx.objectStore("block_revisions");
|
|
859
|
+
await validateStoredPins(
|
|
860
|
+
notes,
|
|
861
|
+
sessions,
|
|
862
|
+
(keys) => Promise.all(keys.map((key) => revisions.get(key)))
|
|
863
|
+
);
|
|
864
|
+
await tx.done;
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Apply remotely committed mutations locally (pull/replay) WITHOUT
|
|
868
|
+
* journaling — a pulled mutation must never re-enter the outbox. Runs in
|
|
869
|
+
* one native transaction together with the cursor update.
|
|
870
|
+
*
|
|
871
|
+
* Unsent-edit overlay: pending outbox groups (committed local writes not
|
|
872
|
+
* yet acknowledged remotely) are read INSIDE the same transaction and
|
|
873
|
+
* replayed after the remote rows — a local edit/delete/clear/wipe that
|
|
874
|
+
* committed while the pull was on the network stays the visible state;
|
|
875
|
+
* the remote row is backed up locally, not clobbered over the user.
|
|
876
|
+
*
|
|
877
|
+
* Conflict policy (deterministic, history-preserving):
|
|
878
|
+
* - notes: an incoming row with a LOWER sourceRevision than the existing
|
|
879
|
+
* row is stale — skipped, never clobbers newer history.
|
|
880
|
+
* - block_revisions: same key with different contents keeps BOTH rows —
|
|
881
|
+
* the incoming version is re-keyed to the next free version for that
|
|
882
|
+
* block. Immutable history is never silently replaced.
|
|
883
|
+
* - memberships (system): remote mutations are skipped — identity truth
|
|
884
|
+
* stays local and snapshot/upload never include them.
|
|
885
|
+
*
|
|
886
|
+
* Returns committed-boundary counts of canonical consumer records (notes +
|
|
887
|
+
* efforts only; internal/journal rows never reach the sync toast).
|
|
888
|
+
* `cursorKey` keeps cursors per backup target.
|
|
889
|
+
*/
|
|
890
|
+
async applySyncMutations(mutations, cursorRev, cursorKey = "cursor") {
|
|
891
|
+
const defsByName = new Map(STORE_DEFS.map((def) => [def.name, def]));
|
|
892
|
+
for (const mutation of mutations) {
|
|
893
|
+
if (mutation.op === "clear" || mutation.op === "wipe") continue;
|
|
894
|
+
const def = defsByName.get(mutation.store);
|
|
895
|
+
if (!def) throw new Error(`unknown store in sync mutation: ${String(mutation.store)}`);
|
|
896
|
+
if (mutation.op === "put" || mutation.op === "delete") {
|
|
897
|
+
const parts = Array.isArray(mutation.key) ? mutation.key : [mutation.key];
|
|
898
|
+
if (parts.length !== def.keyPath.length || !parts.every((part) => typeof part === "string" || typeof part === "number")) {
|
|
899
|
+
throw new Error(`sync ${mutation.op} key arity/shape mismatch for ${mutation.store}`);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
if (mutation.op === "put") validateStorageRow(mutation.store, mutation.value);
|
|
903
|
+
}
|
|
904
|
+
const db = await this.open();
|
|
905
|
+
const names = NON_SYSTEM_STORES;
|
|
906
|
+
const tx = db.transaction([...names, OUTBOX_STORE, SYNC_STATE_STORE], "readwrite");
|
|
907
|
+
const canonical = (store) => store === "notes" || store === "efforts";
|
|
908
|
+
const touched = /* @__PURE__ */ new Map();
|
|
909
|
+
for (const mutation of mutations) {
|
|
910
|
+
if (mutation.op === "clear" || mutation.op === "wipe") continue;
|
|
911
|
+
if (!canonical(mutation.store)) continue;
|
|
912
|
+
const id = `${mutation.store}:${JSON.stringify(mutation.key)}`;
|
|
913
|
+
if (touched.has(id)) continue;
|
|
914
|
+
const key = mutation.key;
|
|
915
|
+
const before = await tx.objectStore(mutation.store).get(key);
|
|
916
|
+
touched.set(id, { store: mutation.store, key, before, beforeExists: before !== void 0 });
|
|
917
|
+
}
|
|
918
|
+
const applyOne = async (mutation, preferLocal) => {
|
|
919
|
+
if (mutation.op === "wipe") {
|
|
920
|
+
for (const name of names) await tx.objectStore(name).clear();
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
if (mutation.store === "memberships") return;
|
|
924
|
+
if (mutation.op === "clear") {
|
|
925
|
+
if (mutation.store === "block_revisions") return;
|
|
926
|
+
const store2 = tx.objectStore(mutation.store);
|
|
927
|
+
const ctx = collectUserScopeContext(await tx.objectStore("notes").getAll());
|
|
928
|
+
for (const row of await store2.getAll()) {
|
|
929
|
+
if (!isUserOwnedRow(mutation.store, row, ctx)) continue;
|
|
930
|
+
const key = keyFromValue(defsByName.get(mutation.store), row);
|
|
931
|
+
if (mutation.store === "meta" && typeof key === "string" && key.startsWith(USER_DELETED_SEED_PREFIX)) continue;
|
|
932
|
+
await applyOne({ op: "delete", store: mutation.store, key }, preferLocal);
|
|
933
|
+
}
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
if (mutation.op === "delete") {
|
|
937
|
+
if (mutation.store === "notes") {
|
|
938
|
+
const noteId = String(Array.isArray(mutation.key) ? mutation.key[0] : mutation.key);
|
|
939
|
+
for (const name of ["block_index", "block_efforts", "block_equipment"]) {
|
|
940
|
+
const store2 = tx.objectStore(name);
|
|
941
|
+
for (const id of await store2.index("by-note").getAllKeys(noteId)) await store2.delete(id);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
if (mutation.store !== "block_revisions") await tx.objectStore(mutation.store).delete(mutation.key);
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
const store = tx.objectStore(mutation.store);
|
|
948
|
+
if (mutation.store === "block_revisions") {
|
|
949
|
+
const existing = await store.get(mutation.key);
|
|
950
|
+
if (existing !== void 0 && !rowsEqual(existing, mutation.value) && !preferLocal) {
|
|
951
|
+
const incoming = mutation.value;
|
|
952
|
+
const existingRow = existing;
|
|
953
|
+
let version = existingRow.version + 1;
|
|
954
|
+
while (await store.get([incoming.noteId, incoming.blockId, version]) !== void 0) version += 1;
|
|
955
|
+
await store.put({ ...incoming, version });
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
if (existing === void 0 || !rowsEqual(existing, mutation.value)) await store.put(mutation.value);
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
if (mutation.store === "notes" && !preferLocal) {
|
|
962
|
+
const existing = await store.get(mutation.key);
|
|
963
|
+
if (existing !== void 0) {
|
|
964
|
+
const existingNote = existing;
|
|
965
|
+
const incomingNote = mutation.value;
|
|
966
|
+
if (existingNote.sourceRevision > incomingNote.sourceRevision) return;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
await store.put(mutation.value);
|
|
970
|
+
};
|
|
971
|
+
for (const mutation of mutations) await applyOne(mutation, false);
|
|
972
|
+
const pending = (await tx.objectStore(OUTBOX_STORE).getAll()).sort((a, b) => a.seq - b.seq);
|
|
973
|
+
for (const group of pending) {
|
|
974
|
+
for (const op of group.ops) await applyOne(op, true);
|
|
975
|
+
}
|
|
976
|
+
let added = 0;
|
|
977
|
+
let updated = 0;
|
|
978
|
+
for (const entry of touched.values()) {
|
|
979
|
+
const final = await tx.objectStore(entry.store).get(entry.key);
|
|
980
|
+
if (final === void 0) continue;
|
|
981
|
+
if (!entry.beforeExists) added += 1;
|
|
982
|
+
else if (!rowsEqual(entry.before, final)) updated += 1;
|
|
983
|
+
}
|
|
984
|
+
if (cursorRev !== null) await tx.objectStore(SYNC_STATE_STORE).put({ key: cursorKey, value: { rev: cursorRev } });
|
|
985
|
+
await tx.done;
|
|
986
|
+
return { added, updated };
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Consistent atomic snapshot, policy-compliant (shared user policy): only
|
|
990
|
+
* user-scope rows of the non-system stores, read in ONE readonly
|
|
991
|
+
* transaction — the backup never mixes the pre/post state of a concurrent
|
|
992
|
+
* local commit. System stores (memberships) are excluded: they never sync.
|
|
993
|
+
* Use `snapshotAllRows` for the lossless (unfiltered) form.
|
|
994
|
+
*/
|
|
995
|
+
async snapshotRows() {
|
|
996
|
+
const db = await this.open();
|
|
997
|
+
const defs = STORE_DEFS.filter((def) => !def.system);
|
|
998
|
+
const tx = db.transaction(defs.map((def) => def.name), "readonly");
|
|
999
|
+
const ctx = collectUserScopeContext(await tx.objectStore("notes").getAll());
|
|
1000
|
+
const out = await Promise.all(
|
|
1001
|
+
defs.map(async (def) => ({
|
|
1002
|
+
store: def.name,
|
|
1003
|
+
rows: (await tx.objectStore(def.name).getAll()).filter((row) => isUserOwnedRow(def.name, row, ctx))
|
|
1004
|
+
}))
|
|
1005
|
+
);
|
|
1006
|
+
await tx.done;
|
|
1007
|
+
return out;
|
|
1008
|
+
}
|
|
1009
|
+
/** Lossless snapshot of every NON-SYSTEM store (meta included) in one
|
|
1010
|
+
* readonly transaction — local export / restore emptiness checks. A
|
|
1011
|
+
* freshly initialized database may hold a default membership, so the
|
|
1012
|
+
* system store is excluded from both uses. */
|
|
1013
|
+
async snapshotAllRows() {
|
|
1014
|
+
const db = await this.open();
|
|
1015
|
+
const defs = STORE_DEFS.filter((def) => !def.system);
|
|
1016
|
+
const tx = db.transaction(defs.map((def) => def.name), "readonly");
|
|
1017
|
+
const out = await Promise.all(
|
|
1018
|
+
defs.map(async (def) => ({ store: def.name, rows: await tx.objectStore(def.name).getAll() }))
|
|
1019
|
+
);
|
|
1020
|
+
await tx.done;
|
|
1021
|
+
return out;
|
|
1022
|
+
}
|
|
1023
|
+
async close() {
|
|
1024
|
+
const opening = this.opening;
|
|
1025
|
+
this.opening = void 0;
|
|
1026
|
+
(await opening?.catch(() => void 0))?.close();
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
var SCRATCH_TABLES = [
|
|
1030
|
+
...STORE_DEFS.map((def) => ({ name: def.name, keyPath: def.keyPath })),
|
|
1031
|
+
...LEGACY_EXTRA_TABLES.map((table) => ({ name: table.name, keyPath: Array.isArray(table.keyPath) ? table.keyPath : [table.keyPath] }))
|
|
1032
|
+
];
|
|
1033
|
+
var SCRATCH_TABLE_BY_NAME = Object.fromEntries(
|
|
1034
|
+
SCRATCH_TABLES.map((table) => [table.name, table.keyPath])
|
|
1035
|
+
);
|
|
1036
|
+
function assertKnownStore(store) {
|
|
1037
|
+
if (!SCRATCH_TABLE_BY_NAME[store]) throw new Error(`unknown store "${store}" in source op log; copy aborted`);
|
|
1038
|
+
}
|
|
1039
|
+
function rowKey(table, row) {
|
|
1040
|
+
const parts = [];
|
|
1041
|
+
for (const part of SCRATCH_TABLE_BY_NAME[table]) {
|
|
1042
|
+
const value = row[part];
|
|
1043
|
+
if (typeof value !== "string" && typeof value !== "number" && !(value instanceof Date)) {
|
|
1044
|
+
throw new Error(`row for store "${table}" is missing keyPath field "${part}"; copy aborted`);
|
|
1045
|
+
}
|
|
1046
|
+
parts.push(value);
|
|
1047
|
+
}
|
|
1048
|
+
return parts.length === 1 ? parts[0] : parts;
|
|
1049
|
+
}
|
|
1050
|
+
function isIdbKey(value) {
|
|
1051
|
+
return typeof value === "string" || typeof value === "number" || value instanceof Date || Array.isArray(value) && value.length > 0 && value.every(isIdbKey);
|
|
1052
|
+
}
|
|
1053
|
+
function arrayBuffersEqual(a, b) {
|
|
1054
|
+
if (a.byteLength !== b.byteLength) return false;
|
|
1055
|
+
const left = new Uint8Array(a);
|
|
1056
|
+
const right = new Uint8Array(b);
|
|
1057
|
+
return left.every((byte, i) => byte === right[i]);
|
|
1058
|
+
}
|
|
1059
|
+
function deleteIdbDatabase(name) {
|
|
1060
|
+
const idb = globalThis.indexedDB;
|
|
1061
|
+
if (!idb) return Promise.resolve();
|
|
1062
|
+
return new Promise((resolve, reject) => {
|
|
1063
|
+
const request = idb.deleteDatabase(name);
|
|
1064
|
+
request.onsuccess = () => {
|
|
1065
|
+
resolve();
|
|
1066
|
+
};
|
|
1067
|
+
request.onerror = () => {
|
|
1068
|
+
reject(request.error ?? new Error(`failed to delete ${name}`));
|
|
1069
|
+
};
|
|
1070
|
+
request.onblocked = () => {
|
|
1071
|
+
resolve();
|
|
1072
|
+
};
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
async function buildScratch(name, version, ops) {
|
|
1076
|
+
const db = await idb.openDB(name, version, {
|
|
1077
|
+
upgrade(scratch) {
|
|
1078
|
+
for (const table of SCRATCH_TABLES) {
|
|
1079
|
+
if (!scratch.objectStoreNames.contains(table.name)) {
|
|
1080
|
+
scratch.createObjectStore(table.name, { keyPath: table.keyPath.length === 1 ? table.keyPath[0] : table.keyPath });
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
});
|
|
1085
|
+
try {
|
|
1086
|
+
for (const op of ops) {
|
|
1087
|
+
if (!db.objectStoreNames.contains(op.table)) {
|
|
1088
|
+
throw new Error(`unknown store "${op.table}" in source; copy aborted`);
|
|
1089
|
+
}
|
|
1090
|
+
if (op.kind === "put") await db.transaction(op.table, "readwrite").objectStore(op.table).put(op.row);
|
|
1091
|
+
else if (op.kind === "delete") await db.transaction(op.table, "readwrite").objectStore(op.table).delete(op.key);
|
|
1092
|
+
else await db.transaction(op.table, "readwrite").objectStore(op.table).clear();
|
|
1093
|
+
}
|
|
1094
|
+
} finally {
|
|
1095
|
+
db.close();
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
async function assertBinaryPreserved(sourceOps, converted) {
|
|
1099
|
+
const source = /* @__PURE__ */ new Map();
|
|
1100
|
+
for (const op of sourceOps) {
|
|
1101
|
+
if (op.kind === "clear") {
|
|
1102
|
+
if (op.table === "attachments") source.clear();
|
|
1103
|
+
} else if (op.kind === "put" && op.table === "attachments") {
|
|
1104
|
+
if (op.row === null || typeof op.row !== "object" || Array.isArray(op.row) || !("id" in op.row)) {
|
|
1105
|
+
throw new Error("malformed attachment row in source; copy aborted");
|
|
1106
|
+
}
|
|
1107
|
+
source.set(String(op.row.id), op.row);
|
|
1108
|
+
} else if (op.kind === "delete" && op.table === "attachments") {
|
|
1109
|
+
source.delete(String(op.key));
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
if (source.size === 0) return;
|
|
1113
|
+
const rows = await converted.readonly("attachments").getAll();
|
|
1114
|
+
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
1115
|
+
for (const [id, row] of source) {
|
|
1116
|
+
const restored = byId.get(id);
|
|
1117
|
+
if (!restored) throw new Error(`attachment ${id} missing after conversion; copy aborted, source untouched`);
|
|
1118
|
+
const before = row.data;
|
|
1119
|
+
const after = restored.data;
|
|
1120
|
+
if (typeof before === "string" || typeof after === "string") {
|
|
1121
|
+
if (before !== after) throw new Error(`attachment ${id} payload changed during conversion; copy aborted, source untouched`);
|
|
1122
|
+
} else if (!(before instanceof ArrayBuffer) || !(after instanceof ArrayBuffer) || !arrayBuffersEqual(before, after)) {
|
|
1123
|
+
throw new Error(`attachment ${id} payload changed during conversion; copy aborted, source untouched`);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
async function convertSyncSource(db, fromVersion, legacy) {
|
|
1128
|
+
if (!Number.isSafeInteger(fromVersion) || fromVersion < 20) {
|
|
1129
|
+
throw new Error(
|
|
1130
|
+
`Backup database schema v${fromVersion} predates the supported migration chain (v20+); the source was left untouched. Restore it with an intermediate app version, or point this app at a fresh backup database.`
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
if (fromVersion > DB_VERSION) {
|
|
1134
|
+
throw new Error(`Backup database schema is newer than this converter supports (v${fromVersion} > v${DB_VERSION}); refusing to downgrade.`);
|
|
1135
|
+
}
|
|
1136
|
+
const raw = [];
|
|
1137
|
+
if (legacy) {
|
|
1138
|
+
for (const table of [...NON_SYSTEM_STORES, ...LEGACY_EXTRA_TABLES.map((table2) => table2.name)]) {
|
|
1139
|
+
for (const row of await readLegacyRows(db, table)) raw.push({ kind: "put", table, row });
|
|
1140
|
+
}
|
|
1141
|
+
} else {
|
|
1142
|
+
for (const op of await readRawSyncOps(db)) {
|
|
1143
|
+
if (op.op === "wipe") {
|
|
1144
|
+
for (const name of [...NON_SYSTEM_STORES, ...LEGACY_EXTRA_TABLES.map((table) => table.name)]) {
|
|
1145
|
+
raw.push({ kind: "clear", table: name });
|
|
1146
|
+
}
|
|
1147
|
+
} else if (op.op === "clear") {
|
|
1148
|
+
assertKnownStore(op.store);
|
|
1149
|
+
raw.push({ kind: "clear", table: op.store });
|
|
1150
|
+
} else if (op.op === "put") {
|
|
1151
|
+
assertKnownStore(op.store);
|
|
1152
|
+
if (op.value === void 0 || op.value === null || typeof op.value !== "object" || Array.isArray(op.value)) {
|
|
1153
|
+
throw new Error(`malformed put payload for store "${op.store}" in source op log; copy aborted`);
|
|
1154
|
+
}
|
|
1155
|
+
if (JSON.stringify(rowKey(op.store, op.value)) !== JSON.stringify(op.key)) {
|
|
1156
|
+
throw new Error(`put key ${JSON.stringify(op.key)} does not match store "${op.store}" keyPath fields; copy aborted`);
|
|
1157
|
+
}
|
|
1158
|
+
raw.push({ kind: "put", table: op.store, row: op.value });
|
|
1159
|
+
} else if (op.op === "delete") {
|
|
1160
|
+
assertKnownStore(op.store);
|
|
1161
|
+
if (!isIdbKey(op.key)) {
|
|
1162
|
+
throw new Error(`malformed delete key for store "${op.store}" in source op log; copy aborted`);
|
|
1163
|
+
}
|
|
1164
|
+
raw.push({ kind: "delete", table: op.store, key: op.key });
|
|
1165
|
+
} else {
|
|
1166
|
+
throw new Error(`unknown op "${String(op.op)}" in source op log; copy aborted`);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
const scratch = `wodwiki-convert-${crypto.randomUUID()}`;
|
|
1171
|
+
const converted = new IndexedDBStorage({ dbName: scratch });
|
|
1172
|
+
try {
|
|
1173
|
+
await buildScratch(scratch, fromVersion, raw);
|
|
1174
|
+
await converted.open();
|
|
1175
|
+
await converted.validateSyncState();
|
|
1176
|
+
await assertBinaryPreserved(raw, converted);
|
|
1177
|
+
return await policySnapshotOps((store) => converted.readonly(store).getAll());
|
|
1178
|
+
} finally {
|
|
1179
|
+
await converted.close();
|
|
1180
|
+
await deleteIdbDatabase(scratch).catch(() => void 0);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
function createMissingStores(db, tx) {
|
|
1184
|
+
for (const def of STORE_DEFS) {
|
|
1185
|
+
if (db.objectStoreNames.contains(def.name)) {
|
|
1186
|
+
const existing = tx.objectStore(def.name);
|
|
1187
|
+
for (const index of def.indexes) {
|
|
1188
|
+
if (!existing.indexNames.contains(index.name)) existing.createIndex(index.name, index.keyPath.length === 1 ? index.keyPath[0] : index.keyPath, { unique: index.unique, multiEntry: index.multiEntry });
|
|
1189
|
+
}
|
|
1190
|
+
continue;
|
|
1191
|
+
}
|
|
1192
|
+
const store = db.createObjectStore(def.name, { keyPath: def.keyPath.length === 1 ? def.keyPath[0] : def.keyPath });
|
|
1193
|
+
for (const index of def.indexes) store.createIndex(index.name, index.keyPath.length === 1 ? index.keyPath[0] : index.keyPath, { unique: index.unique, multiEntry: index.multiEntry });
|
|
1194
|
+
}
|
|
1195
|
+
for (const local of LOCAL_SYNC_STORES) {
|
|
1196
|
+
if (db.objectStoreNames.contains(local.name)) continue;
|
|
1197
|
+
db.createObjectStore(local.name, { keyPath: local.keyPath, autoIncrement: "autoIncrement" in local ? local.autoIncrement : false });
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
async function migrateSchema(db, oldVersion, tx) {
|
|
1201
|
+
const store = (name) => tx.objectStore(name);
|
|
1202
|
+
if (oldVersion === 20 && db.objectStoreNames.contains("sessions")) {
|
|
1203
|
+
const sessionsStore = store("sessions");
|
|
1204
|
+
const eventsStore = store("events");
|
|
1205
|
+
for await (const cursor of sessionsStore) {
|
|
1206
|
+
const legacy = cursor.value;
|
|
1207
|
+
const logs = legacy.data?.logs ?? [];
|
|
1208
|
+
let alreadyProjected = false;
|
|
1209
|
+
for await (const _ev of eventsStore.index("by-result-grain").iterate(IDBKeyRange.bound([legacy.id, ""], [legacy.id, []]))) {
|
|
1210
|
+
alreadyProjected = true;
|
|
1211
|
+
break;
|
|
1212
|
+
}
|
|
1213
|
+
if (!alreadyProjected && logs.length > 0) {
|
|
1214
|
+
const identity = {
|
|
1215
|
+
noteId: legacy.noteId,
|
|
1216
|
+
resultId: legacy.id,
|
|
1217
|
+
segmentId: legacy.segmentId,
|
|
1218
|
+
segmentVersion: legacy.segmentVersion,
|
|
1219
|
+
blockContentId: legacy.blockContentId,
|
|
1220
|
+
origin: legacy.origin,
|
|
1221
|
+
pageId: legacy.pageId,
|
|
1222
|
+
workoutTimestamp: legacy.data?.endTime ?? legacy.createdAt
|
|
1223
|
+
};
|
|
1224
|
+
for (const row of wodWikiWql.toEventRows(logs, identity)) await eventsStore.put(row);
|
|
1225
|
+
for (const row of wodWikiWql.toSummaryEventRows(logs, identity)) await eventsStore.put(row);
|
|
1226
|
+
}
|
|
1227
|
+
const flat = {
|
|
1228
|
+
...legacy,
|
|
1229
|
+
startTime: legacy.data?.startTime ?? legacy.createdAt,
|
|
1230
|
+
endTime: legacy.data?.endTime ?? legacy.createdAt,
|
|
1231
|
+
duration: legacy.data?.duration ?? 0,
|
|
1232
|
+
completed: legacy.data?.completed ?? true,
|
|
1233
|
+
roundsCompleted: legacy.data?.roundsCompleted,
|
|
1234
|
+
totalRounds: legacy.data?.totalRounds,
|
|
1235
|
+
repsCompleted: legacy.data?.repsCompleted
|
|
1236
|
+
};
|
|
1237
|
+
delete flat.data;
|
|
1238
|
+
await cursor.update(flat);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
if (oldVersion === 21 && db.objectStoreNames.contains("meta")) {
|
|
1242
|
+
await store("meta").delete("seed");
|
|
1243
|
+
}
|
|
1244
|
+
if (oldVersion < 25) {
|
|
1245
|
+
for (const name of USER_OWNED_STORES) {
|
|
1246
|
+
if (!db.objectStoreNames.contains(name)) continue;
|
|
1247
|
+
const s = store(name);
|
|
1248
|
+
if (!s.indexNames.contains("by-user")) s.createIndex("by-user", "userId");
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
if (db.objectStoreNames.contains("results")) {
|
|
1252
|
+
const sessionsStore = store("sessions");
|
|
1253
|
+
const resultsStore = store("results");
|
|
1254
|
+
const eventsStore = store("events");
|
|
1255
|
+
for await (const cursor of resultsStore) {
|
|
1256
|
+
const { data: legacyData, ...legacyRow } = cursor.value;
|
|
1257
|
+
const logs = legacyData?.logs ?? [];
|
|
1258
|
+
if (logs.length > 0) {
|
|
1259
|
+
const identity = {
|
|
1260
|
+
noteId: legacyRow.noteId,
|
|
1261
|
+
resultId: legacyRow.id,
|
|
1262
|
+
segmentId: legacyRow.segmentId,
|
|
1263
|
+
segmentVersion: legacyRow.segmentVersion,
|
|
1264
|
+
blockContentId: legacyRow.blockContentId,
|
|
1265
|
+
origin: legacyRow.origin,
|
|
1266
|
+
pageId: legacyRow.pageId,
|
|
1267
|
+
workoutTimestamp: legacyData?.endTime ?? legacyRow.createdAt
|
|
1268
|
+
};
|
|
1269
|
+
for (const row of wodWikiWql.toEventRows(logs, identity)) {
|
|
1270
|
+
if (await eventsStore.get(row.id) === void 0) await eventsStore.put(row);
|
|
1271
|
+
}
|
|
1272
|
+
for (const row of wodWikiWql.toSummaryEventRows(logs, identity)) {
|
|
1273
|
+
if (await eventsStore.get(row.id) === void 0) await eventsStore.put(row);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
if (await sessionsStore.getKey(legacyRow.id) !== void 0) continue;
|
|
1277
|
+
await sessionsStore.put({
|
|
1278
|
+
...legacyRow,
|
|
1279
|
+
startTime: legacyData?.startTime ?? legacyRow.startTime ?? legacyRow.createdAt,
|
|
1280
|
+
endTime: legacyData?.endTime ?? legacyRow.endTime ?? legacyRow.createdAt,
|
|
1281
|
+
duration: legacyData?.duration ?? legacyRow.duration ?? 0,
|
|
1282
|
+
completed: legacyData?.completed ?? legacyRow.completed ?? true,
|
|
1283
|
+
roundsCompleted: legacyData?.roundsCompleted ?? legacyRow.roundsCompleted,
|
|
1284
|
+
totalRounds: legacyData?.totalRounds ?? legacyRow.totalRounds,
|
|
1285
|
+
repsCompleted: legacyData?.repsCompleted ?? legacyRow.repsCompleted
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
db.deleteObjectStore("results");
|
|
1289
|
+
}
|
|
1290
|
+
if (db.objectStoreNames.contains("block_index") && !store("block_index").indexNames.contains("by-source")) {
|
|
1291
|
+
store("block_index").createIndex("by-source", "sourceId");
|
|
1292
|
+
}
|
|
1293
|
+
if (db.objectStoreNames.contains("notes") && !store("notes").indexNames.contains("by-created")) {
|
|
1294
|
+
store("notes").createIndex("by-created", "createdAt");
|
|
1295
|
+
}
|
|
1296
|
+
if (db.objectStoreNames.contains("efforts") && !store("efforts").indexNames.contains("by-source")) {
|
|
1297
|
+
store("efforts").createIndex("by-source", "registrySource");
|
|
1298
|
+
}
|
|
1299
|
+
if (db.objectStoreNames.contains("note_tags") && !store("note_tags").indexNames.contains("by-note-tag")) {
|
|
1300
|
+
const noteTags = store("note_tags");
|
|
1301
|
+
const winnerByPair = /* @__PURE__ */ new Map();
|
|
1302
|
+
const dupes = [];
|
|
1303
|
+
for await (const cursor of noteTags) {
|
|
1304
|
+
const row = cursor.value;
|
|
1305
|
+
const pair = `${row.noteId}${row.tagId}`;
|
|
1306
|
+
const winner = winnerByPair.get(pair);
|
|
1307
|
+
if (winner === void 0 || row.id < winner) {
|
|
1308
|
+
winnerByPair.set(pair, row.id);
|
|
1309
|
+
if (winner !== void 0) dupes.push(winner);
|
|
1310
|
+
} else {
|
|
1311
|
+
dupes.push(row.id);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
for (const id of dupes) await noteTags.delete(id);
|
|
1315
|
+
noteTags.createIndex("by-note-tag", ["noteId", "tagId"], { unique: true });
|
|
1316
|
+
}
|
|
1317
|
+
if (db.objectStoreNames.contains("notes") && db.objectStoreNames.contains("segments")) {
|
|
1318
|
+
const segments = store("segments");
|
|
1319
|
+
const notes = store("notes");
|
|
1320
|
+
const repaired = /* @__PURE__ */ new Set();
|
|
1321
|
+
for await (const cursor of segments) {
|
|
1322
|
+
const segment = cursor.value;
|
|
1323
|
+
if (segment.dataType !== "frontmatter" || repaired.has(segment.noteId)) continue;
|
|
1324
|
+
if (!/(^|\n)dashboard:\s*["']?true["']?\s*(\n|$)/.test(segment.rawContent)) continue;
|
|
1325
|
+
repaired.add(segment.noteId);
|
|
1326
|
+
const note = await notes.get(segment.noteId);
|
|
1327
|
+
if (!note || note.type === "dashboard" && note.sourceId === "dashboards") continue;
|
|
1328
|
+
await notes.put({ ...note, type: "dashboard", sourceId: note.sourceId ?? "dashboards" });
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
if (db.objectStoreNames.contains("segments")) {
|
|
1332
|
+
await migrateSegmentsToBlocks(db, tx);
|
|
1333
|
+
}
|
|
1334
|
+
if (oldVersion >= 30 && oldVersion < 32 && db.objectStoreNames.contains(OUTBOX_STORE)) {
|
|
1335
|
+
const outbox = store(OUTBOX_STORE);
|
|
1336
|
+
const ops = [];
|
|
1337
|
+
for (const group of await outbox.getAll()) {
|
|
1338
|
+
for (const mutation of group.ops) {
|
|
1339
|
+
if (mutation.op === "wipe") {
|
|
1340
|
+
for (const name of NON_SYSTEM_STORES) {
|
|
1341
|
+
if (name !== "block_revisions") ops.push({ op: "clear", store: name });
|
|
1342
|
+
}
|
|
1343
|
+
} else if (mutation.op !== "put" && mutation.store !== "memberships" && mutation.store !== "block_revisions") {
|
|
1344
|
+
ops.push(mutation);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
ops.push(...await policySnapshotOps((name) => store(name).getAll()));
|
|
1349
|
+
await outbox.clear();
|
|
1350
|
+
if (ops.length > 0) await outbox.put({ id: crypto.randomUUID(), at: Date.now(), ops });
|
|
1351
|
+
if (db.objectStoreNames.contains(SYNC_STATE_STORE)) {
|
|
1352
|
+
const state = store(SYNC_STATE_STORE);
|
|
1353
|
+
for (const key of await state.getAllKeys()) {
|
|
1354
|
+
if (typeof key === "string" && key.startsWith(BOOTSTRAP_RUN_PREFIX)) await state.delete(key);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
async function migrateSegmentsToBlocks(db, tx) {
|
|
1360
|
+
const store = (name) => tx.objectStore(name);
|
|
1361
|
+
const segments = await store("segments").getAll();
|
|
1362
|
+
const notesStore = store("notes");
|
|
1363
|
+
const revisionsStore = store("block_revisions");
|
|
1364
|
+
const byNote = /* @__PURE__ */ new Map();
|
|
1365
|
+
for (const segment of segments) {
|
|
1366
|
+
const list = byNote.get(segment.noteId);
|
|
1367
|
+
if (list) list.push(segment);
|
|
1368
|
+
else byNote.set(segment.noteId, [segment]);
|
|
1369
|
+
}
|
|
1370
|
+
const blockIdBySegment = /* @__PURE__ */ new Map();
|
|
1371
|
+
const blockById = /* @__PURE__ */ new Map();
|
|
1372
|
+
for (const [noteId, noteSegments] of byNote) {
|
|
1373
|
+
const latestById = /* @__PURE__ */ new Map();
|
|
1374
|
+
for (const segment of noteSegments) {
|
|
1375
|
+
const current = latestById.get(segment.id);
|
|
1376
|
+
if (!current || segment.version > current.version) latestById.set(segment.id, segment);
|
|
1377
|
+
}
|
|
1378
|
+
const latest = [...latestById.values()].sort((a, b) => (a.position ?? Number.MAX_SAFE_INTEGER) - (b.position ?? Number.MAX_SAFE_INTEGER) || a.id.localeCompare(b.id));
|
|
1379
|
+
const rawContent = latest.map((segment) => segment.sourceContent ?? `${segment.rawContent}
|
|
1380
|
+
`).join("");
|
|
1381
|
+
const note = await notesStore.get(noteId);
|
|
1382
|
+
if (!note) continue;
|
|
1383
|
+
const sections = wodWikiCore.parseDocumentSections(rawContent, { registeredWidgets: wodWikiCore.KNOWN_SECTION_WIDGETS });
|
|
1384
|
+
const actionableSections = sections.filter((section) => wodWikiCore.actionableSectionKind(section) !== null);
|
|
1385
|
+
const legacyActionable = latest.filter((segment) => segment.dataType in SEGMENT_TO_BLOCK_KIND);
|
|
1386
|
+
const blocks = {};
|
|
1387
|
+
actionableSections.forEach((section, index) => {
|
|
1388
|
+
const kind = wodWikiCore.actionableSectionKind(section);
|
|
1389
|
+
const legacy2 = legacyActionable[index];
|
|
1390
|
+
let id = legacy2?.id ?? `migrated-${index}`;
|
|
1391
|
+
while (Object.hasOwn(blocks, id)) id = `${id}-${index}`;
|
|
1392
|
+
const dialect = kind === "wod" && (section.type === "time" || section.type === "log") ? { dialect: section.type, ...section.sport ? { sport: section.sport } : {} } : {};
|
|
1393
|
+
blocks[id] = { id, kind, version: legacy2?.version ?? 1, sourceRevision: 1, from: section.from, to: section.to, ...dialect };
|
|
1394
|
+
if (legacy2) blockIdBySegment.set(`${noteId}${legacy2.id}`, id);
|
|
1395
|
+
blockById.set(`${noteId}${id}`, { noteId, from: section.from, to: section.to, version: blocks[id].version });
|
|
1396
|
+
});
|
|
1397
|
+
const legacy = note;
|
|
1398
|
+
delete legacy.segments;
|
|
1399
|
+
delete legacy.segmentIds;
|
|
1400
|
+
await notesStore.put({ ...legacy, rawContent, sourceRevision: 1, blocks });
|
|
1401
|
+
const revisioned = new Set(legacyActionable.map((segment) => segment.id));
|
|
1402
|
+
for (const segment of noteSegments) {
|
|
1403
|
+
if (!revisioned.has(segment.id)) continue;
|
|
1404
|
+
const dialect = legacyDialect(segment.rawContent);
|
|
1405
|
+
await revisionsStore.put({
|
|
1406
|
+
noteId,
|
|
1407
|
+
blockId: segment.id,
|
|
1408
|
+
version: segment.version,
|
|
1409
|
+
kind: "wod",
|
|
1410
|
+
rawContent: segment.rawContent,
|
|
1411
|
+
savedAt: segment.createdAt,
|
|
1412
|
+
...dialect
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
const sessionsStore = store("sessions");
|
|
1417
|
+
for (const row of await sessionsStore.getAll()) {
|
|
1418
|
+
if (row.segmentId === void 0 && row.segmentVersion === void 0 && row.version === void 0) continue;
|
|
1419
|
+
const { segmentId, segmentVersion, version, ...rest } = row;
|
|
1420
|
+
await sessionsStore.put({
|
|
1421
|
+
...rest,
|
|
1422
|
+
blockId: rest.blockId ?? segmentId ?? "",
|
|
1423
|
+
blockVersion: rest.blockVersion ?? segmentVersion ?? version ?? 1
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
const eventsStore = store("events");
|
|
1427
|
+
for (const row of await eventsStore.getAll()) {
|
|
1428
|
+
if (row.segmentVersion === void 0 && row.segmentId === void 0) continue;
|
|
1429
|
+
const { segmentVersion, segmentId, ...rest } = row;
|
|
1430
|
+
await eventsStore.put({
|
|
1431
|
+
...rest,
|
|
1432
|
+
...rest.blockId === void 0 && segmentId !== void 0 ? { blockId: segmentId } : {},
|
|
1433
|
+
...rest.blockVersion === void 0 && segmentVersion !== void 0 ? { blockVersion: segmentVersion } : {}
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
const blockIndex = store("block_index");
|
|
1437
|
+
for (const row of await blockIndex.getAll()) {
|
|
1438
|
+
const mapped = blockIdBySegment.get(`${row.noteId}${row.segmentId}`) ?? row.segmentId;
|
|
1439
|
+
const block = blockById.get(`${row.noteId}${mapped}`);
|
|
1440
|
+
const next = {
|
|
1441
|
+
...row,
|
|
1442
|
+
id: `${row.noteId}:${mapped}`,
|
|
1443
|
+
blockId: mapped,
|
|
1444
|
+
blockVersion: block?.version ?? row.segmentVersion ?? 1,
|
|
1445
|
+
sourceRevision: 1,
|
|
1446
|
+
...block ? { from: block.from, to: block.to } : {},
|
|
1447
|
+
dataType: row.dataType === "script" ? "wod" : row.dataType
|
|
1448
|
+
};
|
|
1449
|
+
delete next.segmentId;
|
|
1450
|
+
delete next.segmentVersion;
|
|
1451
|
+
if (next.id !== row.id) await blockIndex.delete(row.id);
|
|
1452
|
+
await blockIndex.put(next);
|
|
1453
|
+
}
|
|
1454
|
+
db.deleteObjectStore("segments");
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
exports.IDB_DB_NAME = IDB_DB_NAME;
|
|
1458
|
+
exports.IndexedDBStorage = IndexedDBStorage;
|
|
1459
|
+
exports.convertSyncSource = convertSyncSource;
|
|
1460
|
+
exports.subscribeOutboxWrites = subscribeOutboxWrites;
|
|
1461
|
+
//# sourceMappingURL=index.cjs.map
|
|
1462
|
+
//# sourceMappingURL=index.cjs.map
|