@moyodb/sdk 0.0.0-alpha.3 → 1.0.1
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/LICENSE +202 -0
- package/dist/assets/worker-KDc8Khl_.js +4 -0
- package/dist/assets/worker-KDc8Khl_.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 +26 -9
- package/dist/engine/moyodb_engine.d.ts +125 -54
- package/dist/engine/moyodb_engine.js +935 -391
- package/dist/engine/moyodb_engine_bg.wasm +0 -0
- package/dist/engine/moyodb_engine_bg.wasm.d.ts +57 -47
- package/dist/engine/package.json +3 -3
- package/dist/engine/snippets/moyodb-engine-8ebe6af0cc2e81b7/js/opfs_shim.js +602 -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 +8 -0
- package/dist/indexing.d.ts.map +1 -1
- package/dist/moyodb-sdk.js +2034 -1985
- package/dist/moyodb-sdk.js.map +1 -1
- package/dist/registry.d.ts +3 -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 +50 -37
- package/dist/worker-api.d.ts.map +1 -1
- package/dist/worker-client.d.ts +13 -2
- package/dist/worker-client.d.ts.map +1 -1
- package/dist/worker-protocol.d.ts +42 -3
- package/dist/worker-protocol.d.ts.map +1 -1
- package/dist/worker-server.d.ts.map +1 -1
- package/package.json +13 -9
- 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
|
@@ -0,0 +1,602 @@
|
|
|
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
|
+
// Chromium rejects a SyncAccessHandle.write larger than a signed 32-bit count.
|
|
10
|
+
const OPFS_MAX_WRITE_SIZE = 0x7fffffff;
|
|
11
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
12
|
+
const TEXT_DECODER = new TextDecoder();
|
|
13
|
+
let rootDir = null;
|
|
14
|
+
let nextSessionId = 1;
|
|
15
|
+
const sessions = new Map();
|
|
16
|
+
// Exclusive session handles keep cached sizes valid across Rust backend clones.
|
|
17
|
+
// Length checks for validation and statistics still query OPFS.
|
|
18
|
+
const appendOffsets = new WeakMap();
|
|
19
|
+
function namedError(name, message) {
|
|
20
|
+
const error = new Error(message);
|
|
21
|
+
error.name = name;
|
|
22
|
+
error.code = name;
|
|
23
|
+
return error;
|
|
24
|
+
}
|
|
25
|
+
function corruptionError(message) {
|
|
26
|
+
return namedError('CorruptionError', message);
|
|
27
|
+
}
|
|
28
|
+
async function getRootDir() {
|
|
29
|
+
if (!navigator?.storage?.getDirectory) {
|
|
30
|
+
throw new Error('navigator.storage.getDirectory is unavailable');
|
|
31
|
+
}
|
|
32
|
+
if (!rootDir) {
|
|
33
|
+
rootDir = await navigator.storage.getDirectory();
|
|
34
|
+
}
|
|
35
|
+
return rootDir;
|
|
36
|
+
}
|
|
37
|
+
async function getOrCreateStackdbRoot() {
|
|
38
|
+
const root = await getRootDir();
|
|
39
|
+
return await root.getDirectoryHandle(DB_ROOT_DIR, { create: true });
|
|
40
|
+
}
|
|
41
|
+
function isNotFoundError(error) {
|
|
42
|
+
return error?.name === 'NotFoundError' || error?.name === 'TypeMismatchError';
|
|
43
|
+
}
|
|
44
|
+
async function lookupDirectoryHandle(parent, name) {
|
|
45
|
+
try {
|
|
46
|
+
return await parent.getDirectoryHandle(name, { create: false });
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if (isNotFoundError(err)) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function lookupFileHandle(parent, name) {
|
|
55
|
+
try {
|
|
56
|
+
return await parent.getFileHandle(name, { create: false });
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if (isNotFoundError(err)) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function sessionPath(encodedDbName, generationName = null) {
|
|
65
|
+
return generationName ? `${encodedDbName}/${generationName}` : encodedDbName;
|
|
66
|
+
}
|
|
67
|
+
function slotBytesView(bytes) {
|
|
68
|
+
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
69
|
+
}
|
|
70
|
+
function writeU64(view, offset, value) {
|
|
71
|
+
view.setBigUint64(offset, BigInt(value), true);
|
|
72
|
+
}
|
|
73
|
+
function readU64(view, offset) {
|
|
74
|
+
return view.getBigUint64(offset, true);
|
|
75
|
+
}
|
|
76
|
+
function fnv1a32(bytes, zeroOffset = -1, zeroLength = 0) {
|
|
77
|
+
let hash = 0x811c9dc5;
|
|
78
|
+
const zeroEnd = zeroOffset >= 0 ? zeroOffset + zeroLength : -1;
|
|
79
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
80
|
+
const value = zeroOffset >= 0 && index >= zeroOffset && index < zeroEnd ? 0 : (bytes[index] ?? 0);
|
|
81
|
+
hash ^= value;
|
|
82
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
83
|
+
}
|
|
84
|
+
return hash >>> 0;
|
|
85
|
+
}
|
|
86
|
+
function isGenerationNameValid(name) {
|
|
87
|
+
return typeof name === 'string' && /^gen-[a-z0-9]+-[a-z0-9]+$/i.test(name);
|
|
88
|
+
}
|
|
89
|
+
// Returns only after all bytes are written. The caller must flush for durability.
|
|
90
|
+
export function writeAll(handle, bytes, at) {
|
|
91
|
+
let writtenTotal = 0;
|
|
92
|
+
while (writtenTotal < bytes.length) {
|
|
93
|
+
// Preserve the engine's batch; split only at the browser size limit or
|
|
94
|
+
// after a short write. Suffix views borrow the original buffer.
|
|
95
|
+
const end = Math.min(bytes.length, writtenTotal + OPFS_MAX_WRITE_SIZE);
|
|
96
|
+
const chunk = writtenTotal === 0 && end === bytes.length ? bytes : bytes.subarray(writtenTotal, end);
|
|
97
|
+
const rawWritten = handle.write(chunk, { at: at + writtenTotal });
|
|
98
|
+
const written = Number(rawWritten);
|
|
99
|
+
if (!Number.isSafeInteger(written) || written < 0 || written > chunk.length) {
|
|
100
|
+
throw namedError('StorageError', `opfs write failed: invalid byte count ${String(rawWritten)}`);
|
|
101
|
+
}
|
|
102
|
+
if (written === 0 && chunk.length > 0) {
|
|
103
|
+
throw namedError('StorageError', 'opfs write failed: wrote 0 bytes');
|
|
104
|
+
}
|
|
105
|
+
writtenTotal += written;
|
|
106
|
+
}
|
|
107
|
+
return writtenTotal;
|
|
108
|
+
}
|
|
109
|
+
// Reads until the buffer is full or a read returns zero. The unread suffix is unchanged.
|
|
110
|
+
export function readAll(handle, buffer, at) {
|
|
111
|
+
let readTotal = 0;
|
|
112
|
+
while (readTotal < buffer.length) {
|
|
113
|
+
const rawRead = handle.read(buffer.subarray(readTotal), { at: at + readTotal });
|
|
114
|
+
const read = Number(rawRead);
|
|
115
|
+
if (!Number.isSafeInteger(read) || read < 0 || read > buffer.length - readTotal) {
|
|
116
|
+
throw namedError('StorageError', `opfs read failed: invalid byte count ${String(rawRead)}`);
|
|
117
|
+
}
|
|
118
|
+
if (read === 0) {
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
readTotal += read;
|
|
122
|
+
}
|
|
123
|
+
return readTotal;
|
|
124
|
+
}
|
|
125
|
+
export function encodeControlSlot(generationCounter, activeGeneration) {
|
|
126
|
+
if (!isGenerationNameValid(activeGeneration)) {
|
|
127
|
+
throw new Error(`invalid generation name ${activeGeneration}`);
|
|
128
|
+
}
|
|
129
|
+
const nameBytes = TEXT_ENCODER.encode(activeGeneration);
|
|
130
|
+
if (CONTROL_NAME_OFFSET + nameBytes.length > CONTROL_SLOT_SIZE) {
|
|
131
|
+
throw new Error(`generation name too long: ${nameBytes.length}`);
|
|
132
|
+
}
|
|
133
|
+
const slot = new Uint8Array(CONTROL_SLOT_SIZE);
|
|
134
|
+
const view = slotBytesView(slot);
|
|
135
|
+
slot.set(CONTROL_MAGIC, 0);
|
|
136
|
+
view.setUint32(8, CONTROL_VERSION, true);
|
|
137
|
+
writeU64(view, 12, generationCounter);
|
|
138
|
+
view.setUint16(20, nameBytes.length, true);
|
|
139
|
+
view.setUint16(22, 0, true);
|
|
140
|
+
view.setUint32(CONTROL_CHECKSUM_OFFSET, 0, true);
|
|
141
|
+
view.setUint32(28, 0, true);
|
|
142
|
+
slot.set(nameBytes, CONTROL_NAME_OFFSET);
|
|
143
|
+
const checksum = fnv1a32(slot, CONTROL_CHECKSUM_OFFSET, 4);
|
|
144
|
+
view.setUint32(CONTROL_CHECKSUM_OFFSET, checksum, true);
|
|
145
|
+
return slot;
|
|
146
|
+
}
|
|
147
|
+
// Reject incomplete slots and bad magic or checksums. Validate the remaining
|
|
148
|
+
// fields after the checksum passes, so torn fields cannot mask a valid slot.
|
|
149
|
+
export function decodeControlSlot(slotIndex, bytes) {
|
|
150
|
+
if (bytes.length < CONTROL_SLOT_SIZE) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
for (let index = 0; index < CONTROL_MAGIC.length; index += 1) {
|
|
154
|
+
if (bytes[index] !== CONTROL_MAGIC[index]) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const view = slotBytesView(bytes);
|
|
159
|
+
const expectedChecksum = fnv1a32(bytes.subarray(0, CONTROL_SLOT_SIZE), CONTROL_CHECKSUM_OFFSET, 4);
|
|
160
|
+
const storedChecksum = view.getUint32(CONTROL_CHECKSUM_OFFSET, true);
|
|
161
|
+
if (expectedChecksum !== storedChecksum) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const version = view.getUint32(8, true);
|
|
165
|
+
if (version !== CONTROL_VERSION) {
|
|
166
|
+
throw corruptionError(`unsupported control file version ${version}`);
|
|
167
|
+
}
|
|
168
|
+
const nameLength = view.getUint16(20, true);
|
|
169
|
+
const nameEnd = CONTROL_NAME_OFFSET + nameLength;
|
|
170
|
+
if (nameEnd > CONTROL_SLOT_SIZE) {
|
|
171
|
+
throw corruptionError(`control slot name length out of bounds: ${nameLength}`);
|
|
172
|
+
}
|
|
173
|
+
const activeGeneration = TEXT_DECODER.decode(bytes.subarray(CONTROL_NAME_OFFSET, nameEnd));
|
|
174
|
+
if (!isGenerationNameValid(activeGeneration)) {
|
|
175
|
+
throw corruptionError(`invalid generation in control slot: ${activeGeneration}`);
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
slotIndex,
|
|
179
|
+
generationCounter: readU64(view, 12),
|
|
180
|
+
activeGeneration
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
// `absent`: empty file.
|
|
184
|
+
// `invalid`: nonempty file with no valid slot.
|
|
185
|
+
// `valid`: `control` is the valid slot with the highest generation counter.
|
|
186
|
+
export function readControlStateFromAccessHandle(accessHandle) {
|
|
187
|
+
const size = Number(accessHandle.getSize());
|
|
188
|
+
if (!Number.isSafeInteger(size) || size < 0) {
|
|
189
|
+
throw namedError('StorageError', `invalid control file size ${String(size)}`);
|
|
190
|
+
}
|
|
191
|
+
if (size === 0) {
|
|
192
|
+
return { status: 'absent', control: null };
|
|
193
|
+
}
|
|
194
|
+
const buffer = new Uint8Array(CONTROL_SLOT_SIZE * 2);
|
|
195
|
+
readAll(accessHandle, buffer.subarray(0, Math.min(buffer.length, size)), 0);
|
|
196
|
+
const slot0 = decodeControlSlot(0, buffer.subarray(0, CONTROL_SLOT_SIZE));
|
|
197
|
+
const slot1 = decodeControlSlot(1, buffer.subarray(CONTROL_SLOT_SIZE, CONTROL_SLOT_SIZE * 2));
|
|
198
|
+
if (slot0 && slot1) {
|
|
199
|
+
return { status: 'valid', control: slot0.generationCounter >= slot1.generationCounter ? slot0 : slot1 };
|
|
200
|
+
}
|
|
201
|
+
const control = slot0 ?? slot1;
|
|
202
|
+
return control ? { status: 'valid', control } : { status: 'invalid', control: null };
|
|
203
|
+
}
|
|
204
|
+
async function readControlFile(dbRoot) {
|
|
205
|
+
const fileHandle = await lookupFileHandle(dbRoot, CONTROL_FILE_NAME);
|
|
206
|
+
if (!fileHandle) {
|
|
207
|
+
return { status: 'absent', control: null };
|
|
208
|
+
}
|
|
209
|
+
const accessHandle = await fileHandle.createSyncAccessHandle();
|
|
210
|
+
try {
|
|
211
|
+
return readControlStateFromAccessHandle(accessHandle);
|
|
212
|
+
} finally {
|
|
213
|
+
accessHandle.close();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async function hasLegacyDataFiles(dbRoot, fileKind = 0) {
|
|
217
|
+
if (fileKind === FILE_NAMES.length) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
if (await lookupFileHandle(dbRoot, FILE_NAMES[fileKind])) {
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
return await hasLegacyDataFiles(dbRoot, fileKind + 1);
|
|
224
|
+
}
|
|
225
|
+
// Legacy files remain authoritative after a torn first publication. They must
|
|
226
|
+
// be removed before a published generation accepts writes, so later control
|
|
227
|
+
// damage cannot make stale legacy files appear current.
|
|
228
|
+
async function resolveActiveGeneration(dbRoot) {
|
|
229
|
+
const state = await readControlFile(dbRoot);
|
|
230
|
+
if (state.status === 'valid') {
|
|
231
|
+
return state.control.activeGeneration;
|
|
232
|
+
}
|
|
233
|
+
if (state.status === 'absent') {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
if (await hasLegacyDataFiles(dbRoot)) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
throw corruptionError(`control file ${CONTROL_FILE_NAME} is corrupt: no slot has a valid checksum`);
|
|
240
|
+
}
|
|
241
|
+
async function writeControlState(dbRoot, activeGeneration, expectedCurrentGeneration) {
|
|
242
|
+
const fileHandle = await dbRoot.getFileHandle(CONTROL_FILE_NAME, { create: true });
|
|
243
|
+
const accessHandle = await fileHandle.createSyncAccessHandle();
|
|
244
|
+
try {
|
|
245
|
+
const current = readControlStateFromAccessHandle(accessHandle);
|
|
246
|
+
if (current.status === 'invalid' && !(await hasLegacyDataFiles(dbRoot))) {
|
|
247
|
+
throw corruptionError('refusing to publish a generation over a corrupt control file');
|
|
248
|
+
}
|
|
249
|
+
const currentGeneration = current.control?.activeGeneration ?? null;
|
|
250
|
+
if (expectedCurrentGeneration !== undefined && currentGeneration !== expectedCurrentGeneration) {
|
|
251
|
+
throw namedError(
|
|
252
|
+
'DatabaseBusyError',
|
|
253
|
+
`active generation changed underneath compaction: expected ${String(expectedCurrentGeneration)}, found ${String(currentGeneration)}`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
const nextSlot = current.control ? (current.control.slotIndex === 0 ? 1 : 0) : 0;
|
|
257
|
+
const generationCounter = current.control?.generationCounter ?? 0n;
|
|
258
|
+
if (generationCounter === 0xffffffffffffffffn) {
|
|
259
|
+
throw namedError('StorageError', 'control generation counter exhausted');
|
|
260
|
+
}
|
|
261
|
+
const nextGenerationCounter = generationCounter + 1n;
|
|
262
|
+
const encoded = encodeControlSlot(nextGenerationCounter, activeGeneration);
|
|
263
|
+
writeAll(accessHandle, encoded, nextSlot * CONTROL_SLOT_SIZE);
|
|
264
|
+
accessHandle.flush();
|
|
265
|
+
const published = readControlStateFromAccessHandle(accessHandle);
|
|
266
|
+
if (
|
|
267
|
+
published.status !== 'valid' ||
|
|
268
|
+
published.control.activeGeneration !== activeGeneration ||
|
|
269
|
+
published.control.generationCounter !== nextGenerationCounter
|
|
270
|
+
) {
|
|
271
|
+
throw namedError('StorageError', `control file does not select ${activeGeneration} after publication`);
|
|
272
|
+
}
|
|
273
|
+
} finally {
|
|
274
|
+
accessHandle.close();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function createGenerationName() {
|
|
278
|
+
const timePart = Date.now().toString(36);
|
|
279
|
+
const randomBytes = new Uint8Array(4);
|
|
280
|
+
crypto.getRandomValues(randomBytes);
|
|
281
|
+
const randomPart = Array.from(randomBytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
282
|
+
return `gen-${timePart}-${randomPart}`;
|
|
283
|
+
}
|
|
284
|
+
async function createGenerationDirectory(dbRoot) {
|
|
285
|
+
const generationName = createGenerationName();
|
|
286
|
+
if (await lookupDirectoryHandle(dbRoot, generationName)) {
|
|
287
|
+
return await createGenerationDirectory(dbRoot);
|
|
288
|
+
}
|
|
289
|
+
await dbRoot.getDirectoryHandle(generationName, { create: true });
|
|
290
|
+
return generationName;
|
|
291
|
+
}
|
|
292
|
+
async function getDbRoot(encodedDbName, createIfMissing) {
|
|
293
|
+
const stackdb = await getOrCreateStackdbRoot();
|
|
294
|
+
if (createIfMissing) {
|
|
295
|
+
// Creation also opens existing directories, avoiding a separate lookup.
|
|
296
|
+
return await stackdb.getDirectoryHandle(encodedDbName, { create: true });
|
|
297
|
+
}
|
|
298
|
+
const dbRoot = await lookupDirectoryHandle(stackdb, encodedDbName);
|
|
299
|
+
if (!dbRoot) {
|
|
300
|
+
throw new Error(`database ${encodedDbName} does not exist`);
|
|
301
|
+
}
|
|
302
|
+
return dbRoot;
|
|
303
|
+
}
|
|
304
|
+
async function removeLegacyDataFiles(dbRoot) {
|
|
305
|
+
await FILE_NAMES.reduce(async (previous, fileName) => {
|
|
306
|
+
await previous;
|
|
307
|
+
try {
|
|
308
|
+
await dbRoot.removeEntry(fileName);
|
|
309
|
+
} catch (err) {
|
|
310
|
+
if (!isNotFoundError(err)) {
|
|
311
|
+
throw err;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}, Promise.resolve());
|
|
315
|
+
}
|
|
316
|
+
async function resolveActiveDataDir(dbRoot) {
|
|
317
|
+
const activeGeneration = await resolveActiveGeneration(dbRoot);
|
|
318
|
+
if (!activeGeneration) {
|
|
319
|
+
return {
|
|
320
|
+
dirHandle: dbRoot,
|
|
321
|
+
generationName: null,
|
|
322
|
+
path: dbRoot.name
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
const activeDir = await lookupDirectoryHandle(dbRoot, activeGeneration);
|
|
326
|
+
if (!activeDir) {
|
|
327
|
+
throw corruptionError(`active generation ${activeGeneration} is missing`);
|
|
328
|
+
}
|
|
329
|
+
// Remove stale legacy files before writes can make this generation newer.
|
|
330
|
+
await removeLegacyDataFiles(dbRoot);
|
|
331
|
+
return {
|
|
332
|
+
dirHandle: activeDir,
|
|
333
|
+
generationName: activeGeneration,
|
|
334
|
+
path: `${dbRoot.name}/${activeGeneration}`
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
function closeSession(sessionId) {
|
|
338
|
+
const session = sessions.get(sessionId);
|
|
339
|
+
if (!session) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
for (const handle of session.handles.values()) {
|
|
343
|
+
appendOffsets.delete(handle);
|
|
344
|
+
try {
|
|
345
|
+
handle.close();
|
|
346
|
+
} catch {}
|
|
347
|
+
}
|
|
348
|
+
sessions.delete(sessionId);
|
|
349
|
+
}
|
|
350
|
+
function closeSessionsForDb(encodedDbName) {
|
|
351
|
+
const prefix = `${encodedDbName}/`;
|
|
352
|
+
for (const [sessionId, session] of Array.from(sessions.entries())) {
|
|
353
|
+
if (session.path === encodedDbName || session.path.startsWith(prefix)) {
|
|
354
|
+
closeSession(sessionId);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
async function openSessionForDir(dbPath, dirHandle, generationName) {
|
|
359
|
+
// Acquire independent files concurrently. Publish the session only after
|
|
360
|
+
// all handles are acquired; failures must release every acquired handle.
|
|
361
|
+
const opened = await Promise.allSettled(
|
|
362
|
+
FILE_NAMES.map(async (fileName) => {
|
|
363
|
+
const fileHandle = await dirHandle.getFileHandle(fileName, { create: true });
|
|
364
|
+
if (typeof fileHandle.createSyncAccessHandle !== 'function') {
|
|
365
|
+
throw new Error('createSyncAccessHandle is unavailable');
|
|
366
|
+
}
|
|
367
|
+
return await fileHandle.createSyncAccessHandle();
|
|
368
|
+
})
|
|
369
|
+
);
|
|
370
|
+
const handles = new Map();
|
|
371
|
+
for (let fileKind = 0; fileKind < opened.length; fileKind += 1) {
|
|
372
|
+
const result = opened[fileKind];
|
|
373
|
+
if (result.status === 'fulfilled') {
|
|
374
|
+
handles.set(fileKind, result.value);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const failed = opened.find((result) => result.status === 'rejected');
|
|
378
|
+
if (failed) {
|
|
379
|
+
// Waiting for all results also captures late successes for cleanup.
|
|
380
|
+
for (const handle of handles.values()) {
|
|
381
|
+
try {
|
|
382
|
+
handle.close();
|
|
383
|
+
} catch {}
|
|
384
|
+
}
|
|
385
|
+
// Error precedence follows FILE_NAMES order, regardless of completion order.
|
|
386
|
+
throw failed.reason;
|
|
387
|
+
}
|
|
388
|
+
const sessionId = nextSessionId;
|
|
389
|
+
nextSessionId += 1;
|
|
390
|
+
sessions.set(sessionId, {
|
|
391
|
+
path: dbPath,
|
|
392
|
+
handles
|
|
393
|
+
});
|
|
394
|
+
return { sessionId, generationName };
|
|
395
|
+
}
|
|
396
|
+
function getSession(sessionId) {
|
|
397
|
+
const session = sessions.get(sessionId);
|
|
398
|
+
if (!session) {
|
|
399
|
+
throw namedError('StorageError', `no OPFS session ${sessionId}`);
|
|
400
|
+
}
|
|
401
|
+
return session;
|
|
402
|
+
}
|
|
403
|
+
function getAccessHandle(sessionId, fileKind) {
|
|
404
|
+
const session = getSession(sessionId);
|
|
405
|
+
const handle = session.handles.get(fileKind);
|
|
406
|
+
if (!handle) {
|
|
407
|
+
throw namedError('StorageError', `no OPFS access handle for session ${sessionId} file kind ${fileKind}`);
|
|
408
|
+
}
|
|
409
|
+
return handle;
|
|
410
|
+
}
|
|
411
|
+
function rememberFileSize(handle, size) {
|
|
412
|
+
if (typeof size === 'number' && Number.isSafeInteger(size) && size >= 0) {
|
|
413
|
+
appendOffsets.set(handle, size);
|
|
414
|
+
} else {
|
|
415
|
+
appendOffsets.delete(handle);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function readFileSize(handle) {
|
|
419
|
+
try {
|
|
420
|
+
const size = handle.getSize();
|
|
421
|
+
rememberFileSize(handle, size);
|
|
422
|
+
return size;
|
|
423
|
+
} catch (error) {
|
|
424
|
+
appendOffsets.delete(handle);
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
function lookupOpenFileSize(path) {
|
|
429
|
+
for (const session of sessions.values()) {
|
|
430
|
+
for (let fileKind = 0; fileKind < FILE_NAMES.length; fileKind += 1) {
|
|
431
|
+
if (`${session.path}/${FILE_NAMES[fileKind]}` === path) {
|
|
432
|
+
const handle = session.handles.get(fileKind);
|
|
433
|
+
return Number((handle ? readFileSize(handle) : 0) ?? 0);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
async function sumDirectorySize(dirHandle, pathPrefix) {
|
|
440
|
+
let total = 0;
|
|
441
|
+
for await (const [name, handle] of dirHandle.entries()) {
|
|
442
|
+
const entryPath = pathPrefix ? `${pathPrefix}/${name}` : name;
|
|
443
|
+
if (handle.kind === 'directory') {
|
|
444
|
+
total += await sumDirectorySize(handle, entryPath);
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
const openSize = lookupOpenFileSize(entryPath);
|
|
448
|
+
if (openSize !== null) {
|
|
449
|
+
total += openSize;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
const file = await handle.getFile();
|
|
453
|
+
total += file.size;
|
|
454
|
+
}
|
|
455
|
+
return total;
|
|
456
|
+
}
|
|
457
|
+
export async function opfsOpenActiveDb(encodedDbName, createIfMissing = true) {
|
|
458
|
+
const dbRoot = await getDbRoot(encodedDbName, createIfMissing);
|
|
459
|
+
const active = await resolveActiveDataDir(dbRoot);
|
|
460
|
+
return await openSessionForDir(active.path, active.dirHandle, active.generationName);
|
|
461
|
+
}
|
|
462
|
+
export async function opfsOpenGenerationDb(encodedDbName, generationName, createIfMissing = true) {
|
|
463
|
+
if (!isGenerationNameValid(generationName)) {
|
|
464
|
+
throw new Error(`invalid generation name ${generationName}`);
|
|
465
|
+
}
|
|
466
|
+
const dbRoot = await getDbRoot(encodedDbName, createIfMissing);
|
|
467
|
+
const generationDir = await dbRoot.getDirectoryHandle(generationName, { create: createIfMissing });
|
|
468
|
+
return await openSessionForDir(sessionPath(encodedDbName, generationName), generationDir, generationName);
|
|
469
|
+
}
|
|
470
|
+
export async function opfsReadActiveGeneration(encodedDbName) {
|
|
471
|
+
const dbRoot = await getDbRoot(encodedDbName, false);
|
|
472
|
+
return await resolveActiveGeneration(dbRoot);
|
|
473
|
+
}
|
|
474
|
+
// The WASM view is valid only during this synchronous call. Zero the unread
|
|
475
|
+
// suffix to preserve the backend read contract when the destination is reused.
|
|
476
|
+
export function opfsReadAtInto(sessionId, fileKind, offset, buffer) {
|
|
477
|
+
const handle = getAccessHandle(sessionId, fileKind);
|
|
478
|
+
const read = readAll(handle, buffer, Number(offset));
|
|
479
|
+
buffer.fill(0, read);
|
|
480
|
+
return read;
|
|
481
|
+
}
|
|
482
|
+
export function opfsReadAt(sessionId, fileKind, offset, len) {
|
|
483
|
+
const handle = getAccessHandle(sessionId, fileKind);
|
|
484
|
+
const buffer = new Uint8Array(len);
|
|
485
|
+
readAll(handle, buffer, Number(offset));
|
|
486
|
+
return buffer;
|
|
487
|
+
}
|
|
488
|
+
export function opfsWriteAt(sessionId, fileKind, offset, bytes) {
|
|
489
|
+
const handle = getAccessHandle(sessionId, fileKind);
|
|
490
|
+
try {
|
|
491
|
+
const at = Number(offset);
|
|
492
|
+
const written = writeAll(handle, bytes, at);
|
|
493
|
+
// Empty writes cannot extend the file. A successful overwrite alone
|
|
494
|
+
// cannot establish the full file length.
|
|
495
|
+
if (written > 0) {
|
|
496
|
+
const previous = appendOffsets.get(handle);
|
|
497
|
+
const end = at + written;
|
|
498
|
+
if (previous !== undefined && Number.isSafeInteger(at) && at >= 0 && Number.isSafeInteger(end)) {
|
|
499
|
+
appendOffsets.set(handle, Math.max(previous, end));
|
|
500
|
+
} else {
|
|
501
|
+
appendOffsets.delete(handle);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return written;
|
|
505
|
+
} catch (error) {
|
|
506
|
+
// A failed write may leave a prefix. The next append must read the
|
|
507
|
+
// actual file length, even when the thrown value is not an Error.
|
|
508
|
+
appendOffsets.delete(handle);
|
|
509
|
+
throw error;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
export function opfsFlush(sessionId, fileKind) {
|
|
513
|
+
const handle = getAccessHandle(sessionId, fileKind);
|
|
514
|
+
try {
|
|
515
|
+
handle.flush();
|
|
516
|
+
} catch (error) {
|
|
517
|
+
appendOffsets.delete(handle);
|
|
518
|
+
throw error;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
export function opfsLen(sessionId, fileKind) {
|
|
522
|
+
return BigInt(readFileSize(getAccessHandle(sessionId, fileKind)));
|
|
523
|
+
}
|
|
524
|
+
export function opfsAppendOffset(sessionId, fileKind) {
|
|
525
|
+
const handle = getAccessHandle(sessionId, fileKind);
|
|
526
|
+
const size = appendOffsets.get(handle);
|
|
527
|
+
return size === undefined ? opfsLen(sessionId, fileKind) : BigInt(size);
|
|
528
|
+
}
|
|
529
|
+
export function opfsTruncate(sessionId, fileKind, size) {
|
|
530
|
+
const handle = getAccessHandle(sessionId, fileKind);
|
|
531
|
+
try {
|
|
532
|
+
const length = Number(size);
|
|
533
|
+
handle.truncate(length);
|
|
534
|
+
rememberFileSize(handle, length);
|
|
535
|
+
} catch (error) {
|
|
536
|
+
appendOffsets.delete(handle);
|
|
537
|
+
throw error;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
export function opfsCloseSession(sessionId) {
|
|
541
|
+
closeSession(sessionId);
|
|
542
|
+
}
|
|
543
|
+
export async function opfsPrepareRebuildTarget(encodedDbName) {
|
|
544
|
+
const dbRoot = await getDbRoot(encodedDbName, true);
|
|
545
|
+
const generationName = await createGenerationDirectory(dbRoot);
|
|
546
|
+
return { generationName };
|
|
547
|
+
}
|
|
548
|
+
// Resolves after the control file is flushed and verified to select the new
|
|
549
|
+
// generation. The caller may then switch its live engine.
|
|
550
|
+
export async function opfsSwapActiveGeneration(encodedDbName, generationName, expectedCurrentGeneration) {
|
|
551
|
+
if (!isGenerationNameValid(generationName)) {
|
|
552
|
+
throw new Error(`invalid generation name ${generationName}`);
|
|
553
|
+
}
|
|
554
|
+
const dbRoot = await getDbRoot(encodedDbName, false);
|
|
555
|
+
const generationDir = await lookupDirectoryHandle(dbRoot, generationName);
|
|
556
|
+
if (!generationDir) {
|
|
557
|
+
throw new Error(`generation ${generationName} does not exist`);
|
|
558
|
+
}
|
|
559
|
+
await writeControlState(dbRoot, generationName, expectedCurrentGeneration ?? null);
|
|
560
|
+
}
|
|
561
|
+
// Legacy-file removal must succeed to prevent stale fallback after control
|
|
562
|
+
// damage. Inactive generation cleanup is best effort.
|
|
563
|
+
export async function opfsCleanupInactiveEntries(encodedDbName) {
|
|
564
|
+
const dbRoot = await lookupDirectoryHandle(await getOrCreateStackdbRoot(), encodedDbName);
|
|
565
|
+
if (!dbRoot) {
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const activeGeneration = await resolveActiveGeneration(dbRoot);
|
|
569
|
+
if (activeGeneration) {
|
|
570
|
+
await removeLegacyDataFiles(dbRoot);
|
|
571
|
+
}
|
|
572
|
+
const staleDirectories = [];
|
|
573
|
+
for await (const [name, handle] of dbRoot.entries()) {
|
|
574
|
+
if (handle.kind === 'directory' && name.startsWith('gen-') && name !== activeGeneration) {
|
|
575
|
+
staleDirectories.push(name);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
await staleDirectories.reduce(async (previous, name) => {
|
|
579
|
+
await previous;
|
|
580
|
+
try {
|
|
581
|
+
await dbRoot.removeEntry(name, { recursive: true });
|
|
582
|
+
} catch {}
|
|
583
|
+
}, Promise.resolve());
|
|
584
|
+
}
|
|
585
|
+
export async function opfsDbDirectorySize(encodedDbName) {
|
|
586
|
+
const dbRoot = await lookupDirectoryHandle(await getOrCreateStackdbRoot(), encodedDbName);
|
|
587
|
+
if (!dbRoot) {
|
|
588
|
+
return 0;
|
|
589
|
+
}
|
|
590
|
+
return await sumDirectorySize(dbRoot, encodedDbName);
|
|
591
|
+
}
|
|
592
|
+
export async function opfsRemoveDb(encodedDbName) {
|
|
593
|
+
closeSessionsForDb(encodedDbName);
|
|
594
|
+
const stackdb = await getOrCreateStackdbRoot();
|
|
595
|
+
try {
|
|
596
|
+
await stackdb.removeEntry(encodedDbName, { recursive: true });
|
|
597
|
+
} catch (err) {
|
|
598
|
+
if (!isNotFoundError(err)) {
|
|
599
|
+
throw err;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|