@homurajs/storage 26.10.13 → 26.10.15

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/README.md CHANGED
@@ -1,11 +1,24 @@
1
1
  # @homurajs/storage
2
2
 
3
- 对象存储/图床:三后端(local-fs / s3 兼容 / supabase)+ `/files/<key>` 代理路由。
3
+ 对象存储/图床:四后端(local-fs / s3 兼容 / supabase / **db**)+ `/files/<key>` 代理路由。
4
+
5
+ > **qw-pages 上只有 `db` 后端可用**(其余三个 2026-10-13 实测均不可用,db 后端 2026-10-14 实测可用):
6
+ >
7
+ > - `db`:✅ **Pages 唯一可用的 storage 后端**——二进制**分块** base64 存进 `homura_files`(清单)+
8
+ > `homura_file_chunks`(数据),走 PostgREST(Pages 唯一可用的数据路径)。分块绕开网关 body 上限,
9
+ > 支持大文件(受可配总上限约束,缺省 50MB);2026-10-14 实测 3MB 分 8 块上传 + 流式下载逐字节一致。
10
+ > ⚠ 大文件正解仍是 s3 预签名直传(见下),db 分块是无对象存储时的兜底
11
+ > - `supabase`:❌ 桶建不出来——anon 建桶被 RLS 拒(400 内含 403),平台受控迁移角色对 `storage` schema 也无权(`42501`)
12
+ > - `local`:❌ 默认 `./data/files` 在 Pages 不可写(应用根只读),且 `mkdir` 发生在**每次上传时**而非构造期 → 启动正常、`/_healthz` 报 ok、首次上传才 EACCES;改指 `QWENWORK_WRITABLE_DIR` 也只能当临时缓冲(跨发布清空)
13
+ > - `s3`:⚠️ 理论可行(凭据可经 `homura.config.env` 投递),但需 App 自行安装 optional peer,且 S3 端点出网未验证
14
+ >
15
+ > 详见 `docs/specs/module-qw-pages.md` 与 AGENTS.md 的支持矩阵。
4
16
 
5
17
  ```ts
6
- import { createLocalStorage } from '@homurajs/storage/local' // 独立形态
7
- // import { createS3Storage } from '@homurajs/storage/s3' // minio/oss(AWS SDK 动态加载)
8
- // import { createSupabaseStorage } from '@homurajs/storage/supabase' // qw-pages(手写 REST)
18
+ import { createLocalStorage } from '@homurajs/storage/local' // 独立形态
19
+ // import { createS3Storage } from '@homurajs/storage/s3' // minio/oss(AWS SDK 动态加载,需自装 peer)
20
+ // import { createSupabaseStorage, loadSupabaseStorageEnv } from '@homurajs/storage/supabase'
21
+ // import { createDrizzleFileStore, createRestFileStore } from '@homurajs/storage/db' // DB-backed
9
22
 
10
23
  const storage = createLocalStorage({ rootDir: './data/files' })
11
24
 
@@ -23,4 +36,142 @@ import { createFileProxyRoutes } from '@homurajs/storage/proxy'
23
36
  app.route('/', createFileProxyRoutes(storage))
24
37
  ```
25
38
 
