@vobase/core 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -10
- package/src/__tests__/drizzle-introspection.test.ts +77 -0
- package/src/__tests__/e2e.test.ts +225 -0
- package/src/__tests__/permissions.test.ts +157 -0
- package/src/__tests__/rpc-types.test.ts +92 -0
- package/src/app.test.ts +99 -0
- package/src/app.ts +178 -0
- package/src/audit.test.ts +126 -0
- package/src/auth.test.ts +74 -0
- package/src/contracts/auth.ts +37 -0
- package/{dist/contracts/module.d.ts → src/contracts/module.ts} +6 -6
- package/src/contracts/notify.ts +47 -0
- package/src/contracts/permissions.ts +10 -0
- package/src/contracts/storage.ts +61 -0
- package/src/ctx.test.ts +162 -0
- package/src/ctx.ts +64 -0
- package/src/db/client.test.ts +75 -0
- package/src/db/client.ts +15 -0
- package/src/db/helpers.test.ts +147 -0
- package/src/db/helpers.ts +51 -0
- package/src/db/index.ts +8 -0
- package/{dist/index.d.ts → src/index.ts} +105 -6
- package/src/infra/circuit-breaker.test.ts +74 -0
- package/src/infra/circuit-breaker.ts +57 -0
- package/src/infra/errors.test.ts +175 -0
- package/src/infra/errors.ts +64 -0
- package/src/infra/http-client.test.ts +482 -0
- package/src/infra/http-client.ts +221 -0
- package/src/infra/index.ts +35 -0
- package/src/infra/job.test.ts +85 -0
- package/src/infra/job.ts +94 -0
- package/src/infra/logger.test.ts +65 -0
- package/src/infra/logger.ts +18 -0
- package/src/infra/queue.test.ts +46 -0
- package/src/infra/queue.ts +147 -0
- package/src/infra/throw-proxy.test.ts +34 -0
- package/src/infra/throw-proxy.ts +17 -0
- package/src/infra/webhooks-schema.ts +17 -0
- package/src/infra/webhooks.test.ts +364 -0
- package/src/infra/webhooks.ts +146 -0
- package/src/mcp/auth.test.ts +129 -0
- package/src/mcp/crud.test.ts +128 -0
- package/src/mcp/crud.ts +171 -0
- package/{dist/mcp/index.d.ts → src/mcp/index.ts} +0 -1
- package/src/mcp/server.test.ts +153 -0
- package/src/mcp/server.ts +178 -0
- package/src/middleware/audit.test.ts +169 -0
- package/src/module-registry.ts +18 -0
- package/src/module.test.ts +168 -0
- package/src/module.ts +111 -0
- package/src/modules/audit/index.ts +18 -0
- package/src/modules/audit/middleware.ts +33 -0
- package/src/modules/audit/schema.ts +35 -0
- package/src/modules/audit/track-changes.ts +70 -0
- package/src/modules/auth/audit-hooks.ts +74 -0
- package/src/modules/auth/index.ts +101 -0
- package/src/modules/auth/middleware.ts +51 -0
- package/src/modules/auth/permissions.ts +46 -0
- package/src/modules/auth/schema.ts +184 -0
- package/src/modules/credentials/encrypt.ts +95 -0
- package/src/modules/credentials/index.ts +15 -0
- package/src/modules/credentials/schema.ts +10 -0
- package/src/modules/notify/index.ts +90 -0
- package/src/modules/notify/notify.test.ts +145 -0
- package/src/modules/notify/providers/resend.ts +47 -0
- package/src/modules/notify/providers/smtp.ts +117 -0
- package/src/modules/notify/providers/waba.ts +82 -0
- package/src/modules/notify/schema.ts +27 -0
- package/src/modules/notify/service.ts +93 -0
- package/src/modules/sequences/index.ts +15 -0
- package/src/modules/sequences/next-sequence.ts +48 -0
- package/src/modules/sequences/schema.ts +12 -0
- package/src/modules/storage/index.ts +44 -0
- package/src/modules/storage/providers/local.ts +124 -0
- package/src/modules/storage/providers/s3.ts +83 -0
- package/src/modules/storage/routes.ts +76 -0
- package/src/modules/storage/schema.ts +26 -0
- package/src/modules/storage/service.ts +202 -0
- package/src/modules/storage/storage.test.ts +225 -0
- package/src/schemas.test.ts +44 -0
- package/src/schemas.ts +63 -0
- package/src/sequence.test.ts +56 -0
- package/dist/app.d.ts +0 -37
- package/dist/app.d.ts.map +0 -1
- package/dist/contracts/auth.d.ts +0 -35
- package/dist/contracts/auth.d.ts.map +0 -1
- package/dist/contracts/module.d.ts.map +0 -1
- package/dist/contracts/notify.d.ts +0 -46
- package/dist/contracts/notify.d.ts.map +0 -1
- package/dist/contracts/permissions.d.ts +0 -10
- package/dist/contracts/permissions.d.ts.map +0 -1
- package/dist/contracts/storage.d.ts +0 -54
- package/dist/contracts/storage.d.ts.map +0 -1
- package/dist/ctx.d.ts +0 -40
- package/dist/ctx.d.ts.map +0 -1
- package/dist/db/client.d.ts +0 -4
- package/dist/db/client.d.ts.map +0 -1
- package/dist/db/helpers.d.ts +0 -26
- package/dist/db/helpers.d.ts.map +0 -1
- package/dist/db/index.d.ts +0 -3
- package/dist/db/index.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -89026
- package/dist/infra/circuit-breaker.d.ts +0 -17
- package/dist/infra/circuit-breaker.d.ts.map +0 -1
- package/dist/infra/errors.d.ts +0 -26
- package/dist/infra/errors.d.ts.map +0 -1
- package/dist/infra/http-client.d.ts +0 -31
- package/dist/infra/http-client.d.ts.map +0 -1
- package/dist/infra/index.d.ts +0 -11
- package/dist/infra/index.d.ts.map +0 -1
- package/dist/infra/job.d.ts +0 -14
- package/dist/infra/job.d.ts.map +0 -1
- package/dist/infra/logger.d.ts +0 -7
- package/dist/infra/logger.d.ts.map +0 -1
- package/dist/infra/queue.d.ts +0 -18
- package/dist/infra/queue.d.ts.map +0 -1
- package/dist/infra/throw-proxy.d.ts +0 -7
- package/dist/infra/throw-proxy.d.ts.map +0 -1
- package/dist/infra/webhooks-schema.d.ts +0 -60
- package/dist/infra/webhooks-schema.d.ts.map +0 -1
- package/dist/infra/webhooks.d.ts +0 -46
- package/dist/infra/webhooks.d.ts.map +0 -1
- package/dist/mcp/crud.d.ts +0 -12
- package/dist/mcp/crud.d.ts.map +0 -1
- package/dist/mcp/index.d.ts.map +0 -1
- package/dist/mcp/server.d.ts +0 -10
- package/dist/mcp/server.d.ts.map +0 -1
- package/dist/module-registry.d.ts +0 -3
- package/dist/module-registry.d.ts.map +0 -1
- package/dist/module.d.ts +0 -29
- package/dist/module.d.ts.map +0 -1
- package/dist/modules/audit/index.d.ts +0 -5
- package/dist/modules/audit/index.d.ts.map +0 -1
- package/dist/modules/audit/middleware.d.ts +0 -3
- package/dist/modules/audit/middleware.d.ts.map +0 -1
- package/dist/modules/audit/schema.d.ts +0 -247
- package/dist/modules/audit/schema.d.ts.map +0 -1
- package/dist/modules/audit/track-changes.d.ts +0 -3
- package/dist/modules/audit/track-changes.d.ts.map +0 -1
- package/dist/modules/auth/audit-hooks.d.ts +0 -6
- package/dist/modules/auth/audit-hooks.d.ts.map +0 -1
- package/dist/modules/auth/index.d.ts +0 -17
- package/dist/modules/auth/index.d.ts.map +0 -1
- package/dist/modules/auth/middleware.d.ts +0 -15
- package/dist/modules/auth/middleware.d.ts.map +0 -1
- package/dist/modules/auth/permissions.d.ts +0 -5
- package/dist/modules/auth/permissions.d.ts.map +0 -1
- package/dist/modules/auth/schema.d.ts +0 -2519
- package/dist/modules/auth/schema.d.ts.map +0 -1
- package/dist/modules/credentials/encrypt.d.ts +0 -12
- package/dist/modules/credentials/encrypt.d.ts.map +0 -1
- package/dist/modules/credentials/index.d.ts +0 -4
- package/dist/modules/credentials/index.d.ts.map +0 -1
- package/dist/modules/credentials/schema.d.ts +0 -56
- package/dist/modules/credentials/schema.d.ts.map +0 -1
- package/dist/modules/notify/index.d.ts +0 -36
- package/dist/modules/notify/index.d.ts.map +0 -1
- package/dist/modules/notify/providers/resend.d.ts +0 -7
- package/dist/modules/notify/providers/resend.d.ts.map +0 -1
- package/dist/modules/notify/providers/smtp.d.ts +0 -18
- package/dist/modules/notify/providers/smtp.d.ts.map +0 -1
- package/dist/modules/notify/providers/waba.d.ts +0 -12
- package/dist/modules/notify/providers/waba.d.ts.map +0 -1
- package/dist/modules/notify/schema.d.ts +0 -337
- package/dist/modules/notify/schema.d.ts.map +0 -1
- package/dist/modules/notify/service.d.ts +0 -22
- package/dist/modules/notify/service.d.ts.map +0 -1
- package/dist/modules/sequences/index.d.ts +0 -4
- package/dist/modules/sequences/index.d.ts.map +0 -1
- package/dist/modules/sequences/next-sequence.d.ts +0 -8
- package/dist/modules/sequences/next-sequence.d.ts.map +0 -1
- package/dist/modules/sequences/schema.d.ts +0 -72
- package/dist/modules/sequences/schema.d.ts.map +0 -1
- package/dist/modules/storage/index.d.ts +0 -24
- package/dist/modules/storage/index.d.ts.map +0 -1
- package/dist/modules/storage/providers/local.d.ts +0 -3
- package/dist/modules/storage/providers/local.d.ts.map +0 -1
- package/dist/modules/storage/providers/s3.d.ts +0 -3
- package/dist/modules/storage/providers/s3.d.ts.map +0 -1
- package/dist/modules/storage/routes.d.ts +0 -4
- package/dist/modules/storage/routes.d.ts.map +0 -1
- package/dist/modules/storage/schema.d.ts +0 -273
- package/dist/modules/storage/schema.d.ts.map +0 -1
- package/dist/modules/storage/service.d.ts +0 -35
- package/dist/modules/storage/service.d.ts.map +0 -1
- package/dist/schemas.d.ts +0 -19
- package/dist/schemas.d.ts.map +0 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { mkdirSync, unlinkSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, normalize, dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { validation } from '../../../infra/errors';
|
|
5
|
+
import type {
|
|
6
|
+
StorageProvider,
|
|
7
|
+
StorageObjectInfo,
|
|
8
|
+
LocalProviderConfig,
|
|
9
|
+
} from '../../../contracts/storage';
|
|
10
|
+
|
|
11
|
+
function sanitizePath(inputPath: string): string {
|
|
12
|
+
if (inputPath.includes('..')) {
|
|
13
|
+
throw validation(
|
|
14
|
+
{ path: inputPath },
|
|
15
|
+
'Invalid path: directory traversal not allowed',
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
return normalize(inputPath).replace(/^\/+/, '');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createLocalProvider(config: LocalProviderConfig): StorageProvider {
|
|
22
|
+
const basePath = config.basePath;
|
|
23
|
+
const baseUrl = config.baseUrl ?? '/api/storage';
|
|
24
|
+
|
|
25
|
+
// Ensure base directory exists
|
|
26
|
+
mkdirSync(basePath, { recursive: true });
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
async upload(fullKey, data, opts) {
|
|
30
|
+
const safe = sanitizePath(fullKey);
|
|
31
|
+
const fullPath = join(basePath, safe);
|
|
32
|
+
|
|
33
|
+
if (opts?.maxSize && data.byteLength > opts.maxSize) {
|
|
34
|
+
throw validation(
|
|
35
|
+
{ size: data.byteLength, maxSize: opts.maxSize },
|
|
36
|
+
`File size ${data.byteLength} exceeds maximum allowed size ${opts.maxSize}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const dir = dirname(fullPath);
|
|
41
|
+
mkdirSync(dir, { recursive: true });
|
|
42
|
+
|
|
43
|
+
await Bun.write(fullPath, data);
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
async download(fullKey) {
|
|
47
|
+
const safe = sanitizePath(fullKey);
|
|
48
|
+
const file = Bun.file(join(basePath, safe));
|
|
49
|
+
if (!(await file.exists())) {
|
|
50
|
+
throw validation({ key: fullKey }, `File not found: ${fullKey}`);
|
|
51
|
+
}
|
|
52
|
+
return new Uint8Array(await file.arrayBuffer());
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async delete(fullKey) {
|
|
56
|
+
const safe = sanitizePath(fullKey);
|
|
57
|
+
const fullPath = join(basePath, safe);
|
|
58
|
+
try {
|
|
59
|
+
unlinkSync(fullPath);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
async exists(fullKey) {
|
|
66
|
+
const safe = sanitizePath(fullKey);
|
|
67
|
+
return existsSync(join(basePath, safe));
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
presign(fullKey, _opts) {
|
|
71
|
+
const safe = sanitizePath(fullKey);
|
|
72
|
+
// Local provider returns a proxy URL — the server handles the actual I/O
|
|
73
|
+
return `${baseUrl}/${safe}`;
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
async list(prefix, opts) {
|
|
77
|
+
const safe = sanitizePath(prefix || '');
|
|
78
|
+
const dir = join(basePath, safe);
|
|
79
|
+
const limit = opts?.limit ?? 100;
|
|
80
|
+
|
|
81
|
+
const objects: StorageObjectInfo[] = [];
|
|
82
|
+
|
|
83
|
+
if (!existsSync(dir)) {
|
|
84
|
+
return { objects };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const entries = readdirSync(dir, { recursive: true });
|
|
88
|
+
let skipping = !!opts?.cursor;
|
|
89
|
+
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
const entryStr = typeof entry === 'string' ? entry : entry.toString();
|
|
92
|
+
const fullPath = join(dir, entryStr);
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const stat = statSync(fullPath);
|
|
96
|
+
if (!stat.isFile()) continue;
|
|
97
|
+
} catch {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const key = safe ? `${safe}/${entryStr}` : entryStr;
|
|
102
|
+
|
|
103
|
+
if (skipping) {
|
|
104
|
+
if (key === opts?.cursor) skipping = false;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (objects.length >= limit) {
|
|
109
|
+
return { objects, cursor: objects[objects.length - 1].key };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const stat = statSync(fullPath);
|
|
113
|
+
objects.push({
|
|
114
|
+
key,
|
|
115
|
+
size: stat.size,
|
|
116
|
+
contentType: 'application/octet-stream',
|
|
117
|
+
lastModified: stat.mtime,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { objects };
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { S3Client } from 'bun';
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
StorageProvider,
|
|
5
|
+
StorageObjectInfo,
|
|
6
|
+
S3ProviderConfig,
|
|
7
|
+
} from '../../../contracts/storage';
|
|
8
|
+
import { validation } from '../../../infra/errors';
|
|
9
|
+
|
|
10
|
+
export function createS3Provider(config: S3ProviderConfig): StorageProvider {
|
|
11
|
+
const client = new S3Client({
|
|
12
|
+
accessKeyId: config.accessKeyId,
|
|
13
|
+
secretAccessKey: config.secretAccessKey,
|
|
14
|
+
bucket: config.bucket,
|
|
15
|
+
...(config.region && { region: config.region }),
|
|
16
|
+
...(config.endpoint && { endpoint: config.endpoint }),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
async upload(fullKey, data, opts) {
|
|
21
|
+
if (opts?.maxSize && data.byteLength > opts.maxSize) {
|
|
22
|
+
throw validation(
|
|
23
|
+
{ size: data.byteLength, maxSize: opts.maxSize },
|
|
24
|
+
`File size ${data.byteLength} exceeds maximum allowed size ${opts.maxSize}`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const file = client.file(fullKey);
|
|
29
|
+
await file.write(data, {
|
|
30
|
+
...(opts?.contentType && { type: opts.contentType }),
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async download(fullKey) {
|
|
35
|
+
const file = client.file(fullKey);
|
|
36
|
+
try {
|
|
37
|
+
const buf = await file.arrayBuffer();
|
|
38
|
+
return new Uint8Array(buf);
|
|
39
|
+
} catch {
|
|
40
|
+
throw validation({ key: fullKey }, `File not found: ${fullKey}`);
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
async delete(fullKey) {
|
|
45
|
+
const file = client.file(fullKey);
|
|
46
|
+
await file.delete();
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
async exists(fullKey) {
|
|
50
|
+
const file = client.file(fullKey);
|
|
51
|
+
return file.exists();
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
presign(fullKey, opts) {
|
|
55
|
+
return client.presign(fullKey, {
|
|
56
|
+
expiresIn: opts.expiresIn ?? 3600,
|
|
57
|
+
method: opts.method ?? 'GET',
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
async list(prefix, opts) {
|
|
62
|
+
// Bun's S3Client doesn't have a native list API — use the S3 ListObjectsV2 REST API
|
|
63
|
+
const limit = opts?.limit ?? 100;
|
|
64
|
+
const params = new URLSearchParams({
|
|
65
|
+
'list-type': '2',
|
|
66
|
+
prefix: prefix,
|
|
67
|
+
'max-keys': String(limit),
|
|
68
|
+
});
|
|
69
|
+
if (opts?.cursor) {
|
|
70
|
+
params.set('start-after', opts.cursor);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// const endpoint = config.endpoint ?? `https://s3.${config.region ?? 'us-east-1'}.amazonaws.com`;
|
|
74
|
+
// const url = `${endpoint}/${config.bucket}?${params}`;
|
|
75
|
+
|
|
76
|
+
// Bun's S3Client doesn't expose ListObjectsV2 — full listing requires XML parsing.
|
|
77
|
+
// Most use cases rely on metadata in SQLite; enhance here when needed.
|
|
78
|
+
// This will be enhanced when needed; most use cases rely on metadata in SQLite
|
|
79
|
+
const objects: StorageObjectInfo[] = [];
|
|
80
|
+
return { objects };
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
|
|
3
|
+
import type { StorageService } from './service';
|
|
4
|
+
|
|
5
|
+
export function createStorageRoutes(service: StorageService): Hono {
|
|
6
|
+
const routes = new Hono();
|
|
7
|
+
|
|
8
|
+
// POST /api/storage/:bucket — upload a file
|
|
9
|
+
routes.post('/:bucket', async (c) => {
|
|
10
|
+
const bucketName = c.req.param('bucket');
|
|
11
|
+
const bucket = service.bucket(bucketName);
|
|
12
|
+
|
|
13
|
+
const body = await c.req.parseBody();
|
|
14
|
+
const file = body.file;
|
|
15
|
+
|
|
16
|
+
if (!file || !(file instanceof File)) {
|
|
17
|
+
return c.json({ error: 'Missing file in request body' }, 400);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const data = new Uint8Array(await file.arrayBuffer());
|
|
21
|
+
const key = (body.key as string) || file.name;
|
|
22
|
+
const contentType = file.type || 'application/octet-stream';
|
|
23
|
+
|
|
24
|
+
const obj = await bucket.upload(key, data, { contentType });
|
|
25
|
+
return c.json(obj, 201);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// POST /api/storage/:bucket/confirm — confirm a presigned upload
|
|
29
|
+
routes.post('/:bucket/confirm', async (c) => {
|
|
30
|
+
const bucketName = c.req.param('bucket');
|
|
31
|
+
const bucket = service.bucket(bucketName);
|
|
32
|
+
const { key } = await c.req.json<{ key: string }>();
|
|
33
|
+
|
|
34
|
+
const meta = await bucket.metadata(key);
|
|
35
|
+
if (meta) {
|
|
36
|
+
return c.json(meta);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return c.json({ error: 'Object not found' }, 404);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// GET /api/storage/:bucket — list objects
|
|
43
|
+
routes.get('/:bucket', async (c) => {
|
|
44
|
+
const bucketName = c.req.param('bucket');
|
|
45
|
+
const bucket = service.bucket(bucketName);
|
|
46
|
+
|
|
47
|
+
const prefix = c.req.query('prefix');
|
|
48
|
+
const cursor = c.req.query('cursor');
|
|
49
|
+
const limit = c.req.query('limit');
|
|
50
|
+
|
|
51
|
+
const result = await bucket.list({
|
|
52
|
+
prefix: prefix || undefined,
|
|
53
|
+
cursor: cursor || undefined,
|
|
54
|
+
limit: limit ? Number.parseInt(limit, 10) : undefined,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return c.json(result);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// GET /api/storage/:bucket/:key{.+} — download / proxy a file
|
|
61
|
+
routes.get('/:bucket/:key{.+}', async (c) => {
|
|
62
|
+
const bucketName = c.req.param('bucket');
|
|
63
|
+
const key = c.req.param('key');
|
|
64
|
+
const bucket = service.bucket(bucketName);
|
|
65
|
+
|
|
66
|
+
const data = await bucket.download(key);
|
|
67
|
+
const meta = await bucket.metadata(key);
|
|
68
|
+
const contentType = meta?.contentType ?? 'application/octet-stream';
|
|
69
|
+
|
|
70
|
+
return new Response(data.buffer as ArrayBuffer, {
|
|
71
|
+
headers: { 'content-type': contentType },
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return routes;
|
|
76
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
|
2
|
+
|
|
3
|
+
import { nanoidPrimaryKey } from '../../db/helpers';
|
|
4
|
+
|
|
5
|
+
export const storageObjects = sqliteTable(
|
|
6
|
+
'_storage_objects',
|
|
7
|
+
{
|
|
8
|
+
id: nanoidPrimaryKey(),
|
|
9
|
+
bucket: text('bucket').notNull(),
|
|
10
|
+
key: text('key').notNull(),
|
|
11
|
+
size: integer('size').notNull(),
|
|
12
|
+
contentType: text('content_type').notNull().default('application/octet-stream'),
|
|
13
|
+
metadata: text('metadata'),
|
|
14
|
+
uploadedBy: text('uploaded_by'),
|
|
15
|
+
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
|
16
|
+
.notNull()
|
|
17
|
+
.$defaultFn(() => new Date()),
|
|
18
|
+
},
|
|
19
|
+
(table) => [
|
|
20
|
+
uniqueIndex('storage_objects_bucket_key_idx').on(table.bucket, table.key),
|
|
21
|
+
index('storage_objects_bucket_idx').on(table.bucket),
|
|
22
|
+
index('storage_objects_uploaded_by_idx').on(table.uploadedBy),
|
|
23
|
+
],
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
export const storageSchema = { storageObjects };
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { eq, and } from 'drizzle-orm';
|
|
2
|
+
|
|
3
|
+
import type { VobaseDb } from '../../db/client';
|
|
4
|
+
import type {
|
|
5
|
+
StorageProvider,
|
|
6
|
+
UploadOptions,
|
|
7
|
+
PresignOptions,
|
|
8
|
+
StorageListResult,
|
|
9
|
+
} from '../../contracts/storage';
|
|
10
|
+
import { validation } from '../../infra/errors';
|
|
11
|
+
import { storageObjects } from './schema';
|
|
12
|
+
|
|
13
|
+
export interface BucketConfig {
|
|
14
|
+
access: 'public' | 'private';
|
|
15
|
+
maxSize?: number;
|
|
16
|
+
allowedTypes?: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface StorageObject {
|
|
20
|
+
id: string;
|
|
21
|
+
bucket: string;
|
|
22
|
+
key: string;
|
|
23
|
+
size: number;
|
|
24
|
+
contentType: string;
|
|
25
|
+
metadata: Record<string, string> | null;
|
|
26
|
+
uploadedBy: string | null;
|
|
27
|
+
createdAt: Date;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BucketHandle {
|
|
31
|
+
upload(key: string, data: Buffer | Uint8Array, opts?: UploadOptions): Promise<StorageObject>;
|
|
32
|
+
download(key: string): Promise<Uint8Array>;
|
|
33
|
+
delete(key: string): Promise<void>;
|
|
34
|
+
presign(key: string, opts?: PresignOptions): string;
|
|
35
|
+
list(opts?: BucketListOptions): Promise<StorageListResult>;
|
|
36
|
+
metadata(key: string): Promise<StorageObject | null>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface BucketListOptions {
|
|
40
|
+
prefix?: string;
|
|
41
|
+
cursor?: string;
|
|
42
|
+
limit?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface StorageService {
|
|
46
|
+
bucket(name: string): BucketHandle;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createStorageService(
|
|
50
|
+
provider: StorageProvider,
|
|
51
|
+
buckets: Record<string, BucketConfig>,
|
|
52
|
+
db: VobaseDb,
|
|
53
|
+
): StorageService {
|
|
54
|
+
const validBucketNames = new Set(Object.keys(buckets));
|
|
55
|
+
|
|
56
|
+
function assertBucket(name: string): BucketConfig {
|
|
57
|
+
if (!validBucketNames.has(name)) {
|
|
58
|
+
throw validation(
|
|
59
|
+
{ bucket: name, available: [...validBucketNames] },
|
|
60
|
+
`Unknown storage bucket "${name}". Available buckets: ${[...validBucketNames].join(', ')}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return buckets[name];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fullKey(bucket: string, key: string): string {
|
|
67
|
+
return `${bucket}/${key}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
bucket(name: string): BucketHandle {
|
|
72
|
+
const config = assertBucket(name);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
async upload(key, data, opts) {
|
|
76
|
+
const fk = fullKey(name, key);
|
|
77
|
+
const contentType = opts?.contentType ?? 'application/octet-stream';
|
|
78
|
+
|
|
79
|
+
if (config.maxSize && data.byteLength > config.maxSize) {
|
|
80
|
+
throw validation(
|
|
81
|
+
{ size: data.byteLength, maxSize: config.maxSize },
|
|
82
|
+
`File size ${data.byteLength} exceeds bucket max size ${config.maxSize}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (config.allowedTypes && config.allowedTypes.length > 0) {
|
|
87
|
+
const allowed = config.allowedTypes.some((pattern) => {
|
|
88
|
+
if (pattern.endsWith('/*')) {
|
|
89
|
+
return contentType.startsWith(pattern.slice(0, -1));
|
|
90
|
+
}
|
|
91
|
+
return contentType === pattern;
|
|
92
|
+
});
|
|
93
|
+
if (!allowed) {
|
|
94
|
+
throw validation(
|
|
95
|
+
{ contentType, allowedTypes: config.allowedTypes },
|
|
96
|
+
`Content type "${contentType}" is not allowed in bucket "${name}"`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await provider.upload(fk, data, { ...opts, maxSize: config.maxSize });
|
|
102
|
+
|
|
103
|
+
// Upsert metadata in SQLite
|
|
104
|
+
const existing = db
|
|
105
|
+
.select()
|
|
106
|
+
.from(storageObjects)
|
|
107
|
+
.where(and(eq(storageObjects.bucket, name), eq(storageObjects.key, key)))
|
|
108
|
+
.get();
|
|
109
|
+
|
|
110
|
+
if (existing) {
|
|
111
|
+
db.update(storageObjects)
|
|
112
|
+
.set({
|
|
113
|
+
size: data.byteLength,
|
|
114
|
+
contentType,
|
|
115
|
+
metadata: opts?.metadata ? JSON.stringify(opts.metadata) : null,
|
|
116
|
+
})
|
|
117
|
+
.where(eq(storageObjects.id, existing.id))
|
|
118
|
+
.run();
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
...existing,
|
|
122
|
+
size: data.byteLength,
|
|
123
|
+
contentType,
|
|
124
|
+
metadata: opts?.metadata ?? null,
|
|
125
|
+
createdAt: existing.createdAt,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const row = db
|
|
130
|
+
.insert(storageObjects)
|
|
131
|
+
.values({
|
|
132
|
+
bucket: name,
|
|
133
|
+
key,
|
|
134
|
+
size: data.byteLength,
|
|
135
|
+
contentType,
|
|
136
|
+
metadata: opts?.metadata ? JSON.stringify(opts.metadata) : null,
|
|
137
|
+
uploadedBy: opts?.metadata?.uploadedBy ?? null,
|
|
138
|
+
})
|
|
139
|
+
.returning()
|
|
140
|
+
.get();
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
id: row.id,
|
|
144
|
+
bucket: row.bucket,
|
|
145
|
+
key: row.key,
|
|
146
|
+
size: row.size,
|
|
147
|
+
contentType: row.contentType,
|
|
148
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
|
149
|
+
uploadedBy: row.uploadedBy,
|
|
150
|
+
createdAt: row.createdAt,
|
|
151
|
+
};
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
async download(key) {
|
|
155
|
+
return provider.download(fullKey(name, key));
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
async delete(key) {
|
|
159
|
+
await provider.delete(fullKey(name, key));
|
|
160
|
+
db.delete(storageObjects)
|
|
161
|
+
.where(and(eq(storageObjects.bucket, name), eq(storageObjects.key, key)))
|
|
162
|
+
.run();
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
presign(key, opts) {
|
|
166
|
+
return provider.presign(fullKey(name, key), opts ?? {});
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
async list(opts) {
|
|
170
|
+
const prefix = opts?.prefix
|
|
171
|
+
? fullKey(name, opts.prefix)
|
|
172
|
+
: `${name}/`;
|
|
173
|
+
return provider.list(prefix, {
|
|
174
|
+
cursor: opts?.cursor,
|
|
175
|
+
limit: opts?.limit,
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
async metadata(key) {
|
|
180
|
+
const row = db
|
|
181
|
+
.select()
|
|
182
|
+
.from(storageObjects)
|
|
183
|
+
.where(and(eq(storageObjects.bucket, name), eq(storageObjects.key, key)))
|
|
184
|
+
.get();
|
|
185
|
+
|
|
186
|
+
if (!row) return null;
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
id: row.id,
|
|
190
|
+
bucket: row.bucket,
|
|
191
|
+
key: row.key,
|
|
192
|
+
size: row.size,
|
|
193
|
+
contentType: row.contentType,
|
|
194
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
|
195
|
+
uploadedBy: row.uploadedBy,
|
|
196
|
+
createdAt: row.createdAt,
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|