assafvayner HF Staff commited on
Commit
9eafa48
·
1 Parent(s): 874bd3c

feat(storage): add HfBucketStore with opt-in real-bucket integration test

Browse files
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
- "spike": "tsx scripts/spike-bucket.ts"
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
  });