Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Commit 6cbd7bb

Browse files
authored
Feature/get range (#283)
* first try least impacting effort * did not mean to be abstract * improve error checking for windows support * improve error checking for windows support * improve error checking for windows support * nits * nits part 2
1 parent 73f06ea commit 6cbd7bb

File tree

4 files changed

+112
-4
lines changed

4 files changed

+112
-4
lines changed

packages/shared/src/storage.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ export abstract class Storage {
7878
skipMetadata: true
7979
): Awaitable<StorageListResult<StoredKey>>;
8080

81+
// Implementations (e.g. storage-file) may override this for efficient range requests
82+
async getRangeMaybeExpired?<Meta = unknown>(
83+
key: string,
84+
start: number,
85+
length: number
86+
): Promise<StoredValueMeta<Meta> | undefined>;
87+
8188
// Batch functions, default implementations may be overridden to optimise
8289

8390
async hasMany(keys: string[]): Promise<number> {

packages/storage-file/src/helpers.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import fs from "fs/promises";
22
import path from "path";
33

4-
function onNotFound<T, V>(promise: Promise<T>, value: V): Promise<T | V> {
5-
return promise.catch((e) => {
4+
async function onNotFound<T, V>(promise: Promise<T>, value: V): Promise<T | V> {
5+
try {
6+
return await promise;
7+
} catch (e: any) {
68
if (e.code === "ENOENT") return value;
79
throw e;
8-
});
10+
}
911
}
1012

1113
export function readFile(filePath: string): Promise<Buffer | undefined>;
@@ -20,6 +22,26 @@ export function readFile(
2022
return onNotFound(fs.readFile(filePath, decode && "utf8"), undefined);
2123
}
2224

25+
export async function readFileRange(
26+
filePath: string,
27+
start: number,
28+
length: number
29+
): Promise<Buffer | undefined> {
30+
let fd: fs.FileHandle | null = null;
31+
let res: Buffer;
32+
try {
33+
fd = await fs.open(filePath, "r");
34+
res = Buffer.alloc(length);
35+
await fd.read(res, 0, length, start);
36+
} catch (e: any) {
37+
if (e.code === "ENOENT") return undefined;
38+
throw e;
39+
} finally {
40+
await fd?.close();
41+
}
42+
return res;
43+
}
44+
2345
export async function writeFile(
2446
filePath: string,
2547
data: Uint8Array | string

packages/storage-file/src/index.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ import {
1010
viewToArray,
1111
} from "@miniflare/shared";
1212
import { LocalStorage } from "@miniflare/storage-memory";
13-
import { deleteFile, readFile, walk, writeFile } from "./helpers";
13+
import {
14+
deleteFile,
15+
readFile,
16+
readFileRange,
17+
walk,
18+
writeFile,
19+
} from "./helpers";
1420

1521
const metaSuffix = ".meta.json";
1622

@@ -85,6 +91,33 @@ export class FileStorage extends LocalStorage {
8591
}
8692
}
8793

94+
async getRangeMaybeExpired<Meta = unknown>(
95+
key: string,
96+
start: number,
97+
length: number
98+
): Promise<StoredValueMeta<Meta> | undefined> {
99+
const [filePath] = this.keyPath(key);
100+
if (!filePath) return;
101+
102+
try {
103+
const value = await readFileRange(filePath, start, length);
104+
105+
if (value === undefined) return;
106+
const meta = await this.meta<Meta>(filePath);
107+
return {
108+
value: viewToArray(value),
109+
expiration: meta.expiration,
110+
metadata: meta.metadata,
111+
};
112+
} catch (e: any) {
113+
// We'll get this error if we try to get a namespaced key, where the
114+
// namespace itself is also a key (e.g. trying to get "key/sub-key" where
115+
// "key" is also a key). In this case, "key/sub-key" doesn't exist.
116+
if (e.code === "ENOTDIR") return;
117+
throw e;
118+
}
119+
}
120+
88121
async put<Meta = unknown>(
89122
key: string,
90123
{ value, expiration, metadata }: StoredValueMeta<Meta>

packages/storage-file/test/index.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,33 @@ test("FileStorage: list: returns original keys if sanitised", async (t) => {
6767
cursor: "",
6868
});
6969
});
70+
test("FileStorage: getRangeMaybeExpired: returns partial values", async (t) => {
71+
const storage = await storageFactory.factory(t, {});
72+
await storage.put("key", { value: utf8Encode("123456789") });
73+
74+
const getFront = await storage.getRangeMaybeExpired?.("key", 0, 3);
75+
t.is(utf8Decode(getFront?.value), "123");
76+
const getBack = await storage.getRangeMaybeExpired?.("key", 6, 3);
77+
t.is(utf8Decode(getBack?.value), "789");
78+
const getMiddle = await storage.getRangeMaybeExpired?.("key", 3, 3);
79+
t.is(utf8Decode(getMiddle?.value), "456");
80+
81+
// below 0 start defaults to 0
82+
const outside = await storage.getRangeMaybeExpired?.("key", -2, 3);
83+
t.is(utf8Decode(outside?.value), "123");
84+
// past end adds 0 for each missing byte
85+
const outside2 = await storage.getRangeMaybeExpired?.("key", 12, 7);
86+
t.is(
87+
utf8Decode(outside2?.value),
88+
utf8Decode(new Uint8Array([0, 0, 0, 0, 0, 0, 0]))
89+
);
90+
// length past end just pads with 0s for each missing byte
91+
const outside3 = await storage.getRangeMaybeExpired?.("key", 6, 6);
92+
t.is(
93+
utf8Decode(outside3?.value),
94+
"789" + utf8Decode(new Uint8Array([0, 0, 0]))
95+
);
96+
});
7097

7198
async function unsanitisedStorageFactory(
7299
t: ExecutionContext
@@ -88,6 +115,25 @@ test("FileStorage: get: ignores files outside root", async (t) => {
88115
t.is(utf8Decode((await storage.get("dir/../key"))?.value), "value");
89116
t.is(await storage.get("../secrets.txt"), undefined);
90117
});
118+
test("FileStorage: getRangeMaybeExpired: ignores files outside root", async (t) => {
119+
const storage = await unsanitisedStorageFactory(t);
120+
t.is(
121+
utf8Decode(
122+
(await storage.getRangeMaybeExpired?.("dir/../key", 0, 5))?.value
123+
),
124+
"value"
125+
);
126+
t.is(await storage.getRangeMaybeExpired?.("../secrets.txt", 0, 6), undefined);
127+
});
128+
test("FileStorage: getRangeMaybeExpired: non-existant file returns undefined", async (t) => {
129+
const storage = await unsanitisedStorageFactory(t);
130+
t.is(await storage.getRangeMaybeExpired?.("doesntexist", 0, 6), undefined);
131+
});
132+
test("FileStorage: getRangeMaybeExpired: dir that does not exist will return undefined", async (t) => {
133+
const storage = await unsanitisedStorageFactory(t);
134+
const empty = await storage.getRangeMaybeExpired?.("key/sub-key", 0, 6);
135+
t.is(empty, undefined);
136+
});
91137
test("FileStorage: put: throws on files outside root", async (t) => {
92138
const storage = await unsanitisedStorageFactory(t);
93139
await t.throwsAsync(

0 commit comments

Comments
 (0)