@moyodb/sdk 0.0.0-alpha.3 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +10 -2
- package/dist/assets/worker-R2dy1Ju5.js +4 -0
- package/dist/assets/worker-R2dy1Ju5.js.map +1 -0
- package/dist/change-events.d.ts.map +1 -1
- package/dist/codec.d.ts.map +1 -1
- package/dist/compression.d.ts.map +1 -1
- package/dist/engine/README.md +20 -4
- package/dist/engine/moyodb_engine.d.ts +115 -54
- package/dist/engine/moyodb_engine.js +885 -391
- package/dist/engine/moyodb_engine_bg.wasm +0 -0
- package/dist/engine/moyodb_engine_bg.wasm.d.ts +55 -47
- package/dist/engine/package.json +3 -3
- package/dist/engine/snippets/moyodb-engine-8ebe6af0cc2e81b7/js/opfs_shim.js +513 -0
- package/dist/engine/snippets/moyodb-engine-cff0a0e2441a7a8f/js/opfs_shim.js +192 -81
- package/dist/errors.d.ts +8 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/indexing.d.ts.map +1 -1
- package/dist/moyodb-sdk.js +1853 -1985
- package/dist/moyodb-sdk.js.map +1 -1
- package/dist/registry.d.ts +2 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/subscriptions.d.ts.map +1 -1
- package/dist/types.d.ts +29 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/worker-api.d.ts +51 -37
- package/dist/worker-api.d.ts.map +1 -1
- package/dist/worker-client.d.ts +5 -2
- package/dist/worker-client.d.ts.map +1 -1
- package/dist/worker-protocol.d.ts +14 -3
- package/dist/worker-protocol.d.ts.map +1 -1
- package/dist/worker-server.d.ts.map +1 -1
- package/package.json +10 -8
- package/dist/assets/worker-CaOzbBz1.js +0 -2351
- package/dist/assets/worker-CaOzbBz1.js.map +0 -1
- package/dist/moyodb-sdk.umd.cjs +0 -2
- package/dist/moyodb-sdk.umd.cjs.map +0 -1
|
Binary file
|
|
@@ -2,51 +2,59 @@
|
|
|
2
2
|
/* eslint-disable */
|
|
3
3
|
export const memory: WebAssembly.Memory;
|
|
4
4
|
export const __wbg_wasmengine_free: (a: number, b: number) => void;
|
|
5
|
-
export const
|
|
6
|
-
export const
|
|
7
|
-
export const
|
|
8
|
-
export const
|
|
9
|
-
export const
|
|
10
|
-
export const
|
|
11
|
-
export const
|
|
12
|
-
export const
|
|
13
|
-
export const
|
|
14
|
-
export const
|
|
15
|
-
export const
|
|
16
|
-
export const
|
|
17
|
-
export const
|
|
18
|
-
export const
|
|
19
|
-
export const
|
|
20
|
-
export const
|
|
21
|
-
export const
|
|
22
|
-
export const
|
|
23
|
-
export const
|
|
24
|
-
export const
|
|
25
|
-
export const
|
|
26
|
-
export const
|
|
27
|
-
export const
|
|
28
|
-
export const
|
|
29
|
-
export const
|
|
30
|
-
export const
|
|
5
|
+
export const buildProfile: (a: number) => void;
|
|
6
|
+
export const cleanupInactiveEntries: (a: number, b: number) => number;
|
|
7
|
+
export const dbDirectorySize: (a: number, b: number) => number;
|
|
8
|
+
export const deleteDB: (a: number, b: number) => number;
|
|
9
|
+
export const prepareRebuildTarget: (a: number, b: number) => number;
|
|
10
|
+
export const readActiveGeneration: (a: number, b: number) => number;
|
|
11
|
+
export const swapActiveGeneration: (a: number, b: number, c: number, d: number, e: number, f: number) => number;
|
|
12
|
+
export const wasmengine_abandon: (a: number, b: number) => void;
|
|
13
|
+
export const wasmengine_apply_batch: (a: number, b: number, c: bigint, d: number, e: number, f: number) => void;
|
|
14
|
+
export const wasmengine_apply_batch_packed: (a: number, b: number, c: bigint, d: number, e: number, f: number, g: number) => void;
|
|
15
|
+
export const wasmengine_begin_tx: (a: number, b: number, c: number, d: number) => void;
|
|
16
|
+
export const wasmengine_change_feed_policy: (a: number, b: number) => void;
|
|
17
|
+
export const wasmengine_changes_since: (a: number, b: number, c: bigint, d: number) => void;
|
|
18
|
+
export const wasmengine_checkpoint: (a: number, b: number) => void;
|
|
19
|
+
export const wasmengine_clear_store: (a: number, b: number, c: bigint, d: number, e: number) => void;
|
|
20
|
+
export const wasmengine_close: (a: number, b: number) => void;
|
|
21
|
+
export const wasmengine_commit_tx: (a: number, b: number, c: bigint) => void;
|
|
22
|
+
export const wasmengine_compact_into: (a: number, b: number, c: number) => void;
|
|
23
|
+
export const wasmengine_create_store: (a: number, b: number, c: bigint, d: number, e: number, f: number) => void;
|
|
24
|
+
export const wasmengine_delete: (a: number, b: number, c: bigint, d: number, e: number, f: number, g: number) => void;
|
|
25
|
+
export const wasmengine_delete_many: (a: number, b: number, c: bigint, d: number, e: number, f: number) => void;
|
|
26
|
+
export const wasmengine_delete_many_packed: (a: number, b: number, c: bigint, d: number, e: number, f: number, g: number) => void;
|
|
27
|
+
export const wasmengine_drop_store: (a: number, b: number, c: bigint, d: number, e: number) => void;
|
|
28
|
+
export const wasmengine_export_snapshot: (a: number, b: number) => void;
|
|
29
|
+
export const wasmengine_get: (a: number, b: number, c: bigint, d: number, e: number, f: number, g: number) => void;
|
|
30
|
+
export const wasmengine_get_many: (a: number, b: number, c: bigint, d: number, e: number, f: number) => void;
|
|
31
|
+
export const wasmengine_get_many_packed: (a: number, b: number, c: bigint, d: number, e: number, f: number, g: number) => void;
|
|
32
|
+
export const wasmengine_get_schema_version: (a: number, b: number) => void;
|
|
33
|
+
export const wasmengine_has: (a: number, b: number, c: bigint, d: number, e: number, f: number, g: number) => void;
|
|
34
|
+
export const wasmengine_health: (a: number, b: number) => void;
|
|
35
|
+
export const wasmengine_import_snapshot: (a: number, b: number, c: number, d: number) => void;
|
|
36
|
+
export const wasmengine_list_store_configs: (a: number, b: number) => void;
|
|
37
|
+
export const wasmengine_list_stores: (a: number, b: number) => void;
|
|
38
|
+
export const wasmengine_needs_recovery: (a: number, b: number) => void;
|
|
31
39
|
export const wasmengine_new: () => number;
|
|
32
|
-
export const wasmengine_open: (a: number, b: number, c: number, d:
|
|
33
|
-
export const wasmengine_openGeneration: (a: number, b: number, c: number, d: number, e: number, f:
|
|
34
|
-
export const wasmengine_put: (a: number, b:
|
|
35
|
-
export const wasmengine_put_many: (a: number, b:
|
|
36
|
-
export const wasmengine_put_many_packed: (a: number, b:
|
|
37
|
-
export const
|
|
38
|
-
export const
|
|
39
|
-
export const
|
|
40
|
-
export const
|
|
41
|
-
export const
|
|
42
|
-
export const
|
|
43
|
-
export const
|
|
44
|
-
export const
|
|
45
|
-
export const
|
|
46
|
-
export const
|
|
47
|
-
export const
|
|
48
|
-
export const
|
|
49
|
-
export const
|
|
50
|
-
export const
|
|
51
|
-
export const
|
|
52
|
-
export const
|
|
40
|
+
export const wasmengine_open: (a: number, b: number, c: number, d: number) => number;
|
|
41
|
+
export const wasmengine_openGeneration: (a: number, b: number, c: number, d: number, e: number, f: number) => number;
|
|
42
|
+
export const wasmengine_put: (a: number, b: number, c: bigint, d: number, e: number, f: number, g: number, h: number, i: number, j: number) => void;
|
|
43
|
+
export const wasmengine_put_many: (a: number, b: number, c: bigint, d: number, e: number, f: number, g: number) => void;
|
|
44
|
+
export const wasmengine_put_many_packed: (a: number, b: number, c: bigint, d: number, e: number, f: number, g: number, h: number) => void;
|
|
45
|
+
export const wasmengine_recover: (a: number, b: number) => void;
|
|
46
|
+
export const wasmengine_reset: (a: number, b: number) => void;
|
|
47
|
+
export const wasmengine_rollback_tx: (a: number, b: number, c: bigint) => void;
|
|
48
|
+
export const wasmengine_scan: (a: number, b: number, c: bigint, d: number, e: number, f: number) => void;
|
|
49
|
+
export const wasmengine_set_change_feed_policy: (a: number, b: number, c: bigint, d: number) => void;
|
|
50
|
+
export const wasmengine_set_failpoint: (a: number, b: number, c: number) => void;
|
|
51
|
+
export const wasmengine_set_schema_version: (a: number, b: number, c: bigint, d: bigint) => void;
|
|
52
|
+
export const wasmengine_stats: (a: number, b: number) => void;
|
|
53
|
+
export const __wasm_bindgen_func_elem_665: (a: number, b: number, c: number, d: number) => void;
|
|
54
|
+
export const __wasm_bindgen_func_elem_680: (a: number, b: number, c: number, d: number) => void;
|
|
55
|
+
export const __wbindgen_export: (a: number, b: number) => number;
|
|
56
|
+
export const __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;
|
|
57
|
+
export const __wbindgen_export3: (a: number) => void;
|
|
58
|
+
export const __wbindgen_export4: (a: number, b: number, c: number) => void;
|
|
59
|
+
export const __wbindgen_export5: (a: number, b: number) => void;
|
|
60
|
+
export const __wbindgen_add_to_stack_pointer: (a: number) => number;
|
package/dist/engine/package.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"name": "moyodb-engine",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"description": "Experimental Rust/WASM transactional key-value storage engine for browser apps.",
|
|
5
|
-
"version": "
|
|
6
|
-
"license": "
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"license": "SEE LICENSE IN ../../LICENSE",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/
|
|
9
|
+
"url": "https://github.com/eluvane/MoyoDB"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"moyodb_engine_bg.wasm",
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
const DB_ROOT_DIR = 'stackdb';
|
|
2
|
+
const FILE_NAMES = ['manifest.bin', 'main.bin', 'wal.bin'];
|
|
3
|
+
const CONTROL_FILE_NAME = 'root-manifest.bin';
|
|
4
|
+
const CONTROL_SLOT_SIZE = 4096;
|
|
5
|
+
const CONTROL_VERSION = 1;
|
|
6
|
+
const CONTROL_CHECKSUM_OFFSET = 24;
|
|
7
|
+
const CONTROL_NAME_OFFSET = 32;
|
|
8
|
+
const CONTROL_MAGIC = new Uint8Array([66, 68, 66, 82, 79, 79, 84, 49]);
|
|
9
|
+
const OPFS_WRITE_CHUNK_SIZE = 256 * 1024;
|
|
10
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
11
|
+
const TEXT_DECODER = new TextDecoder();
|
|
12
|
+
let rootDir = null;
|
|
13
|
+
let nextSessionId = 1;
|
|
14
|
+
const sessions = new Map();
|
|
15
|
+
function namedError(name, message) {
|
|
16
|
+
const error = new Error(message);
|
|
17
|
+
error.name = name;
|
|
18
|
+
error.code = name;
|
|
19
|
+
return error;
|
|
20
|
+
}
|
|
21
|
+
function corruptionError(message) {
|
|
22
|
+
return namedError('CorruptionError', message);
|
|
23
|
+
}
|
|
24
|
+
async function getRootDir() {
|
|
25
|
+
if (!navigator?.storage?.getDirectory) {
|
|
26
|
+
throw new Error('navigator.storage.getDirectory is unavailable');
|
|
27
|
+
}
|
|
28
|
+
if (!rootDir) {
|
|
29
|
+
rootDir = await navigator.storage.getDirectory();
|
|
30
|
+
}
|
|
31
|
+
return rootDir;
|
|
32
|
+
}
|
|
33
|
+
async function getOrCreateStackdbRoot() {
|
|
34
|
+
const root = await getRootDir();
|
|
35
|
+
return await root.getDirectoryHandle(DB_ROOT_DIR, { create: true });
|
|
36
|
+
}
|
|
37
|
+
function isNotFoundError(error) {
|
|
38
|
+
return error?.name === 'NotFoundError' || error?.name === 'TypeMismatchError';
|
|
39
|
+
}
|
|
40
|
+
async function lookupDirectoryHandle(parent, name) {
|
|
41
|
+
try {
|
|
42
|
+
return await parent.getDirectoryHandle(name, { create: false });
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (isNotFoundError(err)) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function lookupFileHandle(parent, name) {
|
|
51
|
+
try {
|
|
52
|
+
return await parent.getFileHandle(name, { create: false });
|
|
53
|
+
} catch (err) {
|
|
54
|
+
if (isNotFoundError(err)) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function sessionPath(encodedDbName, generationName = null) {
|
|
61
|
+
return generationName ? `${encodedDbName}/${generationName}` : encodedDbName;
|
|
62
|
+
}
|
|
63
|
+
function slotBytesView(bytes) {
|
|
64
|
+
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
65
|
+
}
|
|
66
|
+
function writeU64(view, offset, value) {
|
|
67
|
+
view.setBigUint64(offset, BigInt(value), true);
|
|
68
|
+
}
|
|
69
|
+
function readU64(view, offset) {
|
|
70
|
+
return Number(view.getBigUint64(offset, true));
|
|
71
|
+
}
|
|
72
|
+
function fnv1a32(bytes, zeroOffset = -1, zeroLength = 0) {
|
|
73
|
+
let hash = 0x811c9dc5;
|
|
74
|
+
const zeroEnd = zeroOffset >= 0 ? zeroOffset + zeroLength : -1;
|
|
75
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
76
|
+
const value = zeroOffset >= 0 && index >= zeroOffset && index < zeroEnd ? 0 : (bytes[index] ?? 0);
|
|
77
|
+
hash ^= value;
|
|
78
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
79
|
+
}
|
|
80
|
+
return hash >>> 0;
|
|
81
|
+
}
|
|
82
|
+
function isGenerationNameValid(name) {
|
|
83
|
+
return typeof name === 'string' && /^gen-[a-z0-9]+-[a-z0-9]+$/i.test(name);
|
|
84
|
+
}
|
|
85
|
+
// Every byte must reach the file or the call fails: a short write that returns
|
|
86
|
+
// normally would let callers publish state that is not on disk.
|
|
87
|
+
export function writeAll(handle, bytes, at) {
|
|
88
|
+
let writtenTotal = 0;
|
|
89
|
+
while (writtenTotal < bytes.length) {
|
|
90
|
+
const chunk = bytes.subarray(writtenTotal, writtenTotal + OPFS_WRITE_CHUNK_SIZE);
|
|
91
|
+
const rawWritten = handle.write(chunk, { at: at + writtenTotal });
|
|
92
|
+
const written = Number(rawWritten);
|
|
93
|
+
if (!Number.isSafeInteger(written) || written < 0 || written > chunk.length) {
|
|
94
|
+
throw namedError('StorageError', `opfs write failed: invalid byte count ${String(rawWritten)}`);
|
|
95
|
+
}
|
|
96
|
+
if (written === 0 && chunk.length > 0) {
|
|
97
|
+
throw namedError('StorageError', 'opfs write failed: wrote 0 bytes');
|
|
98
|
+
}
|
|
99
|
+
writtenTotal += written;
|
|
100
|
+
}
|
|
101
|
+
return writtenTotal;
|
|
102
|
+
}
|
|
103
|
+
// Fills `buffer` from `at`, stopping only at end of file. Bytes past EOF stay zero.
|
|
104
|
+
export function readAll(handle, buffer, at) {
|
|
105
|
+
let readTotal = 0;
|
|
106
|
+
while (readTotal < buffer.length) {
|
|
107
|
+
const rawRead = handle.read(buffer.subarray(readTotal), { at: at + readTotal });
|
|
108
|
+
const read = Number(rawRead);
|
|
109
|
+
if (!Number.isSafeInteger(read) || read < 0 || read > buffer.length - readTotal) {
|
|
110
|
+
throw namedError('StorageError', `opfs read failed: invalid byte count ${String(rawRead)}`);
|
|
111
|
+
}
|
|
112
|
+
if (read === 0) {
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
readTotal += read;
|
|
116
|
+
}
|
|
117
|
+
return readTotal;
|
|
118
|
+
}
|
|
119
|
+
export function encodeControlSlot(generationCounter, activeGeneration) {
|
|
120
|
+
if (!isGenerationNameValid(activeGeneration)) {
|
|
121
|
+
throw new Error(`invalid generation name ${activeGeneration}`);
|
|
122
|
+
}
|
|
123
|
+
const nameBytes = TEXT_ENCODER.encode(activeGeneration);
|
|
124
|
+
if (CONTROL_NAME_OFFSET + nameBytes.length > CONTROL_SLOT_SIZE) {
|
|
125
|
+
throw new Error(`generation name too long: ${nameBytes.length}`);
|
|
126
|
+
}
|
|
127
|
+
const slot = new Uint8Array(CONTROL_SLOT_SIZE);
|
|
128
|
+
const view = slotBytesView(slot);
|
|
129
|
+
slot.set(CONTROL_MAGIC, 0);
|
|
130
|
+
view.setUint32(8, CONTROL_VERSION, true);
|
|
131
|
+
writeU64(view, 12, generationCounter);
|
|
132
|
+
view.setUint16(20, nameBytes.length, true);
|
|
133
|
+
view.setUint16(22, 0, true);
|
|
134
|
+
view.setUint32(CONTROL_CHECKSUM_OFFSET, 0, true);
|
|
135
|
+
view.setUint32(28, 0, true);
|
|
136
|
+
slot.set(nameBytes, CONTROL_NAME_OFFSET);
|
|
137
|
+
const checksum = fnv1a32(slot, CONTROL_CHECKSUM_OFFSET, 4);
|
|
138
|
+
view.setUint32(CONTROL_CHECKSUM_OFFSET, checksum, true);
|
|
139
|
+
return slot;
|
|
140
|
+
}
|
|
141
|
+
// Returns null for any slot whose bytes do not checksum. Fields are interpreted
|
|
142
|
+
// only after the checksum passes, so a torn slot never masks its valid sibling.
|
|
143
|
+
export function decodeControlSlot(slotIndex, bytes) {
|
|
144
|
+
if (bytes.length < CONTROL_SLOT_SIZE) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
for (let index = 0; index < CONTROL_MAGIC.length; index += 1) {
|
|
148
|
+
if (bytes[index] !== CONTROL_MAGIC[index]) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const view = slotBytesView(bytes);
|
|
153
|
+
const expectedChecksum = fnv1a32(bytes.subarray(0, CONTROL_SLOT_SIZE), CONTROL_CHECKSUM_OFFSET, 4);
|
|
154
|
+
const storedChecksum = view.getUint32(CONTROL_CHECKSUM_OFFSET, true);
|
|
155
|
+
if (expectedChecksum !== storedChecksum) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
const version = view.getUint32(8, true);
|
|
159
|
+
if (version !== CONTROL_VERSION) {
|
|
160
|
+
throw corruptionError(`unsupported control file version ${version}`);
|
|
161
|
+
}
|
|
162
|
+
const nameLength = view.getUint16(20, true);
|
|
163
|
+
const nameEnd = CONTROL_NAME_OFFSET + nameLength;
|
|
164
|
+
if (nameEnd > CONTROL_SLOT_SIZE) {
|
|
165
|
+
throw corruptionError(`control slot name length out of bounds: ${nameLength}`);
|
|
166
|
+
}
|
|
167
|
+
const activeGeneration = TEXT_DECODER.decode(bytes.subarray(CONTROL_NAME_OFFSET, nameEnd));
|
|
168
|
+
if (!isGenerationNameValid(activeGeneration)) {
|
|
169
|
+
throw corruptionError(`invalid generation in control slot: ${activeGeneration}`);
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
slotIndex,
|
|
173
|
+
generationCounter: readU64(view, 12),
|
|
174
|
+
activeGeneration
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
// `absent` — the file is empty: no generation has ever been published.
|
|
178
|
+
// `invalid` — the file has bytes but no slot checksums.
|
|
179
|
+
// `valid` — `control` is the newest checksummed slot.
|
|
180
|
+
export function readControlStateFromAccessHandle(accessHandle) {
|
|
181
|
+
const size = Number(accessHandle.getSize());
|
|
182
|
+
if (!Number.isSafeInteger(size) || size < 0) {
|
|
183
|
+
throw namedError('StorageError', `invalid control file size ${String(size)}`);
|
|
184
|
+
}
|
|
185
|
+
if (size === 0) {
|
|
186
|
+
return { status: 'absent', control: null };
|
|
187
|
+
}
|
|
188
|
+
const buffer = new Uint8Array(CONTROL_SLOT_SIZE * 2);
|
|
189
|
+
readAll(accessHandle, buffer.subarray(0, Math.min(buffer.length, size)), 0);
|
|
190
|
+
const slot0 = decodeControlSlot(0, buffer.subarray(0, CONTROL_SLOT_SIZE));
|
|
191
|
+
const slot1 = decodeControlSlot(1, buffer.subarray(CONTROL_SLOT_SIZE, CONTROL_SLOT_SIZE * 2));
|
|
192
|
+
if (slot0 && slot1) {
|
|
193
|
+
return { status: 'valid', control: slot0.generationCounter >= slot1.generationCounter ? slot0 : slot1 };
|
|
194
|
+
}
|
|
195
|
+
const control = slot0 ?? slot1;
|
|
196
|
+
return control ? { status: 'valid', control } : { status: 'invalid', control: null };
|
|
197
|
+
}
|
|
198
|
+
async function readControlFile(dbRoot) {
|
|
199
|
+
const fileHandle = await lookupFileHandle(dbRoot, CONTROL_FILE_NAME);
|
|
200
|
+
if (!fileHandle) {
|
|
201
|
+
return { status: 'absent', control: null };
|
|
202
|
+
}
|
|
203
|
+
const accessHandle = await fileHandle.createSyncAccessHandle();
|
|
204
|
+
try {
|
|
205
|
+
return readControlStateFromAccessHandle(accessHandle);
|
|
206
|
+
} finally {
|
|
207
|
+
accessHandle.close();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async function hasLegacyDataFiles(dbRoot) {
|
|
211
|
+
for (const fileName of FILE_NAMES) {
|
|
212
|
+
if (await lookupFileHandle(dbRoot, fileName)) {
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
// Resolves which directory holds the live database, failing closed on damage.
|
|
219
|
+
//
|
|
220
|
+
// Legacy root files are removed before the first write into a published
|
|
221
|
+
// generation (see opfsCleanupInactiveEntries and opfsOpenActiveDb). So an
|
|
222
|
+
// unreadable control file next to legacy files can only be a torn first
|
|
223
|
+
// publication, and legacy is still authoritative. Without legacy files it is
|
|
224
|
+
// real corruption of the pointer to live data.
|
|
225
|
+
async function resolveActiveGeneration(dbRoot) {
|
|
226
|
+
const state = await readControlFile(dbRoot);
|
|
227
|
+
if (state.status === 'valid') {
|
|
228
|
+
return state.control.activeGeneration;
|
|
229
|
+
}
|
|
230
|
+
if (state.status === 'absent') {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
if (await hasLegacyDataFiles(dbRoot)) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
throw corruptionError(`control file ${CONTROL_FILE_NAME} is corrupt: no slot has a valid checksum`);
|
|
237
|
+
}
|
|
238
|
+
async function writeControlState(dbRoot, activeGeneration, expectedCurrentGeneration) {
|
|
239
|
+
const fileHandle = await dbRoot.getFileHandle(CONTROL_FILE_NAME, { create: true });
|
|
240
|
+
const accessHandle = await fileHandle.createSyncAccessHandle();
|
|
241
|
+
try {
|
|
242
|
+
const current = readControlStateFromAccessHandle(accessHandle);
|
|
243
|
+
if (current.status === 'invalid' && !(await hasLegacyDataFiles(dbRoot))) {
|
|
244
|
+
throw corruptionError('refusing to publish a generation over a corrupt control file');
|
|
245
|
+
}
|
|
246
|
+
const currentGeneration = current.control?.activeGeneration ?? null;
|
|
247
|
+
if (expectedCurrentGeneration !== undefined && currentGeneration !== expectedCurrentGeneration) {
|
|
248
|
+
throw namedError(
|
|
249
|
+
'DatabaseBusyError',
|
|
250
|
+
`active generation changed underneath compaction: expected ${String(expectedCurrentGeneration)}, found ${String(currentGeneration)}`
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
const nextSlot = current.control ? (current.control.slotIndex === 0 ? 1 : 0) : 0;
|
|
254
|
+
const nextGenerationCounter = (current.control?.generationCounter ?? 0) + 1;
|
|
255
|
+
const encoded = encodeControlSlot(nextGenerationCounter, activeGeneration);
|
|
256
|
+
writeAll(accessHandle, encoded, nextSlot * CONTROL_SLOT_SIZE);
|
|
257
|
+
accessHandle.flush();
|
|
258
|
+
const published = readControlStateFromAccessHandle(accessHandle);
|
|
259
|
+
if (
|
|
260
|
+
published.status !== 'valid' ||
|
|
261
|
+
published.control.activeGeneration !== activeGeneration ||
|
|
262
|
+
published.control.generationCounter !== nextGenerationCounter
|
|
263
|
+
) {
|
|
264
|
+
throw namedError('StorageError', `control file does not select ${activeGeneration} after publication`);
|
|
265
|
+
}
|
|
266
|
+
} finally {
|
|
267
|
+
accessHandle.close();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
function createGenerationName() {
|
|
271
|
+
const timePart = Date.now().toString(36);
|
|
272
|
+
const randomBytes = new Uint8Array(4);
|
|
273
|
+
crypto.getRandomValues(randomBytes);
|
|
274
|
+
const randomPart = Array.from(randomBytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
275
|
+
return `gen-${timePart}-${randomPart}`;
|
|
276
|
+
}
|
|
277
|
+
async function getDbRoot(encodedDbName, createIfMissing) {
|
|
278
|
+
const stackdb = await getOrCreateStackdbRoot();
|
|
279
|
+
let dbRoot = await lookupDirectoryHandle(stackdb, encodedDbName);
|
|
280
|
+
if (!dbRoot) {
|
|
281
|
+
if (!createIfMissing) {
|
|
282
|
+
throw new Error(`database ${encodedDbName} does not exist`);
|
|
283
|
+
}
|
|
284
|
+
dbRoot = await stackdb.getDirectoryHandle(encodedDbName, { create: true });
|
|
285
|
+
}
|
|
286
|
+
return dbRoot;
|
|
287
|
+
}
|
|
288
|
+
async function removeLegacyDataFiles(dbRoot) {
|
|
289
|
+
for (const fileName of FILE_NAMES) {
|
|
290
|
+
try {
|
|
291
|
+
await dbRoot.removeEntry(fileName);
|
|
292
|
+
} catch (err) {
|
|
293
|
+
if (!isNotFoundError(err)) {
|
|
294
|
+
throw err;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
async function resolveActiveDataDir(dbRoot) {
|
|
300
|
+
const activeGeneration = await resolveActiveGeneration(dbRoot);
|
|
301
|
+
if (!activeGeneration) {
|
|
302
|
+
return {
|
|
303
|
+
dirHandle: dbRoot,
|
|
304
|
+
generationName: null,
|
|
305
|
+
path: dbRoot.name
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
const activeDir = await lookupDirectoryHandle(dbRoot, activeGeneration);
|
|
309
|
+
if (!activeDir) {
|
|
310
|
+
throw corruptionError(`active generation ${activeGeneration} is missing`);
|
|
311
|
+
}
|
|
312
|
+
// A crash after publication but before legacy cleanup leaves stale root
|
|
313
|
+
// files. They must be gone before this generation accepts writes.
|
|
314
|
+
await removeLegacyDataFiles(dbRoot);
|
|
315
|
+
return {
|
|
316
|
+
dirHandle: activeDir,
|
|
317
|
+
generationName: activeGeneration,
|
|
318
|
+
path: `${dbRoot.name}/${activeGeneration}`
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
function closeSession(sessionId) {
|
|
322
|
+
const session = sessions.get(sessionId);
|
|
323
|
+
if (!session) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
for (const handle of session.handles.values()) {
|
|
327
|
+
try {
|
|
328
|
+
handle.close();
|
|
329
|
+
} catch (_err) {}
|
|
330
|
+
}
|
|
331
|
+
sessions.delete(sessionId);
|
|
332
|
+
}
|
|
333
|
+
function closeSessionsForDb(encodedDbName) {
|
|
334
|
+
const prefix = `${encodedDbName}/`;
|
|
335
|
+
for (const [sessionId, session] of Array.from(sessions.entries())) {
|
|
336
|
+
if (session.path === encodedDbName || session.path.startsWith(prefix)) {
|
|
337
|
+
closeSession(sessionId);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
async function openSessionForDir(dbPath, dirHandle, generationName) {
|
|
342
|
+
const handles = new Map();
|
|
343
|
+
try {
|
|
344
|
+
for (let fileKind = 0; fileKind < FILE_NAMES.length; fileKind += 1) {
|
|
345
|
+
const fileName = FILE_NAMES[fileKind];
|
|
346
|
+
const fileHandle = await dirHandle.getFileHandle(fileName, { create: true });
|
|
347
|
+
if (typeof fileHandle.createSyncAccessHandle !== 'function') {
|
|
348
|
+
throw new Error('createSyncAccessHandle is unavailable');
|
|
349
|
+
}
|
|
350
|
+
const accessHandle = await fileHandle.createSyncAccessHandle();
|
|
351
|
+
handles.set(fileKind, accessHandle);
|
|
352
|
+
}
|
|
353
|
+
} catch (error) {
|
|
354
|
+
for (const handle of handles.values()) {
|
|
355
|
+
try {
|
|
356
|
+
handle.close();
|
|
357
|
+
} catch (_err) {}
|
|
358
|
+
}
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
const sessionId = nextSessionId;
|
|
362
|
+
nextSessionId += 1;
|
|
363
|
+
sessions.set(sessionId, {
|
|
364
|
+
path: dbPath,
|
|
365
|
+
handles
|
|
366
|
+
});
|
|
367
|
+
return { sessionId, generationName };
|
|
368
|
+
}
|
|
369
|
+
function getSession(sessionId) {
|
|
370
|
+
const session = sessions.get(sessionId);
|
|
371
|
+
if (!session) {
|
|
372
|
+
throw namedError('StorageError', `no OPFS session ${sessionId}`);
|
|
373
|
+
}
|
|
374
|
+
return session;
|
|
375
|
+
}
|
|
376
|
+
function getAccessHandle(sessionId, fileKind) {
|
|
377
|
+
const session = getSession(sessionId);
|
|
378
|
+
const handle = session.handles.get(fileKind);
|
|
379
|
+
if (!handle) {
|
|
380
|
+
throw namedError('StorageError', `no OPFS access handle for session ${sessionId} file kind ${fileKind}`);
|
|
381
|
+
}
|
|
382
|
+
return handle;
|
|
383
|
+
}
|
|
384
|
+
function lookupOpenFileSize(path) {
|
|
385
|
+
for (const session of sessions.values()) {
|
|
386
|
+
for (let fileKind = 0; fileKind < FILE_NAMES.length; fileKind += 1) {
|
|
387
|
+
if (`${session.path}/${FILE_NAMES[fileKind]}` === path) {
|
|
388
|
+
return Number(session.handles.get(fileKind)?.getSize() ?? 0);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
async function sumDirectorySize(dirHandle, pathPrefix) {
|
|
395
|
+
let total = 0;
|
|
396
|
+
for await (const [name, handle] of dirHandle.entries()) {
|
|
397
|
+
const entryPath = pathPrefix ? `${pathPrefix}/${name}` : name;
|
|
398
|
+
if (handle.kind === 'directory') {
|
|
399
|
+
total += await sumDirectorySize(handle, entryPath);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
const openSize = lookupOpenFileSize(entryPath);
|
|
403
|
+
if (openSize !== null) {
|
|
404
|
+
total += openSize;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const file = await handle.getFile();
|
|
408
|
+
total += file.size;
|
|
409
|
+
}
|
|
410
|
+
return total;
|
|
411
|
+
}
|
|
412
|
+
export async function opfsOpenActiveDb(encodedDbName, createIfMissing = true) {
|
|
413
|
+
const dbRoot = await getDbRoot(encodedDbName, createIfMissing);
|
|
414
|
+
const active = await resolveActiveDataDir(dbRoot);
|
|
415
|
+
return await openSessionForDir(active.path, active.dirHandle, active.generationName);
|
|
416
|
+
}
|
|
417
|
+
export async function opfsOpenGenerationDb(encodedDbName, generationName, createIfMissing = true) {
|
|
418
|
+
if (!isGenerationNameValid(generationName)) {
|
|
419
|
+
throw new Error(`invalid generation name ${generationName}`);
|
|
420
|
+
}
|
|
421
|
+
const dbRoot = await getDbRoot(encodedDbName, createIfMissing);
|
|
422
|
+
const generationDir = await dbRoot.getDirectoryHandle(generationName, { create: createIfMissing });
|
|
423
|
+
return await openSessionForDir(sessionPath(encodedDbName, generationName), generationDir, generationName);
|
|
424
|
+
}
|
|
425
|
+
export async function opfsReadActiveGeneration(encodedDbName) {
|
|
426
|
+
const dbRoot = await getDbRoot(encodedDbName, false);
|
|
427
|
+
return await resolveActiveGeneration(dbRoot);
|
|
428
|
+
}
|
|
429
|
+
export function opfsReadAt(sessionId, fileKind, offset, len) {
|
|
430
|
+
const handle = getAccessHandle(sessionId, fileKind);
|
|
431
|
+
const buffer = new Uint8Array(len);
|
|
432
|
+
readAll(handle, buffer, Number(offset));
|
|
433
|
+
return buffer;
|
|
434
|
+
}
|
|
435
|
+
export function opfsWriteAt(sessionId, fileKind, offset, bytes) {
|
|
436
|
+
return writeAll(getAccessHandle(sessionId, fileKind), bytes, Number(offset));
|
|
437
|
+
}
|
|
438
|
+
export function opfsFlush(sessionId, fileKind) {
|
|
439
|
+
getAccessHandle(sessionId, fileKind).flush();
|
|
440
|
+
}
|
|
441
|
+
export function opfsLen(sessionId, fileKind) {
|
|
442
|
+
return BigInt(getAccessHandle(sessionId, fileKind).getSize());
|
|
443
|
+
}
|
|
444
|
+
export function opfsTruncate(sessionId, fileKind, size) {
|
|
445
|
+
getAccessHandle(sessionId, fileKind).truncate(Number(size));
|
|
446
|
+
}
|
|
447
|
+
export function opfsCloseSession(sessionId) {
|
|
448
|
+
closeSession(sessionId);
|
|
449
|
+
}
|
|
450
|
+
export async function opfsPrepareRebuildTarget(encodedDbName) {
|
|
451
|
+
const dbRoot = await getDbRoot(encodedDbName, true);
|
|
452
|
+
let generationName = createGenerationName();
|
|
453
|
+
while (await lookupDirectoryHandle(dbRoot, generationName)) {
|
|
454
|
+
generationName = createGenerationName();
|
|
455
|
+
}
|
|
456
|
+
await dbRoot.getDirectoryHandle(generationName, { create: true });
|
|
457
|
+
return { generationName };
|
|
458
|
+
}
|
|
459
|
+
// Publishes `generationName` as the live database. Resolves only after the
|
|
460
|
+
// control file has been flushed and read back selecting the new generation;
|
|
461
|
+
// the caller may switch its live engine only after that.
|
|
462
|
+
export async function opfsSwapActiveGeneration(encodedDbName, generationName, expectedCurrentGeneration) {
|
|
463
|
+
if (!isGenerationNameValid(generationName)) {
|
|
464
|
+
throw new Error(`invalid generation name ${generationName}`);
|
|
465
|
+
}
|
|
466
|
+
const dbRoot = await getDbRoot(encodedDbName, false);
|
|
467
|
+
const generationDir = await lookupDirectoryHandle(dbRoot, generationName);
|
|
468
|
+
if (!generationDir) {
|
|
469
|
+
throw new Error(`generation ${generationName} does not exist`);
|
|
470
|
+
}
|
|
471
|
+
await writeControlState(dbRoot, generationName, expectedCurrentGeneration ?? null);
|
|
472
|
+
}
|
|
473
|
+
// Legacy root files are removed strictly (see resolveActiveGeneration); stale
|
|
474
|
+
// generation directories are best effort because nothing can select them.
|
|
475
|
+
export async function opfsCleanupInactiveEntries(encodedDbName) {
|
|
476
|
+
const dbRoot = await lookupDirectoryHandle(await getOrCreateStackdbRoot(), encodedDbName);
|
|
477
|
+
if (!dbRoot) {
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
const activeGeneration = await resolveActiveGeneration(dbRoot);
|
|
481
|
+
if (activeGeneration) {
|
|
482
|
+
await removeLegacyDataFiles(dbRoot);
|
|
483
|
+
}
|
|
484
|
+
const staleDirectories = [];
|
|
485
|
+
for await (const [name, handle] of dbRoot.entries()) {
|
|
486
|
+
if (handle.kind === 'directory' && name.startsWith('gen-') && name !== activeGeneration) {
|
|
487
|
+
staleDirectories.push(name);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
for (const name of staleDirectories) {
|
|
491
|
+
try {
|
|
492
|
+
await dbRoot.removeEntry(name, { recursive: true });
|
|
493
|
+
} catch (_err) {}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
export async function opfsDbDirectorySize(encodedDbName) {
|
|
497
|
+
const dbRoot = await lookupDirectoryHandle(await getOrCreateStackdbRoot(), encodedDbName);
|
|
498
|
+
if (!dbRoot) {
|
|
499
|
+
return 0;
|
|
500
|
+
}
|
|
501
|
+
return await sumDirectorySize(dbRoot, encodedDbName);
|
|
502
|
+
}
|
|
503
|
+
export async function opfsRemoveDb(encodedDbName) {
|
|
504
|
+
closeSessionsForDb(encodedDbName);
|
|
505
|
+
const stackdb = await getOrCreateStackdbRoot();
|
|
506
|
+
try {
|
|
507
|
+
await stackdb.removeEntry(encodedDbName, { recursive: true });
|
|
508
|
+
} catch (err) {
|
|
509
|
+
if (!isNotFoundError(err)) {
|
|
510
|
+
throw err;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|