Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion jsr.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://jsr.io/schema/config-file.v1.json",
"name": "@ninots/filesystem",
"version": "0.1.0",
"version": "0.1.1",
"exports": "./index.ts",
"publish": {
"include": [
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"main": "index.ts",
"module": "index.ts",
"type": "module",
"version": "0.1.0",
"version": "0.1.1",
"description": "File system operations and storage abstraction for Bun",
"author": "Ninots Framework",
"keywords": [
Expand Down
32 changes: 29 additions & 3 deletions src/adapters/local-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { appendFile, copyFile, mkdir, readdir, rename, rm, stat } from "node:fs/promises";
import path from "node:path";
import type { FilesystemDisk } from "../contracts/filesystem";
import type { FileVisibility, FilesystemDisk } from "../contracts/filesystem";

export interface LocalAdapterConfig {
root: string;
Expand Down Expand Up @@ -59,6 +59,16 @@ export class LocalAdapter implements FilesystemDisk {
return await file.exists();
}

/**
* Determine if a file is missing.
*
* @param targetPath - The path to check
* @returns True if the file does not exist
*/
async missing(targetPath: string): Promise<boolean> {
return !(await this.exists(targetPath));
}

/**
* Get the contents of a file.
*
Expand Down Expand Up @@ -109,6 +119,22 @@ export class LocalAdapter implements FilesystemDisk {
}
}

/**
* Prepend data to a file.
*
* @param targetPath - The path to the file
* @param data - The data to prepend
* @returns True on success, false on failure
*/
async prepend(targetPath: string, data: string): Promise<boolean> {
try {
const existing = (await this.get(targetPath)) ?? "";
return await this.put(targetPath, data + existing);
Comment on lines +129 to +132

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Serialize prepend writes to prevent lost updates.

get() followed by put() is not atomic. Concurrent prepend() calls can read the same old contents, then the last write silently discards the other prefix. Add per-path serialization shared with other write operations, or use an atomic filesystem primitive.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/adapters/local-adapter.ts` around lines 129 - 132, Update
LocalAdapter.prepend to serialize the get-and-put sequence per targetPath,
reusing the existing per-path write coordination used by other write operations
if available. Ensure concurrent prepends to the same path execute sequentially
so both prefixes are retained, while preserving independent concurrency for
different paths and the current return behavior.

} catch {
return false;
}
}

/**
* Delete the file at a given path.
*
Expand Down Expand Up @@ -201,15 +227,15 @@ export class LocalAdapter implements FilesystemDisk {
}
}

async getVisibility(targetPath: string): Promise<string | null> {
async getVisibility(targetPath: string): Promise<FileVisibility | null> {
const file = Bun.file(this.applyPathPrefix(targetPath));
if (!(await file.exists())) {
return null;
}
return "private";
}

async setVisibility(_targetPath: string, _visibility: string): Promise<boolean> {
async setVisibility(_targetPath: string, _visibility: FileVisibility): Promise<boolean> {
// Local filesystem: visibility is a no-op (permissions may be managed externally)
return true;
}
Expand Down
37 changes: 28 additions & 9 deletions src/adapters/s3-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import type { FilesystemDisk } from "../contracts/filesystem";

/**
* Bun S3 ACL values accepted by {@link Bun.S3Client} / presign.
*/
export type S3Acl =
| "private"
| "public-read"
| "public-read-write"
| "authenticated-read"
| "aws-exec-read"
| "bucket-owner-read"
| "bucket-owner-full-control"
| "log-delivery-write";

/**
* Configuration for S3Adapter.
*/
Expand Down Expand Up @@ -37,7 +50,7 @@ export interface S3AdapterConfig {
/**
* Default ACL for uploaded files (optional).
*/
acl?: "public-read" | "private" | "public-read-write" | "authenticated-read";
acl?: S3Acl;
}

/**
Expand All @@ -62,14 +75,20 @@ export interface S3AdapterConfig {
export class S3Adapter implements FilesystemDisk {
private client: import("bun").S3Client;
private root: string;
private defaultAcl?: "public-read" | "private" | "public-read-write" | "authenticated-read";
private bucket: string;
private endpoint?: string;
private region: string;
private defaultAcl?: S3Acl;

constructor(config: S3AdapterConfig) {
this.bucket = config.bucket;
this.endpoint = config.endpoint;
this.region = config.region ?? "us-east-1";
this.client = new Bun.S3Client({
accessKeyId: config.accessKeyId,
bucket: config.bucket,
endpoint: config.endpoint,
region: config.region,
region: this.region,
secretAccessKey: config.secretAccessKey,
});
this.root = config.root ? this.normalizePath(config.root) : "";
Expand Down Expand Up @@ -445,8 +464,8 @@ export class S3Adapter implements FilesystemDisk {
async url(path: string): Promise<string> {
const key = this.resolvePath(path);
// Return S3 URL format
const endpoint = this.client.endpoint || `https://s3.${this.client.region || "us-east-1"}.amazonaws.com`;
return `${endpoint}/${this.client.bucket}/${key}`;
const endpoint = this.endpoint || `https://s3.${this.region}.amazonaws.com`;
return `${endpoint}/${this.bucket}/${key}`;
Comment on lines +467 to +468

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files matching s3-adapter:"
fd -a 's3-adapter\.(ts|js)$' . || true

file="$(fd 's3-adapter\.(ts|js)$' . | head -n 1)"
if [ -n "${file:-}" ]; then
  echo "Selected: $file"
  wc -l "$file"
  echo "Outline:"
  ast-grep outline "$file" || true
  echo "Relevant lines 430-490:"
  sed -n '430,490p' "$file" | nl -ba -v430
fi

echo
echo "S3 key/url related usages:"
rg -n "getObjectUrl|endpoint|bucket|/\\$\\{key\\}|url|sign|presign" "$file" 2>/dev/null || true

echo
echo "Search URL encoding across repo:"
rg -n "encodeURI|encodeURIComponent|URLSearchParams|object keys|objectKey|S3" -S . || true

Repository: nino-ts/filesystem

Length of output: 1017


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="./src/adapters/s3-adapter.ts"
echo "Relevant lines 430-490:"
sed -n '430,490p' "$file"

echo
echo "All occurrences of generated URL expression:"
rg -n "endpoint.*bucket|bucket.*key|getObjectUrl|temporary|presigned|\\$\\{key\\}|\\$\\{endpoint\\}" "$file"

echo
echo "Read-only verifier: extract key interpolation expressions and detect simple URI segment encoding pattern"
python3 - <<'PY'
from pathlib import Path
import re
p = Path('./src/adapters/s3-adapter.ts').read_text()
m = re.search(r'const endpoint = this\.endpoint \|\| `https://s3\.\$\{this\.region\}\.amazonaws\.com`;\s*return `[^`]*(?:\$\{\w+\})+`', p)
print("Found endpoint/url expression:", bool(m))
if m:
    start = max(0, p.rfind('\n', 0, m.start()) + 1)
    line_no = p.count('\n', 0, m.start()) + 1
    print(f"line {line_no}: {m.group(0)}")
PY

Repository: nino-ts/filesystem

Length of output: 2738


URL-encode S3 object keys.

S3 keys may contain spaces, ?, #, and other characters requiring URL encoding; interpolating key directly can address a different object or create a query/fragment instead. Encode each path segment while preserving /, and normalize endpoint slashes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/adapters/s3-adapter.ts` around lines 467 - 468, Update the URL
construction in the S3 adapter method containing endpoint and bucket
interpolation to normalize trailing slashes on endpoint and URL-encode each
segment of key while preserving its slash separators. Keep the bucket and key
path structure intact so special characters cannot alter the URL path or become
query/fragment delimiters.

Source: MCP tools

}

/**
Expand Down Expand Up @@ -479,7 +498,7 @@ export class S3Adapter implements FilesystemDisk {
options?: {
contentType?: string;
contentLength?: number;
acl?: string;
acl?: S3Acl;
},
): Promise<{
url: string;
Expand All @@ -491,7 +510,7 @@ export class S3Adapter implements FilesystemDisk {

// Generate presigned URL for PUT upload
const url = file.presign({
acl: options?.acl || this.defaultAcl || "public-read",
acl: options?.acl ?? this.defaultAcl ?? "public-read",
expiresIn,
method: "PUT",
type: options?.contentType,
Expand Down Expand Up @@ -605,7 +624,7 @@ export class S3Adapter implements FilesystemDisk {
const result = await this.client.list({ prefix });
const results: string[] = [];

for (const obj of result.objects || []) {
for (const obj of result.contents ?? []) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Paginate truncated S3 listings.

result.contents is only the first 1,000-object page. scanDirectory() currently ignores isTruncated/nextContinuationToken, so files(), allFiles(), and directory discovery can silently omit later objects. Loop with continuationToken = response.nextContinuationToken until isTruncated is false.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/adapters/s3-adapter.ts` at line 627, Update scanDirectory() to paginate
S3 list responses: process each page’s result.contents, then reuse
result.nextContinuationToken as continuationToken while result.isTruncated is
true. Preserve the existing object-processing behavior so files(), allFiles(),
and directory discovery include every page.

const key = obj.key;
if (!key) {
continue;
Expand All @@ -630,7 +649,7 @@ export class S3Adapter implements FilesystemDisk {
const parts = relativeKey.split("/");
if (parts.length > 1) {
const dirName = parts[0];
if (!results.includes(dirName)) {
if (dirName !== undefined && !results.includes(dirName)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Preserve nested directory paths.

The new undefined guard is safe, but dirName = parts[0] still emits only the first key component. For example, foo/bar/file.txt yields foo, not foo/bar; recursive listings therefore omit nested directories and scoped listings return incorrect paths. Emit directory prefixes relative to the requested directory.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/adapters/s3-adapter.ts` at line 652, Update the directory-prefix
computation in the S3 listing logic around dirName so nested keys preserve their
full directory path relative to the requested directory, rather than using only
parts[0]. Ensure keys like foo/bar/file.txt emit foo/bar, while retaining the
existing undefined guard and avoiding prefixes outside the requested directory.

results.push(dirName);
}
}
Expand Down
26 changes: 24 additions & 2 deletions src/contracts/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
* allowing application code to remain entirely agnostic to where files
* are physically stored (local disk, cloud, etc.).
*/
/**
* Laravel-style file visibility.
*/
export type FileVisibility = "public" | "private";

export interface FilesystemDisk {
/**
* Determine if a file exists.
Expand All @@ -14,6 +19,14 @@ export interface FilesystemDisk {
*/
exists(path: string): Promise<boolean>;

/**
* Determine if a file is missing (inverse of {@link exists}).
*
* @param path - The path to check
* @returns A promise resolving to true if it does not exist
*/
missing(path: string): Promise<boolean>;

/**
* Get the contents of a file.
*
Expand Down Expand Up @@ -130,15 +143,24 @@ export interface FilesystemDisk {
*/
append(path: string, data: string): Promise<boolean>;

/**
* Prepend data to a file.
*
* @param path - The file path
* @param data - The data to prepend
* @returns A promise resolving to true on success
*/
prepend(path: string, data: string): Promise<boolean>;

/**
* Get the visibility of a file (public/private).
*/
getVisibility(path: string): Promise<string | null>;
getVisibility(path: string): Promise<FileVisibility | null>;
Comment on lines 155 to +158

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate filesystem-related files =="
git ls-files | rg 'src/contracts/filesystem\.ts$|filesystem|storage|s3|adapter|storage' | head -200

echo
echo "== outline src/contracts/filesystem.ts =="
if [ -f src/contracts/filesystem.ts ]; then
  ast-grep outline src/contracts/filesystem.ts || true
  wc -l src/contracts/filesystem.ts
fi

echo
echo "== relevant contract section =="
if [ -f src/contracts/filesystem.ts ]; then
  sed -n '120,190p' src/contracts/filesystem.ts
fi

echo
echo "== search getVisibility/setVisibility implementations/usages =="
rg -n "getVisibility|setVisibility|FileVisibility|setPublic|SetPublic|FilesystemDisk" src || true

Repository: nino-ts/filesystem

Length of output: 10735


🏁 Script executed:

# The previous shell was a capability request only; results are not present.

Repository: nino-ts/filesystem

Length of output: 156


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== S3 adapter visibility methods =="
sed -n '360,450p' src/adapters/s3-adapter.ts

echo
echo "== Memory adapter visibility methods =="
sed -n '260,320p' src/adapters/memory-adapter.ts

echo
echo "== Local adapter visibility methods =="
sed -n '220,255p' src/adapters/local-adapter.ts

echo
echo "== S3 tests visibility-related content =="
rg -n -C 5 "getVisibility|setVisibility|visibility|acl|aclObject|ACL" tests/unit/s3-adapter.test.ts

echo
echo "== package deps relevant =="
if [ -f package.json ]; then jq '.dependencies, .devDependencies | {aws, s3}' package.json; fi

Repository: nino-ts/filesystem

Length of output: 7713


🏁 Script executed:

# The previous shell was a capability request only; results are not present.

Repository: nino-ts/filesystem

Length of output: 156


Make S3 visibility methods honor the visibility contract.

S3Adapter.getVisibility() ignores the requested path and defaults every object to the configured defaultAcl; it should check object existence and read/report the object’s ACL before returning "private" or "public". Also avoid returning success from setVisibility() when it only documents a no-op and does not update S3 ACL state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/contracts/filesystem.ts` around lines 155 - 158, Update
S3Adapter.getVisibility() to use the provided path, check whether the object
exists, and retrieve its S3 ACL so it returns the actual "private" or "public"
visibility instead of defaultAcl. Update S3Adapter.setVisibility() to avoid
reporting success while performing no ACL change; either apply the requested S3
ACL or return the established unsupported-operation result.


/**
* Set the visibility of a file.
*/
setVisibility(path: string, visibility: string): Promise<boolean>;
setVisibility(path: string, visibility: FileVisibility): Promise<boolean>;

/**
* Get the MIME type of a file.
Expand Down
15 changes: 10 additions & 5 deletions src/decorators/compressed-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { FilesystemDisk } from "../contracts/filesystem";

/**
* Bun gzip compression level (zlib / libdeflate).
*/
export type GzipLevel = -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;

Comment on lines +3 to +7

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
curl -fsSL https://bun.sh/docs/runtime/utils | grep -F 'Must be between `-1` and `9`'
rg -n -C2 'type GzipLevel|gzipSync' src/decorators/compressed-adapter.ts

Repository: nino-ts/filesystem

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== relevant occurrences =="
rg -n -C3 'type GzipLevel|gzipSync|gunzipSync|level.*GzipLevel|GzipLevel' src/decorators/compressed-adapter.ts

echo
echo "== targeted docs search =="
curl -fsSL https://bun.sh/docs/runtime/utils | sed -n '/gzipSync/,+60p' | sed -n '1,120p'

echo
echo "== file section =="
sed -n '1,90p;120,170p' src/decorators/compressed-adapter.ts

Repository: nino-ts/filesystem

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== relevant occurrences =="
rg -n -C3 'type GzipLevel|gzipSync|gunzipSync|level.*GzipLevel|GzipLevel' src/decorators/compressed-adapter.ts

echo
echo "== targeted docs search =="
curl -fsSL https://bun.sh/docs/runtime/utils | sed -n '/gzipSync/,+80p' | sed -n '1,160p'

echo
echo "== file section =="
sed -n '1,100p;120,180p' src/decorators/compressed-adapter.ts

Repository: nino-ts/filesystem

Length of output: 50375


🌐 Web query:

Bun gzipSync level option must be between -1 and 9 Bun.gzipSync docs

💡 Result:

The level option for Bun.gzipSync must be between -1 and 9 [1][2][3]. According to the official Bun documentation, the compression level is defined within the ZlibCompressionOptions interface as follows: - A value of -1 uses the default compression level (currently 6) [1][2][3]. - A value of 0 provides no compression [1][2][3]. - A value of 1 offers the least compression and the fastest speed [1][2][3]. - A value of 9 offers the best compression and the slowest speed [1][2][3]. The valid range for the level option is -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, or 9 [1][3].

Citations:


Restrict GzipLevel to -1..9.

Bun.gzipSync only accepts zlib levels -1 through 9; including 10..12 makes invalid call options available through level?: GzipLevel, and these values can fail put() via the try/catch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/decorators/compressed-adapter.ts` around lines 3 - 7, Restrict the
GzipLevel type in compressed-adapter.ts to the valid zlib compression levels -1
through 9, removing 10, 11, and 12 so level options passed to Bun.gzipSync and
put() cannot represent unsupported values.

/**
* Options for CompressedAdapter.
*/
Expand All @@ -17,10 +22,10 @@ export interface CompressedAdapterOptions {
minSize?: number;

/**
* Compression level (1-9).
* Compression level (1-9 zlib; up to 12 for libdeflate).
* @default 6
*/
level?: number;
level?: GzipLevel;
}

/**
Expand All @@ -45,7 +50,7 @@ export class CompressedAdapter implements FilesystemDisk {
private adapter: FilesystemDisk;
private extensions?: string[];
private minSize: number;
private level: number;
private level: GzipLevel;

constructor(adapter: FilesystemDisk, options: CompressedAdapterOptions = {}) {
this.adapter = adapter;
Expand Down Expand Up @@ -101,7 +106,7 @@ export class CompressedAdapter implements FilesystemDisk {
const data = await this.adapter.get(compressedPath);
if (data) {
try {
const decompressed = await Bun.gunzip(data);
const decompressed = Bun.gunzipSync(data);
return new TextDecoder().decode(decompressed);
Comment on lines 106 to 110

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Repo files around candidate names:"
fd -a 'compressed-adapter|archive|filesystem' . | sed 's#^\./##' | head -200

echo
echo "Inspect compressed-adapter outline:"
ast-grep outline src/decorators/compressed-adapter.ts --view expanded || true

echo
echo "Compressed-adapter relevant lines:"
cat -n src/decorators/compressed-adapter.ts | sed -n '1,160p'

echo
echo "Archive outline:"
ast-grep outline src/utils/archive.ts --view expanded || true

echo
echo "Archive relevant lines:"
cat -n src/utils/archive.ts | sed -n '1,150p'

Repository: nino-ts/filesystem

Length of output: 14360


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Filesystem contract:"
cat -n src/contracts/filesystem.ts | sed -n '1,260p'

echo
echo "Search for FilesystemDisk get/getBytes/getBytes/bytes implementations and related adapters:"
rg -n "interface FilesystemDisk|class .*FilesystemDisk|implements FilesystemDisk|get\\(|getBytes\\(|bytes\\(|async get\\(|Promise\\.Uint8Array|Uint8Array|string \\| null" src -S

echo
echo "Try detecting static type shape:"
python3 - <<'PY'
from pathlib import Path
for p in Path("src").rglob("*.ts"):
    s=p.read_text()
    if "class " in s or "interface FilesystemDisk" in s:
        print(f"\n--- {p} ---")
        for i,line in enumerate(s.splitlines(),1):
            if "get(" in line or "interface FilesystemDisk" in line or "implements FilesystemDisk" in line or "bytes(" in line or "getBytes(" in line or "Promise<" in line:
                print(f"{i}: {line[:220]}")
PY

echo
echo "Node text binary probe matching compressor flow:"
node - <<'JS'
const zlib = require('zlib');
const data = new Uint8Array([0xCA, 0xFE, 0xBA, 0xBE, 0xFF]);
const compressed = zlib.gzipSync(data);
const text = Buffer.from(compressed).toString('utf8');
const input = Buffer.from(text, 'utf8');
const decompressed = zlib.gunzipSync(input);
console.log(JSON.stringify({
  original: Array.from(data),
  textBytes: Array.from(input),
  decompressed: Array.from(decompressed),
  equalBufferToStringBuffer: Buffer.compare(compressed, input) === 0,
  equalOriginalToDecompressed: Buffer.compare(data, decompressed) === 0,
}));
JS

Repository: nino-ts/filesystem

Length of output: 36635


Add a byte-exact filesystem read path. FilesystemDisk.get(): Promise<string | null> is used for binary round-trips, so compressed bytes survive UTF-8 text encoding/decoding and gunzipSync sees corrupted output. Add a byte API to the contract/adapters and use it at CompressedAdapter.get(), archive.createFromDirectory(), and archive.createBlob() before decompressing or building archive entries.

📍 Affects 2 files
  • src/decorators/compressed-adapter.ts#L106-L110 (this comment)
  • src/utils/archive.ts#L61-L71
  • src/utils/archive.ts#L108-L118
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/decorators/compressed-adapter.ts` around lines 106 - 110, The
string-based FilesystemDisk read corrupts binary data before decompression or
archiving. Add a byte-exact read method to the disk contract and its adapters,
then update CompressedAdapter.get() in src/decorators/compressed-adapter.ts,
archive.createFromDirectory() at src/utils/archive.ts:61-71, and
archive.createBlob() at src/utils/archive.ts:108-118 to use it when reading
compressed files or archive entries.

} catch {
// If decompression fails, return as-is
Expand Down Expand Up @@ -134,7 +139,7 @@ export class CompressedAdapter implements FilesystemDisk {
}

// Compress and write
const compressed = await Bun.gzip(data, { level: this.level });
const compressed = Bun.gzipSync(new Uint8Array(data), { level: this.level });
return this.adapter.put(compressedPath, compressed);
} catch (_error) {
return false;
Expand Down
3 changes: 3 additions & 0 deletions src/decorators/scoped-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ export class ScopedAdapter implements FilesystemDisk {
* Write the contents of a file (scoped).
*/
async put(path: string, contents: string | Blob | ArrayBuffer | Uint8Array): Promise<boolean> {
if (this.createScope) {
await this.adapter.makeDirectory(this.scope);
}
return this.adapter.put(this.applyScope(path), contents);
}

Expand Down
18 changes: 12 additions & 6 deletions src/decorators/watched-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,25 +127,31 @@ export class WatchedAdapter implements FilesystemDisk {
*/
private createWatcher(path: string): void {
try {
let debounceTimer: ReturnType<typeof setTimeout> | null = null;

const watcher = watch(path, { recursive: this.recursive }, (event, filename) => {
if (!filename) {
return;
}

// Debounce
const callbacks = this.callbacks.get(path);
if (!callbacks) {
return;
}

const eventType = event === "change" ? "change" : "rename";

// Call all callbacks
for (const callback of callbacks) {
try {
callback(eventType, filename);
} catch (_error) {}
if (debounceTimer !== null) {
clearTimeout(debounceTimer);
}

debounceTimer = setTimeout(() => {
for (const callback of callbacks) {
try {
callback(eventType, filename);
} catch (_error) {}
}
}, this.debounce);
Comment on lines +130 to +154

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Debounce per filename and cancel pending delivery on unwatch.

One timer per watched directory drops earlier events when different files change within the debounce window. It also survives unwatch()/unwatchAll(), so removed callbacks can still run. Track timers by watched path and filename, then clear them when the watcher is removed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/decorators/watched-adapter.ts` around lines 130 - 154, Update the watcher
logic around the debounceTimer in the watch setup to maintain separate debounce
timers for each watched path and filename, so events for different files do not
replace one another. When unwatching a path or all watchers, clear and remove
the associated pending timers before removing callbacks/watchers, preventing
delivery to removed callbacks.

});

this.watchers.set(path, watcher);
Expand Down
12 changes: 9 additions & 3 deletions src/manager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { LocalAdapter } from "./adapters/local-adapter";
import type { FilesystemDisk } from "./contracts/filesystem";
import type { FileVisibility, FilesystemDisk } from "./contracts/filesystem";

export interface FilesystemConfig {
/**
Expand Down Expand Up @@ -93,6 +93,9 @@ export class FilesystemManager implements FilesystemDisk {
async exists(path: string): Promise<boolean> {
return this.disk().exists(path);
}
async missing(path: string): Promise<boolean> {
return this.disk().missing(path);
}
async get(path: string): Promise<string | null> {
return this.disk().get(path);
}
Expand Down Expand Up @@ -135,12 +138,15 @@ export class FilesystemManager implements FilesystemDisk {
async append(path: string, data: string): Promise<boolean> {
return this.disk().append(path, data);
}
async prepend(path: string, data: string): Promise<boolean> {
return this.disk().prepend(path, data);
}

async getVisibility(path: string): Promise<string | null> {
async getVisibility(path: string): Promise<FileVisibility | null> {
return this.disk().getVisibility(path);
}

async setVisibility(path: string, visibility: string): Promise<boolean> {
async setVisibility(path: string, visibility: FileVisibility): Promise<boolean> {
return this.disk().setVisibility(path, visibility);
}

Expand Down
1 change: 1 addition & 0 deletions src/testing/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import crypto from "node:crypto";
import { mkdir, rm } from "node:fs/promises";
import path from "node:path";
Expand Down
Loading