26
- 使用示范见模板 `src/server/routes/files.ts`。
39
+ ## db 后端(DB-backed 分块存储,qw-pages 唯一可用)
40
+
41
+ 把二进制**分块**写进数据库——qw-pages 上的兜底后端。三后端在 Pages 全倒,而 `rest`(PostgREST)
42
+ 是 Pages 唯一可用的数据路径,故 db 后端是**第一个能在 Pages 上跑的 storage**。强制分块以绕开两道
43
+ 平台限制:网关 body 上限(实测 ≥1MB 413)+ PostgREST 单值 ~1MB。
44
+
45
+ ```ts
46
+ import { createDrizzleFileStore, createRestFileStore } from '@homurajs/storage/db'
47
+
48
+ // SQL 方言(sqlite / pg):构造时 CREATE TABLE IF NOT EXISTS,与 drizzle 迁移幂等共存
49
+ const storage = createDrizzleFileStore(db, { chunkSize: 384 * 1024, maxTotalBytes: 50 * 1024 * 1024 })
50
+
51
+ // REST 方言(qw-pages):不建表(无 DDL 权限),两张表须由部署期受控迁移预建
52
+ const storage = createRestFileStore(rest, { chunkSize: 384 * 1024, maxTotalBytes: 50 * 1024 * 1024 })
53
+ ```
54
+
55
+ 两张表(`@homurajs/storage/tables` 的 `pgStorageTables` / `sqliteStorageTables`),App spread 进
56
+ 自己的 schema 参与迁移(照 `@homurajs/license/tables` 先例):
57
+
58
+ - `homura_files`:清单(object_key PK / content_type / total_size / chunk_size / chunk_count / status / created_at / updated_at)
59
+ - `homura_file_chunks`:数据分块(object_key + chunk_index 复合主键 / data)
60
+
61
+ ```ts
62
+ // schema/pg.ts
63
+ import { pgStorageTables } from '@homurajs/storage/tables'
64
+ export const homuraFiles = pgStorageTables.homuraFiles
65
+ export const homuraFileChunks = pgStorageTables.homuraFileChunks
66
+ export const pgSchema = { todos, /* ... */, homuraFiles, homuraFileChunks }
67
+ ```
68
+
69
+ **整文件 API 仍可用**(`uploadFile`/`downloadFile`,内部分块/重组,服务端一次性场景);
70
+ **大文件走分块协议**(前端切片,避免整文件进内存、避免撞网关限):
71
+
72
+ ```ts
73
+ // 前端(浏览器):import { uploadChunked } from '@homurajs/storage/chunk-client'
74
+ const r = await uploadChunked(file, { chunkSize: 384 * 1024, onProgress: (loaded, total) => ... })
75
+ // r.url 即 /files/<objectKey> 代理下载地址(服务端流式重组)
76
+ ```
77
+
78
+ 对应服务端路由(模板 `routes/files.ts` 已示范):`POST /api/files/init` → `PUT /api/files/chunk?key=&index=`
79
+ (原始字节 body)→ `POST /api/files/complete`。
80
+
81
+ **关键设计**:
82
+
83
+ - `data` 列是 **base64 文本**(不是 bytea/blob)。PostgREST 对 bytea 的 JSON 编解码不可靠
84
+ (2026-10-14 Pages 实测:308B 进去、618B mojibake 出来),文本列三方言一致、编解码由 `db.ts`
85
+ 显式控制(toBase64/fromBase64)。代价 +33% 体积——对兜底后端可接受
86
+ - **分块绕网关**:每块 base64 后 ≤512KB(raw 缺省 384KB),避开网关 body 限与 PostgREST 单值限。
87
+ 客户端每块发**原始字节**(不 base64),请求体 = raw 块大小,最大化余量
88
+ - **可配总上限**(`maxTotalBytes`,缺省 50MB):DB 存 blob 是反模式(撑大库、无 CDN、慢、贵),
89
+ "无限大"是陷阱,故设上限并在超限时提示改用 s3 预签名直传。⚠ **Pages 上网关先拦**:实测 512KB 稳、
90
+ 768KB 撞 PostgREST 值上限报 500、≥1MB 被 openresty 413——故单块必须小,大文件靠分块累积
91
+ - **流式下载**:`downloadStream` 逐块 yield(Hono `ReadableStream`),避免整文件进内存;代理路由
92
+ `/files/<key>` 优先用它(带 `content-length`,浏览器可显示进度)
93
+ - **残块 GC**:`status=uploading` 超 `staleUploadMs`(缺省 24h)的清单在 initUpload 时顺带清扫
94
+ (概率 `sweepProbability`,缺省 0.05;await 而非 fire-and-forget,兼容 Pages/serverless 进程冻结)
95
+ - `listFiles` 只返 `status=complete` 的清单、只选元数据列(不拉 data)
96
+ - presign 下载无意义(DB 无直连 URL),降级为代理路径 `/files/<key>`
97
+ - 依赖方向:storage → `@homurajs/db` schema-helpers(照 auth/license 先例,见 AGENTS.md 白名单);
98
+ `drizzle-orm` 是 optional peer,`./db` 子路径动态 import
99
+
100
+ ## 大文件正解:预签名直传对象存储
101
+
102
+ DB 分块是兜底;**大文件的正解是浏览器预签名直传 OSS/S3**——字节不过 App、不过网关,既绕开 body
103
+ 上限又不膨胀 DB。s3 后端提供 `getSignedUploadUrl`(预签名 PUT):
104
+
105
+ ```ts
106
+ // 服务端:签发直传 URL(模板经 POST /api/files/presign 暴露)
107
+ const uploadUrl = await s3Storage.getSignedUploadUrl(objectKey, contentType, 900)
108
+
109
+ // 前端:import { uploadViaPresign } from '@homurajs/storage/chunk-client'
110
+ await uploadViaPresign(file, uploadUrl) // 浏览器直接 PUT 到 OSS,流式发送 Blob
111
+ // 下载仍走 /files/<objectKey> 代理(服务端从 OSS 取),或另签 GET 预签名
112
+ ```
113
+
114
+ ⚠ 前置:对象存储侧配好 **CORS**(允许 Pages 域名的 `PUT` + `Content-Type` 头);`uploadViaPresign`
115
+ 的 content-type 必须与签发时一致(签名覆盖了它)。OSS 从浏览器侧的可达性需自行确认。
116
+
117
+ ## s3 后端接阿里云 OSS(配置 recipe)
118
+
119
+ s3 后端用 AWS SDK 的 S3 兼容模式接 OSS——OSS 提供 S3 兼容端点,**无需 OSS 专用 SDK**:
120
+
121
+ ```ts
122
+ import { createS3Storage } from '@homurajs/storage/s3'
123
+
124
+ const storage = createS3Storage({
125
+ // OSS 的 S3 兼容端点:https://oss-<region>.aliyuncs.com(如 oss-cn-hangzhou.aliyuncs.com)
126
+ endpoint: 'https://oss-cn-hangzhou.aliyuncs.com',
127
+ region: 'oss-cn-hangzhou', // OSS 的 region 名(带 oss- 前缀)
128
+ bucket: 'my-bucket',
129
+ accessKeyId: process.env.S3_ACCESS_KEY!, // OSS AccessKey ID
130
+ secretAccessKey: process.env.S3_SECRET_KEY!, // OSS AccessKey Secret
131
+ forcePathStyle: false, // ⚠ OSS 必须 false(virtual-hosted);path-style 会报 SecondLevelDomainForbidden
132
+ autoCreateBucket: false, // ⚠ OSS 桶须在控制台预建;S3 的 CreateBucket 对 OSS 不一定生效,关掉更稳
133
+ presignDirect: false, // 走 /files 代理下载(不暴露 OSS 直连 URL);要直连下载再开
134
+ })
135
+ ```
136
+
137
+ 模板里对应 env(`config/homura.config.env` 或真实环境变量):
138
+
139
+ ```
140
+ STORAGE_BACKEND=s3
141
+ S3_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
142
+ S3_REGION=oss-cn-hangzhou
143
+ S3_BUCKET=my-bucket
144
+ S3_ACCESS_KEY=<OSS AccessKey ID>
145
+ S3_SECRET_KEY=<OSS AccessKey Secret>
146
+ S3_FORCE_PATH_STYLE=false
147
+ ```
148
+
149
+ **OSS 注意点**:
150
+
151
+ - ⚠ **必须 `forcePathStyle: false`(virtual-hosted:`bucket.endpoint/key`)**。OSS 对对象操作强制
152
+ virtual-hosted,path-style(`endpoint/bucket/key`,minio 缺省)会报
153
+ `403 SecondLevelDomainForbidden: Please use virtual hosted style`——2026-10-14 真 OSS 实测确认。
154
+ 预签名 URL 的寻址风格也随此开关,故直传前必须配对(模板经 `S3_FORCE_PATH_STYLE=false` 投递)
155
+ - `region` 用 OSS 的写法(`oss-cn-hangzhou`),不是 AWS 的 `cn-hangzhou`
156
+ - 桶必须**预先在 OSS 控制台创建**并配好读写权限;`autoCreateBucket` 对 OSS 不保证生效,建议关
157
+ - 需 App 自行安装两个 optional peer:`@aws-sdk/client-s3` + `@aws-sdk/s3-request-presigner`
158
+ - ✅ **预签名直传已对真 OSS 实测通过**(2026-10-14):建桶 → `getSignedUploadUrl` → PUT 3MB → 200 →
159
+ `downloadFile` 回读逐字节一致 → 预签名 GET 200。浏览器直传是客户端 → OSS,不经 Pages 网关,
160
+ 故网关 body 上限不适用;服务端 `downloadFile`/代理下载走 Pages→OSS 443 出网,实测可达
161
+ - ⚠ 浏览器直传仍需 OSS 桶配 **CORS**(允许 Pages 域名的 `PUT` + `Content-Type` 头);curl/node 直传
162
+ 不受 CORS 约束(故上述实测用 node fetch 模拟),但真实浏览器流程必须配 CORS
163
+
164
+ supabase 后端的 env 解析用**本包**的 loader,不要用 `@homurajs/db/pg` 的那个:
165
+
166
+ ```ts
167
+ import { createSupabaseStorage, loadSupabaseStorageEnv } from '@homurajs/storage/supabase'
168
+
169
+ // 只需 SUPABASE_URL + SUPABASE_ANON_KEY(qw-pages 会自动注入这两项)
170
+ const { platformUrl, apiKey } = loadSupabaseStorageEnv(process.env)
171
+ const storage = createSupabaseStorage({ platformUrl, apiKey, bucket: 'files' })
172
+ // ⚠ bucket 必须预先存在:本包与平台迁移都建不出桶(见上方警告)
173
+ ```
174
+
175
+ `@homurajs/db/pg` 的 `loadSupabaseEnv` 要 `SUPABASE_PROJECT_REF` + `SUPABASE_DB_PASSWORD`(PG 直连用,仅自托管 supabase 有),拿它来配对象存储会因为缺 PG 凭据而抛错——这个连带依赖已经拆掉。
176
+
177
+ 使用示范见模板 `src/server/routes/files.ts` 与 `app.ts`(buildStorage)。
package/dist/index.d.ts CHANGED
@@ -29,14 +29,82 @@ export interface FileStorage {
29
29
  }>;
