@stablemodels/durable-bash 0.1.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.
@@ -0,0 +1,603 @@
1
+ import { DurableObject } from "cloudflare:workers";
2
+ import {
3
+ EEXIST,
4
+ EISDIR,
5
+ ENOENT,
6
+ ENOTDIR,
7
+ ENOTEMPTY,
8
+ FsError,
9
+ } from "./errors.js";
10
+ import type { DirentData, FsStatData } from "./types.js";
11
+
12
+ const DIR_MODE = 0o755;
13
+ const FILE_MODE = 0o644;
14
+ const SYMLINK_MODE = 0o777;
15
+
16
+ /**
17
+ * Normalize a path: resolve `.` and `..`, remove trailing slashes, ensure leading `/`.
18
+ */
19
+ export function normalizePath(p: string): string {
20
+ if (!p || p === "/") return "/";
21
+ const parts = p.split("/");
22
+ const resolved: string[] = [];
23
+ for (const part of parts) {
24
+ if (part === "" || part === ".") continue;
25
+ if (part === "..") {
26
+ resolved.pop();
27
+ } else {
28
+ resolved.push(part);
29
+ }
30
+ }
31
+ return `/${resolved.join("/")}`;
32
+ }
33
+
34
+ function parentDir(p: string): string {
35
+ const idx = p.lastIndexOf("/");
36
+ if (idx <= 0) return "/";
37
+ return p.substring(0, idx);
38
+ }
39
+
40
+ function ancestors(p: string): string[] {
41
+ const result: string[] = [];
42
+ let current = parentDir(p);
43
+ while (current !== "/") {
44
+ result.push(current);
45
+ current = parentDir(current);
46
+ }
47
+ result.push("/");
48
+ return result.reverse();
49
+ }
50
+
51
+ type FileRow = {
52
+ path: string;
53
+ content: ArrayBuffer | null;
54
+ is_dir: number;
55
+ mode: number;
56
+ size: number;
57
+ mtime_ms: number;
58
+ symlink_target: string | null;
59
+ };
60
+
61
+ export class FsObject extends DurableObject<any> {
62
+ private sql: SqlStorage;
63
+
64
+ constructor(ctx: DurableObjectState, env: Record<string, unknown>) {
65
+ super(ctx, env as any);
66
+ this.sql = ctx.storage.sql;
67
+ this.sql.exec(`
68
+ CREATE TABLE IF NOT EXISTS files (
69
+ path TEXT PRIMARY KEY,
70
+ content BLOB,
71
+ is_dir INTEGER NOT NULL DEFAULT 0,
72
+ mode INTEGER NOT NULL DEFAULT 420,
73
+ size INTEGER NOT NULL DEFAULT 0,
74
+ mtime_ms INTEGER NOT NULL,
75
+ symlink_target TEXT
76
+ )
77
+ `);
78
+ const root = this.sql
79
+ .exec("SELECT 1 FROM files WHERE path = '/'")
80
+ .toArray();
81
+ if (root.length === 0) {
82
+ this.sql.exec(
83
+ "INSERT INTO files (path, is_dir, mode, size, mtime_ms) VALUES ('/', 1, ?, 0, ?)",
84
+ DIR_MODE,
85
+ Date.now(),
86
+ );
87
+ }
88
+ }
89
+
90
+ // ── Private helpers ──────────────────────────────────────────────
91
+
92
+ private getRow(path: string): FileRow | null {
93
+ const rows = this.sql
94
+ .exec("SELECT * FROM files WHERE path = ?", path)
95
+ .toArray();
96
+ if (rows.length === 0) return null;
97
+ return rows[0] as FileRow;
98
+ }
99
+
100
+ private resolveSymlinks(path: string, maxDepth = 20): string {
101
+ let current = path;
102
+ for (let i = 0; i < maxDepth; i++) {
103
+ const row = this.getRow(current);
104
+ if (!row) throw ENOENT(path);
105
+ if (!row.symlink_target) return current;
106
+ const target = row.symlink_target.startsWith("/")
107
+ ? normalizePath(row.symlink_target)
108
+ : normalizePath(`${parentDir(current)}/${row.symlink_target}`);
109
+ current = target;
110
+ }
111
+ throw new FsError("ELOOP", "too many levels of symbolic links", path);
112
+ }
113
+
114
+ /**
115
+ * Resolve path through symlinks if it exists and is a symlink, otherwise return as-is.
116
+ */
117
+ private resolveTarget(normalized: string): string {
118
+ const row = this.getRow(normalized);
119
+ if (row?.symlink_target) {
120
+ return this.resolveSymlinks(normalized);
121
+ }
122
+ return normalized;
123
+ }
124
+
125
+ private ensureParentDirs(path: string): void {
126
+ const dirs = ancestors(path);
127
+ const now = Date.now();
128
+ for (const dir of dirs) {
129
+ // INSERT OR IGNORE avoids the redundant SELECT per ancestor
130
+ this.sql.exec(
131
+ "INSERT OR IGNORE INTO files (path, is_dir, mode, size, mtime_ms) VALUES (?, 1, ?, 0, ?)",
132
+ dir,
133
+ DIR_MODE,
134
+ now,
135
+ );
136
+ }
137
+ }
138
+
139
+ private childPrefix(dirPath: string): string {
140
+ return dirPath === "/" ? "/" : `${dirPath}/`;
141
+ }
142
+
143
+ private toBytes(content: string | Uint8Array): Uint8Array {
144
+ return typeof content === "string"
145
+ ? new TextEncoder().encode(content)
146
+ : content;
147
+ }
148
+
149
+ private bufferToString(buf: ArrayBuffer | null): string {
150
+ return buf !== null ? new TextDecoder().decode(buf) : "";
151
+ }
152
+
153
+ private bufferToUint8Array(buf: ArrayBuffer | null): Uint8Array {
154
+ return buf !== null ? new Uint8Array(buf) : new Uint8Array(0);
155
+ }
156
+
157
+ private extractChildName(fullPath: string, prefix: string): string | null {
158
+ const rest = fullPath.substring(prefix.length);
159
+ const slashIdx = rest.indexOf("/");
160
+ const name = slashIdx === -1 ? rest : rest.substring(0, slashIdx);
161
+ return name || null;
162
+ }
163
+
164
+ private ensureDirRow(path: string, row: FileRow | null): void {
165
+ if (!row) throw ENOENT(path);
166
+ if (!row.is_dir) throw ENOTDIR(path);
167
+ }
168
+
169
+ /**
170
+ * Insert or update a file entry.
171
+ */
172
+ private upsertFile(
173
+ path: string,
174
+ data: {
175
+ content: ArrayBuffer | Uint8Array | null;
176
+ is_dir: number;
177
+ mode: number;
178
+ size: number;
179
+ mtime_ms: number;
180
+ symlink_target?: string | null;
181
+ },
182
+ ): void {
183
+ const existing = this.getRow(path);
184
+ if (existing) {
185
+ this.sql.exec(
186
+ "UPDATE files SET content = ?, is_dir = ?, mode = ?, size = ?, mtime_ms = ?, symlink_target = ? WHERE path = ?",
187
+ data.content,
188
+ data.is_dir,
189
+ data.mode,
190
+ data.size,
191
+ data.mtime_ms,
192
+ data.symlink_target ?? null,
193
+ path,
194
+ );
195
+ } else {
196
+ this.ensureParentDirs(path);
197
+ this.sql.exec(
198
+ "INSERT INTO files (path, content, is_dir, mode, size, mtime_ms, symlink_target) VALUES (?, ?, ?, ?, ?, ?, ?)",
199
+ path,
200
+ data.content,
201
+ data.is_dir,
202
+ data.mode,
203
+ data.size,
204
+ data.mtime_ms,
205
+ data.symlink_target ?? null,
206
+ );
207
+ }
208
+ }
209
+
210
+ // ── Public RPC methods ───────────────────────────────────────────
211
+
212
+ readFile(path: string): { content: string; encoding: string } {
213
+ const resolved = this.resolveSymlinks(normalizePath(path));
214
+ const row = this.getRow(resolved);
215
+ if (!row) throw ENOENT(path);
216
+ if (row.is_dir) throw EISDIR(path);
217
+ return { content: this.bufferToString(row.content), encoding: "utf-8" };
218
+ }
219
+
220
+ readFileBuffer(path: string): { content: Uint8Array } {
221
+ const resolved = this.resolveSymlinks(normalizePath(path));
222
+ const row = this.getRow(resolved);
223
+ if (!row) throw ENOENT(path);
224
+ if (row.is_dir) throw EISDIR(path);
225
+ return { content: this.bufferToUint8Array(row.content) };
226
+ }
227
+
228
+ writeFile(
229
+ path: string,
230
+ content: string | Uint8Array,
231
+ opts?: { mode?: number },
232
+ ): void {
233
+ const normalized = normalizePath(path);
234
+ const bytes = this.toBytes(content);
235
+ const mode = opts?.mode ?? FILE_MODE;
236
+
237
+ const existing = this.getRow(normalized);
238
+ if (existing?.is_dir) throw EISDIR(path);
239
+
240
+ const target = existing?.symlink_target
241
+ ? this.resolveSymlinks(normalized)
242
+ : normalized;
243
+
244
+ this.ensureParentDirs(target);
245
+ this.upsertFile(target, {
246
+ content: bytes,
247
+ is_dir: 0,
248
+ mode,
249
+ size: bytes.byteLength,
250
+ mtime_ms: Date.now(),
251
+ });
252
+ }
253
+
254
+ appendFile(path: string, content: string | Uint8Array): void {
255
+ const normalized = normalizePath(path);
256
+ const target = this.resolveTarget(normalized);
257
+
258
+ const row = this.getRow(target);
259
+ const appendBytes = this.toBytes(content);
260
+
261
+ if (!row) {
262
+ this.ensureParentDirs(target);
263
+ this.sql.exec(
264
+ "INSERT INTO files (path, content, is_dir, mode, size, mtime_ms) VALUES (?, ?, 0, ?, ?, ?)",
265
+ target,
266
+ appendBytes,
267
+ FILE_MODE,
268
+ appendBytes.byteLength,
269
+ Date.now(),
270
+ );
271
+ } else {
272
+ if (row.is_dir) throw EISDIR(path);
273
+ const existing = this.bufferToUint8Array(row.content);
274
+ const combined = new Uint8Array(
275
+ existing.byteLength + appendBytes.byteLength,
276
+ );
277
+ combined.set(existing, 0);
278
+ combined.set(appendBytes, existing.byteLength);
279
+ this.sql.exec(
280
+ "UPDATE files SET content = ?, size = ?, mtime_ms = ? WHERE path = ?",
281
+ combined,
282
+ combined.byteLength,
283
+ Date.now(),
284
+ target,
285
+ );
286
+ }
287
+ }
288
+
289
+ exists(path: string): boolean {
290
+ return this.getRow(normalizePath(path)) !== null;
291
+ }
292
+
293
+ stat(path: string): FsStatData {
294
+ const resolved = this.resolveSymlinks(normalizePath(path));
295
+ const row = this.getRow(resolved);
296
+ if (!row) throw ENOENT(path);
297
+ return {
298
+ isDir: row.is_dir === 1,
299
+ isSymlink: false,
300
+ size: row.size,
301
+ mode: row.mode,
302
+ mtimeMs: row.mtime_ms,
303
+ };
304
+ }
305
+
306
+ lstat(path: string): FsStatData {
307
+ const normalized = normalizePath(path);
308
+ const row = this.getRow(normalized);
309
+ if (!row) throw ENOENT(path);
310
+ return {
311
+ isDir: row.is_dir === 1,
312
+ isSymlink: row.symlink_target !== null,
313
+ size: row.size,
314
+ mode: row.mode,
315
+ mtimeMs: row.mtime_ms,
316
+ };
317
+ }
318
+
319
+ mkdir(path: string, opts?: { recursive?: boolean }): void {
320
+ const normalized = normalizePath(path);
321
+ const existing = this.getRow(normalized);
322
+
323
+ if (existing) {
324
+ if (existing.is_dir && opts?.recursive) return;
325
+ throw EEXIST(path);
326
+ }
327
+
328
+ if (opts?.recursive) {
329
+ this.ensureParentDirs(normalized);
330
+ } else {
331
+ const parent = parentDir(normalized);
332
+ const parentRow = this.getRow(parent);
333
+ if (!parentRow) throw ENOENT(parent);
334
+ if (!parentRow.is_dir) throw ENOTDIR(parent);
335
+ }
336
+
337
+ this.sql.exec(
338
+ "INSERT INTO files (path, is_dir, mode, size, mtime_ms) VALUES (?, 1, ?, 0, ?)",
339
+ normalized,
340
+ DIR_MODE,
341
+ Date.now(),
342
+ );
343
+ }
344
+
345
+ readdir(path: string): string[] {
346
+ const normalized = normalizePath(path);
347
+ const row = this.getRow(normalized);
348
+ this.ensureDirRow(path, row);
349
+
350
+ const prefix = this.childPrefix(normalized);
351
+ const rows = this.sql
352
+ .exec(
353
+ "SELECT path FROM files WHERE path != ? AND path LIKE ?",
354
+ normalized,
355
+ `${prefix}%`,
356
+ )
357
+ .toArray();
358
+
359
+ const names = new Set<string>();
360
+ for (const r of rows) {
361
+ const name = this.extractChildName(r.path as string, prefix);
362
+ if (name) names.add(name);
363
+ }
364
+ return [...names].sort();
365
+ }
366
+
367
+ readdirWithFileTypes(path: string): DirentData[] {
368
+ const normalized = normalizePath(path);
369
+ const row = this.getRow(normalized);
370
+ this.ensureDirRow(path, row);
371
+
372
+ const prefix = this.childPrefix(normalized);
373
+ const rows = this.sql
374
+ .exec(
375
+ "SELECT path, is_dir, symlink_target FROM files WHERE path != ? AND path LIKE ?",
376
+ normalized,
377
+ `${prefix}%`,
378
+ )
379
+ .toArray();
380
+
381
+ const entries = new Map<string, DirentData>();
382
+ for (const r of rows) {
383
+ const fullPath = r.path as string;
384
+ const rest = fullPath.substring(prefix.length);
385
+ const slashIdx = rest.indexOf("/");
386
+ const name = slashIdx === -1 ? rest : rest.substring(0, slashIdx);
387
+ if (!name || entries.has(name)) continue;
388
+
389
+ if (slashIdx !== -1) {
390
+ entries.set(name, {
391
+ name,
392
+ isFile: false,
393
+ isDirectory: true,
394
+ isSymlink: false,
395
+ });
396
+ } else {
397
+ const isDir = (r.is_dir as number) === 1;
398
+ const isSymlink = r.symlink_target !== null;
399
+ entries.set(name, {
400
+ name,
401
+ isFile: !isDir && !isSymlink,
402
+ isDirectory: isDir,
403
+ isSymlink,
404
+ });
405
+ }
406
+ }
407
+
408
+ return [...entries.values()].sort((a, b) => a.name.localeCompare(b.name));
409
+ }
410
+
411
+ rm(path: string, opts?: { recursive?: boolean; force?: boolean }): void {
412
+ const normalized = normalizePath(path);
413
+ const row = this.getRow(normalized);
414
+
415
+ if (!row) {
416
+ if (opts?.force) return;
417
+ throw ENOENT(path);
418
+ }
419
+
420
+ if (row.is_dir) {
421
+ const prefix = this.childPrefix(normalized);
422
+ const children = this.sql
423
+ .exec("SELECT 1 FROM files WHERE path LIKE ? LIMIT 1", `${prefix}%`)
424
+ .toArray();
425
+
426
+ if (children.length > 0) {
427
+ if (!opts?.recursive) throw ENOTEMPTY(path);
428
+ this.sql.exec("DELETE FROM files WHERE path LIKE ?", `${prefix}%`);
429
+ }
430
+ }
431
+
432
+ this.sql.exec("DELETE FROM files WHERE path = ?", normalized);
433
+ }
434
+
435
+ cp(src: string, dest: string, opts?: { recursive?: boolean }): void {
436
+ const srcNorm = this.resolveSymlinks(normalizePath(src));
437
+ const destNorm = normalizePath(dest);
438
+ const srcRow = this.getRow(srcNorm);
439
+ if (!srcRow) throw ENOENT(src);
440
+
441
+ const now = Date.now();
442
+
443
+ if (srcRow.is_dir) {
444
+ if (!opts?.recursive) throw EISDIR(src);
445
+ const prefix = this.childPrefix(srcNorm);
446
+ const rows = this.sql
447
+ .exec(
448
+ "SELECT * FROM files WHERE path = ? OR path LIKE ?",
449
+ srcNorm,
450
+ `${prefix}%`,
451
+ )
452
+ .toArray();
453
+
454
+ this.ensureParentDirs(destNorm);
455
+
456
+ for (const r of rows) {
457
+ const rPath = r.path as string;
458
+ const relativePath =
459
+ rPath === srcNorm ? "" : rPath.substring(srcNorm.length);
460
+ this.upsertFile(destNorm + relativePath, {
461
+ content: r.content as ArrayBuffer | null,
462
+ is_dir: r.is_dir as number,
463
+ mode: r.mode as number,
464
+ size: r.size as number,
465
+ mtime_ms: now,
466
+ symlink_target: r.symlink_target as string | null,
467
+ });
468
+ }
469
+ } else {
470
+ this.upsertFile(destNorm, {
471
+ content: srcRow.content,
472
+ is_dir: 0,
473
+ mode: srcRow.mode,
474
+ size: srcRow.size,
475
+ mtime_ms: now,
476
+ });
477
+ }
478
+ }
479
+
480
+ mv(src: string, dest: string): void {
481
+ const srcNorm = normalizePath(src);
482
+ const destNorm = normalizePath(dest);
483
+ const srcRow = this.getRow(srcNorm);
484
+ if (!srcRow) throw ENOENT(src);
485
+
486
+ this.ensureParentDirs(destNorm);
487
+ const now = Date.now();
488
+
489
+ if (srcRow.is_dir) {
490
+ const prefix = this.childPrefix(srcNorm);
491
+ const rows = this.sql
492
+ .exec("SELECT path FROM files WHERE path LIKE ?", `${prefix}%`)
493
+ .toArray();
494
+
495
+ // All descendants share the same new parent prefix — the directory
496
+ // entry itself (moved below) covers the intermediate dirs, so no
497
+ // per-child ensureParentDirs is needed.
498
+ for (const r of rows) {
499
+ const rPath = r.path as string;
500
+ const newPath = destNorm + rPath.substring(srcNorm.length);
501
+ this.sql.exec(
502
+ "UPDATE files SET path = ?, mtime_ms = ? WHERE path = ?",
503
+ newPath,
504
+ now,
505
+ rPath,
506
+ );
507
+ }
508
+ }
509
+
510
+ const existing = this.getRow(destNorm);
511
+ if (existing) {
512
+ this.sql.exec("DELETE FROM files WHERE path = ?", destNorm);
513
+ }
514
+ this.sql.exec(
515
+ "UPDATE files SET path = ?, mtime_ms = ? WHERE path = ?",
516
+ destNorm,
517
+ now,
518
+ srcNorm,
519
+ );
520
+ }
521
+
522
+ chmod(path: string, mode: number): void {
523
+ const resolved = this.resolveSymlinks(normalizePath(path));
524
+ const row = this.getRow(resolved);
525
+ if (!row) throw ENOENT(path);
526
+ this.sql.exec("UPDATE files SET mode = ? WHERE path = ?", mode, resolved);
527
+ }
528
+
529
+ symlink(target: string, linkPath: string): void {
530
+ const normalized = normalizePath(linkPath);
531
+ const existing = this.getRow(normalized);
532
+ if (existing) throw EEXIST(linkPath);
533
+
534
+ this.ensureParentDirs(normalized);
535
+ this.sql.exec(
536
+ "INSERT INTO files (path, is_dir, mode, size, mtime_ms, symlink_target) VALUES (?, 0, ?, 0, ?, ?)",
537
+ normalized,
538
+ SYMLINK_MODE,
539
+ Date.now(),
540
+ target,
541
+ );
542
+ }
543
+
544
+ /**
545
+ * Create a hard link. Note: this copies content at call time rather than
546
+ * sharing underlying storage — subsequent writes to one path will not
547
+ * update the other. This matches the IFileSystem contract but differs
548
+ * from POSIX hard link semantics.
549
+ */
550
+ link(existingPath: string, newPath: string): void {
551
+ const existingNorm = this.resolveSymlinks(normalizePath(existingPath));
552
+ const newNorm = normalizePath(newPath);
553
+
554
+ const srcRow = this.getRow(existingNorm);
555
+ if (!srcRow) throw ENOENT(existingPath);
556
+ if (srcRow.is_dir) throw EISDIR(existingPath);
557
+
558
+ const destRow = this.getRow(newNorm);
559
+ if (destRow) throw EEXIST(newPath);
560
+
561
+ this.ensureParentDirs(newNorm);
562
+ this.sql.exec(
563
+ "INSERT INTO files (path, content, is_dir, mode, size, mtime_ms) VALUES (?, ?, 0, ?, ?, ?)",
564
+ newNorm,
565
+ srcRow.content,
566
+ srcRow.mode,
567
+ srcRow.size,
568
+ Date.now(),
569
+ );
570
+ }
571
+
572
+ readlink(path: string): string {
573
+ const normalized = normalizePath(path);
574
+ const row = this.getRow(normalized);
575
+ if (!row) throw ENOENT(path);
576
+ if (!row.symlink_target) {
577
+ throw new FsError("EINVAL", "invalid argument", path);
578
+ }
579
+ return row.symlink_target;
580
+ }
581
+
582
+ realpath(path: string): string {
583
+ return this.resolveSymlinks(normalizePath(path));
584
+ }
585
+
586
+ utimes(path: string, _atimeMs: number, mtimeMs: number): void {
587
+ const resolved = this.resolveSymlinks(normalizePath(path));
588
+ const row = this.getRow(resolved);
589
+ if (!row) throw ENOENT(path);
590
+ this.sql.exec(
591
+ "UPDATE files SET mtime_ms = ? WHERE path = ?",
592
+ mtimeMs,
593
+ resolved,
594
+ );
595
+ }
596
+
597
+ getAllPaths(): string[] {
598
+ const rows = this.sql
599
+ .exec("SELECT path FROM files ORDER BY path")
600
+ .toArray();
601
+ return rows.map((r) => r.path as string);
602
+ }
603
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export { DurableFs } from "./durable-fs.js";
2
+ export type { FsStat, DirentEntry } from "./durable-fs.js";
3
+ export { FsObject, normalizePath } from "./fs-object.js";
4
+ export {
5
+ FsError,
6
+ ENOENT,
7
+ EEXIST,
8
+ EISDIR,
9
+ ENOTDIR,
10
+ ENOTEMPTY,
11
+ } from "./errors.js";
12
+ export type { FsStatData, DirentData } from "./types.js";
package/src/types.ts ADDED
@@ -0,0 +1,16 @@
1
+ /** Wire format for stat results (serializable over RPC) */
2
+ export interface FsStatData {
3
+ isDir: boolean;
4
+ isSymlink: boolean;
5
+ size: number;
6
+ mode: number;
7
+ mtimeMs: number;
8
+ }
9
+
10
+ /** Wire format for directory entries (serializable over RPC) */
11
+ export interface DirentData {
12
+ name: string;
13
+ isFile: boolean;
14
+ isDirectory: boolean;
15
+ isSymlink: boolean;
16
+ }