Changes
16 changed files (+596/-488)
-
-
@@ -1,68 +1,92 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create, type MessageShape, type MessageInitShape } from "@bufbuild/protobuf"; import * as proto from "@bufbuild/protobuf"; import { LeaveSchema } from "@yamori/proto/yamori/work_record/v1/leave_pb.js"; import { type LeaveRevisionSchema } from "@yamori/proto/yamori/work_record/v1/leave_revision_pb.js"; import { type LeaveReadMask } from "@yamori/proto/yamori/work_record/v1/leave_read_mask_pb.js"; import { LeaveReadMaskSchema } from "@yamori/proto/yamori/work_record/v1/leave_read_mask_pb.js"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { maskMessage, packDate, unpackDate } from "../../../helpers"; import { packDate, unpackDate } from "../../../helpers"; import { type YamoriDB } from "../../../types"; function now(): number { function now(): proto.MessageInitShape<typeof DateSchema> { const d = new Date(); return packDate({ return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate(), }); }; } export function fromDBEntry( export interface BuildOptions { /** * `currentRevision` を選ぶ際の基準日。未指定の場合は現在日。 */ date?: proto.MessageInitShape<typeof DateSchema>; mask?: proto.MessageInitShape<typeof LeaveReadMaskSchema>; } export function build( entry: YamoriDB["workspaces"]["value"]["leaveDefinitions"][number], date: number = now(), ): MessageShape<typeof LeaveSchema> { const revisions = entry.revisions.map<MessageInitShape<typeof LeaveRevisionSchema>>( (raw) => { return { revisionId: { value: raw.id }, startAt: unpackDate(raw.startAtPackedDate), snapshot: { isWorkerDeemedToBeWorked: raw.snapshot.isWorkerDeemedToBeWorked, }, }; }, ); { date = now(), mask: { fields = LeaveSchema.fields.map((field) => field.number) } = {}, }: BuildOptions = {}, ): proto.MessageShape<typeof LeaveSchema> { const revisions = entry.revisions.map< proto.MessageInitShape<typeof LeaveRevisionSchema> >((raw) => { return { revisionId: { value: raw.id }, startAt: unpackDate(raw.startAtPackedDate), snapshot: { isWorkerDeemedToBeWorked: raw.snapshot.isWorkerDeemedToBeWorked, }, }; }); let currentRevision: MessageInitShape<typeof LeaveRevisionSchema> | null = null; const datePacked = packDate(date); let currentRevision: proto.MessageInitShape<typeof LeaveRevisionSchema> | null = null; for (const rev of revisions) { if (date >= packDate(rev.startAt!)) { if (typeof rev.startAt === "number" && datePacked >= packDate(rev.startAt)) { currentRevision = rev; } currentRevision = rev; } return create(LeaveSchema, { id: { value: entry.id }, 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, }); } const init: proto.MessageInitShape<typeof LeaveSchema> = {}; export function mask( mask: LeaveReadMask | undefined, leave: MessageInitShape<typeof LeaveSchema>, ): MessageShape<typeof LeaveSchema> { if (!mask) { return create(LeaveSchema, leave); for (const field of fields) { switch (field) { case LeaveSchema.field.id.number: init.id = { value: entry.id }; break; case LeaveSchema.field.displayName.number: init.displayName = entry.displayName; break; case LeaveSchema.field.isWorkerDeemedToBeWorked.number: init.isWorkerDeemedToBeWorked = currentRevision?.snapshot?.isWorkerDeemedToBeWorked; break; case LeaveSchema.field.currentRevision.number: init.currentRevision = currentRevision ?? undefined; break; case LeaveSchema.field.revisions.number: init.revisions = revisions; break; case LeaveSchema.field.deletionKey.number: init.deletionKey = entry.deletionKey && { key: entry.deletionKey }; break; case LeaveSchema.field.updateKey.number: init.updateKey = entry.updateKey ? { key: entry.updateKey } : undefined; break; } } return create(LeaveSchema, maskMessage(LeaveSchema, mask, leave)); return proto.create(LeaveSchema, init); }
-
-
-
@@ -1,266 +1,249 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create, type MessageShape, type MessageInitShape } from "@bufbuild/protobuf"; import { LeaveSchema } from "@yamori/proto/yamori/work_record/v1/leave_pb.js"; import * as proto from "@bufbuild/protobuf"; import { WorkRecordSchema } from "@yamori/proto/yamori/work_record/v1/work_record_pb.js"; import { WorkRecordBatchWriteInputSchema } from "@yamori/proto/yamori/work_record/v1/work_record_batch_write_input_pb.js"; import { WorkRecordBatchWriteMaskSchema } from "@yamori/proto/yamori/work_record/v1/work_record_batch_write_mask_pb.js"; import { type WorkRecordReadMask } from "@yamori/proto/yamori/work_record/v1/work_record_read_mask_pb.js"; import { WorkRecordReadMaskSchema } from "@yamori/proto/yamori/work_record/v1/work_record_read_mask_pb.js"; import { WorkRecordFilterSchema } from "@yamori/proto/yamori/work_record/v1/work_record_filter_pb.js"; import { TimeOffSchema } from "@yamori/proto/yamori/work_record/v1/time_off_pb.js"; import { type IDBPObjectStore, type StoreNames } from "idb"; import { maskMessage, unpackDate } from "../../../helpers"; import { packDate, unpackDate } from "../../../helpers"; import { type YamoriDB } from "../../../types"; import * as leave from "./leave"; const { field } = WorkRecordBatchWriteInputSchema; function toDBEntryRecord( export function write( base: YamoriDB["workRecords"]["value"], message: MessageShape<typeof WorkRecordBatchWriteInputSchema>, mask?: MessageShape<typeof WorkRecordBatchWriteMaskSchema>, ): YamoriDB["workRecords"]["value"]["record"] { switch (message.record.case) { case "dayOff": if (mask && !mask.fields.includes(field.dayOff.number)) { return base.record; } return { type: "day_off", }; case "workingDay": if (mask && !mask.fields.includes(field.workingDay.number)) { return base.record; } message: proto.MessageShape<typeof WorkRecordBatchWriteInputSchema>, { fields = WorkRecordBatchWriteInputSchema.fields.map((field) => field.number), }: proto.MessageInitShape<typeof WorkRecordBatchWriteMaskSchema> = {}, ): YamoriDB["workRecords"]["value"] { const value = { ...base }; return { type: "working_day", hasWorked: message.record.value.hasWorkerWorked, timeOffs: message.record.value.timeOffs.map((timeOff) => { switch (timeOff.kind.case) { case "hourlyPaidLeave": return { type: "hourly_paid_leave", hours: timeOff.kind.value.hours, }; case "halvedPaidLeave": return { type: "halved_paid_leave", }; default: throw new Error(`Unsupported time_off type: ${timeOff.kind.case}`); } }), }; case "paidLeave": if (mask && !mask.fields.includes(field.paidLeave.number)) { return base.record; } for (const field of fields) { switch (field) { case WorkRecordBatchWriteInputSchema.field.note.number: value.note = message.note; break; case WorkRecordBatchWriteInputSchema.field.dayOff.number: if (message.record.case === "dayOff") { value.record = { type: "day_off", }; } else if (value.record?.type === "day_off") { value.record = undefined; } break; case WorkRecordBatchWriteInputSchema.field.workingDay.number: if (message.record.case === "workingDay") { value.record = { type: "working_day", hasWorked: message.record.value.hasWorkerWorked, timeOffs: message.record.value.timeOffs.map((timeOff) => { switch (timeOff.kind.case) { case "hourlyPaidLeave": return { type: "hourly_paid_leave", hours: timeOff.kind.value.hours, }; case "halvedPaidLeave": return { type: "halved_paid_leave", }; default: throw new Error(`Unsupported time_off type: ${timeOff.kind.case}`); } }), }; } else if (value.record?.type === "working_day") { value.record = undefined; } break; case WorkRecordBatchWriteInputSchema.field.paidLeave.number: if (message.record.case === "paidLeave") { value.record = { type: "paid_leave", }; } else if (value.record?.type === "paid_leave") { value.record = undefined; } break; case WorkRecordBatchWriteInputSchema.field.legalLeaveId.number: if (message.record.case === "legalLeaveId") { value.record = { type: "legal_leave", leave_id: message.record.value.value, }; } else if (value.record?.type === "legal_leave") { value.record = undefined; } break; case WorkRecordBatchWriteInputSchema.field.specialLeaveId.number: if (message.record.case === "specialLeaveId") { value.record = { type: "special_leave", leave_id: message.record.value.value, }; } else if (value.record?.type === "special_leave") { value.record = undefined; } break; } } return { type: "paid_leave", }; case "legalLeaveId": if (mask && !mask.fields.includes(field.legalLeaveId.number)) { return base.record; } return value; } return { type: "legal_leave", leave_id: message.record.value.value, }; case "specialLeaveId": if (mask && !mask.fields.includes(field.specialLeaveId.number)) { return base.record; } export interface GetAllForWorkerInput { workspaceID: string; workerID: string; return { type: "special_leave", leave_id: message.record.value.value, }; default: switch (base.record?.type) { case "day_off": return !mask || mask.fields.includes(field.dayOff.number) ? undefined : base.record; case "paid_leave": return !mask || mask.fields.includes(field.paidLeave.number) ? undefined : base.record; case "working_day": return !mask || mask.fields.includes(field.workingDay.number) ? undefined : base.record; case "legal_leave": return !mask || mask.fields.includes(field.legalLeaveId.number) ? undefined : base.record; case "special_leave": return !mask || mask.fields.includes(field.specialLeaveId.number) ? undefined : base.record; default: return base.record; } } filter?: proto.MessageInitShape<typeof WorkRecordFilterSchema>; } export function toDBEntry( base: YamoriDB["workRecords"]["value"], message: MessageShape<typeof WorkRecordBatchWriteInputSchema>, mask?: MessageShape<typeof WorkRecordBatchWriteMaskSchema>, ): YamoriDB["workRecords"]["value"] { return { recordId: base.recordId, workerId: base.workerId, workspaceId: base.workspaceId, record: toDBEntryRecord(base, message, mask), note: !mask || mask.fields.includes(WorkRecordBatchWriteInputSchema.field.note.number) ? message.note : base.note, datePacked: base.datePacked, export async function getAllForWorker( { workerID, workspaceID, filter }: GetAllForWorkerInput, store: IDBPObjectStore< YamoriDB, ArrayLike<StoreNames<YamoriDB>>, "workRecords", "readonly" | "readwrite" >, ): Promise<YamoriDB["workRecords"]["value"][]> { const now = new Date(); const today = { year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate(), }; const since = packDate(filter?.since ?? today); const until = packDate(filter?.until ?? today); return store .index("workspaceId/workerId/datePacked") .getAll( IDBKeyRange.bound([workspaceID, workerID, since], [workspaceID, workerID, until]), ); } function getLeave( id: string, datePacked: number, workspace: YamoriDB["workspaces"]["value"], ): MessageShape<typeof LeaveSchema> | undefined { const found = workspace.leaveDefinitions.find((def) => def.id === id); if (!found) { return undefined; } export interface BuildInput { workspace: YamoriDB["workspaces"]["value"]; return leave.fromDBEntry(found, datePacked); mask?: proto.MessageInitShape<typeof WorkRecordReadMaskSchema>; } export function fromDBEntry( export function build( entry: YamoriDB["workRecords"]["value"], workspace: YamoriDB["workspaces"]["value"], ): MessageShape<typeof WorkRecordSchema> { switch (entry.record?.type) { case "day_off": return create(WorkRecordSchema, { date: unpackDate(entry.datePacked), note: entry.note, record: { case: "dayOff", value: {}, }, }); case "working_day": return create(WorkRecordSchema, { date: unpackDate(entry.datePacked), note: entry.note, record: { case: "workingDay", value: { hasWorkerWorked: entry.record.hasWorked, timeOffs: entry.record.timeOffs .map<MessageInitShape<typeof TimeOffSchema> | null>((timeOff) => { switch (timeOff.type) { case "hourly_paid_leave": return { kind: { case: "hourlyPaidLeave", value: { hours: timeOff.hours, { workspace, mask: { fields = WorkRecordSchema.fields.map((field) => field.number), legalLeaveReadMask, specialLeaveReadMask, } = {}, }: BuildInput, ): proto.MessageShape<typeof WorkRecordSchema> { const init: proto.MessageInitShape<typeof WorkRecordSchema> = {}; for (const field of fields) { switch (field) { case WorkRecordSchema.field.date.number: init.date = unpackDate(entry.datePacked); break; case WorkRecordSchema.field.note.number: init.note = entry.note; break; case WorkRecordSchema.field.dayOff.number: if (entry.record?.type === "day_off") { init.record = { case: "dayOff", value: {}, }; } break; case WorkRecordSchema.field.paidLeave.number: if (entry.record?.type === "paid_leave") { init.record = { case: "paidLeave", value: {}, }; } break; case WorkRecordSchema.field.workingDay.number: if (entry.record?.type === "working_day") { init.record = { case: "workingDay", value: { hasWorkerWorked: entry.record.hasWorked, timeOffs: entry.record.timeOffs .map<proto.MessageInitShape<typeof TimeOffSchema> | null>((timeOff) => { switch (timeOff.type) { case "hourly_paid_leave": return { kind: { case: "hourlyPaidLeave", value: { hours: timeOff.hours, }, }, }, }; case "halved_paid_leave": return { kind: { case: "halvedPaidLeave", value: { // TS の型推論がおもちゃなのでこれがないと意味不明な型を // 当てはめようとしてエラーになる。 providedAt: undefined, }; case "halved_paid_leave": return { kind: { case: "halvedPaidLeave", value: { // TS の型推論がおもちゃなのでこれがないと意味不明な型を // 当てはめようとしてエラーになる。 providedAt: undefined, }, }, }, }; default: return null; } }) .filter((t): t is NonNullable<typeof t> => !!t), }, }, }); case "paid_leave": return create(WorkRecordSchema, { date: unpackDate(entry.datePacked), note: entry.note, record: { case: "paidLeave", value: {}, }, }); case "special_leave": { const leave = getLeave(entry.record.leave_id, entry.datePacked, workspace); }; default: return null; } }) .filter((t): t is NonNullable<typeof t> => !!t), }, }; } break; case WorkRecordSchema.field.legalLeave.number: if (entry.record?.type === "legal_leave") { const id = entry.record.leave_id; return create(WorkRecordSchema, { date: unpackDate(entry.datePacked), note: entry.note, record: leave && { case: "specialLeave", value: leave, }, }); } case "legal_leave": { const leave = getLeave(entry.record.leave_id, entry.datePacked, workspace); const found = workspace.leaveDefinitions.find((def) => def.id === id); init.record = found && { case: "legalLeave", value: leave.build(found, { date: unpackDate(entry.datePacked), mask: legalLeaveReadMask, }), }; } break; case WorkRecordSchema.field.specialLeave.number: if (entry.record?.type === "special_leave") { const id = entry.record.leave_id; const found = workspace.leaveDefinitions.find((def) => def.id === id); return create(WorkRecordSchema, { date: unpackDate(entry.datePacked), note: entry.note, record: leave && { case: "legalLeave", value: leave, }, }); init.record = found && { case: "specialLeave", value: leave.build(found, { date: unpackDate(entry.datePacked), mask: specialLeaveReadMask, }), }; } break; } default: return create(WorkRecordSchema, { date: unpackDate(entry.datePacked), note: entry.note, }); } } export function mask( mask: WorkRecordReadMask | undefined, workRecord: MessageInitShape<typeof WorkRecordSchema>, ): MessageShape<typeof WorkRecordSchema> { if (!mask) { return create(WorkRecordSchema, workRecord); } const masked = maskMessage(WorkRecordSchema, mask, workRecord); switch (masked.record.case) { case "legalLeave": return create(WorkRecordSchema, { ...masked, record: { case: "legalLeave", value: leave.mask(mask.legalLeaveReadMask, masked.record.value), }, }); case "specialLeave": return create(WorkRecordSchema, { ...masked, record: { case: "specialLeave", value: leave.mask(mask.specialLeaveReadMask, masked.record.value), }, }); default: return masked; } return proto.create(WorkRecordSchema, init); }
-
-
-
@@ -1,41 +1,73 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create, type MessageInitShape, type MessageShape } from "@bufbuild/protobuf"; import * as proto from "@bufbuild/protobuf"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { type WorkerReadMaskSchema } from "@yamori/proto/yamori/worker/v1/worker_read_mask_pb.js"; import { type IDBPObjectStore, type StoreNames } from "idb"; import { maskMessage } from "../../../helpers"; import { type YamoriDB } from "../../../types"; import * as workRecord from "../../work_record/v1/work_record"; export function fromDBEntry( entry: YamoriDB["workers"]["value"], workspace: YamoriDB["workspaces"]["value"], workRecords: YamoriDB["workRecords"]["value"][], ): MessageShape<typeof WorkerSchema> { return create(WorkerSchema, { id: { value: entry.id }, displayName: entry.displayName, writeWorkRecordKey: { key: entry.writeWorkRecordKey }, workRecords: workRecords.map((record) => workRecord.fromDBEntry(record, workspace)), }); export async function getAllForWorkspace( workspaceID: string, store: IDBPObjectStore< YamoriDB, ArrayLike<StoreNames<YamoriDB>>, "workers", "readonly" | "readwrite" >, ): Promise<YamoriDB["workers"]["value"][]> { const entries = await store.index("workspaceId").getAll(workspaceID); return entries.sort((a, b) => b.updatedAt - a.updatedAt); } export function mask( mask: MessageShape<typeof WorkerReadMaskSchema> | undefined, worker: MessageInitShape<typeof WorkerSchema>, ): MessageShape<typeof WorkerSchema> { if (!mask) { return create(WorkerSchema, worker); } export interface BuildInput { workspace: YamoriDB["workspaces"]["value"]; const masked = maskMessage(WorkerSchema, mask, worker); getWorkRecords(): Promise<YamoriDB["workRecords"]["value"][]>; return create(WorkerSchema, { ...masked, workRecords: masked.workRecords.map((record) => workRecord.mask(mask.workRecordsMask, record), ), }); mask?: proto.MessageInitShape<typeof WorkerReadMaskSchema>; } export async function build( entry: YamoriDB["workers"]["value"], { workspace, getWorkRecords, mask: { fields = WorkerSchema.fields.map((field) => field.number), workRecordsMask, } = {}, }: BuildInput, ): Promise<proto.MessageShape<typeof WorkerSchema>> { const init: proto.MessageInitShape<typeof WorkerSchema> = {}; for (const field of fields) { switch (field) { case WorkerSchema.field.id.number: init.id = { value: entry.id }; break; case WorkerSchema.field.displayName.number: init.displayName = entry.displayName; break; case WorkerSchema.field.writeWorkRecordKey.number: init.writeWorkRecordKey = { key: entry.writeWorkRecordKey }; break; case WorkerSchema.field.workRecords.number: { const records = await getWorkRecords(); init.workRecords = records.map((r) => workRecord.build(r, { workspace, mask: workRecordsMask, }), ); break; } } } return proto.create(WorkerSchema, init); }
-
-
-
@@ -14,7 +14,9 @@ } from "@yamori/proto/yamori/worker/v1/create_request_pb.js";import { CreateResponseSchema } from "@yamori/proto/yamori/worker/v1/create_response_pb.js"; import { isSameBytes, createRandomBytes } from "../../../../helpers"; import { type Context, type YamoriDB } from "../../../../types"; import { type Context } from "../../../../types"; import * as workRecord from "../../../work_record/v1/work_record"; import * as worker from "../worker"; function respond(
-
@@ -26,7 +28,7 @@ ): Uint8Array {return toBinary(CreateResponseSchema, createMessage(CreateResponseSchema, { result })); } export async function create(data: Uint8Array, { db }: Context): Promise<Uint8Array> { export async function create(data: Uint8Array, ctx: Context): Promise<Uint8Array> { let req: CreateRequest; try {
-
@@ -73,10 +75,13 @@ }// TODO: Handle idempotency_key (how?) let workspace: YamoriDB["workspaces"]["value"]; let tx; let ws; try { const found = await db.get("workspaces", req.workspaceId.value); if (!found) { tx = ctx.db.transaction(["workspaces", "workers", "workRecords"], "readwrite"); ws = await tx.objectStore("workspaces").get(req.workspaceId.value); if (!ws) { return respond({ case: "notFound", value: {
-
@@ -85,9 +90,7 @@ },}); } workspace = found; if (!isSameBytes(req.workerAddKey.key, found.capabilities.workerAddKey)) { if (!isSameBytes(req.workerAddKey.key, ws.capabilities.workerAddKey)) { return respond({ case: "capabilityError", value: {
-
@@ -96,6 +99,8 @@ },}); } } catch (error) { tx?.abort(); return respond({ case: "systemError", value: {
-
@@ -111,9 +116,9 @@const id = "wr-" + crypto.randomUUID(); try { const tx = db.transaction("workers", "readwrite"); const store = tx.objectStore("workers"); const key = await tx.store.add({ const addedKey = await store.add({ id, workspaceId: req.workspaceId.value, displayName: req.displayName,
-
@@ -122,20 +127,36 @@ updatedAt: Date.now(),writeWorkRecordKey: createRandomBytes(16), }); const added = await tx.store.get(key); const added = await store.get(addedKey); if (!added) { throw "Failed to query an added entry"; } const value = await worker.build(added, { workspace: ws, getWorkRecords() { return workRecord.getAllForWorker( { workerID: id, workspaceID: ws.id, }, tx.objectStore("workRecords"), ); }, mask: req.readMask, }); await tx.done; return respond({ case: "ok", value: { worker: worker.mask(req.readMask, worker.fromDBEntry(added, workspace, [])), worker: value, }, }); } catch (error) { tx.abort(); return respond({ case: "systemError", value: {
-
-
-
@@ -10,10 +10,9 @@ type MessageShape,} from "@bufbuild/protobuf"; import { GetRequestSchema } from "@yamori/proto/yamori/worker/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/worker/v1/get_response_pb.js"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { packDate } from "../../../../helpers"; import { type Context, type YamoriDB } from "../../../../types"; import { type Context } from "../../../../types"; import * as workRecord from "../../../work_record/v1/work_record"; import * as worker from "../worker"; function respond(
-
@@ -25,7 +24,7 @@ ): Uint8Array {return toBinary(GetResponseSchema, create(GetResponseSchema, { result })); } export async function get(data: Uint8Array, { db }: Context): Promise<Uint8Array> { export async function get(data: Uint8Array, ctx: Context): Promise<Uint8Array> { let req: MessageShape<typeof GetRequestSchema>; try { req = fromBinary(GetRequestSchema, data);
-
@@ -42,7 +41,9 @@ },}); } if (!req.workspaceId) { const { workspaceId, workerId } = req; if (!workspaceId) { return respond({ case: "missingField", value: {
-
@@ -51,7 +52,7 @@ },}); } if (!req.workerId) { if (!workerId) { return respond({ case: "missingField", value: {
-
@@ -60,10 +61,13 @@ },}); } let workspace: YamoriDB["workspaces"]["value"] | undefined; let tx; let ws; try { workspace = await db.get("workspaces", req.workspaceId.value); if (!workspace) { tx = ctx.db.transaction(["workspaces", "workers", "workRecords"], "readonly"); ws = await tx.objectStore("workspaces").get(workspaceId.value); if (!ws) { return respond({ case: "notFound", value: {
-
@@ -72,6 +76,8 @@ },}); } } catch (error) { tx?.abort(); return respond({ case: "systemError", value: {
-
@@ -85,8 +91,9 @@ });} try { const found = await db.get("workers", req.workerId.value); if (!found || found.workspaceId !== req.workspaceId.value) { const found = await tx.objectStore("workers").get(workerId.value); if (!found || found.workspaceId !== ws.id) { return respond({ case: "notFound", value: {
-
@@ -95,35 +102,30 @@ },}); } const now = new Date(); const today = { year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate(), }; const value = await worker.build(found, { workspace: ws, getWorkRecords() { return workRecord.getAllForWorker( { workspaceID: workspaceId.value, workerID: workerId.value, filter: req.workRecordFilter, }, tx.objectStore("workRecords"), ); }, mask: req.readMask, }); const since = packDate(req.workRecordFilter?.since ?? today); const until = packDate(req.workRecordFilter?.until ?? today); const recordRange = IDBKeyRange.bound( [workspace.id, found.id, since], [workspace.id, found.id, until], ); const records = !req.readMask || req.readMask.fields.includes(WorkerSchema.field.workRecords.number) ? await db.getAllFromIndex( "workRecords", "workspaceId/workerId/datePacked", recordRange, ) : []; await tx.done; return respond({ case: "ok", value: worker.mask(req.readMask, worker.fromDBEntry(found, workspace, records)), value, }); } catch (error) { tx.abort(); return respond({ case: "systemError", value: {
-
-
-
@@ -7,10 +7,9 @@ ListRequestSchema,type ListRequest, } from "@yamori/proto/yamori/worker/v1/list_request_pb.js"; import { ListResponseSchema } from "@yamori/proto/yamori/worker/v1/list_response_pb.js"; import { type Worker } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { packDate } from "../../../../helpers"; import { type Context, type YamoriDB } from "../../../../types"; import * as workRecord from "../../../work_record/v1/work_record"; import * as worker from "../worker"; export function respond(
-
@@ -22,7 +21,7 @@ ): Uint8Array {return toBinary(ListResponseSchema, create(ListResponseSchema, { result })); } export async function list(data: Uint8Array, { db }: Context): Promise<Uint8Array> { export async function list(data: Uint8Array, ctx: Context): Promise<Uint8Array> { let req: ListRequest; try { req = fromBinary(ListRequestSchema, data);
-
@@ -48,9 +47,13 @@ },}); } let tx; let workspace: YamoriDB["workspaces"]["value"] | undefined; try { workspace = await db.get("workspaces", req.workspaceId.value); tx = ctx.db.transaction(["workspaces", "workers", "workRecords"], "readonly"); workspace = await tx.objectStore("workspaces").get(req.workspaceId.value); if (!workspace) { return respond({ case: "notFound",
-
@@ -60,6 +63,8 @@ },}); } } catch (error) { tx?.abort(); return respond({ case: "systemError", value: {
-
@@ -73,29 +78,28 @@ });} try { const now = new Date(); const today = { year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate(), }; const since = packDate(req.workRecordFilter?.since ?? today); const until = packDate(req.workRecordFilter?.until ?? today); const workers: Worker[] = await Promise.all( (await db.getAllFromIndex("workers", "workspaceId", workspace.id)) .sort((a, b) => b.updatedAt - a.updatedAt) .map(async (w) => { const recordRange = IDBKeyRange.bound( [workspace.id, w.id, since], [workspace.id, w.id, until], ); const records = await db.getAll("workRecords", recordRange); const entries = await worker.getAllForWorkspace( workspace.id, tx.objectStore("workers"), ); return worker.mask(req.readMask, worker.fromDBEntry(w, workspace, records)); const workers = await Promise.all( entries.map(async (entry) => worker.build(entry, { workspace, getWorkRecords() { return workRecord.getAllForWorker( { workerID: entry.id, workspaceID: workspace.id, filter: req.workRecordFilter, }, tx.objectStore("workRecords"), ); }, mask: req.readMask, }), ), ); return respond({
-
@@ -106,6 +110,8 @@ workers,}, }); } catch (error) { tx.abort(); return respond({ case: "systemError", value: {
-
-
-
@@ -196,7 +196,7 @@ const existing = await tx.store.index("workspaceId/workerId/datePacked") .get([req.workspaceId.value, req.workerId.value, packed]); const payload = workRecord.toDBEntry( const payload = workRecord.write( existing || { recordId: crypto.randomUUID(), workerId: req.workerId.value,
-
@@ -211,12 +211,19 @@ tx.store.put(payload);added.push(payload); } const value = added.map((entry) => workRecord.build(entry, { workspace, mask: req.readMask, }), ); await tx.done; return respond({ case: "ok", value: { workRecords: added.map((entry) => workRecord.fromDBEntry(entry, workspace)), workRecords: value, }, }); } catch (error) {
-
-
-
@@ -1,45 +1,71 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create, type MessageInitShape } from "@bufbuild/protobuf"; import { WorkspaceSchema, type Workspace, } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type WorkspaceReadMask } from "@yamori/proto/yamori/workspace/v1/workspace_read_mask_pb.js"; import * as proto from "@bufbuild/protobuf"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { WorkspaceReadMaskSchema } from "@yamori/proto/yamori/workspace/v1/workspace_read_mask_pb.js"; import { type IDBPObjectStore, type StoreNames } from "idb"; import { maskMessage } from "../../../helpers"; import { type YamoriDB } from "../../../types"; import * as leave from "../../work_record/v1/leave"; export function fromDBEntry(entry: YamoriDB["workspaces"]["value"]): Workspace { return create(WorkspaceSchema, { id: { value: entry.id }, displayName: entry.displayName, deletionKey: { key: entry.capabilities.deletionKey }, updateKey: { key: entry.capabilities.updateKey }, workerAddKey: { key: entry.capabilities.workerAddKey }, createLeaveDefinitionKey: { key: entry.capabilities.createLeaveDefinitionKey }, leaveDefinitions: entry.leaveDefinitions.map((childEntry) => leave.fromDBEntry(childEntry), ), }); export async function getOneById( id: string, store: IDBPObjectStore< YamoriDB, ArrayLike<StoreNames<YamoriDB>>, "workspaces", "readonly" | "readwrite" >, ): Promise<YamoriDB["workspaces"]["value"] | undefined> { return store.get(id); } export function mask( mask: WorkspaceReadMask | undefined, workspace: MessageInitShape<typeof WorkspaceSchema>, ): Workspace { if (!mask) { return create(WorkspaceSchema, workspace); } export interface BuildOptions { mask?: proto.MessageInitShape<typeof WorkspaceReadMaskSchema>; } const masked = maskMessage(WorkspaceSchema, mask, workspace); export function build( entry: YamoriDB["workspaces"]["value"], { mask: { fields = WorkspaceSchema.fields.map((field) => field.number), leaveDefinitionsMask, } = {}, }: BuildOptions = {}, ): proto.MessageShape<typeof WorkspaceSchema> { const init: proto.MessageInitShape<typeof WorkspaceSchema> = {}; return create(WorkspaceSchema, { ...masked, leaveDefinitions: masked.leaveDefinitions && masked.leaveDefinitions.map((def) => leave.mask(mask.leaveDefinitionsMask, def)), } as MessageInitShape<typeof WorkspaceSchema>); for (const field of fields) { switch (field) { case WorkspaceSchema.field.id.number: init.id = { value: entry.id }; break; case WorkspaceSchema.field.displayName.number: init.displayName = entry.displayName; break; case WorkspaceSchema.field.updateKey.number: init.updateKey = { key: entry.capabilities.updateKey }; break; case WorkspaceSchema.field.deletionKey.number: init.deletionKey = { key: entry.capabilities.deletionKey }; break; case WorkspaceSchema.field.workerAddKey.number: init.workerAddKey = { key: entry.capabilities.workerAddKey }; break; case WorkspaceSchema.field.createLeaveDefinitionKey.number: init.createLeaveDefinitionKey = { key: entry.capabilities.createLeaveDefinitionKey, }; break; case WorkspaceSchema.field.leaveDefinitions.number: init.leaveDefinitions = entry.leaveDefinitions.map((def) => leave.build(def, { mask: leaveDefinitionsMask, }), ); } } return proto.create(WorkspaceSchema, init); }
-
-
-
@@ -63,29 +63,6 @@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 { [CONTEXT]: ctx } = await idbBackend();
-
-
-
@@ -7,7 +7,6 @@ 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 } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { createRandomBytes, packDate } from "../../../../helpers"; import { type Context } from "../../../../types";
-
@@ -66,10 +65,12 @@const createLeaveDefinitionKey = new Uint8Array(16); self.crypto.getRandomValues(createLeaveDefinitionKey); const tx = db.transaction("workspaces", "readwrite"); let addedKey: IDBValidKey; try { addedKey = await db.add("workspaces", { addedKey = await tx.objectStore("workspaces").add({ id, displayName: req.displayName, capabilities: {
-
@@ -149,6 +150,8 @@ ],updatedAt: Date.now(), }); } catch (error) { tx.abort(); return toBinary( CreateResponseSchema, createMessage(CreateResponseSchema, {
-
@@ -166,16 +169,30 @@ }),); } let added: Workspace; try { const addedEntry = await db.get("workspaces", addedKey); if (!addedEntry) { const added = await workspace.getOneById(addedKey, tx.objectStore("workspaces")); if (!added) { throw "Failed to query an added entry"; } added = workspace.fromDBEntry(addedEntry); await tx.done; return toBinary( CreateResponseSchema, createMessage(CreateResponseSchema, { result: { case: "ok", value: { workspace: workspace.build(added, { mask: req.readMask, }), }, }, }), ); } catch (error) { tx.abort(); return toBinary( CreateResponseSchema, createMessage(CreateResponseSchema, {
-
@@ -192,16 +209,4 @@ },}), ); } return toBinary( CreateResponseSchema, createMessage(CreateResponseSchema, { result: { case: "ok", value: { workspace: workspace.mask(req.readMask, added), }, }, }), ); }
-
-
-
@@ -12,6 +12,8 @@ import { createRandomBytes, isSameBytes, packDate } from "../../../../helpers";import { type Context, type YamoriDB } from "../../../../types"; import * as leave from "../../../work_record/v1/leave"; import * as workspace from "../workspace"; function respond( payload: Extract< MessageInitShape<typeof CreateLeaveDefinitionResponseSchema>["result"],
-
@@ -105,11 +107,12 @@ value: { path: "leave_definition.revisions.snapshot" },}); } let tx; try { const tx = db.transaction("workspaces", "readwrite"); tx = db.transaction("workspaces", "readwrite"); const store = tx.objectStore("workspaces"); const found = await store.get(req.workspaceId.value); const found = await workspace.getOneById(req.workspaceId.value, store); if (!found) { await tx.done; return respond({
-
@@ -159,7 +162,7 @@ ...found,leaveDefinitions: [...found.leaveDefinitions, entry], }); const updatedWorkspace = await store.get(found.id); const updatedWorkspace = await workspace.getOneById(found.id, store); const updated = updatedWorkspace?.leaveDefinitions.find((def) => def.id === entry.id); if (!updated) { await tx.done;
-
@@ -172,13 +175,16 @@ },}); } const value = leave.build(updated, { mask: req.readMask, }); await tx.done; return respond({ case: "ok", value: leave.mask(req.readMask, leave.fromDBEntry(updated)), }); return respond({ case: "ok", value }); } catch (error) { tx?.abort(); return respond({ case: "systemError", value: {
-
-
-
@@ -21,7 +21,7 @@ * `delete` は予約後だからこれだけ目的語がついている。*/ export async function deleteWorkspace( data: Uint8Array, { db }: Context, ctx: Context, ): Promise<Uint8Array> { let req: DeleteRequest; try {
-
@@ -63,13 +63,13 @@ },}); } let tx; try { const tx = db.transaction("workspaces", "readwrite"); const store = tx.objectStore("workspaces"); tx = ctx.db.transaction("workspaces", "readwrite"); const found = await store.get(req.id.value); if (!found) { await tx.done; const entry = await workspace.getOneById(req.id.value, tx.objectStore("workspaces")); if (!entry) { return respond({ result: { case: "notFound",
-
@@ -80,10 +80,7 @@ },}); } if ( !(found.capabilities.deletionKey instanceof Uint8Array) || !isSameBytes(req.deletionKey.key, found.capabilities.deletionKey) ) { if (!isSameBytes(req.deletionKey.key, entry.capabilities.deletionKey)) { return respond({ result: { case: "capabilityError",
-
@@ -94,18 +91,23 @@ },}); } await store.delete(req.id.value); await tx.objectStore("workspaces").delete(req.id.value); const found = workspace.build(entry, { mask: req.readMask }); await tx.done; return respond({ result: { case: "ok", value: { workspace: workspace.mask(req.readMask, workspace.fromDBEntry(found)), workspace: found, }, }, }); } catch (error) { tx?.abort(); return respond({ result: { case: "systemError",
-
-
-
@@ -15,6 +15,8 @@ import { isSameBytes } from "../../../../helpers";import { type Context } from "../../../../types"; import * as leave from "../../../work_record/v1/leave"; import * as workspace from "../workspace"; function respond( result: Extract< MessageInitShape<typeof DeleteLeaveDefinitionResponseSchema>["result"],
-
@@ -76,11 +78,12 @@ },}); } let tx; try { const tx = db.transaction("workspaces", "readwrite"); tx = db.transaction("workspaces", "readwrite"); const store = tx.objectStore("workspaces"); const found = await store.get(req.workspaceId.value); const found = await workspace.getOneById(req.workspaceId.value, store); if (!found) { await tx.done; return respond({
-
@@ -104,6 +107,10 @@ },}); } const value = leave.build(def, { mask: req.readMask, }); if (!def.deletionKey || !isSameBytes(req.deletionKey.key, def.deletionKey)) { await tx.done; return respond({
-
@@ -126,10 +133,12 @@return respond({ case: "ok", value: { deleted: leave.mask(req.readMask, leave.fromDBEntry(def)), deleted: value, }, }); } catch (error) { tx?.abort(); return respond({ case: "systemError", value: {
-
-
-
@@ -8,10 +8,10 @@ type GetRequest,} from "@yamori/proto/yamori/workspace/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/workspace/v1/get_response_pb.js"; import { type Context, type YamoriDB } from "../../../../types"; import { type Context } from "../../../../types"; import * as workspace from "../workspace"; export async function get(data: Uint8Array, { db }: Context): Promise<Uint8Array> { export async function get(data: Uint8Array, ctx: Context): Promise<Uint8Array> { let req: GetRequest; try { req = fromBinary(GetRequestSchema, data);
-
@@ -47,10 +47,10 @@ }),); } let found: YamoriDB["workspaces"]["value"]; try { const entry = await db.get("workspaces", req.workspaceId.value); if (!entry) { const found = await ctx.db.get("workspaces", req.workspaceId.value); if (!found) { return toBinary( GetResponseSchema, create(GetResponseSchema, {
-
@@ -64,7 +64,17 @@ }),); } found = entry; return toBinary( GetResponseSchema, create(GetResponseSchema, { result: { case: "ok", value: workspace.build(found, { mask: req.readMask, }), }, }), ); } catch (error) { return toBinary( GetResponseSchema,
-
@@ -82,14 +92,4 @@ },}), ); } return toBinary( GetResponseSchema, create(GetResponseSchema, { result: { case: "ok", value: workspace.mask(req.readMask, workspace.fromDBEntry(found)), }, }), ); }
-
-
-
@@ -40,7 +40,11 @@ const index = tx.store.index("updatedAt");const workspaces: Workspace[] = []; for await (const cursor of index.iterate(null, "prev")) { workspaces.push(workspace.mask(req.readMask, workspace.fromDBEntry(cursor.value))); workspaces.push( workspace.build(cursor.value, { mask: req.readMask, }), ); } await tx.done;
-
-
-
@@ -131,7 +131,9 @@ return respond({result: { case: "ok", value: { workspace: workspace.mask(req.readMask, workspace.fromDBEntry(found)), workspace: workspace.build(found, { mask: req.readMask, }), }, }, });
-
@@ -158,7 +160,9 @@ return respond({result: { case: "ok", value: { workspace: workspace.mask(req.readMask, workspace.fromDBEntry(updated)), workspace: workspace.build(updated, { mask: req.readMask, }), }, }, });
-