30
30
  /** 弱后端降级为代理路径 */
31
31
  getPresignedUrl(objectKey: string, expirySeconds: number): Promise<string>;
32
+ /**
33
+ * 预签名**上传** URL(浏览器直传对象存储用,绕开 App 网关的 body 上限)。
34
+ * 可选能力:只有 s3 兼容后端实现;DB/local/supabase 不支持(返回 undefined 或抛错)。
35
+ * 见 Phase B「预签名直传 OSS」。
36
+ */
37
+ getSignedUploadUrl?(objectKey: string, contentType: string, expirySeconds: number): Promise<string>;
38
+ /**
39
+ * 流式下载(可选):逐块 yield,避免整文件进内存。代理路由优先用它,缺失时退回 downloadFile。
40
+ * 分块后端(ChunkedFileStorage)必须实现。
41
+ */
42
+ downloadStream?(objectKey: string): Promise<{
43
+ stream: ReadableStream<Uint8Array>;
44
+ contentType: string;
45
+ size: number;
46
+ }>;
32
47
  deleteFile(objectKey: string): Promise<void>;
33
48
  /** 强制 ns 前缀,无法越权列举 */
34
49
  listFiles(ns: string, prefix: string, limit: number): Promise<FileInfo[]>;
35
50
  }
51
+ /** 清单状态:uploading(分块上传中)| complete(块齐、可下载) */
52
+ export type FileStatus = 'uploading' | 'complete';
53
+ /** 分块文件的清单(manifest)——不含数据本身 */
54
+ export interface FileManifest {
55
+ objectKey: string;
56
+ contentType: string;
57
+ /** 原始总字节数 */
58
+ totalSize: number;
59
+ /** 每块原始字节数(最后一块可能更小) */
60
+ chunkSize: number;
61
+ chunkCount: number;
62
+ status: FileStatus;
63
+ createdAt: Date;
64
+ }
65
+ /**
66
+ * 分块文件存储:在 FileStorage 之上加分块上传协议(init/putChunk/complete)。
67
+ *
68
+ * 为什么需要:qw-pages 网关对单请求 body 有上限(实测 ≥1MB 413),PostgREST 单值也 ~1MB。
69
+ * 把文件切成 ≤chunkSize 的块、每块一个小请求,即可绕开限制支持大文件。前端用
70
+ * `@homurajs/storage/chunk-client` 切片上传;下载由 `downloadStream` 逐块流式重组。
71
+ *
72
+ * 整文件 API(uploadFile/downloadFile)仍可用:内部自动分块/重组(服务端一次性场景)。
73
+ */
74
+ export interface ChunkedFileStorage extends FileStorage {
75
+ /** 初始化分块上传:建清单(status=uploading),返回实际块数与块大小 */
76
+ initUpload(input: {
77
+ objectKey: string;
78
+ contentType: string;
79
+ totalSize: number;
80
+ chunkSize?: number;
81
+ }): Promise<{
82
+ objectKey: string;
83
+ chunkCount: number;
84
+ chunkSize: number;
85
+ }>;
86
+ /** 写入单块(幂等 upsert,可重试/并行);data 为该块原始字节 */
87
+ putChunk(objectKey: string, index: number, data: Uint8Array): Promise<void>;
88
+ /** 完成上传:校验块齐 → status=complete;缺块抛错(残块由 GC 清) */
89
+ completeUpload(objectKey: string): Promise<FileManifest>;
90
+ /** 读清单(不存在返回 null) */
91
+ getManifest(objectKey: string): Promise<FileManifest | null>;
92
+ /** 流式下载(分块后端必须实现) */
93
+ downloadStream(objectKey: string): Promise<{
94
+ stream: ReadableStream<Uint8Array>;
95
+ contentType: string;
96
+ size: number;
97
+ }>;
98
+ }
36
99
  /** 上传载荷(Node 侧 Uint8Array;浏览器 File/Blob 由调用方先转) */
