Changes
12 changed files (+586/-45)
-
-
-
@@ -9,9 +9,12 @@ "clean": "rm -rf dist"}, "dependencies": { "@bufbuild/protobuf": "^2.2.2", "@yamori/proto": "workspace:*" "@yamori/proto": "workspace:*", "idb": "^8.0.0" }, "devDependencies": { "@types/bun": "^1.1.14", "fake-indexeddb": "^6.0.0", "typescript": "^5.7.2", "vite": "^6.0.2" }
-
-
-
@@ -0,0 +1,10 @@// 各メソッドハンドラに渡されるコンテキストの定義。 // // SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type IDBPDatabase } from "idb"; export interface ServiceContext { db: IDBPDatabase; }
-
-
-
@@ -1,56 +1,23 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { fromBinary } from "@bufbuild/protobuf"; import { KeyValueStorageBasedWorkspaceService } from "@yamori/proto/yamori/workspace/v1/key_value_storage_based_workspace_service_pb.js"; import { isValidMessage } from "./message.ts"; import { setup } from "./setup.ts"; import { service } from "./yamori/service.ts"; import { isValidMessage, responseMessage } from "./message.ts"; const ctx = await setup(); addEventListener("message", (ev) => { addEventListener("message", async (ev) => { if (!isValidMessage(ev.data)) { console.warn("Invalid message sent from main thread."); return; } switch (ev.data.service) { case KeyValueStorageBasedWorkspaceService.typeName: { switch (ev.data.method) { case KeyValueStorageBasedWorkspaceService.method.list.name: { fromBinary( KeyValueStorageBasedWorkspaceService.method.list.input, ev.data.data, ); const resp = await service(ev.data, ctx); const resp = responseMessage(KeyValueStorageBasedWorkspaceService.method.list, { result: { case: "ok", value: { workspaces: [ { id: { value: "ws-foo", }, displayName: "Foo", }, ], }, }, }); self.postMessage(resp, { transfer: [resp.data.buffer] }); return; } default: { console.warn(`Unknown method name "${ev.data.method}" at ${ev.data.service}`); return; } } } default: { console.warn(`Unknown service name "${ev.data.service}"`); return; } } self.postMessage(resp, { transfer: [resp.data.buffer], }); }); self.postMessage("ready");
-
-
-
@@ -0,0 +1,27 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { openDB } from "idb"; import { type ServiceContext } from "./context.ts"; /** * すぐに利用可能なコンテキストを生成・初期化して返す。 */ export async function setup(): Promise<ServiceContext> { return { db: await openDB("yamori", 1, { upgrade(db, oldVersion, _newVersion, _transaction, _event) { if (oldVersion < 1) { const workspaces = db.createObjectStore("workspaces", { keyPath: ["id"], }); workspaces.createIndex("updatedAt", "updatedAt", { unique: false, }); } }, }), }; }
-
-
-
@@ -0,0 +1,18 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type ServiceContext } from "../context.ts"; import { type Message } from "../message.ts"; import { keyValueStorageBasedWorkspaceService } from "./workspace/v1/key_value_storage_based_workspace_service/service.ts"; export async function service(request: Message, ctx: ServiceContext): Promise<Message> { switch (request.service) { case "yamori.workspace.v1.KeyValueStorageBasedWorkspaceService": return keyValueStorageBasedWorkspaceService(request, ctx); case "yamori.worker.v1.WorkspaceService": throw new Error("Not implemented"); default: throw new Error(`Unknown service "${request.service}"`); } }
-
-
packages/pwa/src/worker/yamori/workspace/v1/key_value_storage_based_workspace_service/create.test.ts (new)
-
@@ -0,0 +1,173 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "fake-indexeddb/auto"; import { beforeEach, expect, test } from "bun:test"; import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; import { CreateRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_request_pb.js"; import { CreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js"; import { ListRequestSchema } from "@yamori/proto/yamori/workspace/v1/list_request_pb.js"; import { ListResponseSchema } from "@yamori/proto/yamori/workspace/v1/list_response_pb.js"; import { KeySchema } from "@yamori/proto/yamori/idempotency/v1/key_pb.js"; import { IDBFactory } from "fake-indexeddb"; import { openDB } from "idb"; import { setup } from "../../../../setup.ts"; import { create as method } from "./create.ts"; import { list } from "./list.ts"; beforeEach(() => { indexedDB = new IDBFactory(); }); test("Should return a missing field error", async () => { const resp = fromBinary( CreateResponseSchema, await method(toBinary(CreateRequestSchema, create(CreateRequestSchema, {})), { db: await openDB("test"), }), ); if (resp.result.case !== "missingField") { expect.unreachable(); } expect(resp.result.value.path).toBe("display_name"); }); test("Should return an error for invalid message", async () => { const resp = fromBinary( CreateResponseSchema, await method( toBinary( KeySchema, create(KeySchema, { value: "foo", }), ), { db: await openDB("test"), }, ), ); if (resp.result.case !== "systemError") { expect.unreachable(); } expect(resp.result.value.code).toBe("INVALID_MESSAGE"); }); test("Should return for a DB error", async () => { const resp = fromBinary( CreateResponseSchema, await method( toBinary( CreateRequestSchema, create(CreateRequestSchema, { displayName: "Foo", }), ), { db: await openDB("test"), }, ), ); if (resp.result.case !== "systemError") { expect.unreachable(); } expect(resp.result.value.code).toBe("IDB_ERROR"); }); test("Should append a workspace", async () => { const ctx = await setup(); const resp = fromBinary( CreateResponseSchema, await method( toBinary( CreateRequestSchema, create(CreateRequestSchema, { displayName: "Foo", }), ), ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable(); } expect(resp.result.value.workspace?.displayName).toBe("Foo"); expect(resp.result.value.workspace?.id?.value).toStartWith("ws-"); const listResp = fromBinary( ListResponseSchema, await list(toBinary(ListRequestSchema, create(ListRequestSchema)), ctx), ); if (listResp.result.case !== "ok") { expect.unreachable(); } expect(listResp.result.value.workspaces).toHaveLength(1); expect(listResp.result.value.workspaces[0]?.displayName).toBe("Foo"); expect(listResp.result.value.workspaces[0]?.id?.value).toStartWith("ws-"); }); test("Should allow inserting duplicating displayName", async () => { const ctx = await setup(); const [resp1, resp2] = await Promise.all([ method( toBinary( CreateRequestSchema, create(CreateRequestSchema, { displayName: "Foo", }), ), ctx, ).then((r) => fromBinary(CreateResponseSchema, r)), method( toBinary( CreateRequestSchema, create(CreateRequestSchema, { displayName: "Foo", }), ), ctx, ).then((r) => fromBinary(CreateResponseSchema, r)), ]); if (resp1.result.case !== "ok") { expect.unreachable(); } if (resp2.result.case !== "ok") { expect.unreachable(); } expect(resp1.result.value.workspace?.id?.value).toStartWith("ws-"); expect(resp2.result.value.workspace?.id?.value).toStartWith("ws-"); expect(resp1.result.value.workspace?.id?.value).not.toEqual( resp2.result.value.workspace?.id?.value, ); const listResp = fromBinary( ListResponseSchema, await list(toBinary(ListRequestSchema, create(ListRequestSchema)), ctx), ); if (listResp.result.case !== "ok") { expect.unreachable(); } expect(listResp.result.value.workspaces).toHaveLength(2); expect(listResp.result.value.workspaces[0]!.displayName).toBe("Foo"); expect(listResp.result.value.workspaces[1]!.displayName).toBe("Foo"); });
-
-
packages/pwa/src/worker/yamori/workspace/v1/key_value_storage_based_workspace_service/create.ts (new)
-
@@ -0,0 +1,125 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create as createMessage, fromBinary, toBinary } from "@bufbuild/protobuf"; import { CreateRequestSchema, type CreateRequest, } from "@yamori/proto/yamori/workspace/v1/create_request_pb.js"; import { CreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js"; import { type Workspace, WorkspaceSchema, } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type ServiceContext } from "../../../../context.ts"; export async function create( data: Uint8Array, { db }: ServiceContext, ): Promise<Uint8Array> { let req: CreateRequest; try { req = fromBinary(CreateRequestSchema, data); } catch (error) { return toBinary( CreateResponseSchema, createMessage(CreateResponseSchema, { result: { case: "systemError", value: { code: "INVALID_MESSAGE", message: import.meta.env.NODE_ENV === "production" ? "Failed to parse request message" : `Request message does not conform schema: ${error}`, }, }, }), ); } if (!req.displayName) { return toBinary( CreateResponseSchema, createMessage(CreateResponseSchema, { result: { case: "missingField", value: { path: "display_name", }, }, }), ); } // TODO: Handle idempotency_key (how?) const id = "ws-" + crypto.randomUUID(); let addedKey: IDBValidKey; try { addedKey = await db.add("workspaces", { id, displayName: req.displayName, updatedAt: Date.now(), }); } catch (error) { return toBinary( CreateResponseSchema, createMessage(CreateResponseSchema, { result: { case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "Failed to write workspace" : `Failed to write workspace entry to IndexedDB: ${error}`, }, }, }), ); } let added: Workspace; try { const addedEntry = await db.get("workspaces", addedKey); added = createMessage(WorkspaceSchema, { id: { value: addedEntry.id }, displayName: addedEntry.displayName, }); } catch (error) { return toBinary( CreateResponseSchema, createMessage(CreateResponseSchema, { result: { case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "Failed to read an added workspace" : `Failed to read an added workspace entry from IndexedDB: ${error}`, }, }, }), ); } return toBinary( CreateResponseSchema, createMessage(CreateResponseSchema, { result: { case: "ok", value: { workspace: added, }, }, }), ); }
-
-
packages/pwa/src/worker/yamori/workspace/v1/key_value_storage_based_workspace_service/list.test.ts (new)
-
@@ -0,0 +1,113 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "fake-indexeddb/auto"; import { beforeEach, expect, test } from "bun:test"; import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; import { CreateRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_request_pb.js"; import { CreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js"; import { ListRequestSchema } from "@yamori/proto/yamori/workspace/v1/list_request_pb.js"; import { ListResponseSchema } from "@yamori/proto/yamori/workspace/v1/list_response_pb.js"; import { IDBFactory } from "fake-indexeddb"; import { openDB } from "idb"; import { setup } from "../../../../setup.ts"; import { create as createHandler } from "./create.ts"; import { list } from "./list.ts"; beforeEach(() => { indexedDB = new IDBFactory(); }); test("Should return an error response on read error", async () => { const resp = fromBinary( ListResponseSchema, await list(toBinary(ListRequestSchema, create(ListRequestSchema)), { db: await openDB("test"), }), ); if (resp.result.case !== "systemError") { expect.unreachable(); } expect(resp.result.value.code).toBe("IDB_ERROR"); }); test("Should return an empty workspaces on clean slate", async () => { const resp = fromBinary( ListResponseSchema, await list(toBinary(ListRequestSchema, create(ListRequestSchema)), await setup()), ); if (resp.result.case !== "ok") { expect.unreachable(); } expect(resp.result.value.workspaces).toEqual([]); }); function sleep() { return new Promise<void>((resolve) => { setTimeout(() => { resolve(); }, 1); }); } test("Should return workspaces in descending order", async () => { const ctx = await setup(); const add = async (displayName: string) => { return fromBinary( CreateResponseSchema, await createHandler( toBinary( CreateRequestSchema, create(CreateRequestSchema, { displayName, }), ), ctx, ), ); }; const resp1 = await add("Foo"); if (resp1.result.case !== "ok") { expect.unreachable(); } // スリープしないとタイムスタンプが同じになってしまう。 // 実際にこのくらいの短い間隔で来たリクエストは同時と扱って問題ない。 await sleep(); const resp2 = await add("Bar"); if (resp2.result.case !== "ok") { expect.unreachable(); } await sleep(); const resp3 = await add("Baz"); if (resp3.result.case !== "ok") { expect.unreachable(); } const resp = fromBinary( ListResponseSchema, await list(toBinary(ListRequestSchema, create(ListRequestSchema)), ctx), ); if (resp.result.case !== "ok") { expect.unreachable(); } expect(resp.result.value.workspaces).toHaveLength(3); expect(resp.result.value.workspaces[0]!.displayName).toBe("Baz"); expect(resp.result.value.workspaces[1]!.displayName).toBe("Bar"); expect(resp.result.value.workspaces[2]!.displayName).toBe("Foo"); });
-
-
-
@@ -0,0 +1,76 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create, toBinary } from "@bufbuild/protobuf"; import { ListResponseSchema } from "@yamori/proto/yamori/workspace/v1/list_response_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type ServiceContext } from "../../../../context.ts"; export async function list( _data: Uint8Array, { db }: ServiceContext, ): Promise<Uint8Array> { try { const tx = db.transaction("workspaces", "readonly"); const index = tx.store.index("updatedAt"); const workspaces: any[] = []; for await (const cursor of index.iterate(null, "prev")) { workspaces.push({ ...cursor.value }); } return toBinary( ListResponseSchema, create(ListResponseSchema, { result: { case: "ok", value: { workspaces: workspaces.map((workspace) => { return create(WorkspaceSchema, { id: { value: workspace.id, }, displayName: workspace.displayName, }); }), }, }, }), ); } catch (error) { if (error instanceof DOMException) { return toBinary( ListResponseSchema, create(ListResponseSchema, { result: { case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "Failed to access workspace data store" : `Exception thrown during access to IndexedDB: ${error}`, }, }, }), ); } return toBinary( ListResponseSchema, create(ListResponseSchema, { result: { case: "systemError", value: { code: "", message: import.meta.env.NODE_ENV === "production" ? "Unexpected error" : `Unexpected exception thrown: ${error}`, }, }, }), ); } }
-
-
packages/pwa/src/worker/yamori/workspace/v1/key_value_storage_based_workspace_service/service.ts (new)
-
@@ -0,0 +1,28 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type ServiceContext } from "../../../../context.ts"; import { type Message } from "../../../../message.ts"; import { create } from "./create.ts"; import { list } from "./list.ts"; export async function keyValueStorageBasedWorkspaceService( request: Message, ctx: ServiceContext, ): Promise<Message> { switch (request.method) { case "List": return { ...request, data: await list(request.data, ctx), }; case "Create": return { ...request, data: await create(request.data, ctx), }; default: throw new Error(`Unknown method "${request.method}" for ${request.service}`); } }
-
-
-
@@ -7,10 +7,11 @@ "noUnusedLocals": true,"noUnusedParameters": true, "strict": true, "allowImportingTsExtensions": true, "module": "ES2020", "module": "ES2022", "moduleResolution": "Bundler", "noEmit": true, "lib": ["ES2020", "DOM"] "lib": ["ES2020", "DOM"], "target": "ES2020" }, "include": ["*.ts", "src/**/*.ts"] }
-