| 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 |