37
100
  export type FilePayload = Uint8Array | ReadableStream<Uint8Array>;
38
101
  /** object key 校验:禁止路径穿越 */
39
102
  export declare function assertSafeObjectKey(key: string): void;
40
103
  /** 统一代理路径 */
41
104
  export declare function proxyUrl(objectKey: string): string;
105
+ /**
106
+ * 生成业务 objectKey:`<ns>/files/<unixMs>_<safeName>`。
107
+ * 服务端用它在分块上传 init 时定 key(强制 ns 前缀、清洗文件名),与整文件 uploadFile 同格式。
108
+ */
109
+ export declare function generateObjectKey(ns: string, fileName: string, now: number): string;
42
110
  //#endregion
package/dist/index.js CHANGED
@@ -7,5 +7,13 @@ function assertSafeObjectKey(key) {
7
7
  function proxyUrl(objectKey) {
8
8
  return `/files/${objectKey}`;
9
9
  }
10
+ /**
11
+ * 生成业务 objectKey:`<ns>/files/<unixMs>_<safeName>`。
12
+ * 服务端用它在分块上传 init 时定 key(强制 ns 前缀、清洗文件名),与整文件 uploadFile 同格式。
13
+ */
14
+ function generateObjectKey(ns, fileName, now) {
15
+ assertSafeObjectKey(ns);
16
+ return `${ns}/files/${now}_${fileName.replace(/[/\\]/g, "_")}`;
17
+ }
10
18
  //#endregion
11
- export { assertSafeObjectKey, proxyUrl };
19
+ export { assertSafeObjectKey, generateObjectKey, proxyUrl };
package/dist/local.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { assertSafeObjectKey, proxyUrl } from "./index.js";
2
2
  import { createReadStream, existsSync } from "node:fs";
3
3
  import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
4
- import { dirname, join, resolve } from "node:path";
4
+ import { dirname, join, resolve, sep } from "node:path";
5
5
  //#region src/local.ts
6
6
  /**
7
7
  * local-fs 后端:独立单机形态的缺省实现。
@@ -13,7 +13,7 @@ async function* walk(dir, base = dir) {
13
13
  for (const entry of await readdir(dir, { withFileTypes: true })) {
14
14
  const full = join(dir, entry.name);
15
15
  if (entry.isDirectory()) yield* walk(full, base);
16
- else if (!entry.name.endsWith(".meta.json")) yield full.slice(base.length + 1);
16
+ else if (!entry.name.endsWith(".meta.json")) yield full.slice(base.length + 1).split(sep).join("/");
17
17
  }
18
18
  }
19
19
  function createLocalStorage(options) {
package/dist/proxy.js CHANGED
@@ -10,6 +10,14 @@ function createFileProxyRoutes(storage, options) {
10
10
  app.get(`${mount}/:key{.+}`, async (c) => {
11
11
  const key = c.req.param("key");
12
12
  try {
13
+ if (storage.downloadStream) {
14
+ const { stream, contentType, size } = await storage.downloadStream(key);
15
+ return new Response(stream, { headers: {
16
+ "content-type": contentType ?? "application/octet-stream",
17
+ "content-length": String(size),
18
+ "cache-control": "public, max-age=31536000, immutable"
19
+ } });
20
+ }
13
21
  const { data, contentType } = await storage.downloadFile(key);
14
22
  return new Response(data, { headers: {
15
23
  "content-type": contentType ?? "application/octet-stream",
package/dist/s3.d.ts CHANGED
@@ -10,6 +10,13 @@ export interface S3StorageOptions {
10
10
  autoCreateBucket?: boolean;
11
11
  /** 预签名走直连 URL(默认 false:走代理路径降级,避免暴露 endpoint) */
12
12
  presignDirect?: boolean;
13
+ /**
14
+ * 寻址风格:true = path-style(`endpoint/bucket/key`),false = virtual-hosted(`bucket.endpoint/key`)。
15
+ * 缺省 true(minio 需要)。⚠ **阿里云 OSS 必须 false**:OSS 对对象操作强制 virtual-hosted,
16
+ * path-style 会报 `403 SecondLevelDomainForbidden: Please use virtual hosted style`
17
+ * (2026-10-14 真 OSS 实测)。预签名 URL 的寻址风格也随此开关,故直传前必须配对。
18
+ */
19
+ forcePathStyle?: boolean;
13
20
  }
