@hachej/boring-files 0.0.0-stage → 0.2.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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +215 -2
  3. package/dist/client.d.ts +12 -0
  4. package/dist/client.js +1 -0
  5. package/dist/contracts.d.ts +194 -0
  6. package/dist/contracts.js +1 -0
  7. package/dist/git.d.ts +105 -0
  8. package/dist/git.js +205 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.js +1 -0
  11. package/dist/journal.d.ts +83 -0
  12. package/dist/journal.js +124 -0
  13. package/dist/node-http.d.ts +23 -0
  14. package/dist/node-http.js +102 -0
  15. package/dist/platform.d.ts +22 -0
  16. package/dist/platform.js +157 -0
  17. package/dist/publication-input.d.ts +15 -0
  18. package/dist/publication-input.js +164 -0
  19. package/dist/remote-handler.d.ts +11 -0
  20. package/dist/remote-handler.js +63 -0
  21. package/dist/remote-protocol.d.ts +34 -0
  22. package/dist/remote-protocol.js +154 -0
  23. package/dist/remote.d.ts +26 -0
  24. package/dist/remote.js +92 -0
  25. package/dist/request-guard.d.ts +20 -0
  26. package/dist/request-guard.js +75 -0
  27. package/dist/revision-contracts.d.ts +89 -0
  28. package/dist/revision-contracts.js +8 -0
  29. package/dist/revision-handler.d.ts +14 -0
  30. package/dist/revision-handler.js +213 -0
  31. package/dist/revision-protocol.d.ts +22 -0
  32. package/dist/revision-protocol.js +106 -0
  33. package/dist/revision.d.ts +8 -0
  34. package/dist/revision.js +145 -0
  35. package/dist/sqlite-batch.d.ts +19 -0
  36. package/dist/sqlite-batch.js +7 -0
  37. package/dist/sqlite-durable-object.d.ts +24 -0
  38. package/dist/sqlite-durable-object.js +49 -0
  39. package/dist/sqlite-filesystem.d.ts +16 -0
  40. package/dist/sqlite-filesystem.js +423 -0
  41. package/dist/sqlite-settings.d.ts +61 -0
  42. package/dist/sqlite-settings.js +104 -0
  43. package/dist/sqlite.d.ts +37 -0
  44. package/dist/sqlite.js +50 -0
  45. package/dist/text.d.ts +16 -0
  46. package/dist/text.js +29 -0
  47. package/dist/workspace-catalog.d.ts +17 -0
  48. package/dist/workspace-catalog.js +147 -0
  49. package/dist/workspace-identity.d.ts +8 -0
  50. package/dist/workspace-identity.js +1 -0
  51. package/dist/workspace.d.ts +108 -0
  52. package/dist/workspace.js +649 -0
  53. package/package.json +119 -4
