Skip to content

Commit 512a62b

Browse files
committed
code update
1 parent 96ffb46 commit 512a62b

8 files changed

Lines changed: 187 additions & 15 deletions

File tree

packages/filesystem/baidu/baidu.test.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,57 @@ describe("BaiduFileSystem", () => {
3333
expect(updateDynamicRulesMock).not.toHaveBeenCalled();
3434
});
3535

36-
it("create should reject conditional write options as unsupported", async () => {
36+
it("create should reject expectedVersion as unsupported", async () => {
3737
const fs = new BaiduFileSystem("/apps", "token");
3838

39-
await expect(fs.create("test.txt", { expectedDigest: "md5" })).rejects.toMatchObject({
39+
await expect(fs.create("test.txt", { expectedVersion: "version" })).rejects.toMatchObject({
4040
provider: "baidu",
4141
unsupported: true,
4242
});
4343
});
4444

45+
it("writer should reject createOnly when target already exists", async () => {
46+
const fs = new BaiduFileSystem("/apps", "token");
47+
vi.spyOn(fs, "list").mockResolvedValue([
48+
{
49+
name: "test.txt",
50+
path: "/apps",
51+
size: 1,
52+
digest: "md5",
53+
createtime: 1,
54+
updatetime: 1,
55+
},
56+
]);
57+
58+
const writer = await fs.create("test.txt", { createOnly: true });
59+
60+
await expect(writer.write("content")).rejects.toMatchObject({
61+
provider: "baidu",
62+
conflict: true,
63+
});
64+
});
65+
66+
it("writer should reject expectedDigest when remote digest changed", async () => {
67+
const fs = new BaiduFileSystem("/apps", "token");
68+
vi.spyOn(fs, "list").mockResolvedValue([
69+
{
70+
name: "test.txt",
71+
path: "/apps",
72+
size: 1,
73+
digest: "new-md5",
74+
createtime: 1,
75+
updatetime: 1,
76+
},
77+
]);
78+
79+
const writer = await fs.create("test.txt", { expectedDigest: "old-md5" });
80+
81+
await expect(writer.write("content")).rejects.toMatchObject({
82+
provider: "baidu",
83+
conflict: true,
84+
});
85+
});
86+
4587
it("delete should be idempotent when Baidu reports file missing", async () => {
4688
const fetchMock = vi.fn().mockResolvedValue({
4789
json: async () => ({ errno: -9 }),

packages/filesystem/baidu/baidu.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,15 @@ export default class BaiduFileSystem implements FileSystem {
3131
}
3232

3333
async create(path: string, opts?: FileCreateOptions): Promise<FileWriter> {
34-
if (opts?.expectedDigest || opts?.expectedVersion || opts?.createOnly || opts?.overwrite === false) {
34+
if (opts?.expectedVersion) {
3535
throw new FileSystemError({
3636
provider: "baidu",
37-
message: "Baidu filesystem does not support conditional writes",
37+
message: "Baidu filesystem does not expose a version token for conditional writes",
3838
code: "unsupported_conditional_write",
3939
unsupported: true,
4040
});
4141
}
42-
return new BaiduFileWriter(this, joinPath(this.path, path));
42+
return new BaiduFileWriter(this, joinPath(this.path, path), opts);
4343
}
4444

4545
async createDir(dir: string, _opts?: FileCreateOptions): Promise<void> {

packages/filesystem/baidu/rw.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { FileInfo, FileReader, FileWriter } from "../filesystem";
1+
import { FileSystemError } from "../error";
2+
import type { FileCreateOptions, FileInfo, FileReader, FileWriter } from "../filesystem";
23
import { calculateMd5, md5OfText } from "@App/pkg/utils/crypto";
34
import type BaiduFileSystem from "./baidu";
45

@@ -38,9 +39,12 @@ export class BaiduFileWriter implements FileWriter {
3839

3940
fs: BaiduFileSystem;
4041

41-
constructor(fs: BaiduFileSystem, path: string) {
42+
opts?: FileCreateOptions;
43+
44+
constructor(fs: BaiduFileSystem, path: string, opts?: FileCreateOptions) {
4245
this.fs = fs;
4346
this.path = path;
47+
this.opts = opts;
4448
}
4549

4650
size(content: string | Blob) {
@@ -58,6 +62,8 @@ export class BaiduFileWriter implements FileWriter {
5862
}
5963

6064
async write(content: string | Blob): Promise<void> {
65+
await this.checkWritePrecondition();
66+
6167
// 预上传获取id
6268
const size = this.size(content).toString();
6369
const md5 = await this.md5(content);
@@ -124,4 +130,35 @@ export class BaiduFileWriter implements FileWriter {
124130
throw new Error(JSON.stringify(data));
125131
}
126132
}
133+
134+
private async checkWritePrecondition(): Promise<void> {
135+
if (!this.opts?.expectedDigest && !this.opts?.createOnly && this.opts?.overwrite !== false) {
136+
return;
137+
}
138+
const targetName = this.path.substring(this.path.lastIndexOf("/") + 1);
139+
const existing = (await this.fs.list()).find((file) => file.name === targetName);
140+
141+
if (this.opts?.createOnly || this.opts?.overwrite === false) {
142+
if (existing) {
143+
throw new FileSystemError({
144+
provider: "baidu",
145+
message: `File already exists: ${this.path}`,
146+
status: 409,
147+
code: "nameAlreadyExists",
148+
conflict: true,
149+
});
150+
}
151+
return;
152+
}
153+
154+
if (this.opts?.expectedDigest && existing?.digest !== this.opts.expectedDigest) {
155+
throw new FileSystemError({
156+
provider: "baidu",
157+
message: `Baidu file digest changed before write: ${this.path}`,
158+
status: 412,
159+
code: "digestMismatch",
160+
conflict: true,
161+
});
162+
}
163+
}
127164
}

packages/filesystem/googledrive/googledrive.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,25 @@ describe("GoogleDriveFileSystem", () => {
163163
});
164164
});
165165

166+
it("writer should rollback and reject createOnly when Google Drive creates a duplicate name", async () => {
167+
const fs = new GoogleDriveFileSystem("/", "token");
168+
const writer = await fs.create("file.txt", { createOnly: true });
169+
vi.spyOn(fs, "findFileInDirectory").mockResolvedValue(null);
170+
vi.spyOn(fs, "findFilesInDirectory").mockResolvedValue([{ id: "other-file" }, { id: "created-file" }]);
171+
const requestSpy = vi.spyOn(fs, "request").mockResolvedValueOnce({ id: "created-file" }).mockResolvedValueOnce({});
172+
173+
await expect(writer.write("content")).rejects.toMatchObject({
174+
provider: "googledrive",
175+
conflict: true,
176+
});
177+
178+
expect(requestSpy).toHaveBeenCalledTimes(2);
179+
expect(requestSpy.mock.calls[1][0]).toBe(
180+
"https://www.googleapis.com/drive/v3/files/created-file?spaces=appDataFolder"
181+
);
182+
expect((requestSpy.mock.calls[1][1] as RequestInit).method).toBe("DELETE");
183+
});
184+
166185
it("list should clear stale path cache and retry once on provider 404", async () => {
167186
const fs = new GoogleDriveFileSystem("/Base", "token");
168187
const notFoundError = new FileSystemError({

packages/filesystem/googledrive/googledrive.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -361,15 +361,17 @@ export default class GoogleDriveFileSystem implements FileSystem {
361361

362362
// 辅助方法:在指定目录中查找文件
363363
async findFileInDirectory(fileName: string, parentId: string): Promise<string | null> {
364+
const files = await this.findFilesInDirectory(fileName, parentId);
365+
return files[0]?.id || null;
366+
}
367+
368+
async findFilesInDirectory(fileName: string, parentId: string): Promise<Array<{ id: string }>> {
364369
const query = `name='${fileName}' and '${parentId}' in parents and trashed=false and mimeType!='application/vnd.google-apps.folder'`;
365370
const response = await this.request(
366371
`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)&spaces=appDataFolder`
367372
);
368373

369-
if (response.files && response.files.length > 0) {
370-
return response.files[0].id;
371-
}
372-
return null;
374+
return response.files || [];
373375
}
374376

375377
clearPathCache(path?: string): void {

packages/filesystem/googledrive/rw.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,17 +137,45 @@ export class GoogleDriveFileWriter implements FileWriter {
137137
const headers =
138138
this.opts?.createOnly || this.opts?.overwrite === false ? new Headers({ "If-None-Match": "*" }) : undefined;
139139

140-
await this.fs.request(
141-
`https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&spaces=appDataFolder`,
140+
const created = await this.fs.request(
141+
`https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&spaces=appDataFolder&fields=id`,
142142
{
143143
method: "POST",
144144
body: formData,
145145
...(headers ? { headers } : {}),
146146
}
147147
);
148148

149+
if (this.opts?.createOnly || this.opts?.overwrite === false) {
150+
await this.rejectDuplicateCreate(fileName, parentId, created?.id);
151+
}
152+
149153
return Promise.resolve();
150154
}
155+
156+
private async rejectDuplicateCreate(fileName: string, parentId: string, createdId?: string): Promise<void> {
157+
if (!createdId) {
158+
return;
159+
}
160+
const files = await this.fs.findFilesInDirectory(fileName, parentId);
161+
if (!files.length || (files.length === 1 && files[0].id === createdId)) {
162+
return;
163+
}
164+
try {
165+
await this.fs.request(`https://www.googleapis.com/drive/v3/files/${createdId}?spaces=appDataFolder`, {
166+
method: "DELETE",
167+
});
168+
} catch {
169+
// Best-effort cleanup. The conflict still prevents local digest/status from being advanced.
170+
}
171+
throw new FileSystemError({
172+
provider: "googledrive",
173+
message: `Duplicate Google Drive file detected after create: ${this.path}`,
174+
status: 409,
175+
code: "nameAlreadyExists",
176+
conflict: true,
177+
});
178+
}
151179
}
152180

153181
function parseGoogleDriveVersion(version?: string): { fileId: string; matchToken?: string } | undefined {

src/app/service/service_worker/synchronize.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -762,6 +762,45 @@ console.log("ok");`
762762
expect(fs.create).not.toHaveBeenCalledWith("scriptcat-sync.json", expect.anything());
763763
});
764764

765+
it("skips status and digest update when any push task fails", async () => {
766+
const error = new Error("network failed after partial write");
767+
const fs = createFs({
768+
list: vi.fn().mockResolvedValueOnce([]),
769+
});
770+
const service = new SynchronizeService(
771+
{} as any,
772+
{} as any,
773+
{} as any,
774+
{} as any,
775+
{} as any,
776+
{} as any,
777+
{} as any,
778+
{
779+
scriptCodeDAO: {
780+
get: vi.fn().mockResolvedValue({ code: "// code" }),
781+
},
782+
all: vi.fn().mockResolvedValue([
783+
{
784+
uuid: "push-uuid",
785+
name: "push",
786+
updatetime: 1,
787+
createtime: 1,
788+
status: 1,
789+
sort: 0,
790+
metadata: {},
791+
},
792+
]),
793+
} as any
794+
);
795+
vi.spyOn(service, "pushScript").mockRejectedValue(error);
796+
const updateDigestSpy = vi.spyOn(service, "updateFileDigest");
797+
798+
await service.syncOnce(syncConfig, fs);
799+
800+
expect(updateDigestSpy).not.toHaveBeenCalled();
801+
expect(fs.create).not.toHaveBeenCalledWith("scriptcat-sync.json", expect.anything());
802+
});
803+
765804
it("scriptInstall enters cloud_sync queue and updates digest after push", async () => {
766805
let releaseSync!: () => void;
767806
const syncGate = new Promise<void>((resolve) => {

src/app/service/service_worker/synchronize.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -498,8 +498,13 @@ export class SynchronizeService {
498498
Object.assign(pushedFileDigestMap, ret.value);
499499
}
500500
});
501-
if (syncResults.some((ret) => ret.status === "rejected" && isConflictError(ret.reason))) {
502-
this.logger.warn("skip status and digest update because cloud sync hit remote conflict");
501+
const rejected = syncResults.filter((ret) => ret.status === "rejected");
502+
if (rejected.length) {
503+
const hasConflict = rejected.some((ret) => isConflictError(ret.reason));
504+
this.logger.warn("skip status and digest update because cloud sync task failed", {
505+
conflict: hasConflict,
506+
failed: rejected.length,
507+
});
503508
return;
504509
}
505510
// 同步状态

0 commit comments

Comments
 (0)