返回 CodeWhale
draft-claim-lock.ts
根目录 / web / lib / draft-claim-lock.ts
1 /**
2 * The exclusive hold on one community-agent draft while a maintainer action
3 * (post or discard) runs.
4 *
5 * The `DraftClaimLock` Durable Object (exported from `worker.ts`, one instance
6 * per draft identity via `idFromName`) runs `applyDraftLock` against its own
7 * storage. A Durable Object handles one event at a time and its input gate
8 * holds other events while a storage call is pending, so the read and write
9 * below cannot interleave with another request's: exactly one claim wins.
10 *
11 * This module has no `cloudflare:workers` import so it runs under vitest and
12 * the Next.js build; `worker.ts` holds the thin class around it.
13 */
14
15 export type DraftLockAction = "post" | "discard" | "generate";
16
17 export interface PostAttempt { at: string; identity: string }
18
19 export type DraftLockRequest =
20 | { op: "post-status" }
21 | { op: "remember-post"; token: string; attempt: PostAttempt }
22 | { op: "forget-post"; token: string }
23 | { op: "claim"; token: string; action: DraftLockAction; leaseMs: number }
24 /**
25 * `holdMs` > 0 keeps refusing other claims for that long after an action
26 * whose decision was recorded, covering the time the KV decision marker
27 * takes to reach other locations. 0 frees the draft at once.
28 */
29 | { op: "release"; token: string; holdMs: number };
30
31 export type DraftLockResponse =
32 | { ok: true; attempt?: PostAttempt }
33 | { ok: false; holder: DraftLockAction };
34
35 /** The subset of `DurableObjectStorage` the lock uses. */
36 export interface DraftLockStorage {
37 get<T>(key: string): Promise<T | undefined>;
38 put<T>(key: string, value: T): Promise<void>;
39 delete(key: string): Promise<boolean>;
40 }
41
42 interface Lease {
43 token: string;
44 action: DraftLockAction;
45 /** Epoch ms. A lease past this no longer holds, so a crashed action cannot wedge the draft. */
46 expiresAt: number;
47 }
48
49 const LEASE_KEY = "lease";
50
51 export async function applyDraftLock(
52 storage: DraftLockStorage,
53 now: number,
54 req: DraftLockRequest
55 ): Promise<DraftLockResponse> {
56 if (req.op === "post-status") {
57 return { ok: true, attempt: await storage.get<PostAttempt>("post-attempt") };
58 }
59 const lease = await storage.get<Lease>(LEASE_KEY);
60 const live = lease && lease.expiresAt > now ? lease : undefined;
61
62 if (req.op === "remember-post" || req.op === "forget-post") {
63 if (!live || live.token !== req.token) throw new Error("post claim expired");
64 if (req.op === "remember-post") {
65 if (!/^[0-9a-f]{64}$/.test(req.attempt.identity) || !Number.isFinite(Date.parse(req.attempt.at))) throw new Error("invalid post receipt");
66 const previous = await storage.get<PostAttempt>("post-attempt");
67 if (previous && previous.identity !== req.attempt.identity) throw new Error("unresolved post has different text or target");
68 await storage.put("post-attempt", previous ?? req.attempt);
69 } else {
70 await storage.delete("post-attempt");
71 }
72 return { ok: true };
73 }
74 if (req.op === "claim") {
75 if (live && live.token !== req.token) return { ok: false, holder: live.action };
76 await storage.put<Lease>(LEASE_KEY, {
77 token: req.token,
78 action: req.action,
79 expiresAt: now + Math.max(0, req.leaseMs),
80 });
81 return { ok: true };
82 }
83
84 // Release: only the holder's own token can release, so a request whose
85 // lease expired and was retaken cannot free the new holder's claim.
86 if (!live || live.token !== req.token) return { ok: true };
87 if (req.holdMs > 0) {
88 await storage.put<Lease>(LEASE_KEY, { ...live, expiresAt: now + req.holdMs });
89 } else {
90 await storage.delete(LEASE_KEY);
91 }
92 return { ok: true };
93 }
94
95 /** The RPC surface of a `DraftClaimLock` stub. */
96 export interface DraftClaimLockStub {
97 act(req: DraftLockRequest): Promise<DraftLockResponse>;
98 }
99
100 /** The `DRAFT_CLAIM_LOCK` Durable Object namespace binding, as the app uses it. */
101 export interface DraftClaimLockNamespace {
102 idFromName(name: string): DraftClaimLockId;
103 get(id: DraftClaimLockId): DraftClaimLockStub;
104 }
105
106 /** Opaque `DurableObjectId`. */
107 export interface DraftClaimLockId {
108 toString(): string;
109 }
110
110 lines TYPESCRIPT