Changes
4 changed files (+478/-1)
-
-
@@ -14,7 +14,7 @@ db: await openDB("yamori", 1, {upgrade(db, oldVersion, _newVersion, _transaction, _event) { if (oldVersion < 1) { const workspaces = db.createObjectStore("workspaces", { keyPath: ["id"], keyPath: "id", }); workspaces.createIndex("updatedAt", "updatedAt", {
-
-
-
@@ -6,6 +6,7 @@ import { type Message } from "../../../../message.ts";import { create } from "./create.ts"; import { list } from "./list.ts"; import { update } from "./update.ts"; export async function keyValueStorageBasedWorkspaceService( request: Message,
-
@@ -21,6 +22,11 @@ case "Create":return { ...request, data: await create(request.data, ctx), }; case "Update": return { ...request, data: await update(request.data, ctx), }; default: throw new Error(`Unknown method "${request.method}" for ${request.service}`);
-
-
packages/pwa/src/worker/yamori/workspace/v1/key_value_storage_based_workspace_service/update.test.ts (new)
-
@@ -0,0 +1,312 @@// 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, type MessageInitShape, type DescMessage, type MessageShape, } 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 { UpdateRequestSchema } from "@yamori/proto/yamori/workspace/v1/update_request_pb.js"; import { UpdateResponseSchema } from "@yamori/proto/yamori/workspace/v1/update_response_pb.js"; import { IDBFactory } from "fake-indexeddb"; import { openDB } from "idb"; import { setup } from "../../../../setup.ts"; import { type ServiceContext } from "../../../../context.ts"; import { create as createHandler } from "./create.ts"; import { list } from "./list.ts"; import { update } from "./update.ts"; beforeEach(() => { indexedDB = new IDBFactory(); }); function bind<RequestSchema extends DescMessage, ResponseSchema extends DescMessage>( requestSchema: RequestSchema, responseSchema: ResponseSchema, handler: (req: Uint8Array, ctx: ServiceContext) => Promise<Uint8Array>, ): ( req: MessageInitShape<RequestSchema>, ctx: ServiceContext, ) => Promise<MessageShape<ResponseSchema>> { return async (request, ctx) => fromBinary( responseSchema, await handler(toBinary(requestSchema, create(requestSchema, request)), ctx), ); } const createWorkspace = bind(CreateRequestSchema, CreateResponseSchema, createHandler); const listWorkspaces = bind(ListRequestSchema, ListResponseSchema, list); const updateWorkspace = bind(UpdateRequestSchema, UpdateResponseSchema, update); test("Should return an error on IndexedDB exceptions", async () => { const resp = await updateWorkspace( { id: { value: "foo-bar", }, displayName: "Foo", fieldMask: { paths: ["displayName"], }, }, { db: await openDB("test"), }, ); if (resp.result.case !== "systemError") { expect.unreachable(`Expected "systemError", found "${resp.result.case}"`); } expect(resp.result.value.code).toBe("IDB_ERROR"); }); test("Should return a missing field error if ID is nonexistent", async () => { const ctx = await setup(); const created = await createWorkspace( { displayName: "Foo", }, ctx, ); if (created.result.case !== "ok") { expect.unreachable(); } if (!created.result.value.workspace?.id?.value) { expect.unreachable("Create handler assigned an empty ID"); } const updated = await updateWorkspace( { id: { value: created.result.value.workspace.id.value + "-1111", }, displayName: "Bar", fieldMask: { paths: ["displayName"], }, }, ctx, ); if (updated.result.case !== "notFound") { expect.unreachable(`Expected "not_found", found "${updated.result.case}"`); } const workspaces = await listWorkspaces({}, ctx); if (workspaces.result.case !== "ok") { expect.unreachable(); } expect(workspaces.result.value.workspaces).toHaveLength(1); expect(workspaces.result.value.workspaces[0]?.displayName).toBe("Foo"); }); test("Should return an error if ID field is absent", async () => { const ctx = await setup(); const created = await createWorkspace( { displayName: "Foo", }, ctx, ); if (created.result.case !== "ok") { expect.unreachable(); } if (!created.result.value.workspace?.id?.value) { expect.unreachable("Create handler assigned an empty ID"); } const updated = await updateWorkspace( { displayName: "Bar", fieldMask: { paths: ["displayName"], }, }, ctx, ); if (updated.result.case !== "missingField") { expect.unreachable(`Expected "missing_field", found "${updated.result.case}"`); } expect(updated.result.value.path).toBe("id"); const workspaces = await listWorkspaces({}, ctx); if (workspaces.result.case !== "ok") { expect.unreachable(); } expect(workspaces.result.value.workspaces).toHaveLength(1); expect(workspaces.result.value.workspaces[0]?.displayName).toBe("Foo"); }); test("Should return an error if display_name is in field_mask but missing", async () => { const ctx = await setup(); const created = await createWorkspace( { displayName: "Foo", }, ctx, ); if (created.result.case !== "ok") { expect.unreachable(); } if (!created.result.value.workspace?.id?.value) { expect.unreachable("Create handler assigned an empty ID"); } const updated = await updateWorkspace( { id: { value: created.result.value.workspace.id.value, }, fieldMask: { paths: ["display_name"], }, }, ctx, ); if (updated.result.case !== "missingField") { expect.unreachable(`Expected "missing_field", found "${updated.result.case}"`); } expect(updated.result.value.path).toBe("display_name"); const workspaces = await listWorkspaces({}, ctx); if (workspaces.result.case !== "ok") { expect.unreachable(); } expect(workspaces.result.value.workspaces).toHaveLength(1); expect(workspaces.result.value.workspaces[0]?.displayName).toBe("Foo"); }); test("Should do nothing if field_mask is empty", async () => { const ctx = await setup(); const created = await createWorkspace( { displayName: "Foo", }, ctx, ); if (created.result.case !== "ok") { expect.unreachable(); } if (!created.result.value.workspace?.id?.value) { expect.unreachable("Create handler assigned an empty ID"); } const updated = await updateWorkspace( { id: { value: created.result.value.workspace.id.value, }, displayName: "Bar", }, ctx, ); if (updated.result.case !== "ok") { expect.unreachable(`Expected "ok", found "${updated.result.case}"`); } expect(updated.result.value.workspace?.displayName).toBe("Foo"); }); function sleep() { return new Promise<void>((resolve) => { setTimeout(() => void resolve(), 1); }); } test("Should update a workspace", async () => { const ctx = await setup(); const foo = await createWorkspace( { displayName: "Foo", }, ctx, ); if (foo.result.case !== "ok") { expect.unreachable(); } if (!foo.result.value.workspace?.id?.value) { expect.unreachable("Create handler assigned an empty ID"); } // アサーションを簡単にするため、タイムスタンプをずらして順序を付けている await sleep(); const baz = await createWorkspace( { displayName: "Baz", }, ctx, ); if (baz.result.case !== "ok") { expect.unreachable(); } if (!baz.result.value.workspace?.id?.value) { expect.unreachable("Create handler assigned an empty ID"); } // 更新によってタイムスタンプが変わることを確認するため、スリープの必要がある await sleep(); const updated = await updateWorkspace( { id: { value: foo.result.value.workspace.id.value, }, displayName: "Bar", fieldMask: { paths: ["display_name"], }, }, ctx, ); if (updated.result.case !== "ok") { expect.unreachable(`Expected "ok", found "${updated.result.case}"`); } expect(updated.result.value.workspace?.displayName).toBe("Bar"); const workspaces = await listWorkspaces({}, ctx); if (workspaces.result.case !== "ok") { expect.unreachable(); } expect(workspaces.result.value.workspaces).toHaveLength(2); // foo は先に作成されたが、後に更新されたため降順のトップに来る expect(workspaces.result.value.workspaces[0]?.displayName).toBe("Bar"); expect(workspaces.result.value.workspaces[0]?.id?.value).toBe( foo.result.value.workspace.id.value, ); expect(workspaces.result.value.workspaces[1]?.displayName).toBe("Baz"); expect(workspaces.result.value.workspaces[1]?.id?.value).toBe( baz.result.value.workspace.id.value, ); });
-
-
packages/pwa/src/worker/yamori/workspace/v1/key_value_storage_based_workspace_service/update.ts (new)
-
@@ -0,0 +1,159 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create, fromBinary, toBinary, type MessageInitShape } from "@bufbuild/protobuf"; import { type UpdateRequest, UpdateRequestSchema, } from "@yamori/proto/yamori/workspace/v1/update_request_pb.js"; import { UpdateResponseSchema } from "@yamori/proto/yamori/workspace/v1/update_response_pb.js"; import { type ServiceContext } from "../../../../context.ts"; function respond(payload: MessageInitShape<typeof UpdateResponseSchema>): Uint8Array { return toBinary(UpdateResponseSchema, create(UpdateResponseSchema, payload)); } export async function update( data: Uint8Array, { db }: ServiceContext, ): Promise<Uint8Array> { let req: UpdateRequest; try { req = fromBinary(UpdateRequestSchema, data); } catch (error) { return respond({ 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.id?.value) { return respond({ result: { case: "missingField", value: { path: req.id ? "id.value" : "id", }, }, }); } const changes: ((prev: any) => any)[] = []; for (const path of req.fieldMask?.paths ?? []) { switch (path) { case "display_name": { if (!req.displayName) { return respond({ result: { case: "missingField", value: { path: "display_name", }, }, }); } changes.push((prev) => ({ ...prev, displayName: req.displayName })); break; } default: // 前方・後方互換性のために不明なフィールドは無視する break; } } let found: any; try { found = await db.get("workspaces", req.id.value); if (!found) { return respond({ result: { case: "notFound", value: { typeName: "yamori.workspace.v1.Workspace", }, }, }); } } catch (error) { return respond({ result: { case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "Failed to read current workspace data" : `Exception thrown while reading current workspace: ${error}`, }, }, }); } if (!changes.length) { return respond({ result: { case: "ok", value: { workspace: { id: { value: found.id, }, displayName: found.displayName, }, }, }, }); } const payload = changes.reduce((prev, f) => f(prev), found); try { const tx = db.transaction("workspaces", "readwrite"); const store = tx.objectStore("workspaces"); const key = await store.put({ ...payload, updatedAt: Date.now(), }); const updated = await store.get(key); await tx.done; return respond({ result: { case: "ok", value: { workspace: { id: { value: updated.id, }, displayName: updated.displayName, }, }, }, }); } catch (error) { return respond({ result: { case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "Failed to write workspace" : `Exception thrown while writing workspace: ${error}`, }, }, }); } }
-