@coralai/sps-plugin-storage-sync 0.1.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/dist/FakeStorage.d.ts +15 -0
- package/dist/FakeStorage.js +36 -0
- package/dist/MinioBackend.d.ts +40 -0
- package/dist/MinioBackend.js +84 -0
- package/dist/StorageBackend.d.ts +51 -0
- package/dist/StorageBackend.js +28 -0
- package/dist/config.d.ts +36 -0
- package/dist/config.js +11 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +69 -0
- package/dist/keys.d.ts +32 -0
- package/dist/keys.js +51 -0
- package/dist/scanWorkspace.d.ts +55 -0
- package/dist/scanWorkspace.js +204 -0
- package/dist/schema.d.ts +37 -0
- package/dist/schema.js +32 -0
- package/dist/syncProject.d.ts +66 -0
- package/dist/syncProject.js +135 -0
- package/dist/syncRunner.d.ts +82 -0
- package/dist/syncRunner.js +117 -0
- package/dist/syncable.d.ts +46 -0
- package/dist/syncable.js +83 -0
- package/package.json +45 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type StorageBackend, type StorageObject } from './StorageBackend.js';
|
|
2
|
+
export declare class FakeStorage implements StorageBackend {
|
|
3
|
+
readonly objects: Map<string, {
|
|
4
|
+
bytes: Buffer;
|
|
5
|
+
contentType?: string;
|
|
6
|
+
etag: string;
|
|
7
|
+
}>;
|
|
8
|
+
putCalls: string[];
|
|
9
|
+
/** 下一次 put 到这个 key 时抛错(模拟上传失败)。 */
|
|
10
|
+
failNext: string | null;
|
|
11
|
+
put(key: string, bytes: Buffer, contentType?: string): Promise<void>;
|
|
12
|
+
list(prefix: string): Promise<StorageObject[]>;
|
|
13
|
+
/** 没有这个 key ⇒ `null`(与真后端同语义:不抛)。 */
|
|
14
|
+
get(key: string): Promise<Buffer | null>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module providers/storage/FakeStorage
|
|
3
|
+
* @description `StorageBackend` 的内存假实现。**它的存在本身就是"接口中性"的证据** ——
|
|
4
|
+
* 一个不认识 minio 的实现能满足整个接口,说明上层没有漏抽象。
|
|
5
|
+
*
|
|
6
|
+
* @layer providers
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ 放在 `.test.ts` 之外是因为 lint 禁止从测试文件导出(`noExportsInTest`),
|
|
9
|
+
* 而它被两个用例文件共用。**共用的测试替身要有自己的落点**,复制两份才是问题。
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { StorageUnavailableError } from './StorageBackend.js';
|
|
13
|
+
export class FakeStorage {
|
|
14
|
+
objects = new Map();
|
|
15
|
+
putCalls = [];
|
|
16
|
+
/** 下一次 put 到这个 key 时抛错(模拟上传失败)。 */
|
|
17
|
+
failNext = null;
|
|
18
|
+
async put(key, bytes, contentType) {
|
|
19
|
+
if (this.failNext === key) {
|
|
20
|
+
this.failNext = null;
|
|
21
|
+
throw new StorageUnavailableError(`假的上传失败:${key}`);
|
|
22
|
+
}
|
|
23
|
+
this.putCalls.push(key);
|
|
24
|
+
// etag 用 md5:与单段上传的真实语义一致,否则用例会掩盖"分块上传 etag 不是 md5"那个坑
|
|
25
|
+
this.objects.set(key, { bytes, contentType, etag: createHash('md5').update(bytes).digest('hex') });
|
|
26
|
+
}
|
|
27
|
+
async list(prefix) {
|
|
28
|
+
return [...this.objects.entries()]
|
|
29
|
+
.filter(([k]) => k.startsWith(prefix))
|
|
30
|
+
.map(([key, v]) => ({ key, etag: v.etag, size: v.bytes.length }));
|
|
31
|
+
}
|
|
32
|
+
/** 没有这个 key ⇒ `null`(与真后端同语义:不抛)。 */
|
|
33
|
+
async get(key) {
|
|
34
|
+
return this.objects.get(key)?.bytes ?? null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module providers/storage/MinioBackend
|
|
3
|
+
* @description `StorageBackend` 的第一个实现:S3 兼容(MinIO)。
|
|
4
|
+
*
|
|
5
|
+
* @layer providers
|
|
6
|
+
*
|
|
7
|
+
* 🔴 **依赖走 `optionalDependencies` + 动态 import。**
|
|
8
|
+
* `@coralai/sps-cli` 是公共 npm 包,别人装它不需要对象存储。
|
|
9
|
+
* 没装 ⇒ 抛 `StorageUnavailableError` 并**指名说"未安装"**,
|
|
10
|
+
* **绝不静默降级**成"这轮没东西同步" —— 那会让"功能没装"伪装成"没有变化"。
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ 这里是**唯一**允许出现 minio 类型的文件。上层只认 `StorageBackend`。
|
|
13
|
+
* 自检:换掉本文件,上层用例应当全绿(`storageBackend.test.ts` 就是拿假实现证明这一点的)。
|
|
14
|
+
*/
|
|
15
|
+
import type { StorageBackend, StorageObject } from './StorageBackend.js';
|
|
16
|
+
import type { StorageConfig } from './config.js';
|
|
17
|
+
/**
|
|
18
|
+
* `etag` 从 MinIO 回来时**带引号**(`"d41d8…"`),而本地算的 md5 不带。
|
|
19
|
+
* 不剥掉的话每一轮对账都判"变了" ⇒ **每轮重传全部文件**,而且完全不报错 ——
|
|
20
|
+
* 表现是"同步一直在工作",只是流量和耗时莫名其妙。
|
|
21
|
+
*/
|
|
22
|
+
export declare function normalizeEtag(raw: unknown): string;
|
|
23
|
+
export declare class MinioBackend implements StorageBackend {
|
|
24
|
+
private readonly cfg;
|
|
25
|
+
private client;
|
|
26
|
+
constructor(cfg: StorageConfig);
|
|
27
|
+
private ensure;
|
|
28
|
+
put(key: string, bytes: Buffer, contentType?: string): Promise<void>;
|
|
29
|
+
list(prefix: string): Promise<StorageObject[]>;
|
|
30
|
+
/**
|
|
31
|
+
* 读一个对象。
|
|
32
|
+
*
|
|
33
|
+
* 🔴 **"没有这个 key" 与 "读不了" 必须分开**:
|
|
34
|
+
* 前者返回 `null`(调用方据此走兜底),后者抛 `StorageUnavailableError`。
|
|
35
|
+
* 混成一个的表现是"库是空的" —— 而真相可能是**权限不够**,
|
|
36
|
+
* 那会让人去补素材而不是去补策略。
|
|
37
|
+
* MinIO 的 NoSuchKey 走 code,不看消息文本(消息会随版本变)。
|
|
38
|
+
*/
|
|
39
|
+
get(key: string): Promise<Buffer | null>;
|
|
40
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { StorageUnavailableError } from './StorageBackend.js';
|
|
2
|
+
/**
|
|
3
|
+
* `etag` 从 MinIO 回来时**带引号**(`"d41d8…"`),而本地算的 md5 不带。
|
|
4
|
+
* 不剥掉的话每一轮对账都判"变了" ⇒ **每轮重传全部文件**,而且完全不报错 ——
|
|
5
|
+
* 表现是"同步一直在工作",只是流量和耗时莫名其妙。
|
|
6
|
+
*/
|
|
7
|
+
export function normalizeEtag(raw) {
|
|
8
|
+
return String(raw ?? '').replace(/^"|"$/g, '');
|
|
9
|
+
}
|
|
10
|
+
export class MinioBackend {
|
|
11
|
+
cfg;
|
|
12
|
+
client = null;
|
|
13
|
+
constructor(cfg) {
|
|
14
|
+
this.cfg = cfg;
|
|
15
|
+
}
|
|
16
|
+
async ensure() {
|
|
17
|
+
if (this.client)
|
|
18
|
+
return this.client;
|
|
19
|
+
let mod;
|
|
20
|
+
try {
|
|
21
|
+
mod = (await import('minio'));
|
|
22
|
+
}
|
|
23
|
+
catch (cause) {
|
|
24
|
+
throw new StorageUnavailableError("对象存储后端未安装:缺可选依赖 'minio'。安装它(npm i minio)或关掉 ~/.coral/storage.json 的 enabled。", cause);
|
|
25
|
+
}
|
|
26
|
+
const u = new URL(this.cfg.endpoint);
|
|
27
|
+
this.client = new mod.Client({
|
|
28
|
+
endPoint: u.hostname,
|
|
29
|
+
port: u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80,
|
|
30
|
+
useSSL: u.protocol === 'https:',
|
|
31
|
+
accessKey: this.cfg.accessKey,
|
|
32
|
+
secretKey: this.cfg.secretKey,
|
|
33
|
+
region: this.cfg.region ?? 'us-east-1',
|
|
34
|
+
pathStyle: this.cfg.pathStyle ?? true,
|
|
35
|
+
});
|
|
36
|
+
return this.client;
|
|
37
|
+
}
|
|
38
|
+
async put(key, bytes, contentType) {
|
|
39
|
+
const c = await this.ensure();
|
|
40
|
+
// 显式给 size:不给的话客户端会走分块流式上传,而**分块上传的 etag 不是 md5**
|
|
41
|
+
// ⇒ 对账判据(etag == 本地 md5)当场失效,且失效方式是"永远判为不同"。
|
|
42
|
+
await c.putObject(this.cfg.bucket, key, bytes, bytes.length, contentType ? { 'Content-Type': contentType } : undefined);
|
|
43
|
+
}
|
|
44
|
+
async list(prefix) {
|
|
45
|
+
const c = await this.ensure();
|
|
46
|
+
const stream = c.listObjectsV2(this.cfg.bucket, prefix, true);
|
|
47
|
+
return await new Promise((resolve, reject) => {
|
|
48
|
+
const out = [];
|
|
49
|
+
stream.on('data', (o) => {
|
|
50
|
+
// 目录占位项没有 name/etag(MinIO 会回"前缀"条目);跳过它们,别当成对象。
|
|
51
|
+
if (!o.name)
|
|
52
|
+
return;
|
|
53
|
+
out.push({ key: o.name, etag: normalizeEtag(o.etag), size: o.size ?? 0 });
|
|
54
|
+
});
|
|
55
|
+
stream.on('end', () => resolve(out));
|
|
56
|
+
stream.on('error', (e) => reject(new StorageUnavailableError(`列举失败:${prefix}`, e)));
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 读一个对象。
|
|
61
|
+
*
|
|
62
|
+
* 🔴 **"没有这个 key" 与 "读不了" 必须分开**:
|
|
63
|
+
* 前者返回 `null`(调用方据此走兜底),后者抛 `StorageUnavailableError`。
|
|
64
|
+
* 混成一个的表现是"库是空的" —— 而真相可能是**权限不够**,
|
|
65
|
+
* 那会让人去补素材而不是去补策略。
|
|
66
|
+
* MinIO 的 NoSuchKey 走 code,不看消息文本(消息会随版本变)。
|
|
67
|
+
*/
|
|
68
|
+
async get(key) {
|
|
69
|
+
const c = await this.ensure();
|
|
70
|
+
try {
|
|
71
|
+
const stream = await c.getObject(this.cfg.bucket, key);
|
|
72
|
+
const chunks = [];
|
|
73
|
+
for await (const ch of stream)
|
|
74
|
+
chunks.push(ch);
|
|
75
|
+
return Buffer.concat(chunks);
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
const code = e.code;
|
|
79
|
+
if (code === 'NoSuchKey' || code === 'NotFound')
|
|
80
|
+
return null;
|
|
81
|
+
throw new StorageUnavailableError(`读取失败:${key}`, e);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module providers/storage/StorageBackend
|
|
3
|
+
* @description 对象存储的**中性接口**。换一个后端(MinIO / S3 / WebDAV / 别的 HTTP 端点),
|
|
4
|
+
* 上层几乎不用改 —— 与 `AgentBackend` / `RepoBackend` / `MediaProvider` 同一条自检判据。
|
|
5
|
+
*
|
|
6
|
+
* @layer providers
|
|
7
|
+
*
|
|
8
|
+
* 🔴 **接口里不出现任何一家存储的类型或词汇。** 这是"独立性"的全部来源:
|
|
9
|
+
* 独立性来自**接口中性**,不来自代码住在哪个包里。
|
|
10
|
+
* (§2.1:插件 ≠ 解耦。解耦是接口的性质,插件是分发的性质。)
|
|
11
|
+
*
|
|
12
|
+
* 🔴 **只有 put 与 list 两个动作,而且没有 delete。**
|
|
13
|
+
* · 没有 delete 是**有意的**:同步这条路的不变量是「**不删**」——
|
|
14
|
+
* 本地文件没了不等于用户要删桶里那份(工作区可能被重建、节点可能换了)。
|
|
15
|
+
* 接口上不给这个动作,比在实现里"记得别调"可靠。
|
|
16
|
+
* (同族:授权中心也**不该有 DeleteObject**,理由是 artifacts 未版本化 ⇒ 误删不可恢复。)
|
|
17
|
+
* · 没有 `stat` 是因为对账用 `list` 一次拿全前缀更省往返,而且**对账基准必须是桶本身**;
|
|
18
|
+
* 逐个 stat 会诱使实现方去缓存"上次看到的",那正是要避免的形态。
|
|
19
|
+
*/
|
|
20
|
+
/** 桶里一个对象的对账信息。**etag 是内容判据**(单段上传时等于 md5),不看时间。 */
|
|
21
|
+
export interface StorageObject {
|
|
22
|
+
key: string;
|
|
23
|
+
etag: string;
|
|
24
|
+
size: number;
|
|
25
|
+
}
|
|
26
|
+
export interface StorageBackend {
|
|
27
|
+
/**
|
|
28
|
+
* 写一个对象(覆盖同 key)。
|
|
29
|
+
*
|
|
30
|
+
* 🔴 `contentType` 不是可选的装饰:桶是**直连**的,没有一层服务器可以在返回时补救 ——
|
|
31
|
+
* 缺省的 `application/octet-stream` 会让预览图在浏览器里变成一次下载。
|
|
32
|
+
* 所以推的时候就得写对;拿不准时**由调用方决定**,后端不猜。
|
|
33
|
+
*/
|
|
34
|
+
put(key: string, bytes: Buffer, contentType?: string): Promise<void>;
|
|
35
|
+
/** 列一个前缀下的全部对象(递归)。前缀为空即整桶 —— 调用方自己收窄。 */
|
|
36
|
+
list(prefix: string): Promise<StorageObject[]>;
|
|
37
|
+
/**
|
|
38
|
+
* 读一个对象。
|
|
39
|
+
*
|
|
40
|
+
* 🔴 **不存在返回 `null`,不抛** —— "有没有"是正常的问题
|
|
41
|
+
* (占位兜底就是先问"库里有没有")。而**连不上/没权限要抛**
|
|
42
|
+
* `StorageUnavailableError`:那两件事和"这个 key 没有"完全不同,
|
|
43
|
+
* 混在一起的表现是"库是空的",而真相是这个 key 读不了。
|
|
44
|
+
*/
|
|
45
|
+
get(key: string): Promise<Buffer | null>;
|
|
46
|
+
}
|
|
47
|
+
/** 后端不可用时抛这个,**不要静默降级**成"这轮没东西同步"。 */
|
|
48
|
+
export declare class StorageUnavailableError extends Error {
|
|
49
|
+
readonly cause?: unknown | undefined;
|
|
50
|
+
constructor(message: string, cause?: unknown | undefined);
|
|
51
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module providers/storage/StorageBackend
|
|
3
|
+
* @description 对象存储的**中性接口**。换一个后端(MinIO / S3 / WebDAV / 别的 HTTP 端点),
|
|
4
|
+
* 上层几乎不用改 —— 与 `AgentBackend` / `RepoBackend` / `MediaProvider` 同一条自检判据。
|
|
5
|
+
*
|
|
6
|
+
* @layer providers
|
|
7
|
+
*
|
|
8
|
+
* 🔴 **接口里不出现任何一家存储的类型或词汇。** 这是"独立性"的全部来源:
|
|
9
|
+
* 独立性来自**接口中性**,不来自代码住在哪个包里。
|
|
10
|
+
* (§2.1:插件 ≠ 解耦。解耦是接口的性质,插件是分发的性质。)
|
|
11
|
+
*
|
|
12
|
+
* 🔴 **只有 put 与 list 两个动作,而且没有 delete。**
|
|
13
|
+
* · 没有 delete 是**有意的**:同步这条路的不变量是「**不删**」——
|
|
14
|
+
* 本地文件没了不等于用户要删桶里那份(工作区可能被重建、节点可能换了)。
|
|
15
|
+
* 接口上不给这个动作,比在实现里"记得别调"可靠。
|
|
16
|
+
* (同族:授权中心也**不该有 DeleteObject**,理由是 artifacts 未版本化 ⇒ 误删不可恢复。)
|
|
17
|
+
* · 没有 `stat` 是因为对账用 `list` 一次拿全前缀更省往返,而且**对账基准必须是桶本身**;
|
|
18
|
+
* 逐个 stat 会诱使实现方去缓存"上次看到的",那正是要避免的形态。
|
|
19
|
+
*/
|
|
20
|
+
/** 后端不可用时抛这个,**不要静默降级**成"这轮没东西同步"。 */
|
|
21
|
+
export class StorageUnavailableError extends Error {
|
|
22
|
+
cause;
|
|
23
|
+
constructor(message, cause) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.cause = cause;
|
|
26
|
+
this.name = 'StorageUnavailableError';
|
|
27
|
+
}
|
|
28
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module config
|
|
3
|
+
* @description 本插件的配置**形状**。值来自装配档那一行的 `config:`,
|
|
4
|
+
* 由宿主的插件页渲染表单、写进 `~/.coral/assembly/base.yml`(0600)。
|
|
5
|
+
*
|
|
6
|
+
* 🔴 **不读任何配置文件。** 拆插件前这些值分散在 `~/.coral/storage.json`
|
|
7
|
+
* 与 `~/.coral/sync-scope.json` 两处;现在合并成一份装配档 config ——
|
|
8
|
+
* 好处不是"少一个文件",是**卸载插件时配置跟着一起没**
|
|
9
|
+
* (`plugin remove` 删整行),而独立文件会留下孤儿密钥。
|
|
10
|
+
*/
|
|
11
|
+
export interface StorageConfig {
|
|
12
|
+
/** S3 兼容端点,如 `http://127.0.0.1:9000`。 */
|
|
13
|
+
endpoint: string;
|
|
14
|
+
/** 桶名。sps 只认这一个名字,不认识桶里的语义分区。 */
|
|
15
|
+
bucket: string;
|
|
16
|
+
accessKey: string;
|
|
17
|
+
secretKey: string;
|
|
18
|
+
/** S3 region;MinIO 通常用 `us-east-1`,给默认值只是为了签名能算出来。 */
|
|
19
|
+
region?: string;
|
|
20
|
+
/**
|
|
21
|
+
* 路径式寻址(`http://host/bucket/key`)。MinIO 默认需要它;
|
|
22
|
+
* 虚拟主机式(`http://bucket.host/key`)要求 DNS 配合,自建部署一般没有。
|
|
23
|
+
*/
|
|
24
|
+
pathStyle?: boolean;
|
|
25
|
+
/** 🔴 默认关:缺这个字段或为 false ⇒ 同步路径完全不存在。 */
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
}
|
|
28
|
+
/** 插件在装配档里的完整配置。 */
|
|
29
|
+
export interface StorageSyncConfig extends StorageConfig {
|
|
30
|
+
/** 扫描间隔(秒),默认 600。 */
|
|
31
|
+
intervalSec?: number;
|
|
32
|
+
/** 只看近 N 天有改动的项目;0 = 全量。默认 7 —— 全量扫一遍很贵,老项目通常已同步过。 */
|
|
33
|
+
sinceDays?: number;
|
|
34
|
+
/** 单文件大小上限(字节)。超过的跳过并计数。 */
|
|
35
|
+
maxBytes?: number;
|
|
36
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module config
|
|
3
|
+
* @description 本插件的配置**形状**。值来自装配档那一行的 `config:`,
|
|
4
|
+
* 由宿主的插件页渲染表单、写进 `~/.coral/assembly/base.yml`(0600)。
|
|
5
|
+
*
|
|
6
|
+
* 🔴 **不读任何配置文件。** 拆插件前这些值分散在 `~/.coral/storage.json`
|
|
7
|
+
* 与 `~/.coral/sync-scope.json` 两处;现在合并成一份装配档 config ——
|
|
8
|
+
* 好处不是"少一个文件",是**卸载插件时配置跟着一起没**
|
|
9
|
+
* (`plugin remove` 删整行),而独立文件会留下孤儿密钥。
|
|
10
|
+
*/
|
|
11
|
+
export {};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @coralai/sps-plugin-storage-sync
|
|
3
|
+
* @description 工作区 → 对象存储同步 —— 把节点本地盘上的用户产物搬到共享面。
|
|
4
|
+
*
|
|
5
|
+
* ## 为什么是插件,不是 sps 的本体能力
|
|
6
|
+
*
|
|
7
|
+
* 🔴 **桶是平台的需求,不是 sps 的。** sps 自己跑不需要桶 —— 拆分前
|
|
8
|
+
* `sps.storage` seam 的生产消费方是 **0**,那就是证据。
|
|
9
|
+
* 多节点下"studio 读不到工作区"是 studio 的问题;sps 承接它,就是
|
|
10
|
+
* **多了一个不该有的承诺**。
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ 但同步必须跑在**有工作区的那台机器上**(物理约束)——
|
|
13
|
+
* 这正是插件的价值:代码归属可以是平台的,进程跑在 sps 里。
|
|
14
|
+
*
|
|
15
|
+
* ## 配置
|
|
16
|
+
*
|
|
17
|
+
* 全部来自装配档那一行的 `config:`(`~/.coral/assembly/base.yml`,0600)。
|
|
18
|
+
* 拆分前它分散在 `~/.coral/storage.json` 与 `~/.coral/sync-scope.json` 两处;
|
|
19
|
+
* 合并的好处不是"少一个文件",是**卸载时配置跟着一起没** —— 独立文件会留下孤儿密钥。
|
|
20
|
+
*/
|
|
21
|
+
import type { StorageSyncConfig } from './config.js';
|
|
22
|
+
export { Config } from './schema.js';
|
|
23
|
+
export declare const name = "storage-sync";
|
|
24
|
+
export declare const category = "\u5B58\u50A8";
|
|
25
|
+
export declare const description = "\u5DE5\u4F5C\u533A \u2192 \u5BF9\u8C61\u5B58\u50A8\u540C\u6B65:\u6309\u4EA7\u54C1\u58F0\u660E\u7684\u8303\u56F4,\u628A\u7528\u6237\u4EA7\u7269\u589E\u91CF\u642C\u5230 S3 \u517C\u5BB9\u6876\u3002";
|
|
26
|
+
interface ApplyCtx {
|
|
27
|
+
effect(fn: () => void): void;
|
|
28
|
+
logger?: {
|
|
29
|
+
info(msg: string): void;
|
|
30
|
+
warn(msg: string): void;
|
|
31
|
+
};
|
|
32
|
+
reflect: {
|
|
33
|
+
get(name: string): unknown;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export declare function apply(ctx: ApplyCtx, config: StorageSyncConfig): void;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { runSyncPass } from './syncRunner.js';
|
|
2
|
+
export { Config } from './schema.js';
|
|
3
|
+
export const name = 'storage-sync';
|
|
4
|
+
export const category = '存储';
|
|
5
|
+
export const description = '工作区 → 对象存储同步:按产品声明的范围,把用户产物增量搬到 S3 兼容桶。';
|
|
6
|
+
/** 默认 10 分钟一轮。 */
|
|
7
|
+
const DEFAULT_INTERVAL_MS = 10 * 60 * 1000;
|
|
8
|
+
/** 默认只看近 7 天有改动的项目 —— 全量扫一遍很贵,而老项目通常已经同步过。 */
|
|
9
|
+
const DEFAULT_SINCE_DAYS = 7;
|
|
10
|
+
const DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
|
|
11
|
+
/**
|
|
12
|
+
* 🔴 **惰性取,别在 apply 里取一次存起来。** 声明了 `inject` 的话服务缺席会让整条链
|
|
13
|
+
* PENDING;不声明就得容忍"apply 时它可能还没到"——Cordis 不保证无依赖插件的装载顺序。
|
|
14
|
+
* ⇒ 在真正要用的那一刻取。(2026-08-27 在记忆插件那轮实测踩过。)
|
|
15
|
+
*/
|
|
16
|
+
const workspaceOf = (ctx) => ctx.reflect.get('sps.workspace') ?? null;
|
|
17
|
+
export function apply(ctx, config) {
|
|
18
|
+
const log = (l) => ctx.logger?.info(l) ?? console.log(l);
|
|
19
|
+
if (!config?.endpoint || !config?.bucket) {
|
|
20
|
+
log('[sps-sync] 未配置桶(endpoint/bucket),同步不启动。');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (config.enabled === false) {
|
|
24
|
+
log('[sps-sync] 配置里关着,同步不启动。');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const intervalMs = config.intervalSec ? config.intervalSec * 1000 : DEFAULT_INTERVAL_MS;
|
|
28
|
+
const sinceDays = config.sinceDays ?? DEFAULT_SINCE_DAYS;
|
|
29
|
+
const maxBytes = config.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
30
|
+
let timer = null;
|
|
31
|
+
let stopped = false;
|
|
32
|
+
/**
|
|
33
|
+
* 🔴 **一轮没跑完不开下一轮** —— 用 `setTimeout` 自排,不用 `setInterval`。
|
|
34
|
+
* `setInterval` 在一轮变慢时会叠罗汉:两轮并发对同一个前缀列举 + 上传。
|
|
35
|
+
* (这条语义照搬自拆分前的 `daemon/storageSyncLoop`,别改回 setInterval。)
|
|
36
|
+
*/
|
|
37
|
+
const schedule = () => {
|
|
38
|
+
if (stopped)
|
|
39
|
+
return;
|
|
40
|
+
timer = setTimeout(() => {
|
|
41
|
+
void tick().finally(schedule);
|
|
42
|
+
}, intervalMs);
|
|
43
|
+
};
|
|
44
|
+
const tick = async () => {
|
|
45
|
+
try {
|
|
46
|
+
const workspace = workspaceOf(ctx);
|
|
47
|
+
if (!workspace) {
|
|
48
|
+
// 宿主没提供工作区 seam ⇒ 这台部署上没有可同步的东西,静默跳过。
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const sum = await runSyncPass({ root: workspace.root(), workspace, config, sinceDays, maxBytes, log });
|
|
52
|
+
if (sum && sum.uploaded > 0) {
|
|
53
|
+
log(`[sps-sync] 上传 ${sum.uploaded} · 跳过 ${sum.skipped} · 失败 ${sum.failed}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
// ⚠️ 同步失败**绝不能掀翻宿主** —— 它是扩展功能,不是主路径。
|
|
58
|
+
ctx.logger?.warn?.(`[sps-sync] 一轮失败(忽略): ${err instanceof Error ? err.message : String(err)}`);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
log(`[sps-sync] 常驻:间隔=${intervalMs}ms 范围=近 ${sinceDays || '∞'} 天`);
|
|
62
|
+
void tick().finally(schedule);
|
|
63
|
+
// 插件卸载/重挂时停掉循环 —— 否则改一次配置就多一个循环在跑。
|
|
64
|
+
ctx.effect(() => {
|
|
65
|
+
stopped = true;
|
|
66
|
+
if (timer)
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
});
|
|
69
|
+
}
|
package/dist/keys.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module providers/storage/keys
|
|
3
|
+
* @description 桶 key 的拼法。**与 coral `src/storage/keys.ts` 逐字对齐** —— 这是移交的对账面。
|
|
4
|
+
*
|
|
5
|
+
* @layer providers
|
|
6
|
+
*
|
|
7
|
+
* 🔴 **为什么要一个模块而不是各处拼字符串**(照抄 coral 头注里的理由,它是对的):
|
|
8
|
+
* 桶里**没有"路径不存在"这种错误**。写方把 key 拼错一个字符,PUT 照样 200,
|
|
9
|
+
* 只是那个对象从此没人读得到;读方拼错,拿到 404 却无法区分"没同步上来"和"key 写法不一致"。
|
|
10
|
+
* 文件系统会用 ENOENT 立刻告诉你,桶不会。
|
|
11
|
+
*
|
|
12
|
+
* 🔴 **移交期间这是两份实现。** 两份实现的安全做法不是"小心点",是**拿真桶内容对账**:
|
|
13
|
+
* 切换前跑一轮并行比对,逐 key 逐 etag 一致才算数(实施计划任务 5 的验收物)。
|
|
14
|
+
* > 判据:一条安全/正确性规则**做对过一次,不代表它在第二条路上也在**。
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ 这里只有**同步**这条路用得到的三个(assetKey / assetPrefix / thumbKey)。
|
|
17
|
+
* `published/` 与 `library/` 是别的路的写方,**不在本模块** —— 免得看起来像"sps 管着整个桶布局"。
|
|
18
|
+
*/
|
|
19
|
+
/** `./x` → `x`;去掉前导 `/`;`..` 一律抛(围栏在 `syncable`,这里是最后一道形状校验)。 */
|
|
20
|
+
export declare function normalizeRel(rel: string): string;
|
|
21
|
+
/** 项目在共享树下的相对路径(`t<租户>/<产品>/<项目>`),去掉尾部斜杠。 */
|
|
22
|
+
export declare function normalizeProject(projectPath: string): string;
|
|
23
|
+
export declare function assetKey(projectPath: string, rel: string): string;
|
|
24
|
+
/** 某项目全部资源的前缀(对账列举用)。 */
|
|
25
|
+
export declare function assetPrefix(projectPath: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* 缩略图:与原图同一棵树下的 `.thumbs/`,**后缀直接追加而不是替换**。
|
|
28
|
+
*
|
|
29
|
+
* 追加(`hero.png.webp`)而不是替换(`hero.webp`)是有意的:替换会让
|
|
30
|
+
* `hero.png` 与 `hero.jpg` 撞成同一个缩略图 key,而撞了不报错 —— 后写的赢,预览图随机。
|
|
31
|
+
*/
|
|
32
|
+
export declare function thumbKey(projectPath: string, rel: string): string;
|
package/dist/keys.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module providers/storage/keys
|
|
3
|
+
* @description 桶 key 的拼法。**与 coral `src/storage/keys.ts` 逐字对齐** —— 这是移交的对账面。
|
|
4
|
+
*
|
|
5
|
+
* @layer providers
|
|
6
|
+
*
|
|
7
|
+
* 🔴 **为什么要一个模块而不是各处拼字符串**(照抄 coral 头注里的理由,它是对的):
|
|
8
|
+
* 桶里**没有"路径不存在"这种错误**。写方把 key 拼错一个字符,PUT 照样 200,
|
|
9
|
+
* 只是那个对象从此没人读得到;读方拼错,拿到 404 却无法区分"没同步上来"和"key 写法不一致"。
|
|
10
|
+
* 文件系统会用 ENOENT 立刻告诉你,桶不会。
|
|
11
|
+
*
|
|
12
|
+
* 🔴 **移交期间这是两份实现。** 两份实现的安全做法不是"小心点",是**拿真桶内容对账**:
|
|
13
|
+
* 切换前跑一轮并行比对,逐 key 逐 etag 一致才算数(实施计划任务 5 的验收物)。
|
|
14
|
+
* > 判据:一条安全/正确性规则**做对过一次,不代表它在第二条路上也在**。
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ 这里只有**同步**这条路用得到的三个(assetKey / assetPrefix / thumbKey)。
|
|
17
|
+
* `published/` 与 `library/` 是别的路的写方,**不在本模块** —— 免得看起来像"sps 管着整个桶布局"。
|
|
18
|
+
*/
|
|
19
|
+
/** `./x` → `x`;去掉前导 `/`;`..` 一律抛(围栏在 `syncable`,这里是最后一道形状校验)。 */
|
|
20
|
+
export function normalizeRel(rel) {
|
|
21
|
+
const clean = rel.replace(/^\.\//, '').replace(/^\/+/, '');
|
|
22
|
+
if (!clean)
|
|
23
|
+
throw new Error('相对路径为空');
|
|
24
|
+
if (clean.split('/').includes('..'))
|
|
25
|
+
throw new Error(`相对路径含 ..:${rel}`);
|
|
26
|
+
return clean;
|
|
27
|
+
}
|
|
28
|
+
/** 项目在共享树下的相对路径(`t<租户>/<产品>/<项目>`),去掉尾部斜杠。 */
|
|
29
|
+
export function normalizeProject(projectPath) {
|
|
30
|
+
return normalizeRel(projectPath).replace(/\/+$/, '');
|
|
31
|
+
}
|
|
32
|
+
export function assetKey(projectPath, rel) {
|
|
33
|
+
return `assets/${normalizeProject(projectPath)}/${normalizeRel(rel)}`;
|
|
34
|
+
}
|
|
35
|
+
/** 某项目全部资源的前缀(对账列举用)。 */
|
|
36
|
+
export function assetPrefix(projectPath) {
|
|
37
|
+
return `assets/${normalizeProject(projectPath)}/`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* 缩略图:与原图同一棵树下的 `.thumbs/`,**后缀直接追加而不是替换**。
|
|
41
|
+
*
|
|
42
|
+
* 追加(`hero.png.webp`)而不是替换(`hero.webp`)是有意的:替换会让
|
|
43
|
+
* `hero.png` 与 `hero.jpg` 撞成同一个缩略图 key,而撞了不报错 —— 后写的赢,预览图随机。
|
|
44
|
+
*/
|
|
45
|
+
export function thumbKey(projectPath, rel) {
|
|
46
|
+
const clean = normalizeRel(rel);
|
|
47
|
+
const i = clean.lastIndexOf('/');
|
|
48
|
+
const dir = i < 0 ? '' : `${clean.slice(0, i)}/`;
|
|
49
|
+
const base = i < 0 ? clean : clean.slice(i + 1);
|
|
50
|
+
return `assets/${normalizeProject(projectPath)}/.thumbs/${dir}${base}.webp`;
|
|
51
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export interface ProjectDir {
|
|
2
|
+
/** 相对共享根:`t<租户>/<产品>/<项目>`。**这就是 key 里的 projectPath。** */
|
|
3
|
+
rel: string;
|
|
4
|
+
abs: string;
|
|
5
|
+
/** 布局第二段。声明按它取。 */
|
|
6
|
+
product: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ScannedFile {
|
|
9
|
+
/** 相对项目根。 */
|
|
10
|
+
rel: string;
|
|
11
|
+
abs: string;
|
|
12
|
+
size: number;
|
|
13
|
+
/** 内容判据。与桶的 etag 比 —— **不看 mtime**。 */
|
|
14
|
+
md5: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ScanResult {
|
|
17
|
+
files: ScannedFile[];
|
|
18
|
+
/**
|
|
19
|
+
* 声明里有、但盘上不存在的路径。
|
|
20
|
+
* 🔴 **与 `unreadable` 分开**:第一轮上线时 61 个 coralplatform 项目被报成"权限读不到",
|
|
21
|
+
* 实际是**目录不存在**(`ENOENT`)—— 而 `unreadable` 的语义是"基线 0,非 0 即告警",
|
|
22
|
+
* 于是它每一轮都会假报一次。**别让一个字段同时表示两件事**(今天第三次踩)。
|
|
23
|
+
*/
|
|
24
|
+
missing: string[];
|
|
25
|
+
/**
|
|
26
|
+
* 枚举时跳过的点开头条目(`.sprites.pre-orient.png` 这类中间产物)。
|
|
27
|
+
* 🔴 它们**不该走安全下限的"整趟拒绝"**:那条是给"声明里点名了不该给的东西"用的。
|
|
28
|
+
* 中间产物触发整趟拒绝 ⇒ 一个项目会**永久停止同步**,而且理由看起来像安全事件。
|
|
29
|
+
*/
|
|
30
|
+
skippedDot: string[];
|
|
31
|
+
/**
|
|
32
|
+
* 因权限读不到而跳过的相对路径。
|
|
33
|
+
* 🔴 **基线是 0 ⇒ 非 0 即变化**,是告警不是趋势:它既可能是"防线在生效"(0600 凭据读不到),
|
|
34
|
+
* 也可能是"误伤了用户数据"。两种含义都不该无声发生。
|
|
35
|
+
*/
|
|
36
|
+
unreadable: string[];
|
|
37
|
+
}
|
|
38
|
+
/** 发现共享根下的全部项目(三段布局)。不做任何内容判断。 */
|
|
39
|
+
export declare function discoverProjects(root: string): Promise<ProjectDir[]>;
|
|
40
|
+
/**
|
|
41
|
+
* 声明范围内最近一次改动的时间。
|
|
42
|
+
*
|
|
43
|
+
* @returns 毫秒时间戳;**`null` = 范围内一个路径都不存在**(与"存在但很旧"是两件事)
|
|
44
|
+
*/
|
|
45
|
+
export declare function scopeLastModified(projectAbs: string, include: string[]): Promise<number | null>;
|
|
46
|
+
/**
|
|
47
|
+
* 按声明枚举文件并算 md5。
|
|
48
|
+
*
|
|
49
|
+
* ⚠️ `maxBytes`:整份读进内存算 md5(与 coral 同做法 —— 上传时本来也要这份字节)。
|
|
50
|
+
* 上限**显式**给,不靠运气:树里有 172MB 的项目,一个失控的大文件不该把进程带走。
|
|
51
|
+
* 超限的算**失败**(计入 errors),不是静默跳过 —— 静默跳过会让"这个资源永远不在桶里"没人知道。
|
|
52
|
+
*/
|
|
53
|
+
export declare function scanByScope(projectAbs: string, include: string[], maxBytes: number): Promise<ScanResult & {
|
|
54
|
+
oversized: string[];
|
|
55
|
+
}>;
|