@@ -0,0 +1,423 @@
1
+ import { FileError, LineScanner, err, ok } from '@earendil-works/pi-durable/env';
2
+ import { randomUUID } from './platform.js';
3
+ import { registerSqliteBatch } from './sqlite-batch.js';
4
+ function normalize(path) {
5
+ const parts = [];
6
+ for (const part of path.split('/')) {
7
+ if (part === '' || part === '.')
8
+ continue;
9
+ if (part === '..')
10
+ parts.pop();
11
+ else
12
+ parts.push(part);
13
+ }
14
+ return `/${parts.join('/')}`;
15
+ }
16
+ const dirname = (path) => path.slice(0, path.lastIndexOf('/')) || '/';
17
+ const basename = (path) => path.slice(path.lastIndexOf('/') + 1);
18
+ const within = (base, path) => path === base || path.startsWith(base === '/' ? '/' : `${base}/`);
19
+ // Results, errors and line scans are Pi's own (`@earendil-works/pi-durable/env`, its portable contracts: no engine), so this file
20
+ // system answers exactly like Pi's environments.
21
+ const fileError = (code, message, path) => new FileError(code, message, path);
22
+ const isFileError = (error) => error instanceof FileError;
23
+ /**
24
+ * How often a watcher compares snapshots. A write through this file system (this connection) is compared at once, right after
25
+ * its transaction; polling remains for writes through another connection to the same database (`mode: 'polling'`).
26
+ */
27
+ const WATCH_INTERVAL_MS = 1000;
28
+ /** A FileError whose message starts with the POSIX code, as shells and Git read it. */
29
+ const failure = (code, errno, path, what) => fileError(code, `${errno}: ${what}, '${path}'`, path);
30
+ const missing = (path) => failure('not_found', 'ENOENT', path, 'no such file or directory');
31
+ export function openSqliteFileSystem(options) {
32
+ const { connection: db, workspace } = options;
33
+ if (typeof workspace !== 'string' || workspace === '')
34
+ throw new TypeError('A workspace name is required');
35
+ if (typeof options.cwd !== 'string' || !options.cwd.startsWith('/'))
36
+ throw new TypeError('An absolute working directory is required');
37
+ const now = () => Date.now();
38
+ function row(path) {
39
+ if (path === '/')
40
+ return { kind: 'directory', size: 0, mtime: 0 };
41
+ const found = db.get('SELECT kind, coalesce(length(bytes), 0) AS size, mtime_ms FROM boring_workspace_files WHERE workspace = ? AND path = ?', workspace, path);
42
+ return found ? { kind: found.kind, size: Number(found.size), mtime: Number(found.mtime_ms) } : undefined;
43
+ }
44
+ /** Why `path` does not exist: an ancestor that is a file makes it `ENOTDIR`, otherwise `ENOENT`. */
45
+ function absent(path) {
46
+ for (let ancestor = dirname(path); ancestor !== '/'; ancestor = dirname(ancestor)) {
47
+ const found = row(ancestor);
48
+ if (found)
49
+ return found.kind === 'directory' ? missing(path) : failure('not_directory', 'ENOTDIR', path, 'not a directory');
50
+ }
51
+ return missing(path);
52
+ }
53
+ function existing(path) {
54
+ const found = row(path);
55
+ if (!found)
56
+ throw absent(path);
57
+ return found;
58
+ }
59
+ function directory(path) {
60
+ if (existing(path).kind !== 'directory')
61
+ throw failure('not_directory', 'ENOTDIR', path, 'not a directory');
62
+ }
63
+ /** The parent of a path about to be created must be a directory; the error names the path. */
64
+ function parent(path) {
65
+ const found = row(dirname(path));
66
+ if (!found)
67
+ throw absent(path);
68
+ if (found.kind !== 'directory')
69
+ throw failure('not_directory', 'ENOTDIR', path, 'not a directory');
70
+ }
71
+ /** Paths written, renamed or removed by this file system since the last comparison: reported as changed even when size and time look alike. */
72
+ const touched = new Set();
73
+ function bytes(path) {
74
+ if (existing(path).kind === 'directory')
75
+ throw failure('is_directory', 'EISDIR', path, 'illegal operation on a directory');
76
+ const found = db.get('SELECT bytes FROM boring_workspace_files WHERE workspace = ? AND path = ?', workspace, path);
77
+ if (!(found?.bytes instanceof Uint8Array))
78
+ throw fileError('unknown', 'Invalid stored bytes', path);
79
+ return Uint8Array.from(found.bytes);
80
+ }
81
+ function put(path, kind, content) {
82
+ touched.add(path);
83
+ db.run('INSERT INTO boring_workspace_files (workspace, path, parent, kind, bytes, mtime_ms) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(workspace, path) DO UPDATE SET kind = excluded.kind, bytes = excluded.bytes, mtime_ms = excluded.mtime_ms', workspace, path, dirname(path), kind, content, now());
84
+ }
85
+ function write(path, content) {
86
+ if (path === '/')
87
+ throw failure('is_directory', 'EISDIR', path, 'illegal operation on a directory');
88
+ const found = row(path);
89
+ if (found?.kind === 'directory')
90
+ throw failure('is_directory', 'EISDIR', path, 'illegal operation on a directory');
91
+ // Like Pi's own environments (NodeExecutionEnv), a write creates the missing parent directories; a parent that is a file still fails.
92
+ if (!found) {
93
+ if (!row(dirname(path)))
94
+ makeDirectory(dirname(path), true);
95
+ parent(path);
96
+ }
97
+ put(path, 'file', content);
98
+ }
99
+ function makeDirectory(path, recursive) {
100
+ const found = row(path);
101
+ if (found) {
102
+ if (found.kind !== 'directory')
103
+ throw failure('invalid', 'EEXIST', path, 'file already exists');
104
+ if (!recursive)
105
+ throw failure('invalid', 'EEXIST', path, 'file already exists');
106
+ return;
107
+ }
108
+ if (recursive)
109
+ makeDirectory(dirname(path), true);
110
+ else
111
+ parent(path);
112
+ put(path, 'directory', null);
113
+ }
114
+ const children = (path) => db.all('SELECT path FROM boring_workspace_files WHERE workspace = ? AND parent = ? ORDER BY path', workspace, path).map(item => item.path);
115
+ /** Every row at or below `path`. */
116
+ const subtree = (path) => `workspace = ? AND (path = ? OR substr(path, 1, ?) = ?)`;
117
+ const subtreeArgs = (path) => [workspace, path, path.length + 1, `${path}/`];
118
+ const info = (path, found) => ({ name: basename(path), path, kind: found.kind === 'directory' ? 'directory' : 'file', size: found.size, mtimeMs: found.mtime });
119
+ db.transaction('write', () => {
120
+ db.exec(`
121
+ CREATE TABLE IF NOT EXISTS boring_workspaces (workspace TEXT PRIMARY KEY, incarnation TEXT NOT NULL) STRICT;
122
+ CREATE TABLE IF NOT EXISTS boring_workspace_files (
123
+ workspace TEXT NOT NULL, path TEXT NOT NULL, parent TEXT NOT NULL, kind TEXT NOT NULL, bytes BLOB, mtime_ms INTEGER NOT NULL,
124
+ PRIMARY KEY (workspace, path)
125
+ ) STRICT;
126
+ CREATE INDEX IF NOT EXISTS boring_workspace_files_parent ON boring_workspace_files (workspace, parent);
127
+ `);
128
+ db.run('INSERT OR IGNORE INTO boring_workspaces VALUES (?, ?)', workspace, randomUUID());
129
+ makeDirectory(normalize(options.cwd), true);
130
+ });
131
+ const incarnation = db.get('SELECT incarnation FROM boring_workspaces WHERE workspace = ?', workspace)?.incarnation;
132
+ if (typeof incarnation !== 'string')
133
+ throw new Error('The workspace has no incarnation');
134
+ /** Each open watcher's comparison, run right after a write through this file system commits. */
135
+ const watchers = new Set();
136
+ let notifying = false;
137
+ function notify() {
138
+ if (watchers.size === 0) {
139
+ touched.clear();
140
+ return;
141
+ }
142
+ if (notifying)
143
+ return;
144
+ notifying = true;
145
+ // After the current synchronous work: a batch write inside a caller's transaction is compared once it has committed.
146
+ queueMicrotask(() => { notifying = false; const paths = [...touched]; touched.clear(); for (const check of [...watchers])
147
+ check(paths); });
148
+ }
149
+ async function run(path, context, mode, work) {
150
+ if (context.abortSignal?.aborted)
151
+ return err(fileError('aborted', 'Operation aborted', path));
152
+ if (typeof path !== 'string' || path.includes('\0'))
153
+ return err(fileError('invalid', 'Invalid path', String(path)));
154
+ try {
155
+ const value = db.transaction(mode, () => work(normalize(path.startsWith('/') ? path : `${fs.cwd}/${path}`)));
156
+ if (mode === 'write')
157
+ notify();
158
+ return ok(value);
159
+ }
160
+ catch (error) {
161
+ return err(isFileError(error) ? error : fileError('unknown', error instanceof Error ? error.message : String(error), path));
162
+ }
163
+ }
164
+ async function temporary(prefix, suffix, kind, context) {
165
+ if (`${prefix}${suffix}`.includes('/'))
166
+ return err(fileError('invalid', 'Temporary names must be basenames'));
167
+ return run('/tmp', context, 'write', () => {
168
+ makeDirectory('/tmp', true);
169
+ const path = `/tmp/${prefix}${randomUUID()}${suffix}`;
170
+ put(path, kind, kind === 'file' ? new Uint8Array() : null);
171
+ return path;
172
+ });
173
+ }
174
+ const text = (path) => new TextDecoder().decode(bytes(path));
175
+ /** The size and modification time of every watched path, to compare between polls. */
176
+ function snapshot(targets) {
177
+ const seen = new Map();
178
+ const hidden = (name, target) => (target.exclude?.hidden && name.startsWith('.')) || target.exclude?.names?.includes(name);
179
+ for (const target of targets) {
180
+ const root = normalize(target.path.startsWith('/') ? target.path : `${fs.cwd}/${target.path}`), found = row(root);
181
+ seen.set(root, found ? `${found.kind}:${found.size}:${found.mtime}` : 'missing');
182
+ if (found?.kind !== 'directory')
183
+ continue;
184
+ const rows = target.recursive
185
+ ? db.all(`SELECT path, kind, coalesce(length(bytes), 0) AS size, mtime_ms FROM boring_workspace_files WHERE ${subtree(root)} AND path != ?`, ...subtreeArgs(root), root)
186
+ : db.all('SELECT path, kind, coalesce(length(bytes), 0) AS size, mtime_ms FROM boring_workspace_files WHERE workspace = ? AND parent = ?', workspace, root);
187
+ for (const item of rows) {
188
+ if (item.path.slice(root.length + (root === '/' ? 0 : 1)).split('/').some(name => hidden(name, target)))
189
+ continue;
190
+ seen.set(item.path, `${item.kind}:${Number(item.size)}:${Number(item.mtime_ms)}`);
191
+ }
192
+ }
193
+ return seen;
194
+ }
195
+ const encode = (content) => typeof content === 'string' ? new TextEncoder().encode(content) : Uint8Array.from(content);
196
+ const fs = {
197
+ id: `sqlite:${workspace}`, cwd: normalize(options.cwd), incarnation,
198
+ absolutePath: (path, context) => run(path, context, 'read', absolute => absolute),
199
+ joinPath: async (parts, context) => {
200
+ if (context.abortSignal?.aborted)
201
+ return err(fileError('aborted', 'Operation aborted'));
202
+ const joined = parts.join('/');
203
+ return ok(joined.startsWith('/') ? normalize(joined) : normalize(`/${joined}`).slice(1) || '.');
204
+ },
205
+ readTextFile: (path, context) => run(path, context, 'read', text),
206
+ readBinaryFile: (path, context) => run(path, context, 'read', bytes),
207
+ // The reader holds the bytes as they were when opened: it keeps reading that file even if its path is renamed or rewritten.
208
+ // There are no symbolic links here, so `noFollow` changes nothing.
209
+ openBinaryReader: (path, _options, context) => run(path, context, 'read', absolute => {
210
+ const content = bytes(absolute), opened = info(absolute, existing(absolute));
211
+ let closed = false;
212
+ const guard = (inner) => inner.abortSignal?.aborted ? fileError('aborted', 'Operation aborted', absolute)
213
+ : closed ? fileError('invalid', 'Binary reader is closed', absolute) : undefined;
214
+ const reader = {
215
+ info: async (inner) => { const failed = guard(inner); return failed ? err(failed) : ok({ ...opened }); },
216
+ read: async (offset, length, inner) => {
217
+ const failed = guard(inner);
218
+ if (failed)
219
+ return err(failed);
220
+ if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(length) || length < 0)
221
+ return err(fileError('invalid', 'Offset and length must be non-negative integers', absolute));
222
+ return ok(content.slice(offset, offset + length));
223
+ },
224
+ scanLines: async ({ startLine, endLine }, inner) => {
225
+ const failed = guard(inner);
226
+ if (failed)
227
+ return err(failed);
228
+ if (!Number.isSafeInteger(startLine) || startLine < 0 || (endLine !== undefined && (!Number.isSafeInteger(endLine) || endLine <= startLine))) {
229
+ return err(fileError('invalid', 'Line range must be non-negative integers with endLine > startLine', absolute));
230
+ }
231
+ // Pi's own scanner over the bytes held in the row: the same answer as Pi's Node environment for the same file.
232
+ const scanner = new LineScanner(startLine, endLine);
233
+ scanner.push(content);
234
+ return ok(scanner.finish());
235
+ },
236
+ close: async () => { closed = true; },
237
+ };
238
+ return reader;
239
+ }),
240
+ readTextLines: (path, options, context) => run(path, context, 'read', absolute => {
241
+ const lines = text(absolute).split('\n');
242
+ if (lines.at(-1) === '')
243
+ lines.pop();
244
+ return options?.maxLines === undefined ? lines : lines.slice(0, Math.max(0, options.maxLines));
245
+ }),
246
+ openTextLineReader: (path, context) => run(path, context, 'read', absolute => {
247
+ const content = text(absolute);
248
+ let offset = 0, closed = false;
249
+ const reader = {
250
+ readLine: async (inner) => {
251
+ if (inner.abortSignal?.aborted)
252
+ return err(fileError('aborted', 'Operation aborted', absolute));
253
+ if (closed)
254
+ return err(fileError('invalid', 'Text line reader is closed', absolute));
255
+ if (offset === content.length)
256
+ return ok(undefined);
257
+ const newline = content.indexOf('\n', offset);
258
+ const line = { text: content.slice(offset, newline === -1 ? undefined : newline), terminated: newline !== -1 };
259
+ offset = newline === -1 ? content.length : newline + 1;
260
+ return ok(line);
261
+ },
262
+ close: async () => { closed = true; },
263
+ };
264
+ return reader;
265
+ }),
266
+ writeFile: (path, content, context) => run(path, context, 'write', absolute => write(absolute, encode(content))),
267
+ appendFile: (path, content, context) => run(path, context, 'write', absolute => {
268
+ const added = encode(content);
269
+ if (!row(absolute))
270
+ return write(absolute, added);
271
+ const before = bytes(absolute), joined = new Uint8Array(before.length + added.length);
272
+ joined.set(before);
273
+ joined.set(added, before.length);
274
+ write(absolute, joined);
275
+ }),
276
+ truncateFile: (path, size, context) => run(path, context, 'write', absolute => {
277
+ if (!Number.isSafeInteger(size) || size < 0)
278
+ throw fileError('invalid', 'File size must be a non-negative safe integer', absolute);
279
+ const before = bytes(absolute), after = new Uint8Array(size);
280
+ after.set(before.subarray(0, size));
281
+ write(absolute, after);
282
+ }),
283
+ // Every write is already a committed SQLite transaction.
284
+ flushFile: (path, context) => run(path, context, 'read', absolute => { existing(absolute); }),
285
+ // One transaction: a reader sees the old or the new path, never both or neither. A directory moves with everything below it.
286
+ renameFile: (source, destination, context) => run(source, context, 'write', from => {
287
+ const to = normalize(destination.startsWith('/') ? destination : `${fs.cwd}/${destination}`);
288
+ const moved = existing(from);
289
+ if (from === to)
290
+ return;
291
+ touched.add(from);
292
+ touched.add(to);
293
+ if (from === '/' || within(from, to))
294
+ throw failure('invalid', 'EINVAL', to, 'cannot move a directory into itself');
295
+ const target = row(to);
296
+ if (target) {
297
+ if (moved.kind !== 'directory' && target.kind === 'directory')
298
+ throw failure('is_directory', 'EISDIR', to, 'illegal operation on a directory');
299
+ if (moved.kind === 'directory' && target.kind !== 'directory')
300
+ throw failure('not_directory', 'ENOTDIR', to, 'not a directory');
301
+ if (target.kind === 'directory' && children(to).length)
302
+ throw failure('invalid', 'ENOTEMPTY', to, 'directory not empty');
303
+ db.run('DELETE FROM boring_workspace_files WHERE workspace = ? AND path = ?', workspace, to);
304
+ }
305
+ else
306
+ parent(to);
307
+ db.run(`UPDATE boring_workspace_files SET path = ? || substr(path, ?), parent = CASE WHEN path = ? THEN ? ELSE ? || substr(parent, ?) END WHERE ${subtree(from)}`, to, from.length + 1, from, dirname(to), to, from.length + 1, ...subtreeArgs(from));
308
+ }),
309
+ fileInfo: (path, context) => run(path, context, 'read', absolute => info(absolute, existing(absolute))),
310
+ listDir: (path, context) => run(path, context, 'read', absolute => {
311
+ directory(absolute);
312
+ return children(absolute).map(child => info(child, existing(child)));
313
+ }),
314
+ // The entries as they were when opened, in path order, paged by `next`.
315
+ openDirReader: (path, context) => run(path, context, 'read', absolute => {
316
+ directory(absolute);
317
+ const entries = children(absolute).map(child => info(child, existing(child)));
318
+ let offset = 0, closed = false;
319
+ const reader = {
320
+ next: async (maxEntries, inner) => {
321
+ if (inner.abortSignal?.aborted)
322
+ return err(fileError('aborted', 'Operation aborted', absolute));
323
+ if (closed)
324
+ return err(fileError('invalid', 'Directory reader is closed', absolute));
325
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 1)
326
+ return err(fileError('invalid', 'maxEntries must be a positive integer', absolute));
327
+ const page = entries.slice(offset, offset + maxEntries);
328
+ offset += page.length;
329
+ return ok({ entries: page, done: offset >= entries.length });
330
+ },
331
+ close: async () => { closed = true; },
332
+ };
333
+ return reader;
334
+ }),
335
+ // SQLite reports no changes, so the watcher compares snapshots of the watched rows: at once after each write through this file
336
+ // system, and every second for other connections. The first snapshot is taken before the watcher is returned, so a change made
337
+ // after `watch` resolves is always seen.
338
+ watch: async (targets, onChange, context) => {
339
+ if (context.abortSignal?.aborted)
340
+ return err(fileError('aborted', 'Operation aborted'));
341
+ if (!Array.isArray(targets) || targets.some(target => typeof target?.path !== 'string' || target.path.includes('\0')))
342
+ return err(fileError('invalid', 'Invalid watch target'));
343
+ let previous;
344
+ try {
345
+ previous = db.transaction('read', () => snapshot(targets));
346
+ }
347
+ catch (error) {
348
+ return err(isFileError(error) ? error : fileError('unknown', String(error)));
349
+ }
350
+ let stopped = false;
351
+ const report = (change) => { if (!stopped)
352
+ try {
353
+ onChange(change);
354
+ }
355
+ catch { /* the host's callback cannot stop the watcher */ } };
356
+ const hidden = (name, target) => (target.exclude?.hidden && name.startsWith('.')) || target.exclude?.names?.includes(name);
357
+ /** Whether a path this file system just wrote lies under a watched target, as the snapshot would have listed it. */
358
+ const covered = (path) => targets.some(target => {
359
+ const root = normalize(target.path.startsWith('/') ? target.path : `${fs.cwd}/${target.path}`);
360
+ if (path === root)
361
+ return true;
362
+ if (!within(root, path))
363
+ return false;
364
+ const names = path.slice(root.length + (root === '/' ? 0 : 1)).split('/');
365
+ return (target.recursive || names.length === 1) && !names.some(name => hidden(name, target));
366
+ });
367
+ const check = (direct = []) => {
368
+ if (stopped)
369
+ return;
370
+ let next;
371
+ try {
372
+ next = db.transaction('read', () => snapshot(targets));
373
+ }
374
+ catch (error) {
375
+ report({ error: isFileError(error) ? error : fileError('unknown', String(error)) });
376
+ stop();
377
+ return;
378
+ }
379
+ const written = new Set(direct.filter(covered));
380
+ const changed = [...new Set([...previous.keys(), ...next.keys(), ...written])].filter(path => written.has(path) || previous.get(path) !== next.get(path));
381
+ previous = next;
382
+ if (changed.length)
383
+ report({ paths: changed });
384
+ };
385
+ const timer = setInterval(() => check(), WATCH_INTERVAL_MS);
386
+ const stop = () => { stopped = true; clearInterval(timer); watchers.delete(check); };
387
+ watchers.add(check);
388
+ const watcher = { mode: 'polling', close: async () => { stop(); } };
389
+ return ok(watcher);
390
+ },
391
+ canonicalPath: (path, context) => run(path, context, 'read', absolute => { existing(absolute); return absolute; }),
392
+ exists: (path, context) => run(path, context, 'read', absolute => row(absolute) !== undefined),
393
+ createDir: (path, options, context) => run(path, context, 'write', absolute => {
394
+ // Like `mkdir -p`, an existing directory is fine when recursive (the default).
395
+ if (options?.recursive !== false && row(absolute)?.kind === 'directory')
396
+ return;
397
+ makeDirectory(absolute, options?.recursive !== false);
398
+ }),
399
+ remove: (path, options, context) => run(path, context, 'write', absolute => {
400
+ const found = row(absolute);
401
+ if (!found) {
402
+ if (options?.force)
403
+ return;
404
+ throw absent(absolute);
405
+ }
406
+ if (absolute === '/' || absolute === fs.cwd)
407
+ throw failure('permission_denied', 'EPERM', absolute, 'the workspace root cannot be removed');
408
+ if (found.kind === 'directory' && !options?.recursive && children(absolute).length)
409
+ throw failure('invalid', 'ENOTEMPTY', absolute, 'directory not empty');
410
+ touched.add(absolute);
411
+ db.run(`DELETE FROM boring_workspace_files WHERE ${subtree(absolute)}`, ...subtreeArgs(absolute));
412
+ }),
413
+ createTempDir: (prefix, context) => temporary(prefix ?? 'tmp-', '', 'directory', context),
414
+ createTempFile: (options, context) => temporary(options?.prefix ?? 'tmp-', options?.suffix ?? '', 'file', context),
415
+ cleanup: async () => { },
416
+ };
417
+ registerSqliteBatch(fs, {
418
+ connection: db,
419
+ bytes: path => { const found = row(normalize(path)); return !found ? null : found.kind === 'directory' ? undefined : bytes(normalize(path)); },
420
+ write: (path, content) => { const absolute = normalize(path); makeDirectory(dirname(absolute), true); write(absolute, content); notify(); },
421
+ });
422
+ return fs;
423
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The one place v4 chooses SQLite connection settings (journal mode, locking mode, busy timeout, synchronous, temp store).
3
+ * Every opener (`openNodeConnection`, `openNodeDatabase` for Pi's own storage, the browser's SQLite Wasm) takes a
4
+ * `SqliteSettings` and turns it into statements here; `npm run check` refuses `PRAGMA journal_mode` and `PRAGMA locking_mode`
5
+ * anywhere else. Values are allow-listed: there is no free-form pragma name or value, so a setting cannot inject SQL.
6
+ * Pure: no `node:*` import, safe in a browser bundle.
7
+ */
8
+ /** `wal` needs shared memory between the connections of one host: never on a network file system. */
9
+ export type SqliteJournalMode = 'wal' | 'delete' | 'truncate' | 'persist';
10
+ /** `exclusive`: the connection takes the file's lock when it opens and keeps it until it closes (one owner per file). */
11
+ export type SqliteLockingMode = 'normal' | 'exclusive';
12
+ export type SqliteSynchronous = 'off' | 'normal' | 'full' | 'extra';
13
+ export type SqliteTempStore = 'default' | 'file' | 'memory';
14
+ export interface SqliteSettings {
15
+ readonly journalMode?: SqliteJournalMode;
16
+ readonly lockingMode?: SqliteLockingMode;
17
+ /** How long a statement waits for another connection's lock before failing with `database is locked`. 0 to 600 000 ms. */
18
+ readonly busyTimeoutMs?: number;
19
+ readonly synchronous?: SqliteSynchronous;
20
+ readonly tempStore?: SqliteTempStore;
21
+ }
22
+ /** Complete settings: what an opener applies after filling the caller's partial settings from `sqliteSettings.localDisk`. */
23
+ export type ResolvedSqliteSettings = Required<SqliteSettings>;
24
+ /**
25
+ * Presets. `localDisk` is the default of every v4 opener (and what `openNodeConnection` always did). `networkFilesystem` is for a
26
+ * file on NFS such as Amazon EFS, owned by one process (one writer per SQLite file, docs/architecture/HOST-RECIPE-AWS.md):
27
+ * rollback journal instead of WAL (WAL's shared-memory index is unsafe across hosts), the lock taken once at open and held
28
+ * (no per-transaction lock round trips; a second opener fails with `SqliteLockedError` instead of writing beside the owner),
29
+ * full sync, temporary tables in memory.
30
+ */
31
+ export declare const sqliteSettings: {
32
+ readonly localDisk: ResolvedSqliteSettings;
33
+ readonly networkFilesystem: ResolvedSqliteSettings;
34
+ };
35
+ /** Checks `settings` and returns a frozen copy of the given fields only. Throws a `TypeError` naming the first invalid field. */
36
+ export declare function validateSqliteSettings(settings: SqliteSettings): SqliteSettings;
37
+ /** `settings` over `base` (the local-disk preset unless given), validated. */
38
+ export declare function resolveSqliteSettings(settings?: SqliteSettings, base?: ResolvedSqliteSettings): ResolvedSqliteSettings;
39
+ /**
40
+ * The statements for the given fields, in the order they must run: the busy timeout first (so the others wait for a lock),
41
+ * the locking mode before the journal mode (exclusive WAL then needs no shared memory).
42
+ */
43
+ export declare function sqlitePragmas(settings: SqliteSettings): string[];
44
+ /** The file is held by another connection (`lockingMode: 'exclusive'` elsewhere, or a writer that outlasted the busy timeout). */
45
+ export declare class SqliteLockedError extends Error {
46
+ readonly filename: string;
47
+ readonly code = "sqlite-locked";
48
+ constructor(filename: string, options?: {
49
+ cause?: unknown;
50
+ });
51
+ }
52
+ /**
53
+ * Applies resolved settings to an open database through its `exec`/`get`, checks the journal mode took effect (`memory` is what an
54
+ * in-memory database reports for any of them), and under `exclusive` takes the lock now with an empty exclusive transaction,
55
+ * so the owner is decided at open. A lock held elsewhere becomes `SqliteLockedError`.
56
+ */
57
+ export declare function applySqliteSettings(db: {
58
+ readonly exec: (sql: string) => void;
59
+ readonly get: (sql: string) => Record<string, unknown> | undefined;
60
+ }, settings: ResolvedSqliteSettings, filename: string): void;
61
+ //# sourceMappingURL=sqlite-settings.d.ts.map
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The one place v4 chooses SQLite connection settings (journal mode, locking mode, busy timeout, synchronous, temp store).
3
+ * Every opener (`openNodeConnection`, `openNodeDatabase` for Pi's own storage, the browser's SQLite Wasm) takes a
4
+ * `SqliteSettings` and turns it into statements here; `npm run check` refuses `PRAGMA journal_mode` and `PRAGMA locking_mode`
5
+ * anywhere else. Values are allow-listed: there is no free-form pragma name or value, so a setting cannot inject SQL.
6
+ * Pure: no `node:*` import, safe in a browser bundle.
7
+ */
8
+ /**
9
+ * Presets. `localDisk` is the default of every v4 opener (and what `openNodeConnection` always did). `networkFilesystem` is for a
10
+ * file on NFS such as Amazon EFS, owned by one process (one writer per SQLite file, docs/architecture/HOST-RECIPE-AWS.md):
11
+ * rollback journal instead of WAL (WAL's shared-memory index is unsafe across hosts), the lock taken once at open and held
12
+ * (no per-transaction lock round trips; a second opener fails with `SqliteLockedError` instead of writing beside the owner),
13
+ * full sync, temporary tables in memory.
14
+ */
15
+ export const sqliteSettings = Object.freeze({
16
+ localDisk: Object.freeze({ journalMode: 'wal', lockingMode: 'normal', busyTimeoutMs: 5000, synchronous: 'full', tempStore: 'default' }),
17
+ networkFilesystem: Object.freeze({ journalMode: 'delete', lockingMode: 'exclusive', busyTimeoutMs: 10000, synchronous: 'full', tempStore: 'memory' }),
18
+ });
19
+ const allowed = {
20
+ journalMode: ['wal', 'delete', 'truncate', 'persist'],
21
+ lockingMode: ['normal', 'exclusive'],
22
+ synchronous: ['off', 'normal', 'full', 'extra'],
23
+ tempStore: ['default', 'file', 'memory'],
24
+ };
25
+ const KEYS = new Set(['journalMode', 'lockingMode', 'busyTimeoutMs', 'synchronous', 'tempStore']);
26
+ /** Checks `settings` and returns a frozen copy of the given fields only. Throws a `TypeError` naming the first invalid field. */
27
+ export function validateSqliteSettings(settings) {
28
+ if (typeof settings !== 'object' || settings === null || Array.isArray(settings))
29
+ throw new TypeError('SQLite settings must be an object');
30
+ const copy = {};
31
+ for (const [key, value] of Object.entries(settings)) {
32
+ if (!KEYS.has(key))
33
+ throw new TypeError(`Unknown SQLite setting ${JSON.stringify(key)}`);
34
+ if (value === undefined)
35
+ continue;
36
+ if (key === 'busyTimeoutMs') {
37
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 600_000)
38
+ throw new TypeError('busyTimeoutMs must be an integer from 0 to 600000');
39
+ }
40
+ else if (!allowed[key].includes(value)) {
41
+ throw new TypeError(`${key} must be one of ${allowed[key].join(', ')}`);
42
+ }
43
+ copy[key] = value;
44
+ }
45
+ return Object.freeze(copy);
46
+ }
47
+ /** `settings` over `base` (the local-disk preset unless given), validated. */
48
+ export function resolveSqliteSettings(settings = {}, base = sqliteSettings.localDisk) {
49
+ return Object.freeze({ ...base, ...validateSqliteSettings(settings) });
50
+ }
51
+ /**
52
+ * The statements for the given fields, in the order they must run: the busy timeout first (so the others wait for a lock),
53
+ * the locking mode before the journal mode (exclusive WAL then needs no shared memory).
54
+ */
55
+ export function sqlitePragmas(settings) {
56
+ const valid = validateSqliteSettings(settings), statements = [];
57
+ if (valid.busyTimeoutMs !== undefined)
58
+ statements.push(`PRAGMA busy_timeout = ${valid.busyTimeoutMs}`);
59
+ if (valid.lockingMode !== undefined)
60
+ statements.push(`PRAGMA locking_mode = ${valid.lockingMode.toUpperCase()}`);
61
+ if (valid.journalMode !== undefined)
62
+ statements.push(`PRAGMA journal_mode = ${valid.journalMode.toUpperCase()}`);
63
+ if (valid.synchronous !== undefined)
64
+ statements.push(`PRAGMA synchronous = ${valid.synchronous.toUpperCase()}`);
65
+ if (valid.tempStore !== undefined)
66
+ statements.push(`PRAGMA temp_store = ${valid.tempStore.toUpperCase()}`);
67
+ return statements;
68
+ }
69
+ /** The file is held by another connection (`lockingMode: 'exclusive'` elsewhere, or a writer that outlasted the busy timeout). */
70
+ export class SqliteLockedError extends Error {
71
+ filename;
72
+ code = 'sqlite-locked';
73
+ constructor(filename, options) {
74
+ super(`The SQLite file ${filename} is locked by another connection (one writer per file); it was not opened`, options);
75
+ this.filename = filename;
76
+ this.name = 'SqliteLockedError';
77
+ }
78
+ }
79
+ const isLocked = (error) => /database is locked|SQLITE_BUSY|database table is locked/i.test(String(error?.message ?? ''));
80
+ /**
81
+ * Applies resolved settings to an open database through its `exec`/`get`, checks the journal mode took effect (`memory` is what an
82
+ * in-memory database reports for any of them), and under `exclusive` takes the lock now with an empty exclusive transaction,
83
+ * so the owner is decided at open. A lock held elsewhere becomes `SqliteLockedError`.
84
+ */
85
+ export function applySqliteSettings(db, settings, filename) {
86
+ try {
87
+ for (const statement of sqlitePragmas(settings)) {
88
+ if (!statement.startsWith('PRAGMA journal_mode')) {
89
+ db.exec(statement);
90
+ continue;
91
+ }
92
+ const mode = String(db.get(statement)?.journal_mode ?? '').toLowerCase();
93
+ if (mode !== settings.journalMode && mode !== 'memory')
94
+ throw new Error(`SQLite kept journal_mode ${mode || '(none)'} instead of ${settings.journalMode} for ${filename}`);
95
+ }
96
+ if (settings.lockingMode === 'exclusive')
97
+ db.exec('BEGIN EXCLUSIVE; COMMIT');
98
+ }
99
+ catch (error) {
100
+ if (isLocked(error))
101
+ throw new SqliteLockedError(filename, { cause: error });
102
+ throw error;
103
+ }
104
+ }
@@ -0,0 +1,37 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ import type { SqliteValue } from '@earendil-works/pi-durable/storage/sqlite';
3
+ import { type SqliteSettings } from './sqlite-settings.js';
4
+ /**
5
+ * The synchronous SQL surface the SQLite workspace backend (`openSqliteFileSystem`) and the workspace journal
6
+ * (`createWorkspaceJournal`) need: the statement methods of Pi Durable's `SqliteExecutor` (same names, positional
7
+ * `SqliteValue` bindings, rows as plain objects) without promises, plus a transaction. It is synchronous on purpose: a conditional
8
+ * write is one critical section (conflict check, file rows, receipt) that must commit or roll back as a unit, and a synchronous
9
+ * callback cannot interleave with another operation. Pi's own `SqliteDatabase` is asynchronous, so it needs a queue to give that
10
+ * guarantee; engines whose SQL is synchronous (`node:sqlite`, a Durable Object's `ctx.storage.sql`) satisfy this directly.
11
+ */
12
+ export interface SqliteConnection {
13
+ readonly exec: (sql: string) => void;
14
+ readonly run: (sql: string, ...params: SqliteValue[]) => void;
15
+ readonly get: <Row extends object = Record<string, unknown>>(sql: string, ...params: SqliteValue[]) => Row | undefined;
16
+ readonly all: <Row extends object = Record<string, unknown>>(sql: string, ...params: SqliteValue[]) => Row[];
17
+ /**
18
+ * Run `work` atomically: every statement it issues commits together, or none does when it throws (the error is rethrown).
19
+ * `write` takes the write lock up front where the engine distinguishes it. `work` must not await. A transaction opened inside
20
+ * `work` joins the outer one.
21
+ */
22
+ readonly transaction: <Value>(mode: 'read' | 'write', work: () => Value) => Value;
23
+ /** Optional: release the connection. Its owner calls it; the file system and the journal borrow the connection and never close it. */
24
+ readonly close?: () => void;
25
+ }
26
+ export * from './sqlite-settings.js';
27
+ /**
28
+ * A `node:sqlite` database with `settings` applied (partial settings are filled from `sqliteSettings.localDisk`: WAL, full sync,
29
+ * 5 s busy timeout). For Pi's own durable storage on the same settings, hand it to Pi's public adapter:
30
+ * `SqliteStorage.open(new NodeSqliteDatabase(openNodeDatabase(file, sqliteSettings.networkFilesystem)))`. Throws
31
+ * `SqliteLockedError` when another connection holds the file under `lockingMode: 'exclusive'`. Loaded without a static import so
32
+ * other runtimes can bundle this module.
33
+ */
34
+ export declare function openNodeDatabase(filename: string, settings?: SqliteSettings): DatabaseSync;
35
+ /** A `node:sqlite` file as a connection (`openNodeDatabase` with the same settings), `BEGIN IMMEDIATE` for writes. */
36
+ export declare function openNodeConnection(filename: string, settings?: SqliteSettings): SqliteConnection;
37
+ //# sourceMappingURL=sqlite.d.ts.map