14
21
  export declare function createS3Storage(options: S3StorageOptions): FileStorage;
15
22
  //#endregion
package/dist/s3.js CHANGED
@@ -17,7 +17,7 @@ function createS3Storage(options) {
17
17
  const client = new deps.S3Client({
18
18
  endpoint: options.endpoint,
19
19
  region: options.region,
20
- forcePathStyle: true,
20
+ forcePathStyle: options.forcePathStyle ?? true,
21
21
  credentials: {
22
22
  accessKeyId: options.accessKeyId,
23
23
  secretAccessKey: options.secretAccessKey
@@ -85,6 +85,21 @@ function createS3Storage(options) {
85
85
  Key: objectKey
86
86
  }), { expiresIn: expirySeconds });
87
87
  },
88
+ /**
89
+ * 预签名**上传** URL(PUT):浏览器拿到后直传对象存储,字节不过 App。
90
+ * 这是大文件的正解——绕开 App 网关的 body 上限(qw-pages 实测 ≥1MB 413),
91
+ * 也避免 DB-backed 分块的开销。前端用 @homurajs/storage/chunk-client 的 uploadViaPresign 对接。
92
+ * ⚠ 纯本地 SigV4 计算,不联网;但浏览器直传需对象存储侧配好 CORS(允许 Pages 域名的 PUT)。
93
+ */
94
+ async getSignedUploadUrl(objectKey, contentType, expirySeconds) {
95
+ assertSafeObjectKey(objectKey);
96
+ const { client, deps } = await ensure();
97
+ return deps.getSignedUrl(client, new deps.PutObjectCommand({
98
+ Bucket: options.bucket,
99
+ Key: objectKey,
100
+ ContentType: contentType
101
+ }), { expiresIn: expirySeconds });
102
+ },
88
103
  async deleteFile(objectKey) {
89
104
  const { client, deps } = await ensure();
90
105
  await client.send(new deps.DeleteObjectCommand({
@@ -8,4 +8,25 @@ export interface SupabaseStorageOptions {
8
8
  fetchImpl?: typeof fetch;
9
9
  }
10
10
  export declare function createSupabaseStorage(options: SupabaseStorageOptions): FileStorage;
11
+ export interface SupabaseStorageEnv {
12
+ /** 平台 URL(已去尾斜杠),如 https://supabase.<平台域> */
13
+ platformUrl: string;
14
+ /** anon key(public bucket 足够;private bucket 需 service role key,而平台不下发) */
15
+ apiKey: string;
16
+ }
17
+ /**
18
+ * 从 env 解析 supabase storage 所需的最小集:平台 URL + anon key。
19
+ *
20
+ * 与 @homurajs/db/pg 的 loadSupabaseEnv(PG 直连,需 PROJECT_REF + DB_PASSWORD)**刻意分离**:
21
+ * 2026-10-13 实测 qw-pages 只注入 SUPABASE_URL / SUPABASE_ANON_KEY / SUPABASE_INSTANCE_ID,
22
+ * 既无 project ref 也无 DB 密码,且平台没有自定义 env 注入口 → PG 直连在 Pages 上不可用。
23
+ * 旧实现让 storage 复用 PG 的 loader,导致「只想要对象存储」也被 PG 凭据缺失一起拖死。
24
+ *
25
+ * ⚠ Pages 上的硬限制(均为实测):
26
+ * - anon 无建桶权:POST /storage/v1/bucket → 400(内层 403 "new row violates row-level security policy")
27
+ * - 受控迁移角色对 storage schema 也无权:INSERT INTO storage.buckets → 42501 permission denied
28
+ * → **bucket 必须预先存在**,否则 upload/download 一律 "Bucket not found"。
29
+ * - GET /storage/v1/bucket 可达(200,实测返回空数组),说明端点与鉴权本身没问题。
30
+ */
31
+ export declare function loadSupabaseStorageEnv(env: NodeJS.ProcessEnv): SupabaseStorageEnv;
11
32
  //#endregion
package/dist/supabase.js CHANGED
@@ -105,5 +105,29 @@ function toBytes(data) {
105
105
  if (data instanceof Uint8Array) return data;
106
106
  throw new Error("supabase 后端不支持流式载荷:请先聚合为 Uint8Array");
107
107
  }
108
+ /**
109
+ * 从 env 解析 supabase storage 所需的最小集:平台 URL + anon key。
110
+ *
111
+ * 与 @homurajs/db/pg 的 loadSupabaseEnv(PG 直连,需 PROJECT_REF + DB_PASSWORD)**刻意分离**:
112
+ * 2026-10-13 实测 qw-pages 只注入 SUPABASE_URL / SUPABASE_ANON_KEY / SUPABASE_INSTANCE_ID,
113
+ * 既无 project ref 也无 DB 密码,且平台没有自定义 env 注入口 → PG 直连在 Pages 上不可用。
114
+ * 旧实现让 storage 复用 PG 的 loader,导致「只想要对象存储」也被 PG 凭据缺失一起拖死。
115
+ *
116
+ * ⚠ Pages 上的硬限制(均为实测):
117
+ * - anon 无建桶权:POST /storage/v1/bucket → 400(内层 403 "new row violates row-level security policy")
118
+ * - 受控迁移角色对 storage schema 也无权:INSERT INTO storage.buckets → 42501 permission denied
119
+ * → **bucket 必须预先存在**,否则 upload/download 一律 "Bucket not found"。
120
+ * - GET /storage/v1/bucket 可达(200,实测返回空数组),说明端点与鉴权本身没问题。
121
+ */
122
+ function loadSupabaseStorageEnv(env) {
123
+ const platformUrl = (env["SUPABASE_URL"] ?? env["SUPABASE_PLATFORM_URL"] ?? "").replace(/\/$/, "");
124
+ const apiKey = env["SUPABASE_ANON_KEY"] ?? "";
125
+ const missing = [!platformUrl ? "SUPABASE_URL" : null, !apiKey ? "SUPABASE_ANON_KEY" : null].filter((x) => x !== null);
126
+ if (missing.length > 0) throw new Error(`supabase storage 环境不完整:缺少 ${missing.join(" 与 ")}。(qw-pages 会自动注入这两项;独立部署需自行设置。SUPABASE_PROJECT_REF / SUPABASE_DB_PASSWORD 是 PG 直连用的,Pages 不下发,storage 也不需要)`);
127
+ return {
128
+ platformUrl,
129
+ apiKey
130
+ };
131
+ }
108
132
  //#endregion
109
- export { createSupabaseStorage };
133
+ export { createSupabaseStorage, loadSupabaseStorageEnv };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homurajs/storage",
3
- "version": "26.10.13",
3
+ "version": "26.10.15",
4
4
  "type": "module",
5
5
  "description": "homura 对象存储/图床适配:supabase-storage / s3 兼容(minio/oss) / local-fs 三后端 + 代理路由",
6
6
  "engines": {
@@ -26,6 +26,18 @@
26
26
  "types": "./dist/supabase.d.ts",
27
27
  "import": "./dist/supabase.js"
28
28
  },
29
+ "./db": {
30
+ "types": "./dist/db.d.ts",
31
+ "import": "./dist/db.js"
32
+ },
33
+ "./tables": {
34
+ "types": "./dist/tables.d.ts",
35
+ "import": "./dist/tables.js"
36
+ },
37
+ "./chunk-client": {
38
+ "types": "./dist/chunk-client.d.ts",
39
+ "import": "./dist/chunk-client.js"
40
+ },
29
41
  "./proxy": {
30
42
  "types": "./dist/proxy.d.ts",
31
43
  "import": "./dist/proxy.js"
@@ -36,11 +48,13 @@
36
48
  "registry": "https://registry.npmjs.org/"
37
49
  },
38
50
  "dependencies": {
39
- "@homurajs/core": "26.10.13"
51
+ "@homurajs/core": "26.10.15",
52
+ "@homurajs/db": "26.10.15"
40
53
  },
41
54
  "peerDependencies": {
42
55
  "@aws-sdk/client-s3": ">=3.0.0",
43
56
  "@aws-sdk/s3-request-presigner": ">=3.0.0",
57
+ "drizzle-orm": "1.0.0-rc.4",
44
58
  "hono": ">=4.0.0"
45
59
  },
46
60
  "peerDependenciesMeta": {
@@ -50,6 +64,9 @@
50
64
  "@aws-sdk/s3-request-presigner": {
51
65
  "optional": true
52
66
  },
67
+ "drizzle-orm": {
68
+ "optional": true
69
+ },
53
70
  "hono": {
54
71
  "optional": true
55
72
  }
@@ -57,6 +74,7 @@
57
74
  "devDependencies": {
58
75
  "@aws-sdk/client-s3": "^3.700.0",
59
76
  "@aws-sdk/s3-request-presigner": "^3.700.0",
77
+ "drizzle-orm": "1.0.0-rc.4",
60
78
  "hono": "^4.10.0",
61
79
  "tsdown": "^0.23.1"
62
80
  },