Spaces:
Sleeping
Sleeping
Commit ·
9eafa48
1
Parent(s): 874bd3c
feat(storage): add HfBucketStore with opt-in real-bucket integration test
Browse files- package-lock.json +0 -35
- package.json +1 -2
- src/lib/server/storage/hf-bucket-store.ts +103 -0
- tests/integration/env.ts +23 -0
- tests/integration/hf-bucket-store.test.ts +58 -0
- vite.config.ts +1 -1
package-lock.json
CHANGED
|
@@ -21,7 +21,6 @@
|
|
| 21 |
"svelte": "^5.38.0",
|
| 22 |
"svelte-check": "^4.3.0",
|
| 23 |
"tailwindcss": "^4.1.0",
|
| 24 |
-
"tsx": "^4.20.0",
|
| 25 |
"typescript": "^5.9.0",
|
| 26 |
"vite": "^7.1.0",
|
| 27 |
"vitest": "^3.2.0"
|
|
@@ -2981,40 +2980,6 @@
|
|
| 2981 |
"node": ">=6"
|
| 2982 |
}
|
| 2983 |
},
|
| 2984 |
-
"node_modules/tsx": {
|
| 2985 |
-
"version": "4.23.12",
|
| 2986 |
-
"resolved": "https://npm.registries.huggingface.tech/tsx/-/tsx-4.23.12.tgz",
|
| 2987 |
-
"integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
|
| 2988 |
-
"dev": true,
|
| 2989 |
-
"license": "MIT",
|
| 2990 |
-
"dependencies": {
|
| 2991 |
-
"esbuild": "~0.28.0"
|
| 2992 |
-
},
|
| 2993 |
-
"bin": {
|
| 2994 |
-
"tsx": "dist/cli.mjs"
|
| 2995 |
-
},
|
| 2996 |
-
"engines": {
|
| 2997 |
-
"node": ">=18.0.0"
|
| 2998 |
-
},
|
| 2999 |
-
"optionalDependencies": {
|
| 3000 |
-
"fsevents": "~2.3.3"
|
| 3001 |
-
}
|
| 3002 |
-
},
|
| 3003 |
-
"node_modules/tsx/node_modules/fsevents": {
|
| 3004 |
-
"version": "2.3.3",
|
| 3005 |
-
"resolved": "https://npm.registries.huggingface.tech/fsevents/-/fsevents-2.3.3.tgz",
|
| 3006 |
-
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
| 3007 |
-
"dev": true,
|
| 3008 |
-
"hasInstallScript": true,
|
| 3009 |
-
"license": "MIT",
|
| 3010 |
-
"optional": true,
|
| 3011 |
-
"os": [
|
| 3012 |
-
"darwin"
|
| 3013 |
-
],
|
| 3014 |
-
"engines": {
|
| 3015 |
-
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
| 3016 |
-
}
|
| 3017 |
-
},
|
| 3018 |
"node_modules/typescript": {
|
| 3019 |
"version": "5.9.3",
|
| 3020 |
"resolved": "https://npm.registries.huggingface.tech/typescript/-/typescript-5.9.3.tgz",
|
|
|
|
| 21 |
"svelte": "^5.38.0",
|
| 22 |
"svelte-check": "^4.3.0",
|
| 23 |
"tailwindcss": "^4.1.0",
|
|
|
|
| 24 |
"typescript": "^5.9.0",
|
| 25 |
"vite": "^7.1.0",
|
| 26 |
"vitest": "^3.2.0"
|
|
|
|
| 2980 |
"node": ">=6"
|
| 2981 |
}
|
| 2982 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2983 |
"node_modules/typescript": {
|
| 2984 |
"version": "5.9.3",
|
| 2985 |
"resolved": "https://npm.registries.huggingface.tech/typescript/-/typescript-5.9.3.tgz",
|
package.json
CHANGED
|
@@ -12,7 +12,7 @@
|
|
| 12 |
"test": "vitest run",
|
| 13 |
"test:watch": "vitest",
|
| 14 |
"test:e2e": "playwright test",
|
| 15 |
-
"
|
| 16 |
},
|
| 17 |
"dependencies": {
|
| 18 |
"@huggingface/hub": "^2.16.1",
|
|
@@ -28,7 +28,6 @@
|
|
| 28 |
"svelte": "^5.38.0",
|
| 29 |
"svelte-check": "^4.3.0",
|
| 30 |
"tailwindcss": "^4.1.0",
|
| 31 |
-
"tsx": "^4.20.0",
|
| 32 |
"typescript": "^5.9.0",
|
| 33 |
"vite": "^7.1.0",
|
| 34 |
"vitest": "^3.2.0"
|
|
|
|
| 12 |
"test": "vitest run",
|
| 13 |
"test:watch": "vitest",
|
| 14 |
"test:e2e": "playwright test",
|
| 15 |
+
"test:integration": "vitest run tests/integration"
|
| 16 |
},
|
| 17 |
"dependencies": {
|
| 18 |
"@huggingface/hub": "^2.16.1",
|
|
|
|
| 28 |
"svelte": "^5.38.0",
|
| 29 |
"svelte-check": "^4.3.0",
|
| 30 |
"tailwindcss": "^4.1.0",
|
|
|
|
| 31 |
"typescript": "^5.9.0",
|
| 32 |
"vite": "^7.1.0",
|
| 33 |
"vitest": "^3.2.0"
|
src/lib/server/storage/hf-bucket-store.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { commit, copyFile, deleteFiles, downloadFile, listFiles, uploadFiles } from '@huggingface/hub';
|
| 2 |
+
import { assertValidPath, type BucketStore, type StoreEntry } from './store';
|
| 3 |
+
|
| 4 |
+
function statusOf(e: unknown): number | undefined {
|
| 5 |
+
return typeof e === 'object' && e !== null ? (e as { statusCode?: number }).statusCode : undefined;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
function toBlob(bytes: Uint8Array, type?: string): Blob {
|
| 9 |
+
return new Blob([bytes as unknown as BlobPart], type ? { type } : undefined);
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
/**
|
| 13 |
+
* BucketStore backed by a Hugging Face Storage Bucket through `@huggingface/hub`.
|
| 14 |
+
* `repo` is the full bucket id, e.g. `buckets/assafvayner/splitwise-data`.
|
| 15 |
+
*/
|
| 16 |
+
export class HfBucketStore implements BucketStore {
|
| 17 |
+
constructor(
|
| 18 |
+
private readonly repo: `buckets/${string}`,
|
| 19 |
+
private readonly accessToken: string
|
| 20 |
+
) {}
|
| 21 |
+
|
| 22 |
+
private get auth() {
|
| 23 |
+
return { repo: this.repo, accessToken: this.accessToken };
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
async get(path: string): Promise<Uint8Array | null> {
|
| 27 |
+
assertValidPath(path);
|
| 28 |
+
const blob = await downloadFile({ ...this.auth, path });
|
| 29 |
+
if (!blob) return null;
|
| 30 |
+
return new Uint8Array(await blob.arrayBuffer());
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
async put(path: string, bytes: Uint8Array, contentType = 'application/octet-stream'): Promise<void> {
|
| 34 |
+
assertValidPath(path);
|
| 35 |
+
await uploadFiles({
|
| 36 |
+
...this.auth,
|
| 37 |
+
commitTitle: `put ${path}`,
|
| 38 |
+
files: [{ path, content: toBlob(bytes, contentType) }]
|
| 39 |
+
});
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
async append(path: string, bytes: Uint8Array): Promise<void> {
|
| 43 |
+
assertValidPath(path);
|
| 44 |
+
const original = await downloadFile({ ...this.auth, path });
|
| 45 |
+
if (!original) return this.put(path, bytes, 'application/x-ndjson');
|
| 46 |
+
try {
|
| 47 |
+
await commit({
|
| 48 |
+
...this.auth,
|
| 49 |
+
title: `append ${path}`,
|
| 50 |
+
operations: [
|
| 51 |
+
{
|
| 52 |
+
operation: 'edit',
|
| 53 |
+
path,
|
| 54 |
+
originalContent: original,
|
| 55 |
+
edits: [{ content: toBlob(bytes), start: original.size, end: original.size }]
|
| 56 |
+
}
|
| 57 |
+
]
|
| 58 |
+
});
|
| 59 |
+
} catch (e) {
|
| 60 |
+
console.warn(`[hf-bucket-store] edit-append failed for ${path}; falling back to full rewrite`, e);
|
| 61 |
+
const existing = new Uint8Array(await original.arrayBuffer());
|
| 62 |
+
const joined = new Uint8Array(existing.length + bytes.length);
|
| 63 |
+
joined.set(existing, 0);
|
| 64 |
+
joined.set(bytes, existing.length);
|
| 65 |
+
await this.put(path, joined, 'application/x-ndjson');
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
async list(prefix: string): Promise<StoreEntry[]> {
|
| 70 |
+
const dir = prefix.replace(/\/+$/, '');
|
| 71 |
+
const out: StoreEntry[] = [];
|
| 72 |
+
try {
|
| 73 |
+
for await (const entry of listFiles({ ...this.auth, path: dir || undefined, recursive: true, expand: true })) {
|
| 74 |
+
if (entry.type !== 'file' || !entry.path.startsWith(prefix)) continue;
|
| 75 |
+
out.push({
|
| 76 |
+
path: entry.path,
|
| 77 |
+
size: entry.size,
|
| 78 |
+
uploadedAt: entry.uploadedAt ? new Date(entry.uploadedAt) : undefined
|
| 79 |
+
});
|
| 80 |
+
}
|
| 81 |
+
} catch (e) {
|
| 82 |
+
if (statusOf(e) === 404) return [];
|
| 83 |
+
throw e;
|
| 84 |
+
}
|
| 85 |
+
return out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
async delete(paths: string[]): Promise<void> {
|
| 89 |
+
if (paths.length === 0) return;
|
| 90 |
+
for (const p of paths) assertValidPath(p);
|
| 91 |
+
await deleteFiles({ ...this.auth, paths, commitTitle: `delete ${paths.length} object(s)` });
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
async copy(src: string, dst: string): Promise<void> {
|
| 95 |
+
assertValidPath(src);
|
| 96 |
+
assertValidPath(dst);
|
| 97 |
+
await copyFile({
|
| 98 |
+
accessToken: this.accessToken,
|
| 99 |
+
source: { repo: this.repo, path: src },
|
| 100 |
+
destination: { repo: this.repo, path: dst }
|
| 101 |
+
});
|
| 102 |
+
}
|
| 103 |
+
}
|
tests/integration/env.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { readFileSync } from 'node:fs';
|
| 2 |
+
|
| 3 |
+
/** Loads KEY=value lines from .env into process.env without overriding existing variables. */
|
| 4 |
+
export function loadDotEnv(path = '.env'): void {
|
| 5 |
+
let text: string;
|
| 6 |
+
try {
|
| 7 |
+
text = readFileSync(path, 'utf8');
|
| 8 |
+
} catch {
|
| 9 |
+
return;
|
| 10 |
+
}
|
| 11 |
+
for (const line of text.split('\n')) {
|
| 12 |
+
const m = /^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
|
| 13 |
+
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export function realBucket(): { token: string; bucket: `buckets/${string}` } | null {
|
| 18 |
+
loadDotEnv();
|
| 19 |
+
const token = process.env.HF_TOKEN;
|
| 20 |
+
const bucket = process.env.HF_BUCKET;
|
| 21 |
+
if (!token || !bucket?.startsWith('buckets/')) return null;
|
| 22 |
+
return { token, bucket: bucket as `buckets/${string}` };
|
| 23 |
+
}
|
tests/integration/hf-bucket-store.test.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterAll, describe, expect, it } from 'vitest';
|
| 2 |
+
import { ulid } from '../../src/lib/domain/ulid';
|
| 3 |
+
import { HfBucketStore } from '../../src/lib/server/storage/hf-bucket-store';
|
| 4 |
+
import { PrefixedStore } from '../../src/lib/server/storage/prefixed-store';
|
| 5 |
+
import { decodeText, encodeText } from '../../src/lib/server/storage/store';
|
| 6 |
+
import { realBucket } from './env';
|
| 7 |
+
|
| 8 |
+
const creds = realBucket();
|
| 9 |
+
|
| 10 |
+
describe.skipIf(!creds)(
|
| 11 |
+
'HfBucketStore against the real bucket',
|
| 12 |
+
() => {
|
| 13 |
+
const store = new PrefixedStore(new HfBucketStore(creds!.bucket, creds!.token), `test/${ulid()}`);
|
| 14 |
+
|
| 15 |
+
afterAll(async () => {
|
| 16 |
+
await store.wipe();
|
| 17 |
+
expect(await store.list('')).toEqual([]);
|
| 18 |
+
}, 60_000);
|
| 19 |
+
|
| 20 |
+
it('returns null for a missing object', async () => {
|
| 21 |
+
expect(await store.get('missing.json')).toBeNull();
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
it('put + get round trip, and read-after-write returns the newest bytes', async () => {
|
| 25 |
+
await store.put('a.json', encodeText('{"a":1}'), 'application/json');
|
| 26 |
+
expect(decodeText((await store.get('a.json'))!)).toBe('{"a":1}');
|
| 27 |
+
for (let i = 0; i < 3; i++) {
|
| 28 |
+
await store.put('rw.txt', encodeText(`v${i}`));
|
| 29 |
+
expect(decodeText((await store.get('rw.txt'))!)).toBe(`v${i}`);
|
| 30 |
+
}
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
it('lists by prefix and returns [] for an unknown prefix', async () => {
|
| 34 |
+
await store.put('events/one.json', encodeText('1'));
|
| 35 |
+
const listed = await store.list('events/');
|
| 36 |
+
expect(listed.map((e) => e.path)).toEqual(['events/one.json']);
|
| 37 |
+
expect(listed[0].size).toBe(1);
|
| 38 |
+
expect(await store.list('nothing-here/')).toEqual([]);
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
it('append creates the object, then appends via the edit operation', async () => {
|
| 42 |
+
await store.append('ledger.jsonl', encodeText('{"n":1}\n'));
|
| 43 |
+
await store.append('ledger.jsonl', encodeText('{"n":2}\n'));
|
| 44 |
+
expect(decodeText((await store.get('ledger.jsonl'))!)).toBe('{"n":1}\n{"n":2}\n');
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
it('copies server-side', async () => {
|
| 48 |
+
await store.copy('ledger.jsonl', 'backups/ledger.jsonl');
|
| 49 |
+
expect(decodeText((await store.get('backups/ledger.jsonl'))!)).toBe('{"n":1}\n{"n":2}\n');
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
it('deletes, ignoring missing paths', async () => {
|
| 53 |
+
await store.delete(['a.json', 'never-existed.txt']);
|
| 54 |
+
expect(await store.get('a.json')).toBeNull();
|
| 55 |
+
});
|
| 56 |
+
},
|
| 57 |
+
60_000
|
| 58 |
+
);
|
vite.config.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { defineConfig } from 'vitest/config';
|
|
| 5 |
export default defineConfig({
|
| 6 |
plugins: [tailwindcss(), sveltekit()],
|
| 7 |
test: {
|
| 8 |
-
include: ['tests/unit/**/*.test.ts'],
|
| 9 |
environment: 'node'
|
| 10 |
}
|
| 11 |
});
|
|
|
|
| 5 |
export default defineConfig({
|
| 6 |
plugins: [tailwindcss(), sveltekit()],
|
| 7 |
test: {
|
| 8 |
+
include: ['tests/unit/**/*.test.ts', 'tests/integration/**/*.test.ts'],
|
| 9 |
environment: 'node'
|
| 10 |
}
|
| 11 |
});
|