@catmint-fs/sqlite-adapter 0.0.0-prealpha.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 +339 -0
- package/README.md +110 -0
- package/dist/index.d.ts +107 -0
- package/dist/index.js +809 -0
- package/package.json +60 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
// src/sqlite-adapter.ts
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
|
|
4
|
+
// src/schema.ts
|
|
5
|
+
function initializeSchema(db, caseSensitive) {
|
|
6
|
+
db.exec(`
|
|
7
|
+
CREATE TABLE IF NOT EXISTS files (
|
|
8
|
+
path TEXT PRIMARY KEY,
|
|
9
|
+
type TEXT NOT NULL CHECK(type IN ('file', 'directory', 'symlink')),
|
|
10
|
+
content BLOB,
|
|
11
|
+
target TEXT,
|
|
12
|
+
mode INTEGER NOT NULL DEFAULT 438,
|
|
13
|
+
uid INTEGER NOT NULL DEFAULT 0,
|
|
14
|
+
gid INTEGER NOT NULL DEFAULT 0,
|
|
15
|
+
size INTEGER NOT NULL DEFAULT 0,
|
|
16
|
+
created_at INTEGER NOT NULL,
|
|
17
|
+
modified_at INTEGER NOT NULL
|
|
18
|
+
)
|
|
19
|
+
`);
|
|
20
|
+
db.exec(`
|
|
21
|
+
CREATE TABLE IF NOT EXISTS metadata (
|
|
22
|
+
key TEXT PRIMARY KEY,
|
|
23
|
+
value TEXT
|
|
24
|
+
)
|
|
25
|
+
`);
|
|
26
|
+
const existing = db.prepare("SELECT value FROM metadata WHERE key = 'schema_version'").get();
|
|
27
|
+
if (!existing) {
|
|
28
|
+
const insertMeta = db.prepare(
|
|
29
|
+
"INSERT OR IGNORE INTO metadata (key, value) VALUES (?, ?)"
|
|
30
|
+
);
|
|
31
|
+
const initMeta = db.transaction(() => {
|
|
32
|
+
insertMeta.run("schema_version", "1");
|
|
33
|
+
insertMeta.run("case_sensitive", caseSensitive ? "true" : "false");
|
|
34
|
+
});
|
|
35
|
+
initMeta();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// src/import-export.ts
|
|
40
|
+
import * as fs from "fs";
|
|
41
|
+
import * as path from "path";
|
|
42
|
+
function importFrom(db, sourcePath) {
|
|
43
|
+
const insertStmt = db.prepare(`
|
|
44
|
+
INSERT OR REPLACE INTO files (path, type, content, target, mode, uid, gid, size, created_at, modified_at)
|
|
45
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
46
|
+
`);
|
|
47
|
+
const importAll = db.transaction(() => {
|
|
48
|
+
const rootStat = fs.lstatSync(sourcePath);
|
|
49
|
+
insertStmt.run(
|
|
50
|
+
"/",
|
|
51
|
+
"directory",
|
|
52
|
+
null,
|
|
53
|
+
null,
|
|
54
|
+
rootStat.mode,
|
|
55
|
+
rootStat.uid,
|
|
56
|
+
rootStat.gid,
|
|
57
|
+
0,
|
|
58
|
+
Math.floor(rootStat.birthtimeMs),
|
|
59
|
+
Math.floor(rootStat.mtimeMs)
|
|
60
|
+
);
|
|
61
|
+
walkAndImport(insertStmt, sourcePath, "/");
|
|
62
|
+
});
|
|
63
|
+
importAll();
|
|
64
|
+
}
|
|
65
|
+
function walkAndImport(insertStmt, hostPath, dbPath) {
|
|
66
|
+
const entries = fs.readdirSync(hostPath, { withFileTypes: true });
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
const fullHostPath = path.join(hostPath, entry.name);
|
|
69
|
+
const fullDbPath = dbPath === "/" ? `/${entry.name}` : `${dbPath}/${entry.name}`;
|
|
70
|
+
const lstatResult = fs.lstatSync(fullHostPath);
|
|
71
|
+
if (entry.isSymbolicLink()) {
|
|
72
|
+
const target = fs.readlinkSync(fullHostPath);
|
|
73
|
+
insertStmt.run(
|
|
74
|
+
fullDbPath,
|
|
75
|
+
"symlink",
|
|
76
|
+
null,
|
|
77
|
+
target,
|
|
78
|
+
lstatResult.mode,
|
|
79
|
+
lstatResult.uid,
|
|
80
|
+
lstatResult.gid,
|
|
81
|
+
0,
|
|
82
|
+
Math.floor(lstatResult.birthtimeMs),
|
|
83
|
+
Math.floor(lstatResult.mtimeMs)
|
|
84
|
+
);
|
|
85
|
+
} else if (entry.isDirectory()) {
|
|
86
|
+
insertStmt.run(
|
|
87
|
+
fullDbPath,
|
|
88
|
+
"directory",
|
|
89
|
+
null,
|
|
90
|
+
null,
|
|
91
|
+
lstatResult.mode,
|
|
92
|
+
lstatResult.uid,
|
|
93
|
+
lstatResult.gid,
|
|
94
|
+
0,
|
|
95
|
+
Math.floor(lstatResult.birthtimeMs),
|
|
96
|
+
Math.floor(lstatResult.mtimeMs)
|
|
97
|
+
);
|
|
98
|
+
walkAndImport(insertStmt, fullHostPath, fullDbPath);
|
|
99
|
+
} else if (entry.isFile()) {
|
|
100
|
+
const content = fs.readFileSync(fullHostPath);
|
|
101
|
+
insertStmt.run(
|
|
102
|
+
fullDbPath,
|
|
103
|
+
"file",
|
|
104
|
+
content,
|
|
105
|
+
null,
|
|
106
|
+
lstatResult.mode,
|
|
107
|
+
lstatResult.uid,
|
|
108
|
+
lstatResult.gid,
|
|
109
|
+
content.length,
|
|
110
|
+
Math.floor(lstatResult.birthtimeMs),
|
|
111
|
+
Math.floor(lstatResult.mtimeMs)
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function exportTo(db, destPath) {
|
|
117
|
+
const allRows = db.prepare("SELECT * FROM files ORDER BY path").all();
|
|
118
|
+
const directories = [];
|
|
119
|
+
const files = [];
|
|
120
|
+
const symlinks = [];
|
|
121
|
+
for (const row of allRows) {
|
|
122
|
+
switch (row.type) {
|
|
123
|
+
case "directory":
|
|
124
|
+
directories.push(row);
|
|
125
|
+
break;
|
|
126
|
+
case "file":
|
|
127
|
+
files.push(row);
|
|
128
|
+
break;
|
|
129
|
+
case "symlink":
|
|
130
|
+
symlinks.push(row);
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
directories.sort((a, b) => {
|
|
135
|
+
const depthA = a.path === "/" ? 0 : a.path.split("/").length;
|
|
136
|
+
const depthB = b.path === "/" ? 0 : b.path.split("/").length;
|
|
137
|
+
return depthA - depthB;
|
|
138
|
+
});
|
|
139
|
+
for (const dir of directories) {
|
|
140
|
+
const hostPath = dbPathToHost(dir.path, destPath);
|
|
141
|
+
fs.mkdirSync(hostPath, { recursive: true, mode: dir.mode & 4095 });
|
|
142
|
+
try {
|
|
143
|
+
fs.chownSync(hostPath, dir.uid, dir.gid);
|
|
144
|
+
} catch {
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const atime = dir.modified_at / 1e3;
|
|
148
|
+
const mtime = dir.modified_at / 1e3;
|
|
149
|
+
fs.utimesSync(hostPath, atime, mtime);
|
|
150
|
+
} catch {
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const file of files) {
|
|
154
|
+
const hostPath = dbPathToHost(file.path, destPath);
|
|
155
|
+
const content = file.content ?? Buffer.alloc(0);
|
|
156
|
+
fs.writeFileSync(hostPath, content, { mode: file.mode & 4095 });
|
|
157
|
+
try {
|
|
158
|
+
fs.chownSync(hostPath, file.uid, file.gid);
|
|
159
|
+
} catch {
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const atime = file.modified_at / 1e3;
|
|
163
|
+
const mtime = file.modified_at / 1e3;
|
|
164
|
+
fs.utimesSync(hostPath, atime, mtime);
|
|
165
|
+
} catch {
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const symlink of symlinks) {
|
|
169
|
+
const hostPath = dbPathToHost(symlink.path, destPath);
|
|
170
|
+
const target = symlink.target ?? "";
|
|
171
|
+
try {
|
|
172
|
+
fs.unlinkSync(hostPath);
|
|
173
|
+
} catch {
|
|
174
|
+
}
|
|
175
|
+
fs.symlinkSync(target, hostPath);
|
|
176
|
+
try {
|
|
177
|
+
fs.lchownSync(hostPath, symlink.uid, symlink.gid);
|
|
178
|
+
} catch {
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function dbPathToHost(dbPath, destPath) {
|
|
183
|
+
if (dbPath === "/") {
|
|
184
|
+
return destPath;
|
|
185
|
+
}
|
|
186
|
+
return path.join(destPath, dbPath.slice(1));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/types.ts
|
|
190
|
+
import { FsError } from "@catmint-fs/core";
|
|
191
|
+
|
|
192
|
+
// src/sqlite-adapter.ts
|
|
193
|
+
var MAX_SYMLINK_HOPS = 40;
|
|
194
|
+
var DEFAULT_FILE_MODE = 438;
|
|
195
|
+
var DEFAULT_DIR_MODE = 511;
|
|
196
|
+
var SqliteAdapter = class {
|
|
197
|
+
db;
|
|
198
|
+
caseSensitiveMode;
|
|
199
|
+
closed = false;
|
|
200
|
+
// Prepared statements for performance
|
|
201
|
+
stmtGetByPath;
|
|
202
|
+
stmtGetByPathNoCase;
|
|
203
|
+
stmtInsert;
|
|
204
|
+
stmtUpsert;
|
|
205
|
+
stmtDelete;
|
|
206
|
+
stmtUpdateMode;
|
|
207
|
+
stmtUpdateOwner;
|
|
208
|
+
stmtUpdatePath;
|
|
209
|
+
constructor(options) {
|
|
210
|
+
const { database, caseSensitive = true } = options;
|
|
211
|
+
this.caseSensitiveMode = caseSensitive;
|
|
212
|
+
this.db = new Database(database);
|
|
213
|
+
this.db.pragma("journal_mode = WAL");
|
|
214
|
+
this.db.pragma("foreign_keys = ON");
|
|
215
|
+
initializeSchema(this.db, this.caseSensitiveMode);
|
|
216
|
+
this.stmtGetByPath = this.db.prepare(
|
|
217
|
+
"SELECT * FROM files WHERE path = ?"
|
|
218
|
+
);
|
|
219
|
+
this.stmtGetByPathNoCase = this.db.prepare(
|
|
220
|
+
"SELECT * FROM files WHERE path = ? COLLATE NOCASE"
|
|
221
|
+
);
|
|
222
|
+
this.stmtInsert = this.db.prepare(`
|
|
223
|
+
INSERT INTO files (path, type, content, target, mode, uid, gid, size, created_at, modified_at)
|
|
224
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
225
|
+
`);
|
|
226
|
+
this.stmtUpsert = this.db.prepare(`
|
|
227
|
+
INSERT INTO files (path, type, content, target, mode, uid, gid, size, created_at, modified_at)
|
|
228
|
+
VALUES (@path, @type, @content, @target, @mode, @uid, @gid, @size, @created_at, @modified_at)
|
|
229
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
230
|
+
type = @type,
|
|
231
|
+
content = @content,
|
|
232
|
+
target = @target,
|
|
233
|
+
mode = @mode,
|
|
234
|
+
uid = @uid,
|
|
235
|
+
gid = @gid,
|
|
236
|
+
size = @size,
|
|
237
|
+
modified_at = @modified_at
|
|
238
|
+
`);
|
|
239
|
+
this.stmtDelete = this.db.prepare("DELETE FROM files WHERE path = ?");
|
|
240
|
+
this.stmtUpdateMode = this.db.prepare(
|
|
241
|
+
"UPDATE files SET mode = ?, modified_at = ? WHERE path = ?"
|
|
242
|
+
);
|
|
243
|
+
this.stmtUpdateOwner = this.db.prepare(
|
|
244
|
+
"UPDATE files SET uid = ?, gid = ?, modified_at = ? WHERE path = ?"
|
|
245
|
+
);
|
|
246
|
+
this.stmtUpdatePath = this.db.prepare(
|
|
247
|
+
"UPDATE files SET path = ?, modified_at = ? WHERE path = ?"
|
|
248
|
+
);
|
|
249
|
+
this.ensureRoot();
|
|
250
|
+
}
|
|
251
|
+
assertNotClosed() {
|
|
252
|
+
if (this.closed) {
|
|
253
|
+
throw new FsError("DISPOSED", "adapter is closed");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
ensureRoot() {
|
|
257
|
+
const root = this.getRow("/");
|
|
258
|
+
if (!root) {
|
|
259
|
+
const now = Date.now();
|
|
260
|
+
this.stmtInsert.run(
|
|
261
|
+
"/",
|
|
262
|
+
"directory",
|
|
263
|
+
null,
|
|
264
|
+
null,
|
|
265
|
+
DEFAULT_DIR_MODE,
|
|
266
|
+
0,
|
|
267
|
+
0,
|
|
268
|
+
0,
|
|
269
|
+
now,
|
|
270
|
+
now
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Normalize a path for lookup. For case-insensitive mode, converts to lowercase.
|
|
276
|
+
*/
|
|
277
|
+
normalizePath(p) {
|
|
278
|
+
if (!p.startsWith("/")) {
|
|
279
|
+
p = "/" + p;
|
|
280
|
+
}
|
|
281
|
+
p = p.replace(/\/+/g, "/");
|
|
282
|
+
if (p.length > 1 && p.endsWith("/")) {
|
|
283
|
+
p = p.slice(0, -1);
|
|
284
|
+
}
|
|
285
|
+
if (!this.caseSensitiveMode) {
|
|
286
|
+
p = p.toLowerCase();
|
|
287
|
+
}
|
|
288
|
+
return p;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Get a row from the files table, respecting case sensitivity.
|
|
292
|
+
*/
|
|
293
|
+
getRow(dbPath) {
|
|
294
|
+
if (this.caseSensitiveMode) {
|
|
295
|
+
return this.stmtGetByPath.get(dbPath);
|
|
296
|
+
}
|
|
297
|
+
return this.stmtGetByPathNoCase.get(dbPath);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Resolve symlinks for a given path, returning the final resolved row.
|
|
301
|
+
* Throws ELOOP if the symlink chain exceeds MAX_SYMLINK_HOPS.
|
|
302
|
+
* Throws ENOENT if any hop in the chain is missing.
|
|
303
|
+
*/
|
|
304
|
+
resolveSymlinks(dbPath) {
|
|
305
|
+
let currentPath = dbPath;
|
|
306
|
+
let hops = 0;
|
|
307
|
+
while (true) {
|
|
308
|
+
const row = this.getRow(currentPath);
|
|
309
|
+
if (!row) {
|
|
310
|
+
throw new FsError(
|
|
311
|
+
"ENOENT",
|
|
312
|
+
`no such file or directory: ${currentPath}`
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
if (row.type !== "symlink") {
|
|
316
|
+
return row;
|
|
317
|
+
}
|
|
318
|
+
hops++;
|
|
319
|
+
if (hops > MAX_SYMLINK_HOPS) {
|
|
320
|
+
throw new FsError(
|
|
321
|
+
"ELOOP",
|
|
322
|
+
`too many levels of symbolic links: ${dbPath}`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
const target = row.target;
|
|
326
|
+
if (target.startsWith("/")) {
|
|
327
|
+
currentPath = this.normalizePath(target);
|
|
328
|
+
} else {
|
|
329
|
+
const parent = getParentPath(currentPath);
|
|
330
|
+
currentPath = this.normalizePath(
|
|
331
|
+
parent === "/" ? `/${target}` : `${parent}/${target}`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Check that the parent directory of a path exists and is actually a directory.
|
|
338
|
+
*/
|
|
339
|
+
ensureParentExists(p) {
|
|
340
|
+
if (p === "/") return;
|
|
341
|
+
const parent = getParentPath(p);
|
|
342
|
+
const parentRow = this.getRow(parent);
|
|
343
|
+
if (!parentRow) {
|
|
344
|
+
throw new FsError(
|
|
345
|
+
"ENOENT",
|
|
346
|
+
`no such file or directory: ${parent}`
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
if (parentRow.type === "symlink") {
|
|
350
|
+
const resolved = this.resolveSymlinks(parent);
|
|
351
|
+
if (resolved.type !== "directory") {
|
|
352
|
+
throw new FsError("ENOTDIR", `not a directory: ${parent}`);
|
|
353
|
+
}
|
|
354
|
+
} else if (parentRow.type !== "directory") {
|
|
355
|
+
throw new FsError("ENOTDIR", `not a directory: ${parent}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Get direct children of a directory using SQL.
|
|
360
|
+
*/
|
|
361
|
+
getDirectChildren(dirPath) {
|
|
362
|
+
if (dirPath === "/") {
|
|
363
|
+
if (this.caseSensitiveMode) {
|
|
364
|
+
return this.db.prepare(
|
|
365
|
+
"SELECT * FROM files WHERE path != '/' AND path LIKE '/%' ESCAPE '\\' AND path NOT LIKE '/%/%' ESCAPE '\\'"
|
|
366
|
+
).all();
|
|
367
|
+
}
|
|
368
|
+
return this.db.prepare(
|
|
369
|
+
"SELECT * FROM files WHERE path != '/' COLLATE NOCASE AND path LIKE '/%' ESCAPE '\\' COLLATE NOCASE AND path NOT LIKE '/%/%' ESCAPE '\\' COLLATE NOCASE"
|
|
370
|
+
).all();
|
|
371
|
+
}
|
|
372
|
+
const prefix = escapeLike(dirPath) + "/%";
|
|
373
|
+
const deepPrefix = escapeLike(dirPath) + "/%/%";
|
|
374
|
+
if (this.caseSensitiveMode) {
|
|
375
|
+
return this.db.prepare(
|
|
376
|
+
"SELECT * FROM files WHERE path LIKE ? ESCAPE '\\' AND path NOT LIKE ? ESCAPE '\\'"
|
|
377
|
+
).all(prefix, deepPrefix);
|
|
378
|
+
}
|
|
379
|
+
return this.db.prepare(
|
|
380
|
+
"SELECT * FROM files WHERE path LIKE ? ESCAPE '\\' COLLATE NOCASE AND path NOT LIKE ? ESCAPE '\\' COLLATE NOCASE"
|
|
381
|
+
).all(prefix, deepPrefix);
|
|
382
|
+
}
|
|
383
|
+
rowToStatResult(row) {
|
|
384
|
+
const type = row.type;
|
|
385
|
+
return {
|
|
386
|
+
mode: row.mode,
|
|
387
|
+
uid: row.uid,
|
|
388
|
+
gid: row.gid,
|
|
389
|
+
size: row.size,
|
|
390
|
+
atimeMs: row.modified_at,
|
|
391
|
+
mtimeMs: row.modified_at,
|
|
392
|
+
ctimeMs: row.modified_at,
|
|
393
|
+
birthtimeMs: row.created_at,
|
|
394
|
+
isFile: () => type === "file",
|
|
395
|
+
isDirectory: () => type === "directory",
|
|
396
|
+
isSymbolicLink: () => type === "symlink"
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
rowToDirentEntry(row) {
|
|
400
|
+
const name = getBaseName(row.path);
|
|
401
|
+
const type = row.type;
|
|
402
|
+
return {
|
|
403
|
+
name,
|
|
404
|
+
isFile: () => type === "file",
|
|
405
|
+
isDirectory: () => type === "directory",
|
|
406
|
+
isSymbolicLink: () => type === "symlink"
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
// ─── FsAdapter implementation ────────────────────────────────────
|
|
410
|
+
capabilities() {
|
|
411
|
+
return {
|
|
412
|
+
permissions: true,
|
|
413
|
+
symlinks: true,
|
|
414
|
+
caseSensitive: this.caseSensitiveMode
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
async readFile(filePath) {
|
|
418
|
+
this.assertNotClosed();
|
|
419
|
+
const p = this.normalizePath(filePath);
|
|
420
|
+
const row = this.resolveSymlinks(p);
|
|
421
|
+
if (row.type === "directory") {
|
|
422
|
+
throw new FsError(
|
|
423
|
+
"EISDIR",
|
|
424
|
+
`illegal operation on a directory: ${p}`
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
if (!row.content) {
|
|
428
|
+
return new Uint8Array(0);
|
|
429
|
+
}
|
|
430
|
+
return new Uint8Array(row.content);
|
|
431
|
+
}
|
|
432
|
+
createReadStream(filePath) {
|
|
433
|
+
this.assertNotClosed();
|
|
434
|
+
const p = this.normalizePath(filePath);
|
|
435
|
+
const row = this.resolveSymlinks(p);
|
|
436
|
+
if (row.type === "directory") {
|
|
437
|
+
throw new FsError(
|
|
438
|
+
"EISDIR",
|
|
439
|
+
`illegal operation on a directory: ${p}`
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
const content = row.content ? new Uint8Array(row.content) : new Uint8Array(0);
|
|
443
|
+
return new ReadableStream({
|
|
444
|
+
start(controller) {
|
|
445
|
+
controller.enqueue(content);
|
|
446
|
+
controller.close();
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
async readdir(dirPath) {
|
|
451
|
+
this.assertNotClosed();
|
|
452
|
+
const p = this.normalizePath(dirPath);
|
|
453
|
+
const row = this.resolveSymlinks(p);
|
|
454
|
+
if (row.type !== "directory") {
|
|
455
|
+
throw new FsError("ENOTDIR", `not a directory: ${p}`);
|
|
456
|
+
}
|
|
457
|
+
const children = this.getDirectChildren(row.path);
|
|
458
|
+
return children.map((child) => this.rowToDirentEntry(child));
|
|
459
|
+
}
|
|
460
|
+
async stat(filePath) {
|
|
461
|
+
this.assertNotClosed();
|
|
462
|
+
const p = this.normalizePath(filePath);
|
|
463
|
+
const row = this.resolveSymlinks(p);
|
|
464
|
+
return this.rowToStatResult(row);
|
|
465
|
+
}
|
|
466
|
+
async lstat(filePath) {
|
|
467
|
+
this.assertNotClosed();
|
|
468
|
+
const p = this.normalizePath(filePath);
|
|
469
|
+
const row = this.getRow(p);
|
|
470
|
+
if (!row) {
|
|
471
|
+
throw new FsError(
|
|
472
|
+
"ENOENT",
|
|
473
|
+
`no such file or directory: ${p}`
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
return this.rowToStatResult(row);
|
|
477
|
+
}
|
|
478
|
+
async readlink(filePath) {
|
|
479
|
+
this.assertNotClosed();
|
|
480
|
+
const p = this.normalizePath(filePath);
|
|
481
|
+
const row = this.getRow(p);
|
|
482
|
+
if (!row) {
|
|
483
|
+
throw new FsError(
|
|
484
|
+
"ENOENT",
|
|
485
|
+
`no such file or directory: ${p}`
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
if (row.type !== "symlink") {
|
|
489
|
+
throw new FsError("EINVAL", `invalid argument: ${p}`);
|
|
490
|
+
}
|
|
491
|
+
return row.target;
|
|
492
|
+
}
|
|
493
|
+
async exists(filePath) {
|
|
494
|
+
this.assertNotClosed();
|
|
495
|
+
const p = this.normalizePath(filePath);
|
|
496
|
+
try {
|
|
497
|
+
this.resolveSymlinks(p);
|
|
498
|
+
return true;
|
|
499
|
+
} catch {
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
async writeFile(filePath, data, options) {
|
|
504
|
+
this.assertNotClosed();
|
|
505
|
+
const p = this.normalizePath(filePath);
|
|
506
|
+
this.ensureParentExists(p);
|
|
507
|
+
let resolvedPath = p;
|
|
508
|
+
const row = this.getRow(p);
|
|
509
|
+
if (row && row.type === "symlink") {
|
|
510
|
+
const resolved = this.resolveSymlinks(p);
|
|
511
|
+
resolvedPath = resolved.path;
|
|
512
|
+
}
|
|
513
|
+
const existing = resolvedPath !== p ? this.getRow(resolvedPath) : row;
|
|
514
|
+
if (existing && existing.type === "directory") {
|
|
515
|
+
throw new FsError(
|
|
516
|
+
"EISDIR",
|
|
517
|
+
`illegal operation on a directory: ${resolvedPath}`
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
const now = Date.now();
|
|
521
|
+
const mode = options?.mode ?? DEFAULT_FILE_MODE;
|
|
522
|
+
const uid = options?.uid ?? 0;
|
|
523
|
+
const gid = options?.gid ?? 0;
|
|
524
|
+
const content = Buffer.from(data);
|
|
525
|
+
this.stmtUpsert.run({
|
|
526
|
+
path: resolvedPath,
|
|
527
|
+
type: "file",
|
|
528
|
+
content,
|
|
529
|
+
target: null,
|
|
530
|
+
mode,
|
|
531
|
+
uid,
|
|
532
|
+
gid,
|
|
533
|
+
size: content.length,
|
|
534
|
+
created_at: existing ? existing.created_at : now,
|
|
535
|
+
modified_at: now
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
async mkdir(dirPath, options) {
|
|
539
|
+
this.assertNotClosed();
|
|
540
|
+
const p = this.normalizePath(dirPath);
|
|
541
|
+
const mode = options?.mode ?? DEFAULT_DIR_MODE;
|
|
542
|
+
const uid = options?.uid ?? 0;
|
|
543
|
+
const gid = options?.gid ?? 0;
|
|
544
|
+
const recursive = options?.recursive ?? false;
|
|
545
|
+
if (recursive) {
|
|
546
|
+
this.mkdirRecursive(p, mode, uid, gid);
|
|
547
|
+
} else {
|
|
548
|
+
const existing = this.getRow(p);
|
|
549
|
+
if (existing) {
|
|
550
|
+
throw new FsError("EEXIST", `file already exists: ${p}`);
|
|
551
|
+
}
|
|
552
|
+
this.ensureParentExists(p);
|
|
553
|
+
const now = Date.now();
|
|
554
|
+
this.stmtInsert.run(
|
|
555
|
+
p,
|
|
556
|
+
"directory",
|
|
557
|
+
null,
|
|
558
|
+
null,
|
|
559
|
+
mode,
|
|
560
|
+
uid,
|
|
561
|
+
gid,
|
|
562
|
+
0,
|
|
563
|
+
now,
|
|
564
|
+
now
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
mkdirRecursive(p, mode, uid, gid) {
|
|
569
|
+
if (p === "/") return;
|
|
570
|
+
const existing = this.getRow(p);
|
|
571
|
+
if (existing) {
|
|
572
|
+
if (existing.type === "directory") {
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
throw new FsError("EEXIST", `file already exists: ${p}`);
|
|
576
|
+
}
|
|
577
|
+
const parent = getParentPath(p);
|
|
578
|
+
this.mkdirRecursive(parent, mode, uid, gid);
|
|
579
|
+
const now = Date.now();
|
|
580
|
+
this.stmtInsert.run(p, "directory", null, null, mode, uid, gid, 0, now, now);
|
|
581
|
+
}
|
|
582
|
+
async rm(filePath, options) {
|
|
583
|
+
this.assertNotClosed();
|
|
584
|
+
const p = this.normalizePath(filePath);
|
|
585
|
+
const recursive = options?.recursive ?? false;
|
|
586
|
+
const force = options?.force ?? false;
|
|
587
|
+
const row = this.getRow(p);
|
|
588
|
+
if (!row) {
|
|
589
|
+
if (force) return;
|
|
590
|
+
throw new FsError(
|
|
591
|
+
"ENOENT",
|
|
592
|
+
`no such file or directory: ${p}`
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
if (row.type === "directory" && !recursive) {
|
|
596
|
+
throw new FsError(
|
|
597
|
+
"EISDIR",
|
|
598
|
+
`illegal operation on a directory: ${p}`
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
if (recursive) {
|
|
602
|
+
if (p === "/") {
|
|
603
|
+
this.db.prepare("DELETE FROM files WHERE path != '/'").run();
|
|
604
|
+
} else {
|
|
605
|
+
const prefix = escapeLike(p + "/");
|
|
606
|
+
this.db.prepare(
|
|
607
|
+
"DELETE FROM files WHERE path = ? OR path LIKE ? ESCAPE '\\'"
|
|
608
|
+
).run(p, prefix + "%");
|
|
609
|
+
}
|
|
610
|
+
} else {
|
|
611
|
+
this.stmtDelete.run(p);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
async rmdir(dirPath) {
|
|
615
|
+
this.assertNotClosed();
|
|
616
|
+
const p = this.normalizePath(dirPath);
|
|
617
|
+
const row = this.getRow(p);
|
|
618
|
+
if (!row) {
|
|
619
|
+
throw new FsError(
|
|
620
|
+
"ENOENT",
|
|
621
|
+
`no such file or directory: ${p}`
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
if (row.type !== "directory") {
|
|
625
|
+
throw new FsError("ENOTDIR", `not a directory: ${p}`);
|
|
626
|
+
}
|
|
627
|
+
const children = this.getDirectChildren(p);
|
|
628
|
+
if (children.length > 0) {
|
|
629
|
+
throw new FsError("ENOTEMPTY", `directory not empty: ${p}`);
|
|
630
|
+
}
|
|
631
|
+
this.stmtDelete.run(p);
|
|
632
|
+
}
|
|
633
|
+
async rename(from, to) {
|
|
634
|
+
this.assertNotClosed();
|
|
635
|
+
const fromPath = this.normalizePath(from);
|
|
636
|
+
const toPath = this.normalizePath(to);
|
|
637
|
+
const row = this.getRow(fromPath);
|
|
638
|
+
if (!row) {
|
|
639
|
+
throw new FsError(
|
|
640
|
+
"ENOENT",
|
|
641
|
+
`no such file or directory: ${fromPath}`
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
this.ensureParentExists(toPath);
|
|
645
|
+
const existingTo = this.getRow(toPath);
|
|
646
|
+
if (existingTo) {
|
|
647
|
+
if (existingTo.type === "directory") {
|
|
648
|
+
const children = this.getDirectChildren(toPath);
|
|
649
|
+
if (children.length > 0) {
|
|
650
|
+
throw new FsError(
|
|
651
|
+
"ENOTEMPTY",
|
|
652
|
+
`directory not empty: ${toPath}`
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
this.stmtDelete.run(toPath);
|
|
657
|
+
}
|
|
658
|
+
const now = Date.now();
|
|
659
|
+
if (row.type === "directory") {
|
|
660
|
+
const renameAll = this.db.transaction(() => {
|
|
661
|
+
const prefix = escapeLike(fromPath + "/");
|
|
662
|
+
const descendants = this.db.prepare(
|
|
663
|
+
"SELECT path FROM files WHERE path LIKE ? ESCAPE '\\'"
|
|
664
|
+
).all(prefix + "%");
|
|
665
|
+
this.stmtUpdatePath.run(toPath, now, fromPath);
|
|
666
|
+
const updateStmt = this.db.prepare(
|
|
667
|
+
"UPDATE files SET path = ?, modified_at = ? WHERE path = ?"
|
|
668
|
+
);
|
|
669
|
+
for (const desc of descendants) {
|
|
670
|
+
const newPath = toPath + desc.path.slice(fromPath.length);
|
|
671
|
+
updateStmt.run(newPath, now, desc.path);
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
renameAll();
|
|
675
|
+
} else {
|
|
676
|
+
this.stmtUpdatePath.run(toPath, now, fromPath);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
async symlink(target, linkPath) {
|
|
680
|
+
this.assertNotClosed();
|
|
681
|
+
const p = this.normalizePath(linkPath);
|
|
682
|
+
const existing = this.getRow(p);
|
|
683
|
+
if (existing) {
|
|
684
|
+
throw new FsError("EEXIST", `file already exists: ${p}`);
|
|
685
|
+
}
|
|
686
|
+
this.ensureParentExists(p);
|
|
687
|
+
const now = Date.now();
|
|
688
|
+
this.stmtInsert.run(p, "symlink", null, target, 511, 0, 0, 0, now, now);
|
|
689
|
+
}
|
|
690
|
+
async chmod(filePath, mode) {
|
|
691
|
+
this.assertNotClosed();
|
|
692
|
+
const p = this.normalizePath(filePath);
|
|
693
|
+
const row = this.resolveSymlinks(p);
|
|
694
|
+
const now = Date.now();
|
|
695
|
+
const changes = this.stmtUpdateMode.run(mode, now, row.path);
|
|
696
|
+
if (changes.changes === 0) {
|
|
697
|
+
throw new FsError(
|
|
698
|
+
"ENOENT",
|
|
699
|
+
`no such file or directory: ${p}`
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
async chown(filePath, uid, gid) {
|
|
704
|
+
this.assertNotClosed();
|
|
705
|
+
const p = this.normalizePath(filePath);
|
|
706
|
+
const row = this.resolveSymlinks(p);
|
|
707
|
+
const now = Date.now();
|
|
708
|
+
const changes = this.stmtUpdateOwner.run(uid, gid, now, row.path);
|
|
709
|
+
if (changes.changes === 0) {
|
|
710
|
+
throw new FsError(
|
|
711
|
+
"ENOENT",
|
|
712
|
+
`no such file or directory: ${p}`
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
async lchown(filePath, uid, gid) {
|
|
717
|
+
this.assertNotClosed();
|
|
718
|
+
const p = this.normalizePath(filePath);
|
|
719
|
+
const row = this.getRow(p);
|
|
720
|
+
if (!row) {
|
|
721
|
+
throw new FsError(
|
|
722
|
+
"ENOENT",
|
|
723
|
+
`no such file or directory: ${p}`
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
const now = Date.now();
|
|
727
|
+
this.stmtUpdateOwner.run(uid, gid, now, row.path);
|
|
728
|
+
}
|
|
729
|
+
async checkPermission(filePath, op) {
|
|
730
|
+
this.assertNotClosed();
|
|
731
|
+
const p = this.normalizePath(filePath);
|
|
732
|
+
const row = this.resolveSymlinks(p);
|
|
733
|
+
const mode = row.mode;
|
|
734
|
+
let bit;
|
|
735
|
+
switch (op) {
|
|
736
|
+
case "read":
|
|
737
|
+
bit = 4;
|
|
738
|
+
break;
|
|
739
|
+
case "write":
|
|
740
|
+
bit = 2;
|
|
741
|
+
break;
|
|
742
|
+
case "execute":
|
|
743
|
+
bit = 1;
|
|
744
|
+
break;
|
|
745
|
+
}
|
|
746
|
+
const ownerBits = mode >> 6 & 7;
|
|
747
|
+
const groupBits = mode >> 3 & 7;
|
|
748
|
+
const otherBits = mode & 7;
|
|
749
|
+
if ((ownerBits & bit) !== 0) return;
|
|
750
|
+
if ((groupBits & bit) !== 0) return;
|
|
751
|
+
if ((otherBits & bit) !== 0) return;
|
|
752
|
+
throw new FsError("EACCES", `permission denied: ${p}`);
|
|
753
|
+
}
|
|
754
|
+
async initialize(root) {
|
|
755
|
+
this.assertNotClosed();
|
|
756
|
+
}
|
|
757
|
+
// ─── Extra methods ───────────────────────────────────────────────
|
|
758
|
+
/**
|
|
759
|
+
* Import files from a host filesystem path into this database.
|
|
760
|
+
*/
|
|
761
|
+
async importFrom(sourcePath) {
|
|
762
|
+
this.assertNotClosed();
|
|
763
|
+
importFrom(this.db, sourcePath);
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Export files from this database to a host filesystem path.
|
|
767
|
+
*/
|
|
768
|
+
async exportTo(destPath) {
|
|
769
|
+
this.assertNotClosed();
|
|
770
|
+
exportTo(this.db, destPath);
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Close the database connection.
|
|
774
|
+
*/
|
|
775
|
+
async close() {
|
|
776
|
+
if (this.closed) return;
|
|
777
|
+
this.closed = true;
|
|
778
|
+
this.db.close();
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Get the underlying better-sqlite3 Database instance.
|
|
782
|
+
* Useful for advanced operations or testing.
|
|
783
|
+
*/
|
|
784
|
+
getDatabase() {
|
|
785
|
+
this.assertNotClosed();
|
|
786
|
+
return this.db;
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
function getParentPath(p) {
|
|
790
|
+
if (p === "/") return "/";
|
|
791
|
+
const lastSlash = p.lastIndexOf("/");
|
|
792
|
+
if (lastSlash === 0) return "/";
|
|
793
|
+
return p.slice(0, lastSlash);
|
|
794
|
+
}
|
|
795
|
+
function getBaseName(p) {
|
|
796
|
+
if (p === "/") return "/";
|
|
797
|
+
const lastSlash = p.lastIndexOf("/");
|
|
798
|
+
return p.slice(lastSlash + 1);
|
|
799
|
+
}
|
|
800
|
+
function escapeLike(s) {
|
|
801
|
+
return s.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
|
|
802
|
+
}
|
|
803
|
+
export {
|
|
804
|
+
FsError,
|
|
805
|
+
SqliteAdapter,
|
|
806
|
+
exportTo,
|
|
807
|
+
importFrom,
|
|
808
|
+
initializeSchema
|
|
809
|
+
};
|