Spaces:
Running
Running
Commit ·
0d57d79
1
Parent(s): ee86043
feat(storage): add BucketStore interface, LocalFsStore and PrefixedStore
Browse files
src/lib/server/storage/local-fs-store.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { appendFile, copyFile, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
| 2 |
+
import { dirname, join, relative, sep } from 'node:path';
|
| 3 |
+
import { assertValidPath, type BucketStore, type StoreEntry } from './store';
|
| 4 |
+
|
| 5 |
+
function isEnoent(e: unknown): boolean {
|
| 6 |
+
return typeof e === 'object' && e !== null && (e as { code?: string }).code === 'ENOENT';
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
/** Directory-backed store for local development and tests. Writes are atomic (temp file + rename). */
|
| 10 |
+
export class LocalFsStore implements BucketStore {
|
| 11 |
+
constructor(private readonly root: string) {}
|
| 12 |
+
|
| 13 |
+
private abs(path: string): string {
|
| 14 |
+
assertValidPath(path);
|
| 15 |
+
return join(this.root, ...path.split('/'));
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
async get(path: string): Promise<Uint8Array | null> {
|
| 19 |
+
try {
|
| 20 |
+
return new Uint8Array(await readFile(this.abs(path)));
|
| 21 |
+
} catch (e) {
|
| 22 |
+
if (isEnoent(e)) return null;
|
| 23 |
+
throw e;
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
async put(path: string, bytes: Uint8Array, contentType?: string): Promise<void> {
|
| 28 |
+
const target = this.abs(path);
|
| 29 |
+
await mkdir(dirname(target), { recursive: true });
|
| 30 |
+
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
| 31 |
+
await writeFile(tmp, bytes);
|
| 32 |
+
await rename(tmp, target);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
async append(path: string, bytes: Uint8Array): Promise<void> {
|
| 36 |
+
const target = this.abs(path);
|
| 37 |
+
await mkdir(dirname(target), { recursive: true });
|
| 38 |
+
await appendFile(target, bytes);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
async list(prefix: string): Promise<StoreEntry[]> {
|
| 42 |
+
const out: StoreEntry[] = [];
|
| 43 |
+
const walk = async (dir: string): Promise<void> => {
|
| 44 |
+
let entries;
|
| 45 |
+
try {
|
| 46 |
+
entries = await readdir(dir, { withFileTypes: true });
|
| 47 |
+
} catch (e) {
|
| 48 |
+
if (isEnoent(e)) return;
|
| 49 |
+
throw e;
|
| 50 |
+
}
|
| 51 |
+
for (const ent of entries) {
|
| 52 |
+
const full = join(dir, ent.name);
|
| 53 |
+
if (ent.isDirectory()) {
|
| 54 |
+
await walk(full);
|
| 55 |
+
} else if (ent.isFile() && !ent.name.endsWith('.tmp')) {
|
| 56 |
+
const rel = relative(this.root, full).split(sep).join('/');
|
| 57 |
+
if (rel.startsWith(prefix)) {
|
| 58 |
+
const s = await stat(full);
|
| 59 |
+
out.push({ path: rel, size: s.size, uploadedAt: s.mtime });
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
};
|
| 64 |
+
await walk(this.root);
|
| 65 |
+
return out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
async delete(paths: string[]): Promise<void> {
|
| 69 |
+
for (const p of paths) await rm(this.abs(p), { force: true });
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
async copy(src: string, dst: string): Promise<void> {
|
| 73 |
+
const target = this.abs(dst);
|
| 74 |
+
await mkdir(dirname(target), { recursive: true });
|
| 75 |
+
await copyFile(this.abs(src), target);
|
| 76 |
+
}
|
| 77 |
+
}
|
src/lib/server/storage/prefixed-store.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { assertValidPath, type BucketStore, type StoreEntry } from './store';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Confines another store to `<prefix>/`, so several apps (or test runs) can share one
|
| 5 |
+
* bucket under different directories. Paths seen by callers never include the prefix.
|
| 6 |
+
*/
|
| 7 |
+
export class PrefixedStore implements BucketStore {
|
| 8 |
+
private readonly prefix: string;
|
| 9 |
+
|
| 10 |
+
constructor(
|
| 11 |
+
private readonly inner: BucketStore,
|
| 12 |
+
prefix: string
|
| 13 |
+
) {
|
| 14 |
+
const trimmed = prefix.replace(/^\/+|\/+$/g, '');
|
| 15 |
+
assertValidPath(trimmed);
|
| 16 |
+
this.prefix = `${trimmed}/`;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
private full(path: string): string {
|
| 20 |
+
assertValidPath(path);
|
| 21 |
+
return this.prefix + path;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
get(path: string): Promise<Uint8Array | null> {
|
| 25 |
+
return this.inner.get(this.full(path));
|
| 26 |
+
}
|
| 27 |
+
put(path: string, bytes: Uint8Array, contentType?: string): Promise<void> {
|
| 28 |
+
return this.inner.put(this.full(path), bytes, contentType);
|
| 29 |
+
}
|
| 30 |
+
append(path: string, bytes: Uint8Array): Promise<void> {
|
| 31 |
+
return this.inner.append(this.full(path), bytes);
|
| 32 |
+
}
|
| 33 |
+
async list(prefix: string): Promise<StoreEntry[]> {
|
| 34 |
+
const entries = await this.inner.list(this.prefix + prefix);
|
| 35 |
+
return entries.map((e) => ({ ...e, path: e.path.slice(this.prefix.length) }));
|
| 36 |
+
}
|
| 37 |
+
delete(paths: string[]): Promise<void> {
|
| 38 |
+
return this.inner.delete(paths.map((p) => this.full(p)));
|
| 39 |
+
}
|
| 40 |
+
copy(src: string, dst: string): Promise<void> {
|
| 41 |
+
return this.inner.copy(this.full(src), this.full(dst));
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/** Deletes every object under this store's prefix. Returns how many were deleted. */
|
| 45 |
+
async wipe(): Promise<number> {
|
| 46 |
+
const entries = await this.inner.list(this.prefix);
|
| 47 |
+
await this.inner.delete(entries.map((e) => e.path));
|
| 48 |
+
return entries.length;
|
| 49 |
+
}
|
| 50 |
+
}
|
src/lib/server/storage/store.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface StoreEntry {
|
| 2 |
+
path: string;
|
| 3 |
+
size: number;
|
| 4 |
+
uploadedAt?: Date;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* Minimal object-store abstraction. Paths are bucket-relative, use `/` separators,
|
| 9 |
+
* and never start with `/`.
|
| 10 |
+
*/
|
| 11 |
+
export interface BucketStore {
|
| 12 |
+
/** Resolves null when the object does not exist. */
|
| 13 |
+
get(path: string): Promise<Uint8Array | null>;
|
| 14 |
+
/** Creates or overwrites. */
|
| 15 |
+
put(path: string, bytes: Uint8Array, contentType?: string): Promise<void>;
|
| 16 |
+
/** Appends bytes to an existing object, creating it if missing. */
|
| 17 |
+
append(path: string, bytes: Uint8Array): Promise<void>;
|
| 18 |
+
/** Lists files whose path starts with `prefix`, sorted by path. */
|
| 19 |
+
list(prefix: string): Promise<StoreEntry[]>;
|
| 20 |
+
/** Deletes objects; missing ones are ignored. */
|
| 21 |
+
delete(paths: string[]): Promise<void>;
|
| 22 |
+
/** Server-side copy. Rejects if `src` does not exist. */
|
| 23 |
+
copy(src: string, dst: string): Promise<void>;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
const SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
|
| 27 |
+
|
| 28 |
+
export function assertValidPath(path: string): void {
|
| 29 |
+
if (path.length === 0 || path.length > 512) throw new Error(`Invalid path: ${path}`);
|
| 30 |
+
if (path.startsWith('/') || path.endsWith('/')) throw new Error(`Invalid path: ${path}`);
|
| 31 |
+
for (const seg of path.split('/')) {
|
| 32 |
+
if (seg === '' || seg === '.' || seg === '..' || !SEGMENT_RE.test(seg)) throw new Error(`Invalid path: ${path}`);
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export const encodeText = (s: string): Uint8Array => new TextEncoder().encode(s);
|
| 37 |
+
export const decodeText = (b: Uint8Array): string => new TextDecoder().decode(b);
|
tests/unit/storage/local-fs-store.test.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { mkdtemp, rm } from 'node:fs/promises';
|
| 2 |
+
import { tmpdir } from 'node:os';
|
| 3 |
+
import { join } from 'node:path';
|
| 4 |
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
| 5 |
+
import { LocalFsStore } from '../../../src/lib/server/storage/local-fs-store';
|
| 6 |
+
import { assertValidPath } from '../../../src/lib/server/storage/store';
|
| 7 |
+
|
| 8 |
+
const enc = (s: string) => new TextEncoder().encode(s);
|
| 9 |
+
const dec = (b: Uint8Array | null) => (b ? new TextDecoder().decode(b) : null);
|
| 10 |
+
|
| 11 |
+
describe('LocalFsStore', () => {
|
| 12 |
+
let dir: string;
|
| 13 |
+
let store: LocalFsStore;
|
| 14 |
+
beforeEach(async () => {
|
| 15 |
+
dir = await mkdtemp(join(tmpdir(), 'splitwise-store-'));
|
| 16 |
+
store = new LocalFsStore(dir);
|
| 17 |
+
});
|
| 18 |
+
afterEach(() => rm(dir, { recursive: true, force: true }));
|
| 19 |
+
|
| 20 |
+
it('returns null for missing files', async () => {
|
| 21 |
+
expect(await store.get('nope.json')).toBeNull();
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
it('puts, gets and overwrites', async () => {
|
| 25 |
+
await store.put('a/b.json', enc('one'), 'application/json');
|
| 26 |
+
expect(dec(await store.get('a/b.json'))).toBe('one');
|
| 27 |
+
await store.put('a/b.json', enc('two'));
|
| 28 |
+
expect(dec(await store.get('a/b.json'))).toBe('two');
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
it('appends, creating the file when missing', async () => {
|
| 32 |
+
await store.append('log.jsonl', enc('1\n'));
|
| 33 |
+
await store.append('log.jsonl', enc('2\n'));
|
| 34 |
+
expect(dec(await store.get('log.jsonl'))).toBe('1\n2\n');
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
it('lists files under a prefix, sorted by path', async () => {
|
| 38 |
+
await store.put('events/b.json', enc('b'));
|
| 39 |
+
await store.put('events/a.json', enc('a'));
|
| 40 |
+
await store.put('other.json', enc('o'));
|
| 41 |
+
const listed = await store.list('events/');
|
| 42 |
+
expect(listed.map((e) => e.path)).toEqual(['events/a.json', 'events/b.json']);
|
| 43 |
+
expect(listed[0].size).toBe(1);
|
| 44 |
+
expect(await store.list('missing/')).toEqual([]);
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
it('deletes (ignoring missing) and copies', async () => {
|
| 48 |
+
await store.put('x.txt', enc('x'));
|
| 49 |
+
await store.copy('x.txt', 'backups/x.txt');
|
| 50 |
+
expect(dec(await store.get('backups/x.txt'))).toBe('x');
|
| 51 |
+
await store.delete(['x.txt', 'not-there.txt']);
|
| 52 |
+
expect(await store.get('x.txt')).toBeNull();
|
| 53 |
+
await expect(store.copy('gone.txt', 'y.txt')).rejects.toThrow();
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
it('rejects unsafe paths', () => {
|
| 57 |
+
expect(() => assertValidPath('../etc/passwd')).toThrow();
|
| 58 |
+
expect(() => assertValidPath('/abs')).toThrow();
|
| 59 |
+
expect(() => assertValidPath('a//b')).toThrow();
|
| 60 |
+
expect(() => assertValidPath('ok/path.json')).not.toThrow();
|
| 61 |
+
});
|
| 62 |
+
});
|
tests/unit/storage/prefixed-store.test.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { mkdtemp, rm } from 'node:fs/promises';
|
| 2 |
+
import { tmpdir } from 'node:os';
|
| 3 |
+
import { join } from 'node:path';
|
| 4 |
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
| 5 |
+
import { LocalFsStore } from '../../../src/lib/server/storage/local-fs-store';
|
| 6 |
+
import { PrefixedStore } from '../../../src/lib/server/storage/prefixed-store';
|
| 7 |
+
import { decodeText, encodeText } from '../../../src/lib/server/storage/store';
|
| 8 |
+
|
| 9 |
+
describe('PrefixedStore', () => {
|
| 10 |
+
let dir: string;
|
| 11 |
+
let inner: LocalFsStore;
|
| 12 |
+
let a: PrefixedStore;
|
| 13 |
+
let b: PrefixedStore;
|
| 14 |
+
beforeEach(async () => {
|
| 15 |
+
dir = await mkdtemp(join(tmpdir(), 'splitwise-prefixed-'));
|
| 16 |
+
inner = new LocalFsStore(dir);
|
| 17 |
+
a = new PrefixedStore(inner, 'tenants/a');
|
| 18 |
+
b = new PrefixedStore(inner, '/tenants/b/');
|
| 19 |
+
});
|
| 20 |
+
afterEach(() => rm(dir, { recursive: true, force: true }));
|
| 21 |
+
|
| 22 |
+
it('stores objects under the prefix and reads them back', async () => {
|
| 23 |
+
await a.put('users.json', encodeText('A'), 'application/json');
|
| 24 |
+
expect(decodeText((await a.get('users.json'))!)).toBe('A');
|
| 25 |
+
expect(decodeText((await inner.get('tenants/a/users.json'))!)).toBe('A');
|
| 26 |
+
expect(await b.get('users.json')).toBeNull();
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
it('lists with the prefix stripped and never leaks other prefixes', async () => {
|
| 30 |
+
await a.put('events/1.json', encodeText('1'));
|
| 31 |
+
await a.put('events/2.json', encodeText('2'));
|
| 32 |
+
await b.put('events/9.json', encodeText('9'));
|
| 33 |
+
expect((await a.list('events/')).map((e) => e.path)).toEqual(['events/1.json', 'events/2.json']);
|
| 34 |
+
expect((await a.list('')).map((e) => e.path)).toEqual(['events/1.json', 'events/2.json']);
|
| 35 |
+
expect((await b.list('events/')).map((e) => e.path)).toEqual(['events/9.json']);
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
it('appends, copies and deletes inside the prefix', async () => {
|
| 39 |
+
await a.append('ledger.jsonl', encodeText('x\n'));
|
| 40 |
+
await a.append('ledger.jsonl', encodeText('y\n'));
|
| 41 |
+
await a.copy('ledger.jsonl', 'backups/l.jsonl');
|
| 42 |
+
expect(decodeText((await inner.get('tenants/a/backups/l.jsonl'))!)).toBe('x\ny\n');
|
| 43 |
+
await a.delete(['ledger.jsonl', 'missing.txt']);
|
| 44 |
+
expect(await a.get('ledger.jsonl')).toBeNull();
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
it('wipe removes only its own subtree', async () => {
|
| 48 |
+
await a.put('x.txt', encodeText('x'));
|
| 49 |
+
await a.put('deep/y.txt', encodeText('y'));
|
| 50 |
+
await b.put('z.txt', encodeText('z'));
|
| 51 |
+
expect(await a.wipe()).toBe(2);
|
| 52 |
+
expect(await a.list('')).toEqual([]);
|
| 53 |
+
expect(await b.get('z.txt')).not.toBeNull();
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
it('rejects invalid prefixes', () => {
|
| 57 |
+
expect(() => new PrefixedStore(inner, '')).toThrow();
|
| 58 |
+
expect(() => new PrefixedStore(inner, '../x')).toThrow();
|
| 59 |
+
});
|
| 60 |
+
});
|