Changes
17 changed files (+1123/-238)
-
-
@@ -12,7 +12,9 @@ "packages/idb_backend": {"name": "@yamori/idb_backend", "dependencies": { "@bufbuild/protobuf": "^2.2.2", "@date-fns/tz": "^1.2.0", "@yamori/proto": "packages/proto", "date-fns": "^4.1.0", "idb": "^8.0.0", }, "devDependencies": {
-
@@ -478,7 +480,7 @@ "@vitest/spy": ["@vitest/spy@2.0.5", "", { "dependencies": { "tinyspy": "^3.0.0" } }, "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA=="],"@vitest/utils": ["@vitest/utils@2.1.8", "", { "dependencies": { "@vitest/pretty-format": "2.1.8", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA=="], "@yamori/idb_backend": ["@yamori/idb_backend@workspace:packages/idb_backend", { "dependencies": { "@bufbuild/protobuf": "^2.2.2", "@yamori/proto": "packages/proto", "idb": "^8.0.0" }, "devDependencies": { "@types/bun": "^1.1.14", "fake-indexeddb": "^6.0.0", "typescript": "^5.7.2" } }], "@yamori/idb_backend": ["@yamori/idb_backend@workspace:packages/idb_backend", { "dependencies": { "@bufbuild/protobuf": "^2.2.2", "@date-fns/tz": "^1.2.0", "@yamori/proto": "packages/proto", "date-fns": "^4.1.0", "idb": "^8.0.0" }, "devDependencies": { "@types/bun": "^1.1.14", "fake-indexeddb": "^6.0.0", "typescript": "^5.7.2" } }], "@yamori/proto": ["@yamori/proto@workspace:packages/proto", { "devDependencies": { "@bufbuild/protobuf": "^2.2.2", "@bufbuild/protoc-gen-es": "^2.2.2" }, "peerDependencies": { "@bufbuild/protobuf": "^2.2.0" } }],
-
-
-
@@ -53,7 +53,9 @@ }}, "dependencies": { "@bufbuild/protobuf": "^2.2.2", "@date-fns/tz": "^1.2.0", "@yamori/proto": "workspace:*", "date-fns": "^4.1.0", "idb": "^8.0.0" }, "devDependencies": {
-
-
-
@@ -8,7 +8,35 @@ type DescMessage,type MessageShape, type MessageInitShape, } from "@bufbuild/protobuf"; import { TZDate, tz } from "@date-fns/tz"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { type DateArg, getYear, getMonth, getDate } from "date-fns"; export const TZ = "Asia/Tokyo"; export function jst(d: MessageInitShape<typeof DateSchema>) { if (typeof d.year !== "number") { throw new Error("Cannot convert year-less date to Date"); } if (typeof d.month !== "number") { throw new Error("Cannot convert month-less date to Date"); } if (typeof d.day !== "number") { throw new Error("Cannot convert day-less date to Date"); } return new TZDate(d.year, d.month - 1, d.day, "Asia/Tokyo"); } export function toProtoDate<DateType extends Date>(d: DateArg<DateType>) { return create(DateSchema, { year: getYear(d, { in: tz(TZ) }), month: getMonth(d, { in: tz(TZ) }) + 1, day: getDate(d, { in: tz(TZ) }), }); } export function isSameBytes(a: Uint8Array, b: Uint8Array): boolean { if (a.byteLength !== b.byteLength) {
-
-
-
@@ -5,8 +5,10 @@ import { openDB } from "idb";import { createRandomBytes, packDate } from "./helpers"; import { CONTEXT } from "./symbols"; import { type Context, type RPCMessage } from "./types"; import { type Context, type RPCMessage, type YamoriDB } from "./types"; import { service } from "./yamori/service"; import { automaticallyProvide } from "./yamori/worker/v1/paid_leave_provision"; class IDBBackend { [CONTEXT]: Context;
-
@@ -21,251 +23,359 @@ }} export async function idbBackend(): Promise<IDBBackend> { return new IDBBackend({ db: await openDB("yamori", 9, { // TODO: マイグレーションを個別のファイルに切り分ける async upgrade(db, oldVersion, _newVersion, transaction, _event) { // v1 if (oldVersion < 1) { const workspaces = db.createObjectStore("workspaces", { keyPath: "id", }); const db = await openDB<YamoriDB>("yamori", 10, { // TODO: マイグレーションを個別のファイルに切り分ける async upgrade(db, oldVersion, _newVersion, transaction, _event) { // v1 if (oldVersion < 1) { const workspaces = db.createObjectStore("workspaces", { keyPath: "id", }); workspaces.createIndex("updatedAt", "updatedAt", { unique: false, }); } workspaces.createIndex("updatedAt", "updatedAt", { unique: false, }); } // v2 if (oldVersion < 2) { const store = transaction.objectStore("workspaces"); // v2 if (oldVersion < 2) { const store = transaction.objectStore("workspaces"); for await (const cursor of store.iterate()) { const deletionKey = new Uint8Array(16); self.crypto.getRandomValues(deletionKey); for await (const cursor of store.iterate()) { const deletionKey = new Uint8Array(16); self.crypto.getRandomValues(deletionKey); const updateKey = new Uint8Array(16); self.crypto.getRandomValues(updateKey); const updateKey = new Uint8Array(16); self.crypto.getRandomValues(updateKey); const workerAddKey = new Uint8Array(16); self.crypto.getRandomValues(workerAddKey); const workerAddKey = new Uint8Array(16); self.crypto.getRandomValues(workerAddKey); await cursor.update({ ...cursor.value, capabilities: { ...cursor.value.capabilities, deletionKey, updateKey, workerAddKey, }, }); } await cursor.update({ ...cursor.value, capabilities: { ...cursor.value.capabilities, deletionKey, updateKey, workerAddKey, }, }); } } // v3 if (oldVersion < 3) { const workers = db.createObjectStore("workers", { keyPath: "id", }); // v3 if (oldVersion < 3) { const workers = db.createObjectStore("workers", { keyPath: "id", }); workers.createIndex("workspaceId", "workspaceId", { unique: false, }); workers.createIndex("workspaceId", "workspaceId", { unique: false, }); workers.createIndex("updatedAt", "updatedAt", { unique: false, }); } workers.createIndex("updatedAt", "updatedAt", { unique: false, }); } // v4 if (oldVersion < 4) { const store = transaction.objectStore("workspaces"); // v4 if (oldVersion < 4) { const store = transaction.objectStore("workspaces"); for await (const cursor of store.iterate()) { const createLeaveDefinitionKey = new Uint8Array(16); self.crypto.getRandomValues(createLeaveDefinitionKey); for await (const cursor of store.iterate()) { const createLeaveDefinitionKey = new Uint8Array(16); self.crypto.getRandomValues(createLeaveDefinitionKey); await cursor.update({ ...cursor.value, capabilities: { ...cursor.value.capabilities, createLeaveDefinitionKey, }, }); } await cursor.update({ ...cursor.value, capabilities: { ...cursor.value.capabilities, createLeaveDefinitionKey, }, }); } } // v5 if (oldVersion < 5) { // v6 でカバーされるためスキップ } // v5 if (oldVersion < 5) { // v6 でカバーされるためスキップ } // v6 if (oldVersion < 6) { const store = transaction.objectStore("workspaces"); // v6 if (oldVersion < 6) { const store = transaction.objectStore("workspaces"); for await (const cursor of store.iterate()) { const defs = cursor.value.leaveDefinitions; for await (const cursor of store.iterate()) { const defs = cursor.value.leaveDefinitions; await cursor.update({ ...cursor.value, // 労働基準法 第三十九条 第十項 で定義されている「出勤したものとみなす」休業に // ついてはどんな会社でも必ず必須となるため一律で登録する。 leaveDefinitions: [ { id: defs.find( (def) => def.createdBy === "system" && def.displayName === "育児休業", )?.id || `lv-${self.crypto.randomUUID()}`, displayName: "育児休業", updateKey: createRandomBytes(), createdBy: "system", revisions: [ { id: `lr-${self.crypto.randomUUID()}`, // 法律第七十九号 startAtPackedDate: packDate({ year: 1994, month: 4, day: 1, }), snapshot: { isWorkerDeemedToBeWorked: true, }, await cursor.update({ ...cursor.value, // 労働基準法 第三十九条 第十項 で定義されている「出勤したものとみなす」休業に // ついてはどんな会社でも必ず必須となるため一律で登録する。 leaveDefinitions: [ { id: defs.find( (def) => def.createdBy === "system" && def.displayName === "育児休業", )?.id || `lv-${self.crypto.randomUUID()}`, displayName: "育児休業", updateKey: createRandomBytes(), createdBy: "system", revisions: [ { id: `lr-${self.crypto.randomUUID()}`, // 法律第七十九号 startAtPackedDate: packDate({ year: 1994, month: 4, day: 1, }), snapshot: { isWorkerDeemedToBeWorked: true, }, ], }, { id: defs.find( (def) => def.createdBy === "system" && def.displayName === "介護休業", )?.id || `lv-${self.crypto.randomUUID()}`, displayName: "介護休業", updateKey: createRandomBytes(), createdBy: "system", revisions: [ { id: `lr-${self.crypto.randomUUID()}`, // 法律第百七号 startAtPackedDate: packDate({ year: 1995, month: 10, day: 1, }), snapshot: { isWorkerDeemedToBeWorked: true, }, }, ], }, { id: defs.find( (def) => def.createdBy === "system" && def.displayName === "介護休業", )?.id || `lv-${self.crypto.randomUUID()}`, displayName: "介護休業", updateKey: createRandomBytes(), createdBy: "system", revisions: [ { id: `lr-${self.crypto.randomUUID()}`, // 法律第百七号 startAtPackedDate: packDate({ year: 1995, month: 10, day: 1, }), snapshot: { isWorkerDeemedToBeWorked: true, }, ], }, { id: defs.find( (def) => def.createdBy === "system" && def.displayName === "産前産後休業", )?.id || `lv-${self.crypto.randomUUID()}`, displayName: "産前産後休業", updateKey: createRandomBytes(), createdBy: "system", revisions: [ { id: `lr-${self.crypto.randomUUID()}`, // 最初からある (法律第四十九号) // 三十九条の施行年月日は調べてもわからなかったので早い方 (遅い方は11/1) // ただぶっちゃけ昔のことだからシステム運用的にはどっちでもいい。 // こんな昔のデータを入れるとしたら付与日数なんかも過去の計算式を用意 // する必要があるため、あくまでも記録・教育的な側面のデータ。 startAtPackedDate: packDate({ year: 1947, month: 9, day: 1, }), snapshot: { isWorkerDeemedToBeWorked: true, }, }, ], }, { id: defs.find( (def) => def.createdBy === "system" && def.displayName === "産前産後休業", )?.id || `lv-${self.crypto.randomUUID()}`, displayName: "産前産後休業", updateKey: createRandomBytes(), createdBy: "system", revisions: [ { id: `lr-${self.crypto.randomUUID()}`, // 最初からある (法律第四十九号) // 三十九条の施行年月日は調べてもわからなかったので早い方 (遅い方は11/1) // ただぶっちゃけ昔のことだからシステム運用的にはどっちでもいい。 // こんな昔のデータを入れるとしたら付与日数なんかも過去の計算式を用意 // する必要があるため、あくまでも記録・教育的な側面のデータ。 startAtPackedDate: packDate({ year: 1947, month: 9, day: 1, }), snapshot: { isWorkerDeemedToBeWorked: true, }, ], }, ...defs.filter((def) => def.createdBy !== "system"), ], }); } }, ], }, ...defs.filter((def) => def.createdBy !== "system"), ], }); } } // v7 if (oldVersion < 7) { const store = transaction.objectStore("workspaces"); // 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, }; }), }); } 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, }; }), }); } } // v8 if (oldVersion < 8) { const store = transaction.objectStore("workers"); // v8 if (oldVersion < 8) { const store = transaction.objectStore("workers"); for await (const cursor of store.iterate()) { await cursor.update({ ...cursor.value, writeWorkRecordKey: cursor.value.writeWorkRecordKey || createRandomBytes(16), }); } for await (const cursor of store.iterate()) { await cursor.update({ ...cursor.value, writeWorkRecordKey: cursor.value.writeWorkRecordKey || createRandomBytes(16), }); } const workRecords = db.createObjectStore("workRecords", { keyPath: "recordId", }); const workRecords = db.createObjectStore("workRecords", { keyPath: "recordId", workRecords.createIndex( "workspaceId/workerId/datePacked", ["workspaceId", "workerId", "datePacked"], { unique: true, }, ); } // v9 if (oldVersion < 9) { const store = transaction.objectStore("workers"); for await (const cursor of store.iterate()) { await cursor.update({ ...cursor.value, providePaidLeaveKey: cursor.value.providePaidLeaveKey || createRandomBytes(16), }); } workRecords.createIndex( "workspaceId/workerId/datePacked", ["workspaceId", "workerId", "datePacked"], { unique: true, const paidLeaveProvision = db.createObjectStore("paidLeaveProvision", { keyPath: "id", }); paidLeaveProvision.createIndex( "workspaceId,workerId", ["workspaceId", "workerId"], { unique: false }, ); paidLeaveProvision.createIndex( "workspaceId,workerId,providedAtPacked", ["workspaceId", "workerId", "providedAtPacked"], { unique: true }, ); } // v10 if (oldVersion < 10) { const provisionTables = db.createObjectStore("paidLeaveProvisionTable", { keyPath: "id", }); provisionTables.createIndex("id,workspaceId", ["id", "workspaceId"], { unique: true, }); provisionTables.createIndex("workspaceId", "workspaceId", { multiEntry: true, unique: false, }); const generateID = () => "pt-" + crypto.randomUUID(); const systemTables = [ // https://laws.e-gov.go.jp/law/322AC0000000049#Mp-Ch_4-At_39 { id: generateID(), displayName: "通常", initialRevision: { firstProvisionAmountDays: 10, subsequentProvisionAmountDays: [11, 12, 14, 16, 18, 20], }, revisions: [], workspaceId: "", }, // https://laws.e-gov.go.jp/law/322M40000100023#Mp-At_24_3 { id: generateID(), displayName: "週所定労働日数4日", initialRevision: { firstProvisionAmountDays: 7, subsequentProvisionAmountDays: [8, 9, 10, 12, 13, 15], }, revisions: [], workspaceId: "", }, { id: generateID(), displayName: "週所定労働日数3日", initialRevision: { firstProvisionAmountDays: 5, subsequentProvisionAmountDays: [6, 6, 8, 9, 10, 11], }, revisions: [], workspaceId: "", }, { id: generateID(), displayName: "週所定労働日数2日", initialRevision: { firstProvisionAmountDays: 3, subsequentProvisionAmountDays: [4, 4, 5, 6, 6, 7], }, revisions: [], workspaceId: "", }, { id: generateID(), displayName: "週所定労働日数1日", initialRevision: { firstProvisionAmountDays: 1, subsequentProvisionAmountDays: [2, 2, 2, 3, 3, 3], }, ); revisions: [], workspaceId: "", }, ] satisfies YamoriDB["paidLeaveProvisionTable"]["value"][]; for (const table of systemTables) { await provisionTables.put(table); } // v9 if (oldVersion < 9) { const store = transaction.objectStore("workers"); const workspaces = transaction.objectStore("workspaces"); for await (const cursor of store.iterate()) { await cursor.update({ ...cursor.value, providePaidLeaveKey: cursor.value.providePaidLeaveKey || createRandomBytes(16), for await (const workspace of workspaces.iterate()) { for (const table of systemTables) { await provisionTables.put({ ...table, id: generateID(), workspaceId: workspace.value.id, baseId: table.id, }); } } } }, }); const paidLeaveProvision = db.createObjectStore("paidLeaveProvision", { keyPath: "id", }); const tx = db.transaction( ["workers", "paidLeaveProvision", "paidLeaveProvisionTable"], "readwrite", ); paidLeaveProvision.createIndex( "workspaceId,workerId", ["workspaceId", "workerId"], { unique: false }, ); const workers = tx.objectStore("workers"); const paidLeaveProvisions = tx.objectStore("paidLeaveProvision"); const paidLeaveProvisionTables = tx.objectStore("paidLeaveProvisionTable"); paidLeaveProvision.createIndex( "workspaceId,workerId,providedAtPacked", ["workspaceId", "workerId", "providedAtPacked"], { unique: true }, ); } }, }), for await (const worker of workers.iterate()) { await automaticallyProvide({ worker: worker.value, workers, paidLeaveProvisions, paidLeaveProvisionTables, }); } await tx.done; return new IDBBackend({ db, }); }
-
-
-
@@ -44,8 +44,11 @@ workspaceId: string;displayName: string; createdAt: number; updatedAt: number; firstProvisionAtPacked?: number; paidLeaveProvisionTableId?: string; writeWorkRecordKey: Uint8Array; providePaidLeaveKey: Uint8Array; lastAutomaticProvisionRanAt?: number; }; indexes: { workspaceId: string;
-
@@ -115,6 +118,30 @@ };indexes: { "workspaceId,workerId": [string, string]; "workspaceId,workerId,providedAtPacked": [string, string, number]; }; }; paidLeaveProvisionTable: { key: string; value: { id: string; workspaceId?: string; baseId?: string; displayName: string; updateKey?: Uint8Array; initialRevision: { firstProvisionAmountDays: number; subsequentProvisionAmountDays: number[]; }; revisions: { startAtPacked: number; firstProvisionAmountDays: number; subsequentProvisionAmountDays: number[]; }[]; }; indexes: { "id,workspaceId": [string, string]; workspaceId: string; }; }; }
-
-
-
@@ -0,0 +1,162 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { PaidLeaveProvisionTableSchema } from "@yamori/proto/yamori/paid_leave_provision/v1/paid_leave_provision_table_pb.js"; import { PaidLeaveProvisionTableRevisionSchema } from "@yamori/proto/yamori/paid_leave_provision/v1/paid_leave_provision_table_revision_pb.js"; import { PaidLeaveProvisionTableReadMaskSchema } from "@yamori/proto/yamori/paid_leave_provision/v1/paid_leave_provision_table_read_mask_pb.js"; import { type IDBPObjectStore, type StoreNames } from "idb"; import { unpackDate, packDate } from "../../../helpers"; import { type YamoriDB } from "../../../types"; export type CacheStore = Map< string | undefined, YamoriDB["paidLeaveProvisionTable"]["value"][] >; export function createCache(): CacheStore { return new Map(); } export interface GetAllForInput { workspaceId?: string; cache?: CacheStore; store: IDBPObjectStore< YamoriDB, ArrayLike<StoreNames<YamoriDB>>, "paidLeaveProvisionTable", "readonly" | "readwrite" >; } export async function getAllFor({ workspaceId, cache, store, }: GetAllForInput): Promise<YamoriDB["paidLeaveProvisionTable"]["value"][]> { const hit = cache && cache.get(workspaceId); if (hit) { return hit; } const entries = await store.index("workspaceId").getAll(workspaceId); if (cache) { cache.set(workspaceId, entries); } return entries; } function now(): proto.MessageInitShape<typeof DateSchema> { const d = new Date(); return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate(), }; } export interface BuildOptions { mask?: proto.MessageInitShape<typeof PaidLeaveProvisionTableReadMaskSchema>; /** * `currentRevision` を選ぶ際の基準日。未指定の場合は現在日。 */ date?: proto.MessageInitShape<typeof DateSchema>; getPaidLeaveProvisionTables( workspaceId?: string, ): Promise<YamoriDB["paidLeaveProvisionTable"]["value"][]>; } export async function build( entry: YamoriDB["paidLeaveProvisionTable"]["value"], { date = now(), getPaidLeaveProvisionTables, mask: { fields = PaidLeaveProvisionTableSchema.fields.map((field) => field.number), baseMask, } = {}, }: BuildOptions, ): Promise<proto.MessageShape<typeof PaidLeaveProvisionTableSchema>> { const init: proto.MessageInitShape<typeof PaidLeaveProvisionTableSchema> = {}; const revisions: proto.MessageInitShape< typeof PaidLeaveProvisionTableRevisionSchema >[] = [ { firstProvisionAmountDays: entry.initialRevision.firstProvisionAmountDays, subsequentProvisionAmountDays: entry.initialRevision.subsequentProvisionAmountDays, }, ...entry.revisions.map((rev) => { return { startAt: unpackDate(rev.startAtPacked), firstProvisionAmountDays: rev.firstProvisionAmountDays, subsequentProvisionAmountDays: rev.subsequentProvisionAmountDays, }; }), ]; for (const field of fields) { switch (field) { case PaidLeaveProvisionTableSchema.field.id.number: init.id = { value: entry.id }; break; case PaidLeaveProvisionTableSchema.field.displayName.number: init.displayName = entry.displayName; break; case PaidLeaveProvisionTableSchema.field.updateKey.number: init.updateKey = entry.updateKey && { key: entry.updateKey }; break; case PaidLeaveProvisionTableSchema.field.revisions.number: init.revisions = revisions; break; case PaidLeaveProvisionTableSchema.field.currentRevision.number: { const match = revisions.findLast((rev) => { if (!rev.startAt) { return true; } return packDate(rev.startAt) >= packDate(date); }); init.currentRevision = match; break; } case PaidLeaveProvisionTableSchema.field.base.number: { const found = (await getPaidLeaveProvisionTables()).find((t) => t.id === entry.baseId) || (entry.workspaceId ? (await getPaidLeaveProvisionTables(entry.workspaceId)).find( (t) => t.id === entry.baseId, ) : undefined); if (!found) { break; } init.base = await build(found, { date, getPaidLeaveProvisionTables, mask: { fields: ( baseMask?.fields ?? PaidLeaveProvisionTableSchema.fields.map((field) => field.number) ).filter( (field) => field !== PaidLeaveProvisionTableSchema.field.base.number, ), }, }); break; } } } return proto.create(PaidLeaveProvisionTableSchema, init); }
-
-
-
@@ -0,0 +1,198 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "fake-indexeddb/auto"; import { afterEach, beforeEach, describe, expect, setSystemTime, test } from "bun:test"; import { TZDate } from "@date-fns/tz"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { IDBFactory } from "fake-indexeddb"; import { idbBackend } from "../../../lib"; import { toProtoDate, TZ } from "../../../helpers"; import { CONTEXT } from "../../../symbols"; import { createWorkspace } from "../../workspace/v1/workspace_service/_test_utils"; import { createWorker, getWorker } from "./worker_service/_test_utils"; import { automaticallyProvide } from "./paid_leave_provision"; describe("automaticallyProvide", () => { beforeEach(() => { indexedDB = new IDBFactory(); }); afterEach(() => { setSystemTime(); }); test("Should skip workers missing first_paid_leave_provision_at", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const paidLeaveProvisionTableId = workspace.paidLeaveProvisionTables.sort( (a, b) => (b.currentRevision?.firstProvisionAmountDays ?? 0) - (a.currentRevision?.firstProvisionAmountDays ?? 0), )[0]?.id; const alice = await createWorker(ctx, workspace, { workspaceId: workspace.id, workerAddKey: workspace.workerAddKey, displayName: "Alice", paidLeaveProvisionTableId, }); const tx = ctx.db.transaction( ["workers", "paidLeaveProvision", "paidLeaveProvisionTable"], "readwrite", ); const workers = tx.objectStore("workers"); const paidLeaveProvisions = tx.objectStore("paidLeaveProvision"); await automaticallyProvide({ worker: (await workers.get(alice.id!.value))!, workers: workers, paidLeaveProvisions, paidLeaveProvisionTables: tx.objectStore("paidLeaveProvisionTable"), }); expect(await paidLeaveProvisions.count()).toBe(0); await tx.done; }); test("Should provide paid leaves", async () => { setSystemTime(new TZDate("2022-06-01", TZ)); const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const paidLeaveProvisionTableId = workspace.paidLeaveProvisionTables.sort( (a, b) => (b.currentRevision?.firstProvisionAmountDays ?? 0) - (a.currentRevision?.firstProvisionAmountDays ?? 0), )[0]?.id; // createWorker が自動付与を行うため個別に呼び出す必要はない。 const alice = await createWorker(ctx, workspace, { workspaceId: workspace.id, workerAddKey: workspace.workerAddKey, displayName: "Alice", paidLeaveProvisionTableId, firstPaidLeaveProvisionAt: toProtoDate(new TZDate("2022-01-01", TZ)), }); expect(alice.paidLeaveProvisions).toEqual([ expect.objectContaining({ amountDays: 10, remainingDays: 10, isHalvedDayRemaining: false, providedAt: expect.objectContaining({ year: 2022, month: 1, day: 1, }), expiresAt: expect.objectContaining({ year: 2024, month: 1, day: 1, }), }), ]); }); test("Should skip already-provided ones", async () => { setSystemTime(new TZDate("2022-06-01", TZ)); const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const paidLeaveProvisionTableId = workspace.paidLeaveProvisionTables.sort( (a, b) => (b.currentRevision?.firstProvisionAmountDays ?? 0) - (a.currentRevision?.firstProvisionAmountDays ?? 0), )[0]?.id; const alice = await createWorker(ctx, workspace, { workspaceId: workspace.id, workerAddKey: workspace.workerAddKey, displayName: "Alice", paidLeaveProvisionTableId, firstPaidLeaveProvisionAt: toProtoDate(new TZDate("2022-01-01", TZ)), }); const tx = ctx.db.transaction( ["workers", "paidLeaveProvision", "paidLeaveProvisionTable"], "readwrite", ); const workers = tx.objectStore("workers"); const paidLeaveProvisions = tx.objectStore("paidLeaveProvision"); setSystemTime(new TZDate("2022-06-02", TZ)); await automaticallyProvide({ worker: (await workers.get(alice.id!.value))!, workers: workers, paidLeaveProvisions, paidLeaveProvisionTables: tx.objectStore("paidLeaveProvisionTable"), }); const after = await getWorker(ctx, { workspaceId: workspace.id, workerId: alice.id, readMask: { fields: [WorkerSchema.field.paidLeaveProvisions.number], }, }); expect(after.paidLeaveProvisions).toEqual([ expect.objectContaining({ amountDays: 10, remainingDays: 10, isHalvedDayRemaining: false, providedAt: expect.objectContaining({ year: 2022, month: 1, day: 1, }), expiresAt: expect.objectContaining({ year: 2024, month: 1, day: 1, }), }), ]); await tx.done; }); test("Should be able to provide more than one", async () => { setSystemTime(new TZDate("2024-06-01", TZ)); const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const paidLeaveProvisionTableId = workspace.paidLeaveProvisionTables.sort( (a, b) => (b.currentRevision?.firstProvisionAmountDays ?? 0) - (a.currentRevision?.firstProvisionAmountDays ?? 0), )[0]?.id; const alice = await createWorker(ctx, workspace, { workspaceId: workspace.id, workerAddKey: workspace.workerAddKey, displayName: "Alice", paidLeaveProvisionTableId, firstPaidLeaveProvisionAt: toProtoDate(new TZDate("2022-01-01", TZ)), }); expect(alice.paidLeaveProvisions).toHaveLength(3); }); });
-
-
-
@@ -3,15 +3,17 @@ // SPDX-License-Identifier: AGPL-3.0-onlyimport * as proto from "@bufbuild/protobuf"; import { timestampFromMs } from "@bufbuild/protobuf/wkt"; import { tz } from "@date-fns/tz"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { PaidLeaveProvisionInputSchema } from "@yamori/proto/yamori/worker/v1/paid_leave_provision_input_pb.js"; import { PaidLeaveProvisionInputMaskSchema } from "@yamori/proto/yamori/worker/v1/paid_leave_provision_input_mask_pb.js"; import { PaidLeaveProvisionSchema } from "@yamori/proto/yamori/worker/v1/paid_leave_provision_pb.js"; import { PaidLeaveProvisionReadMaskSchema } from "@yamori/proto/yamori/worker/v1/paid_leave_provision_read_mask_pb.js"; import { PaidLeaveProvisionFilterSchema } from "@yamori/proto/yamori/worker/v1/paid_leave_provision_filter_pb.js"; import { addYears, differenceInYears, subDays, eachYearOfInterval } from "date-fns"; import { type IDBPObjectStore, type StoreNames } from "idb"; import { packDate, unpackDate } from "../../../helpers"; import { packDate, unpackDate, jst, toProtoDate, TZ } from "../../../helpers"; import { type YamoriDB } from "../../../types"; // TODO: 閏年の 2/29 の 2 年後がどうなるのか確認する
-
@@ -43,6 +45,111 @@ remainingDays: 0,createdAt: Date.now(), updatedAt: Date.now(), }; } export interface AutomaticallyProvideInput { worker: YamoriDB["workers"]["value"]; workers: IDBPObjectStore< YamoriDB, ArrayLike<StoreNames<YamoriDB>>, "workers", "readwrite" >; paidLeaveProvisions: IDBPObjectStore< YamoriDB, ArrayLike<StoreNames<YamoriDB>>, "paidLeaveProvision", "readwrite" >; paidLeaveProvisionTables: IDBPObjectStore< YamoriDB, ArrayLike<StoreNames<YamoriDB>>, "paidLeaveProvisionTable", "readwrite" >; } export async function automaticallyProvide({ worker, workers, paidLeaveProvisions, paidLeaveProvisionTables, }: AutomaticallyProvideInput): Promise<void> { if ( !worker.paidLeaveProvisionTableId || typeof worker.firstProvisionAtPacked !== "number" ) { // 未設定のためスキップ。 return; } const table = await paidLeaveProvisionTables.get(worker.paidLeaveProvisionTableId); if (!table) { // TODO: Make this error a custom one. throw new Error( "Nonexistent provision table ID: " + worker.paidLeaveProvisionTableId, ); } const firstProvisionAt = jst(unpackDate(worker.firstProvisionAtPacked)); const lookupStart = typeof worker.lastAutomaticProvisionRanAt === "number" ? jst(unpackDate(worker.lastAutomaticProvisionRanAt)) : subDays(firstProvisionAt, 1, { in: tz(TZ) }); const now = new Date(); const intervals = eachYearOfInterval( { start: firstProvisionAt, end: now, }, { in: tz(TZ) }, ).filter((d) => d > lookupStart); if (!intervals.length) { // 最終更新日を書き込んだところで次回のパフォーマンスが変わるわけでも // ないため、DB アクセスを最小にするためスキップする。 return; } for (const date of intervals) { const revision = table.revisions.findLast((rev) => jst(unpackDate(rev.startAtPacked)) >= date) || table.initialRevision; const years = differenceInYears(date, firstProvisionAt, { in: tz(TZ) }); const amountDays = years === 0 || !revision.subsequentProvisionAmountDays.length ? revision.firstProvisionAmountDays : revision.subsequentProvisionAmountDays[ Math.max( 0, Math.min(years - 1, revision.subsequentProvisionAmountDays.length - 1), ) ]!; await paidLeaveProvisions.put({ id: crypto.randomUUID(), workerId: worker.id, workspaceId: worker.workspaceId, providedAtPacked: packDate(toProtoDate(date)), expiresAtPacked: packDate(toProtoDate(addYears(date, 2))), amountDays, remainingDays: amountDays, createdAt: +now, updatedAt: +now, }); } await workers.put({ ...worker, lastAutomaticProvisionRanAt: +now, }); } export function write(
-
-
-
@@ -180,6 +180,38 @@expect(resp.result.value.typeName).toBe("yamori.workspace.v1.Workspace"); }); test("Should return not_found for nonexistent provision table ID", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createTestWorkspace(ctx); const resp = fromBinary( CreateResponseSchema, await method( toBinary( CreateRequestSchema, create(CreateRequestSchema, { workspaceId: workspace.id, workerAddKey: workspace.workerAddKey, displayName: "Alice", paidLeaveProvisionTableId: { value: "pt-sys-not-found", }, }), ), ctx, ), ); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", found ${resp.result.case}`); } expect(resp.result.value.typeName).toBe( "yamori.paid_leave_provision.v1.PaidLeaveProvisionTable", ); }); test("Should return an error for capability mismatch", async () => { const { [CONTEXT]: ctx } = await idbBackend();
-
-
-
@@ -13,11 +13,12 @@ type CreateRequest,} 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 { isSameBytes, createRandomBytes, packDate } from "../../../../helpers"; import { type Context } from "../../../../types"; import * as workRecord from "../../../work_record/v1/work_record"; import * as worker from "../worker"; import * as paidLeaveProvision from "../paid_leave_provision"; function respond( result: Exclude<
-
@@ -78,7 +79,16 @@let tx; let ws; try { tx = ctx.db.transaction(["workspaces", "workers", "workRecords"], "readwrite"); tx = ctx.db.transaction( [ "workspaces", "workers", "workRecords", "paidLeaveProvision", "paidLeaveProvisionTable", ], "readwrite", ); ws = await tx.objectStore("workspaces").get(req.workspaceId.value); if (!ws) {
-
@@ -113,6 +123,38 @@ },}); } if (req.paidLeaveProvisionTableId) { try { const store = tx.objectStore("paidLeaveProvisionTable"); const found = await store.get(req.paidLeaveProvisionTableId.value); if ( !found || (typeof found.workspaceId === "string" && found.workspaceId !== ws.id) ) { return respond({ case: "notFound", value: { typeName: "yamori.paid_leave_provision.v1.PaidLeaveProvisionTable", }, }); } } catch (error) { tx.abort(); return respond({ case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "An error occurred during provision table ID check" : `Exception thrown during provision table ID check: ${error}`, }, }); } } const id = "wr-" + crypto.randomUUID(); try {
-
@@ -126,6 +168,9 @@ createdAt: Date.now(),updatedAt: Date.now(), writeWorkRecordKey: createRandomBytes(16), providePaidLeaveKey: createRandomBytes(16), firstProvisionAtPacked: req.firstPaidLeaveProvisionAt && packDate(req.firstPaidLeaveProvisionAt), paidLeaveProvisionTableId: req.paidLeaveProvisionTableId?.value, }); const added = await store.get(addedKey);
-
@@ -133,6 +178,15 @@ if (!added) {throw "Failed to query an added entry"; } const paidLeaveProvisions = tx.objectStore("paidLeaveProvision"); await paidLeaveProvision.automaticallyProvide({ worker: added, workers: store, paidLeaveProvisions, paidLeaveProvisionTables: tx.objectStore("paidLeaveProvisionTable"), }); const value = await worker.build(added, { workspace: ws, getWorkRecords() {
-
@@ -145,8 +199,13 @@ tx.objectStore("workRecords"),); }, async getPaidLeaveProvisions() { // 作成時に付与は行っていない。 return []; return paidLeaveProvision.getAllForWorker( { workspaceID: ws.id, workerID: id, }, paidLeaveProvisions, ); }, mask: req.readMask, });
-
-
-
@@ -8,6 +8,7 @@ import { type IDBPObjectStore, type StoreNames } from "idb";import { type YamoriDB } from "../../../types"; import * as leave from "../../work_record/v1/leave"; import * as paidLeaveProvisionTable from "../../paid_leave_provision/v1/paid_leave_provision_table"; export async function getOneById( id: string,
-
@@ -23,17 +24,23 @@ }export interface BuildOptions { mask?: proto.MessageInitShape<typeof WorkspaceReadMaskSchema>; getPaidLeaveProvisionTables( workspaceId?: string, ): Promise<YamoriDB["paidLeaveProvisionTable"]["value"][]>; } export function build( export async function build( entry: YamoriDB["workspaces"]["value"], { mask: { fields = WorkspaceSchema.fields.map((field) => field.number), leaveDefinitionsMask, paidLeaveProvisionTableMask, } = {}, }: BuildOptions = {}, ): proto.MessageShape<typeof WorkspaceSchema> { getPaidLeaveProvisionTables, }: BuildOptions, ): Promise<proto.MessageShape<typeof WorkspaceSchema>> { const init: proto.MessageInitShape<typeof WorkspaceSchema> = {}; for (const field of fields) {
-
@@ -64,6 +71,17 @@ leave.build(def, {mask: leaveDefinitionsMask, }), ); break; case WorkspaceSchema.field.paidLeaveProvisionTables.number: init.paidLeaveProvisionTables = await Promise.all( (await getPaidLeaveProvisionTables(entry.id)).map(async (table) => { return paidLeaveProvisionTable.build(table, { mask: paidLeaveProvisionTableMask, getPaidLeaveProvisionTables, }); }), ); break; } }
-
-
-
@@ -253,3 +253,29 @@ ],}), ); }); test("Should copy system provision tables into the created workspace", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const resp = fromBinary( CreateResponseSchema, await method( toBinary( CreateRequestSchema, create(CreateRequestSchema, { displayName: "Foo", readMask: { fields: [WorkspaceSchema.field.paidLeaveProvisionTables.number], }, }), ), ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable(); } expect(resp.result.value.workspace?.paidLeaveProvisionTables).toHaveLength(5); });
-
-
-
@@ -10,6 +10,7 @@ import { CreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js";import { createRandomBytes, packDate } from "../../../../helpers"; import { type Context } from "../../../../types"; import * as paidLeaveProvisionTable from "../../../paid_leave_provision/v1/paid_leave_provision_table"; import * as workspace from "../workspace"; export async function create(data: Uint8Array, { db }: Context): Promise<Uint8Array> {
-
@@ -65,7 +66,7 @@const createLeaveDefinitionKey = new Uint8Array(16); self.crypto.getRandomValues(createLeaveDefinitionKey); const tx = db.transaction("workspaces", "readwrite"); const tx = db.transaction(["workspaces", "paidLeaveProvisionTable"], "readwrite"); let addedKey: IDBValidKey;
-
@@ -175,6 +176,35 @@ if (!added) {throw "Failed to query an added entry"; } const plptCache = paidLeaveProvisionTable.createCache(); const paidLeaveProvisionTables = tx.objectStore("paidLeaveProvisionTable"); for await (const systemTable of paidLeaveProvisionTables .index("workspaceId") .iterate("")) { if (systemTable.value.workspaceId) { continue; } await paidLeaveProvisionTables.put({ ...systemTable.value, id: `pt-${crypto.randomUUID()}`, workspaceId: id, baseId: systemTable.value.id, }); } const ws = await workspace.build(added, { mask: req.readMask, getPaidLeaveProvisionTables(workspaceId) { return paidLeaveProvisionTable.getAllFor({ workspaceId, cache: plptCache, store: paidLeaveProvisionTables, }); }, }); await tx.done; return toBinary(
-
@@ -183,9 +213,7 @@ createMessage(CreateResponseSchema, {result: { case: "ok", value: { workspace: workspace.build(added, { mask: req.readMask, }), workspace: ws, }, }, }),
-
-
-
@@ -10,6 +10,7 @@ import { DeleteResponseSchema } from "@yamori/proto/yamori/workspace/v1/delete_response_pb.js";import { isSameBytes } from "../../../../helpers"; import { type Context } from "../../../../types"; import * as paidLeaveProvisionTable from "../../../paid_leave_provision/v1/paid_leave_provision_table"; import * as workspace from "../workspace"; function respond(payload: MessageInitShape<typeof DeleteResponseSchema>): Uint8Array {
-
@@ -65,7 +66,7 @@ }let tx; try { tx = ctx.db.transaction("workspaces", "readwrite"); tx = ctx.db.transaction(["workspaces", "paidLeaveProvisionTable"], "readwrite"); const entry = await workspace.getOneById(req.id.value, tx.objectStore("workspaces"));
-
@@ -91,9 +92,29 @@ },}); } const plptCache = paidLeaveProvisionTable.createCache(); const paidLeaveProvisionTables = tx.objectStore("paidLeaveProvisionTable"); const ws = await workspace.build(entry, { mask: req.readMask, getPaidLeaveProvisionTables(workspaceId) { return paidLeaveProvisionTable.getAllFor({ workspaceId, cache: plptCache, store: paidLeaveProvisionTables, }); }, }); await tx.objectStore("workspaces").delete(req.id.value); const found = workspace.build(entry, { mask: req.readMask }); const tableKeys = await paidLeaveProvisionTables .index("workspaceId") .getAllKeys(req.id.value); for (const key of tableKeys) { await paidLeaveProvisionTables.delete(key); } await tx.done;
-
@@ -101,7 +122,7 @@ return respond({result: { case: "ok", value: { workspace: found, workspace: ws, }, }, });
-
-
-
@@ -9,6 +9,7 @@ } 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 } from "../../../../types"; import * as paidLeaveProvisionTable from "../../../paid_leave_provision/v1/paid_leave_provision_table"; import * as workspace from "../workspace"; export async function get(data: Uint8Array, ctx: Context): Promise<Uint8Array> {
-
@@ -48,7 +49,8 @@ );} try { const found = await ctx.db.get("workspaces", req.workspaceId.value); const tx = ctx.db.transaction(["workspaces", "paidLeaveProvisionTable"], "readonly"); const found = await tx.objectStore("workspaces").get(req.workspaceId.value); if (!found) { return toBinary(
-
@@ -64,14 +66,28 @@ }),); } const plptCache = paidLeaveProvisionTable.createCache(); const paidLeaveProvisionTables = tx.objectStore("paidLeaveProvisionTable"); const ws = await workspace.build(found, { mask: req.readMask, getPaidLeaveProvisionTables(workspaceId) { return paidLeaveProvisionTable.getAllFor({ workspaceId, cache: plptCache, store: paidLeaveProvisionTables, }); }, }); await tx.done; return toBinary( GetResponseSchema, create(GetResponseSchema, { result: { case: "ok", value: workspace.build(found, { mask: req.readMask, }), value: ws, }, }), );
-
-
-
@@ -10,6 +10,7 @@ import { ListResponseSchema } from "@yamori/proto/yamori/workspace/v1/list_response_pb.js";import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type Context } from "../../../../types"; import * as paidLeaveProvisionTable from "../../../paid_leave_provision/v1/paid_leave_provision_table"; import * as workspace from "../workspace"; export async function list(data: Uint8Array, { db }: Context): Promise<Uint8Array> {
-
@@ -35,14 +36,24 @@ );} try { const tx = db.transaction("workspaces", "readonly"); const index = tx.store.index("updatedAt"); const tx = db.transaction(["workspaces", "paidLeaveProvisionTable"], "readonly"); const index = tx.objectStore("workspaces").index("updatedAt"); const plptCache = paidLeaveProvisionTable.createCache(); const paidLeaveProvisionTables = tx.objectStore("paidLeaveProvisionTable"); const workspaces: Workspace[] = []; for await (const cursor of index.iterate(null, "prev")) { workspaces.push( workspace.build(cursor.value, { await workspace.build(cursor.value, { mask: req.readMask, getPaidLeaveProvisionTables(workspaceId) { return paidLeaveProvisionTable.getAllFor({ workspaceId, cache: plptCache, store: paidLeaveProvisionTables, }); }, }), ); }
-
-
-
@@ -10,6 +10,7 @@ import { UpdateResponseSchema } from "@yamori/proto/yamori/workspace/v1/update_response_pb.js";import { isSameBytes } from "../../../../helpers"; import { type Context, type YamoriDB } from "../../../../types"; import * as paidLeaveProvisionTable from "../../../paid_leave_provision/v1/paid_leave_provision_table"; import * as workspace from "../workspace"; function respond(payload: MessageInitShape<typeof UpdateResponseSchema>): Uint8Array {
-
@@ -82,9 +83,27 @@ break;} } let tx; try { tx = db.transaction(["workspaces", "paidLeaveProvisionTable"], "readwrite"); } catch (error) { return respond({ result: { case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "Failed to begin transaction" : `Exception thrown while beginning transaction: ${error}`, }, }, }); } let found: YamoriDB["workspaces"]["value"]; try { const entry = await db.get("workspaces", req.id.value); const entry = await tx.objectStore("workspaces").get(req.id.value); if (!entry) { return respond({ result: {
-
@@ -126,13 +145,23 @@ },}); } const plptCache = paidLeaveProvisionTable.createCache(); const paidLeaveProvisionTables = tx.objectStore("paidLeaveProvisionTable"); if (!changes.length) { return respond({ result: { case: "ok", value: { workspace: workspace.build(found, { workspace: await workspace.build(found, { mask: req.readMask, getPaidLeaveProvisionTables(workspaceId) { return paidLeaveProvisionTable.getAllFor({ workspaceId, cache: plptCache, store: paidLeaveProvisionTables, }); }, }), }, },
-
@@ -142,7 +171,6 @@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,
-
@@ -150,19 +178,29 @@ updatedAt: Date.now(),}); const updated = await store.get(key); await tx.done; if (!updated) { throw "Failed to query an updated entry"; } const ws = await workspace.build(updated, { mask: req.readMask, getPaidLeaveProvisionTables(workspaceId) { return paidLeaveProvisionTable.getAllFor({ workspaceId, cache: plptCache, store: paidLeaveProvisionTables, }); }, }); await tx.done; return respond({ result: { case: "ok", value: { workspace: workspace.build(updated, { mask: req.readMask, }), workspace: ws, }, }, });
-