Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { createMirakcProxy } from "./routes/mirakc.ts";
import { transcode } from "./routes/transcode.ts";
import { createKeywordRulesRoutes } from "./routes/keyword-rules.ts";
import { createNotificationSettingsRoutes } from "./routes/notification-settings.ts";
import { Kv } from "./store/kv.ts";
import { KeywordRuleStore } from "./store/keyword-rules.ts";
import { NotificationSettingsStore } from "./store/notification-settings.ts";
import { createKv } from "./store/kv.ts";
import { createKeywordRuleStore } from "./store/keyword-rules.ts";
import { createNotificationSettingsStore } from "./store/notification-settings.ts";
import {
isValidNtfyUrl,
type NotificationEventKey,
Expand All @@ -29,9 +29,9 @@ const app = new Hono();

// 設定系データの永続化先 (Deno KV、${DATA_DIR:-./data}/kv.sqlite3)。
// 1 接続を全 store で共有する。
const kv = new Kv();
const keywordRuleStore = new KeywordRuleStore(kv);
const notificationSettingsStore = new NotificationSettingsStore(kv);
const kv = createKv();
const keywordRuleStore = createKeywordRuleStore(kv);
const notificationSettingsStore = createNotificationSettingsStore(kv);

const mirakcUrl = Deno.env.get("MIRAKC_URL");
const apiUrl = mirakcUrl === undefined ? undefined : mirakcApiUrlOf(mirakcUrl);
Expand Down
11 changes: 7 additions & 4 deletions server/store/keyword-rules.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { assertEquals } from "@std/assert";
import { KeywordRuleStore } from "./keyword-rules.ts";
import { Kv } from "./kv.ts";
import {
createKeywordRuleStore,
type KeywordRuleStore,
} from "./keyword-rules.ts";
import { createKv } from "./kv.ts";
import type { KeywordRuleInput } from "../lib/keyword-rules.ts";

function input(overrides: Partial<KeywordRuleInput> = {}): KeywordRuleInput {
Expand All @@ -16,9 +19,9 @@ function input(overrides: Partial<KeywordRuleInput> = {}): KeywordRuleInput {
}

async function withStore(fn: (store: KeywordRuleStore) => Promise<void>) {
const kv = new Kv(":memory:");
const kv = createKv(":memory:");
try {
await fn(new KeywordRuleStore(kv));
await fn(createKeywordRuleStore(kv));
} finally {
await kv.close();
}
Expand Down
68 changes: 10 additions & 58 deletions server/store/keyword-rules.ts
Original file line number Diff line number Diff line change
@@ -1,72 +1,24 @@
/**
* キーワード自動録画ルールの永続化。キーは
* `["settings", "keyword-rules", <id>]` — 今後の設定系も `["settings", ...]`
* 名前空間に追加していく。KV の接続・基本操作は server/store/kv.ts に委ねる。
* 名前空間に追加していく。汎用の collectionStore (server/store/kv.ts) に
* prefix と型ガードを渡すだけの薄い構成。並び順は collectionStore の既定
* (createdAt 降順 → id = 登録の新しい順) をそのまま使う。
*/

import {
isKeywordRule,
type KeywordRule,
type KeywordRuleInput,
} from "../lib/keyword-rules.ts";
import type { Kv } from "./kv.ts";
import { type CollectionStore, collectionStore, type Kv } from "./kv.ts";

const PREFIX = ["settings", "keyword-rules"] as const;

function keyOf(id: string): Deno.KvKey {
return [...PREFIX, id];
}
export type KeywordRuleStore = CollectionStore<KeywordRule, KeywordRuleInput>;

/** キーワードルールのストア。Kv は main.ts で生成したものを共有する。 */
export class KeywordRuleStore {
#kv: Kv;

constructor(kv: Kv) {
this.#kv = kv;
}

/** 保存済みルールの一覧 (登録の新しい順)。 */
async list(): Promise<KeywordRule[]> {
const values = await this.#kv.listValues([...PREFIX]);
return values
.filter(isKeywordRule)
.sort((a, b) => b.createdAt - a.createdAt || a.id.localeCompare(b.id));
}

/** ルールを追加する。 */
async add(
input: KeywordRuleInput,
now: number = Date.now(),
): Promise<KeywordRule> {
const rule: KeywordRule = {
...input,
id: crypto.randomUUID(),
createdAt: now,
};
await this.#kv.set(keyOf(rule.id), rule);
return rule;
}

/** 既存ルールを上書きする。id / createdAt は維持。無ければ null。 */
async update(
id: string,
input: KeywordRuleInput,
): Promise<KeywordRule | null> {
const current = await this.#kv.get(keyOf(id));
if (!isKeywordRule(current)) {
return null;
}
const rule: KeywordRule = {
...input,
id,
createdAt: current.createdAt,
};
await this.#kv.set(keyOf(id), rule);
return rule;
}

/** id 一致のルールを削除する。削除したら true、見つからなければ false。 */
remove(id: string): Promise<boolean> {
return this.#kv.remove(keyOf(id));
}
export function createKeywordRuleStore(kv: Kv): KeywordRuleStore {
return collectionStore<KeywordRuleInput, KeywordRule>(kv, {
prefix: ["settings", "keyword-rules"],
isValid: isKeywordRule,
});
}
6 changes: 3 additions & 3 deletions server/store/kv.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { assertEquals } from "@std/assert";
import { dataDir, Kv, kvPath } from "./kv.ts";
import { createKv, dataDir, kvPath } from "./kv.ts";

function withEnv(key: string, value: string | null, fn: () => void) {
const original = Deno.env.get(key);
Expand Down Expand Up @@ -41,7 +41,7 @@ Deno.test("kvPath: DATA_DIR 配下の kv.sqlite3", () => {
});

Deno.test("Kv: set / get / listValues / remove / close が動く", async () => {
const kv = new Kv(":memory:");
const kv = createKv(":memory:");
try {
assertEquals(await kv.get(["a", "1"]), null);

Expand All @@ -63,7 +63,7 @@ Deno.test("Kv: set / get / listValues / remove / close が動く", async () => {
Deno.test("Kv: 同じディレクトリにファイルを作る (mkdir 込み)", async () => {
const dir = await Deno.makeTempDir();
try {
const kv = new Kv(`${dir}/nested/kv.sqlite3`);
const kv = createKv(`${dir}/nested/kv.sqlite3`);
await kv.set(["x"], 1);
assertEquals(await kv.get(["x"]), 1);
await kv.close();
Expand Down
218 changes: 159 additions & 59 deletions server/store/kv.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
/**
* 設定系データの永続化基盤 (Deno KV のラッパー)。
* 設定系データの永続化基盤 (Deno KV のラッパーと汎用ストア)。
*
* - 置き場所は環境変数 `DATA_DIR` (既定 `./data`) 配下の `kv.sqlite3`。
* Docker 運用ではこのディレクトリを volume にマウントして永続化する
* - `Kv` が lazy open・親ディレクトリ作成・基本操作 (get / set / list /
* remove / close) を引き受け、各 store (server/store/*.ts) はキー設計と
* 型ガードに専念する
* - 1 プロセスで 1 インスタンスを共有する (main.ts で生成して各 store に
* 注入)。テストは `new Kv(":memory:")` で分離する
* - `createKv` が lazy open・親ディレクトリ作成・基本操作 (get / set /
* listValues / remove / close) を引き受ける。1 プロセスで 1 インスタンスを
* 共有する (main.ts で生成して各ストアに注入)。テストは
* `createKv(":memory:")` で分離する
* - `collectionStore` / `singletonStore` が CRUD の骨格を担い、各ドメインの
* ストア (server/store/*.ts) はキー設計・型ガード・正規化を渡すだけの薄い
* 構成にする。class は持たず、依存 (Kv) はクロージャに束ねる
*/

/** 設定系データのディレクトリ (`DATA_DIR`、既定 `./data`)。 */
Expand All @@ -21,68 +23,166 @@ export function kvPath(): string {
return `${dataDir()}/kv.sqlite3`;
}

/** Deno KV の薄いラッパー。 */
export class Kv {
#path: string;
#kv: Promise<Deno.Kv> | null = null;

/** path はテスト用に差し替え可能 (`":memory:"` など)。既定は kvPath()。 */
constructor(path: string = kvPath()) {
this.#path = path;
}
/** Deno KV の薄いラッパー。lazy-open 状態はクロージャに閉じる。 */
export type Kv = {
/** key の値。無ければ null。 */
get(key: Deno.KvKey): Promise<unknown>;
/** key に値を保存する。 */
set(key: Deno.KvKey, value: unknown): Promise<void>;
/** prefix 配下の値一覧 (キー順)。 */
listValues(prefix: Deno.KvKey): Promise<unknown[]>;
/** key を削除する。存在していたら true。 */
remove(key: Deno.KvKey): Promise<boolean>;
/** KV を閉じる (テスト用)。 */
close(): Promise<void>;
};

#open(): Promise<Deno.Kv> {
if (this.#kv === null) {
this.#kv = (async () => {
/** Kv を生成する。path はテスト用に差し替え可能 (`":memory:"` など)。 */
export function createKv(path: string = kvPath()): Kv {
let opened: Promise<Deno.Kv> | null = null;
const open = (): Promise<Deno.Kv> => {
if (opened === null) {
opened = (async () => {
// SQLite ファイルの親ディレクトリが無いと openKv が失敗する。
const dir = this.#path.replace(/\/[^/]*$/, "");
if (dir !== "" && dir !== this.#path) {
const dir = path.replace(/\/[^/]*$/, "");
if (dir !== "" && dir !== path) {
await Deno.mkdir(dir, { recursive: true });
}
return await Deno.openKv(this.#path);
return await Deno.openKv(path);
})();
}
return this.#kv;
}
return opened;
};

/** key の値。無ければ null。 */
async get(key: Deno.KvKey): Promise<unknown> {
const kv = await this.#open();
return (await kv.get(key)).value;
}
return {
async get(key) {
const db = await open();
return (await db.get(key)).value;
},
async set(key, value) {
const db = await open();
await db.set(key, value);
},
async listValues(prefix) {
const db = await open();
const values: unknown[] = [];
for await (const entry of db.list({ prefix })) {
values.push(entry.value);
}
return values;
},
async remove(key) {
const db = await open();
const current = await db.get(key);
if (current.versionstamp === null) {
return false;
}
await db.delete(key);
return true;
},
async close() {
if (opened !== null) {
(await opened).close();
opened = null;
}
},
};
}

/** key に値を保存する。 */
async set(key: Deno.KvKey, value: unknown): Promise<void> {
const kv = await this.#open();
await kv.set(key, value);
}
/**
* prefix 配下に複数レコードを持つコレクション型ストア。各レコードは安定 id と
* createdAt を持つ (`["settings", <name>, <id>]`)。Deno KV は id を自動採番
* しないため (versionstamp は更新で変わり安定 id にならない)、add で
* crypto.randomUUID() を採番する。
*/
export type CollectionStore<T, Input> = {
/** 保存済みレコードの一覧 (既定で createdAt 降順 → id)。 */
list(): Promise<T[]>;
/** レコードを追加する。id / createdAt を付与する。 */
add(input: Input, now?: number): Promise<T>;
/** 既存レコードを上書きする。id / createdAt は維持。無ければ null。 */
update(id: string, input: Input): Promise<T | null>;
/** id 一致のレコードを削除する。削除したら true、無ければ false。 */
remove(id: string): Promise<boolean>;
};

/** prefix 配下の値一覧 (キー順)。 */
async listValues(prefix: Deno.KvKey): Promise<unknown[]> {
const kv = await this.#open();
const values: unknown[] = [];
for await (const entry of kv.list({ prefix })) {
values.push(entry.value);
}
return values;
}
/**
* コレクション型ストアを生成する。レコードは `{ ...input, id, createdAt }` の
* 形で保存し、読み戻しは isValid で検証して壊れた値・旧形状を除く。
*/
export function collectionStore<
Input,
T extends Input & { id: string; createdAt: number },
>(
kv: Kv,
cfg: {
/** キーの prefix (`["settings", <name>]`)。 */
prefix: Deno.KvKey;
/** 読み戻した値が T か判定する型ガード。 */
isValid: (value: unknown) => value is T;
/** 並び順。既定は createdAt 降順 → id。 */
sort?: (a: T, b: T) => number;
},
): CollectionStore<T, Input> {
const { prefix, isValid } = cfg;
const sort = cfg.sort ??
((a: T, b: T) => b.createdAt - a.createdAt || a.id.localeCompare(b.id));
const keyOf = (id: string): Deno.KvKey => [...prefix, id];

/** key を削除する。存在していたら true。 */
async remove(key: Deno.KvKey): Promise<boolean> {
const kv = await this.#open();
const current = await kv.get(key);
if (current.versionstamp === null) {
return false;
}
await kv.delete(key);
return true;
}
return {
async list() {
const values = await kv.listValues(prefix);
return values.filter(isValid).sort(sort);
},
async add(input, now = Date.now()) {
const record = { ...input, id: crypto.randomUUID(), createdAt: now } as T;
await kv.set(keyOf(record.id), record);
return record;
},
async update(id, input) {
const current = await kv.get(keyOf(id));
if (!isValid(current)) {
return null;
}
const record = { ...input, id, createdAt: current.createdAt } as T;
await kv.set(keyOf(id), record);
return record;
},
remove(id) {
return kv.remove(keyOf(id));
},
};
}

/** KV を閉じる (テスト用)。 */
async close(): Promise<void> {
if (this.#kv !== null) {
(await this.#kv).close();
this.#kv = null;
}
}
/** 単一キーに 1 値だけ持つシングルトン型ストア。 */
export type SingletonStore<T> = {
/** 保存済みの値 (normalize 経由。未保存・不正値は normalize の既定値)。 */
get(): Promise<T>;
/** 値を全上書きで保存する。 */
set(value: T): Promise<T>;
};

/**
* シングルトン型ストアを生成する。読み戻しは normalize に通す
* (旧形状の補完・既定値フォールバックは normalize の責務)。
*/
export function singletonStore<T>(
kv: Kv,
cfg: {
/** 保存先のキー。 */
key: Deno.KvKey;
/** 読み戻した値を T に正規化する (既定値フォールバック込み)。 */
normalize: (value: unknown) => T;
},
): SingletonStore<T> {
const { key, normalize } = cfg;
return {
async get() {
return normalize(await kv.get(key));
},
async set(value) {
await kv.set(key, value);
return value;
},
};
}
Loading
Loading