Changes
9 changed files (+483/-80)
-
-
@@ -22,7 +22,8 @@ }export async function idbBackend(): Promise<IDBBackend> { return new IDBBackend({ db: await openDB("yamori", 6, { db: await openDB("yamori", 7, { // TODO: マイグレーションを個別のファイルに切り分ける async upgrade(db, oldVersion, _newVersion, transaction, _event) { // v1 if (oldVersion < 1) {
-
@@ -189,6 +190,24 @@ ],}, ...defs.filter((def) => def.createdBy !== "system"), ], }); } } // v7 if (oldVersion < 7) { const store = transaction.objectStore("workspaces"); for await (const cursor of store.iterate()) { await cursor.update({ ...cursor.value, leaveDefinitions: cursor.value.leaveDefinitions.map((def) => { return { ...def, deletionKey: def.createdBy === "user" ? createRandomBytes(16) : undefined, }; }), }); } }
-
-
-
@@ -21,6 +21,7 @@ leaveDefinitions: {id: string; displayName: string; updateKey: Uint8Array | null; deletionKey?: Uint8Array; createdBy: "user" | "system"; revisions: { id: string;
-
-
-
@@ -53,6 +53,7 @@ isWorkerDeemedToBeWorked:currentRevision?.snapshot?.isWorkerDeemedToBeWorked ?? false, displayName: entry.displayName, updateKey: entry.updateKey ? { key: entry.updateKey } : undefined, deletionKey: entry.deletionKey ? { key: entry.deletionKey } : undefined, currentRevision: currentRevision ?? undefined, revisions, });
-
-
-
@@ -0,0 +1,86 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { expect } from "bun:test"; import { create, fromBinary, toBinary, type MessageShape, type MessageInitShape, } 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 { GetRequestSchema } from "@yamori/proto/yamori/workspace/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/workspace/v1/get_response_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type Context } from "../../../../types"; import { workspaceService } from "./service"; export async function createWorkspace( ctx: Context, ): Promise<MessageShape<typeof WorkspaceSchema>> { const resp = fromBinary( CreateResponseSchema, await workspaceService( { service: "yamori.workspace.v1.WorkspaceService", method: "Create", data: toBinary( CreateRequestSchema, create(CreateRequestSchema, { displayName: "Foo", }), ), }, ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable( `Expected "ok" for workspace creation, found ${JSON.stringify(resp.result.case)}`, ); } if (!resp.result.value.workspace) { expect.unreachable( `Expected Create method to return a created workspace, got empty value`, ); } return resp.result.value.workspace; } export async function getWorkspace( ctx: Context, id: MessageInitShape<typeof WorkspaceSchema>["id"], ): Promise<MessageShape<typeof WorkspaceSchema>> { const resp = fromBinary( GetResponseSchema, await workspaceService( { service: "yamori.workspace.v1.WorkspaceService", method: "Get", data: toBinary( GetRequestSchema, create(GetRequestSchema, { workspaceId: id, }), ), }, ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable( `Expected "ok" for getting workspace op, found ${JSON.stringify(resp.result.case)}`, ); } return resp.result.value; }
-
-
-
@@ -14,66 +14,20 @@ type MessageInitShape,} from "@bufbuild/protobuf"; import { CreateLeaveDefinitionRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_request_pb.js"; import { CreateLeaveDefinitionResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_response_pb.js"; 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 { GetRequestSchema } from "@yamori/proto/yamori/workspace/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/workspace/v1/get_response_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { IDBFactory } from "fake-indexeddb"; import { idbBackend } from "../../../../lib"; import { type Context } from "../../../../types"; import { CONTEXT } from "../../../../symbols"; import { createWorkspace, getWorkspace } from "./_test_utils"; import { workspaceService } from "./service"; beforeEach(() => { indexedDB = new IDBFactory(); }); async function createWorkspace( ctx: Context, ): Promise<MessageShape<typeof WorkspaceSchema>> { const resp = fromBinary( CreateResponseSchema, await workspaceService( { service: "yamori.workspace.v1.WorkspaceService", method: "Create", data: toBinary( CreateRequestSchema, create(CreateRequestSchema, { displayName: "Foo", readMask: { fields: [ WorkspaceSchema.field.id.number, WorkspaceSchema.field.leaveDefinitions.number, WorkspaceSchema.field.createLeaveDefinitionKey.number, ], }, }), ), }, ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable( `Expected "ok" for workspace creation, found ${JSON.stringify(resp.result.case)}`, ); } if (!resp.result.value.workspace) { expect.unreachable( `Expected Create method to return a created workspace, got empty value`, ); } return resp.result.value.workspace; } async function createLeaveDefinition( export async function createLeaveDefinition( ctx: Context, request: MessageInitShape<typeof CreateLeaveDefinitionRequestSchema>, ): Promise<MessageShape<typeof CreateLeaveDefinitionResponseSchema>> {
-
@@ -91,36 +45,6 @@ },ctx, ), ); } async function getWorkspace( ctx: Context, id: MessageInitShape<typeof WorkspaceSchema>["id"], ): Promise<MessageShape<typeof WorkspaceSchema>> { const resp = fromBinary( GetResponseSchema, await workspaceService( { service: "yamori.workspace.v1.WorkspaceService", method: "Get", data: toBinary( GetRequestSchema, create(GetRequestSchema, { workspaceId: id, }), ), }, ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable( `Expected "ok" for getting workspace op, found ${JSON.stringify(resp.result.case)}`, ); } return resp.result.value; } test("Should return missing_field if workspace_id is empty", async () => {
-
-
-
@@ -8,7 +8,7 @@ type CreateLeaveDefinitionRequest,} from "@yamori/proto/yamori/workspace/v1/create_leave_definition_request_pb.js"; import { CreateLeaveDefinitionResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_response_pb.js"; import { isSameBytes, packDate } from "../../../../helpers"; import { createRandomBytes, isSameBytes, packDate } from "../../../../helpers"; import { type Context, type YamoriDB } from "../../../../types"; import * as leave from "../../../work_record/v1/leave";
-
@@ -141,6 +141,7 @@ const entry: YamoriDB["workspaces"]["value"]["leaveDefinitions"][number] = {id: `lv-${self.crypto.randomUUID()}`, displayName: req.leaveDefinition.displayName, updateKey, deletionKey: createRandomBytes(16), createdBy: "user", revisions: req.leaveDefinition.revisions.map((revision) => { return {
-
-
packages/idb_backend/src/yamori/workspace/v1/workspace_service/delete_leave_definition.test.ts (new)
-
@@ -0,0 +1,224 @@// 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 MessageShape, type MessageInitShape, } from "@bufbuild/protobuf"; import { LeaveSchema } from "@yamori/proto/yamori/work_record/v1/leave_pb.js"; import { CreateLeaveDefinitionRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_request_pb.js"; import { CreateLeaveDefinitionResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_response_pb.js"; import { DeleteLeaveDefinitionRequestSchema } from "@yamori/proto/yamori/workspace/v1/delete_leave_definition_request_pb.js"; import { DeleteLeaveDefinitionResponseSchema } from "@yamori/proto/yamori/workspace/v1/delete_leave_definition_response_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { IDBFactory } from "fake-indexeddb"; import { idbBackend } from "../../../../lib"; import { type Context } from "../../../../types"; import { CONTEXT } from "../../../../symbols"; import { createWorkspace, getWorkspace } from "./_test_utils"; import { workspaceService } from "./service"; beforeEach(() => { indexedDB = new IDBFactory(); }); async function createLeaveDefinition( ctx: Context, workspace: MessageInitShape<typeof WorkspaceSchema>, ) { const resp = fromBinary( CreateLeaveDefinitionResponseSchema, await workspaceService( { service: "yamori.workspace.v1.WorkspaceService", method: "CreateLeaveDefinition", data: toBinary( CreateLeaveDefinitionRequestSchema, create(CreateLeaveDefinitionRequestSchema, { workspaceId: workspace.id, createLeaveDefinitionKey: workspace.createLeaveDefinitionKey, leaveDefinition: { displayName: "Foo", revisions: [ { startAt: { year: 2000, month: 1, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: true }, }, ], }, }), ), }, ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable( `Expected "ok" for leave definition creation, got ${JSON.stringify(resp.result.case)}`, ); } return resp.result.value; } async function deleteLeaveDefinition( ctx: Context, request: MessageInitShape<typeof DeleteLeaveDefinitionRequestSchema>, ): Promise<MessageShape<typeof DeleteLeaveDefinitionResponseSchema>> { return fromBinary( DeleteLeaveDefinitionResponseSchema, await workspaceService( { service: "yamori.workspace.v1.WorkspaceService", method: "DeleteLeaveDefinition", data: toBinary( DeleteLeaveDefinitionRequestSchema, create(DeleteLeaveDefinitionRequestSchema, request), ), }, ctx, ), ); } test("Should return missing_field if workspace_id is empty", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const def = await createLeaveDefinition(ctx, workspace); const resp = await deleteLeaveDefinition(ctx, { leaveDefinitionId: def.id, deletionKey: def.deletionKey, }); if (resp.result.case !== "missingField") { expect.unreachable( `Expected "missing_field", got ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe("workspace_id"); }); test("Should return missing_field if leave_definition_id is empty", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const def = await createLeaveDefinition(ctx, workspace); const resp = await deleteLeaveDefinition(ctx, { workspaceId: workspace.id, deletionKey: def.deletionKey, }); if (resp.result.case !== "missingField") { expect.unreachable( `Expected "missing_field", got ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe("leave_definition_id"); }); test("Should return not_found if workspace_id does not match", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const def = await createLeaveDefinition(ctx, workspace); const resp = await deleteLeaveDefinition(ctx, { workspaceId: { value: workspace.id?.value + "-xxx" }, leaveDefinitionId: def.id, deletionKey: def.deletionKey, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.workspace.v1.Workspace"); }); test("Should return not_found if leave_definition_id does not match", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const def = await createLeaveDefinition(ctx, workspace); const resp = await deleteLeaveDefinition(ctx, { workspaceId: workspace.id, leaveDefinitionId: { value: def.id + "-xxx" }, deletionKey: def.deletionKey, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.work_record.v1.Leave"); }); test("Should check deletion_key", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const def = await createLeaveDefinition(ctx, workspace); const resp = await deleteLeaveDefinition(ctx, { workspaceId: workspace.id, leaveDefinitionId: def.id, deletionKey: { key: new Uint8Array([0, 0, 0]) }, }); if (resp.result.case !== "capabilityError") { expect.unreachable( `Expected "capability_error", got ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe("deletion_key"); }); test("Should delete a leave definition", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const def = await createLeaveDefinition(ctx, workspace); const resp = await deleteLeaveDefinition(ctx, { workspaceId: workspace.id, leaveDefinitionId: def.id, deletionKey: def.deletionKey, readMask: { fields: [LeaveSchema.field.id.number] }, }); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.deleted?.id).not.toBeEmpty(); expect(resp.result.value.deleted?.currentRevision).toBeEmpty(); const workspaceAfter = await getWorkspace(ctx, workspace.id); expect(workspaceAfter.leaveDefinitions.length).toEqual( workspace.leaveDefinitions.length, ); });
-
-
-
@@ -0,0 +1,144 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create, fromBinary, toBinary, type MessageShape, type MessageInitShape, } from "@bufbuild/protobuf"; import { DeleteLeaveDefinitionRequestSchema } from "@yamori/proto/yamori/workspace/v1/delete_leave_definition_request_pb.js"; import { DeleteLeaveDefinitionResponseSchema } from "@yamori/proto/yamori/workspace/v1/delete_leave_definition_response_pb.js"; import { isSameBytes } from "../../../../helpers"; import { type Context } from "../../../../types"; import * as leave from "../../../work_record/v1/leave"; function respond( result: Extract< MessageInitShape<typeof DeleteLeaveDefinitionResponseSchema>["result"], { case: string } >, ): Uint8Array { return toBinary( DeleteLeaveDefinitionResponseSchema, create(DeleteLeaveDefinitionResponseSchema, { result, }), ); } export async function deleteLeaveDefinition( data: Uint8Array, { db }: Context, ): Promise<Uint8Array> { let req: MessageShape<typeof DeleteLeaveDefinitionRequestSchema>; try { req = fromBinary(DeleteLeaveDefinitionRequestSchema, data); } catch (error) { return respond({ 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.workspaceId?.value) { return respond({ case: "missingField", value: { path: req.workspaceId ? "workspace_id.value" : "workspace_id", }, }); } if (!req.leaveDefinitionId?.value) { return respond({ case: "missingField", value: { path: req.leaveDefinitionId ? "leave_definition_id.value" : "leave_definition_id", }, }); } if (!req.deletionKey?.key) { return respond({ case: "missingField", value: { path: req.deletionKey ? "deletion_key.value" : "deletion_key", }, }); } try { const tx = db.transaction("workspaces", "readwrite"); const store = tx.objectStore("workspaces"); const found = await store.get(req.workspaceId.value); if (!found) { await tx.done; return respond({ case: "notFound", value: { typeName: "yamori.workspace.v1.Workspace", }, }); } const def = found.leaveDefinitions.find( (def) => def.id === req.leaveDefinitionId?.value, ); if (!def) { await tx.done; return respond({ case: "notFound", value: { typeName: "yamori.work_record.v1.Leave", }, }); } if (!def.deletionKey || !isSameBytes(req.deletionKey.key, def.deletionKey)) { await tx.done; return respond({ case: "capabilityError", value: { path: "deletion_key", }, }); } await store.put({ ...found, leaveDefinitions: found.leaveDefinitions.filter( (def) => def.id !== req.leaveDefinitionId?.value, ), }); await tx.done; return respond({ case: "ok", value: { deleted: leave.mask(req.readMask, leave.fromDBEntry(def)), }, }); } catch (error) { return respond({ case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "Failed to delete a leave definition" : `Exception thrown during delete transaction: ${error}`, }, }); } }
-
-
-
@@ -5,6 +5,7 @@ import { type Context, type RPCMessage } from "../../../../types";import { create } from "./create"; import { createLeaveDefinition } from "./create_leave_definition"; import { deleteLeaveDefinition } from "./delete_leave_definition"; import { deleteWorkspace } from "./delete"; import { get } from "./get"; import { list } from "./list";
-
@@ -27,6 +28,8 @@ case "Get":return get(request.data, ctx); case "CreateLeaveDefinition": return createLeaveDefinition(request.data, ctx); case "DeleteLeaveDefinition": return deleteLeaveDefinition(request.data, ctx); default: throw new Error(`Unknown method "${request.method}" for ${request.service}`); }
-