Unified · Split
| @@ -1,65 +1,69 @@ | ||
| 1 | +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; | |
| 1 | 2 | import path from "node:path"; |
| 2 | 3 | |
| 3 | 4 | type ApprovalStatus = "pending" | "approved" | "denied"; |
| 4 | 5 | |
| 5 | 6 | interface ApprovalItem { |
| 6 | 7 | id: string; |
| 7 | − title: string; | |
| 8 | − details: string; | |
| 9 | − status: ApprovalStatus; | |
| 8 | + action: string; | |
| 9 | + justification: string; | |
| 10 | 10 | createdAt: number; |
| 11 | + status: ApprovalStatus; | |
| 12 | + decidedAt: number | null; | |
| 11 | 13 | } |
| 12 | 14 | |
| 13 | −function parseArgs(argv: string[]): { session: string; inline: boolean; tmuxSession: string; agentCommand: string } { | |
| 14 | − const session = argv[0] && !argv[0].startsWith("-") ? argv[0] : "claude"; | |
| 15 | − const inline = argv.includes("--inline"); | |
| 15 | +interface PersistedState { | |
| 16 | + approvals: ApprovalItem[]; | |
| 17 | +} | |
| 18 | + | |
| 19 | +interface ParsedArgs { | |
| 20 | + session: string; | |
| 21 | + inline: boolean; | |
| 22 | + tmuxSession: string; | |
| 23 | + agentCommand: string; | |
| 24 | + approvalId: string | null; | |
| 25 | +} | |
| 16 | 26 | |
| 17 | − const tmuxSessionFlagIndex = argv.indexOf("--tmux-session"); | |
| 18 | − const tmuxSession = tmuxSessionFlagIndex >= 0 && argv[tmuxSessionFlagIndex + 1] | |
| 19 | − ? String(argv[tmuxSessionFlagIndex + 1]) | |
| 20 | − : "executor-approvals"; | |
| 27 | +const DEFAULT_MCP_CONFIG_PATH = path.resolve(import.meta.dir, "claude-mcp-demo.json"); | |
| 28 | +const HOME_DIR = process.env.HOME ?? "."; | |
| 29 | +const STATE_PATH = path.join(HOME_DIR, ".executor-lite-approvals", "mcp-state.json"); | |
| 21 | 30 | |
| 22 | − const agentCommandFlagIndex = argv.indexOf("--agent-cmd"); | |
| 23 | − const agentCommand = agentCommandFlagIndex >= 0 && argv[agentCommandFlagIndex + 1] | |
| 24 | − ? String(argv[agentCommandFlagIndex + 1]) | |
| 25 | − : session; | |
| 31 | +mkdirSync(path.dirname(STATE_PATH), { recursive: true }); | |
| 26 | 32 | |
| 27 | − return { session, inline, tmuxSession, agentCommand }; | |
| 33 | +function shellQuote(value: string): string { | |
| 34 | + return `'${value.replace(/'/g, "'\\''")}'`; | |
| 28 | 35 | } |
| 29 | 36 | |
| 30 | −function hasTmuxBinary(): boolean { | |
| 31 | − const result = Bun.spawnSync({ cmd: ["tmux", "-V"] }); | |
| 32 | − return result.exitCode === 0; | |
| 37 | +function getFlag(argv: string[], name: string): string | null { | |
| 38 | + const index = argv.indexOf(name); | |
| 39 | + if (index < 0) return null; | |
| 40 | + const value = argv[index + 1]; | |
| 41 | + return typeof value === "string" ? value : null; | |
| 33 | 42 | } |
| 34 | 43 | |
| 35 | −function getTmuxClients(): string[] { | |
| 36 | − const result = Bun.spawnSync({ cmd: ["tmux", "list-clients", "-F", "#{client_name}"] }); | |
| 37 | − if (result.exitCode !== 0) return []; | |
| 44 | +function defaultAgentCommand(session: string): string { | |
| 45 | + if (session === "claude") { | |
| 46 | + const allowedTools = "mcp__memory-approval-demo__dangerous_action"; | |
| 47 | + return `claude --strict-mcp-config --mcp-config ${shellQuote(DEFAULT_MCP_CONFIG_PATH)} --allowedTools ${shellQuote(allowedTools)} --permission-mode bypassPermissions`; | |
| 48 | + } | |
| 38 | 49 | |
| 39 | − return Buffer.from(result.stdout) | |
| 40 | − .toString("utf8") | |
| 41 | − .split("\n") | |
| 42 | − .map((line) => line.trim()) | |
| 43 | − .filter((line) => line.length > 0 && line !== "no current client"); | |
| 50 | + return session; | |
| 44 | 51 | } |
| 45 | 52 | |
| 46 | −async function launchTmuxPopup(session: string, clientTarget: string): Promise<void> { | |
| 47 | − const scriptPath = path.resolve(import.meta.dir, "approval-client.ts"); | |
| 48 | − const command = `bun run ${scriptPath} ${session} --inline`; | |
| 49 | − Bun.spawnSync({ | |
| 50 | − cmd: ["tmux", "display-popup", "-t", clientTarget, "-E", "-w", "82%", "-h", "70%", command], | |
| 51 | − stdin: "inherit", | |
| 52 | − stdout: "inherit", | |
| 53 | − stderr: "inherit", | |
| 54 | − }); | |
| 55 | −} | |
| 53 | +function parseArgs(argv: string[]): ParsedArgs { | |
| 54 | + const positionalSession = argv[0] && !argv[0].startsWith("-") ? argv[0] : "claude"; | |
| 55 | + const session = getFlag(argv, "--session") ?? positionalSession; | |
| 56 | + const inline = argv.includes("--inline"); | |
| 56 | 57 | |
| 57 | −function shellQuote(value: string): string { | |
| 58 | − return `'${value.replace(/'/g, "'\\''")}'`; | |
| 58 | + const tmuxSession = getFlag(argv, "--tmux-session") ?? "executor-approvals"; | |
| 59 | + const agentCommand = getFlag(argv, "--agent-cmd") ?? defaultAgentCommand(session); | |
| 60 | + const approvalId = getFlag(argv, "--approval-id"); | |
| 61 | + | |
| 62 | + return { session, inline, tmuxSession, agentCommand, approvalId }; | |
| 59 | 63 | } |
| 60 | 64 | |
| 61 | −function tmuxSessionExists(tmuxSession: string): boolean { | |
| 62 | − const result = Bun.spawnSync({ cmd: ["tmux", "has-session", "-t", tmuxSession] }); | |
| 65 | +function hasTmuxBinary(): boolean { | |
| 66 | + const result = Bun.spawnSync({ cmd: ["tmux", "-V"] }); | |
| 63 | 67 | return result.exitCode === 0; |
| 64 | 68 | } |
| 65 | 69 | |
| @@ -73,32 +77,24 @@ function runTmuxCommand(cmd: string[]): boolean { · 7 unmodified lines | ||
| 73 | 77 | return result.exitCode === 0; |
| 74 | 78 | } |
| 75 | 79 | |
| 80 | +function tmuxSessionExists(tmuxSession: string): boolean { | |
| 81 | + const result = Bun.spawnSync({ cmd: ["tmux", "has-session", "-t", tmuxSession] }); | |
| 82 | + return result.exitCode === 0; | |
| 83 | +} | |
| 84 | + | |
| 76 | 85 | function ensureSessionUiOptions(tmuxSession: string): void { |
| 77 | 86 | runTmuxCommand(["tmux", "set-option", "-t", tmuxSession, "mouse", "on"]); |
| 78 | 87 | runTmuxCommand(["tmux", "set-option", "-t", tmuxSession, "pane-border-status", "top"]); |
| 79 | 88 | runTmuxCommand(["tmux", "set-option", "-t", tmuxSession, "pane-border-format", "#{pane_index}: #{pane_title}"]); |
| 80 | 89 | } |
| 81 | 90 | |
| 82 | −function setPaneTitles(windowTarget: string): void { | |
| 83 | − runTmuxCommand(["tmux", "select-pane", "-t", `${windowTarget}.0`, "-T", "Claude"]); | |
| 84 | − runTmuxCommand(["tmux", "select-pane", "-t", `${windowTarget}.1`, "-T", "Approvals"]); | |
| 85 | −} | |
| 86 | − | |
| 87 | −async function launchManagedTmuxWorkspace(session: string, tmuxSession: string, agentCommand: string): Promise<boolean> { | |
| 88 | − const scriptPath = path.resolve(import.meta.dir, "approval-client.ts"); | |
| 89 | − const approvalCommand = `bun run ${shellQuote(scriptPath)} ${shellQuote(session)} --inline`; | |
| 90 | − | |
| 91 | +async function launchManagedTmuxWorkspace(tmuxSession: string, agentCommand: string): Promise<boolean> { | |
| 91 | 92 | if (!tmuxSessionExists(tmuxSession)) { |
| 92 | 93 | const created = runTmuxCommand(["tmux", "new-session", "-d", "-s", tmuxSession, agentCommand]); |
| 93 | 94 | if (!created) return false; |
| 94 | 95 | |
| 95 | 96 | ensureSessionUiOptions(tmuxSession); |
| 96 | − | |
| 97 | − const split = runTmuxCommand(["tmux", "split-window", "-t", `${tmuxSession}:0`, "-v", "-p", "30", approvalCommand]); | |
| 98 | − if (!split) return false; | |
| 99 | − | |
| 100 | − setPaneTitles(`${tmuxSession}:0`); | |
| 101 | − runTmuxCommand(["tmux", "select-pane", "-t", `${tmuxSession}:0.0`]); | |
| 97 | + runTmuxCommand(["tmux", "select-pane", "-t", `${tmuxSession}:0.0`, "-T", "Claude"]); | |
| 102 | 98 | return runTmuxCommand(["tmux", "attach-session", "-t", tmuxSession]); |
| 103 | 99 | } |
| 104 | 100 | |
| @@ -109,84 +105,96 @@ async function launchManagedTmuxWorkspace(session: string, tmuxSession: string, · 4 unmodified lines | ||
| 109 | 105 | const createdWindow = runTmuxCommand(["tmux", "new-window", "-t", `${tmuxSession}:`, "-n", windowName, agentCommand]); |
| 110 | 106 | if (!createdWindow) return false; |
| 111 | 107 | |
| 112 | − const splitWindow = runTmuxCommand(["tmux", "split-window", "-t", windowTarget, "-v", "-p", "30", approvalCommand]); | |
| 113 | − if (!splitWindow) return false; | |
| 114 | − | |
| 115 | − setPaneTitles(windowTarget); | |
| 116 | − runTmuxCommand(["tmux", "select-pane", "-t", `${windowTarget}.0`]); | |
| 108 | + runTmuxCommand(["tmux", "select-pane", "-t", `${windowTarget}.0`, "-T", "Claude"]); | |
| 109 | + runTmuxCommand(["tmux", "select-window", "-t", windowTarget]); | |
| 117 | 110 | return runTmuxCommand(["tmux", "attach-session", "-t", tmuxSession]); |
| 118 | 111 | } |
| 119 | 112 | |
| 120 | −function makeApproval(session: string, index: number): ApprovalItem { | |
| 121 | − const actions = [ | |
| 122 | − "Write DNS record", | |
| 123 | − "Delete production file", | |
| 124 | − "Rotate API token", | |
| 125 | − "Deploy migration", | |
| 126 | − "Run destructive cleanup", | |
| 127 | − ]; | |
| 128 | − | |
| 129 | − const title = actions[index % actions.length] ?? "Tool action"; | |
| 130 | − const id = `appr_${crypto.randomUUID().slice(0, 8)}`; | |
| 131 | − | |
| 132 | − return { | |
| 133 | − id, | |
| 134 | − title, | |
| 135 | − details: `Session: ${session} -> tools.example.action()`, | |
| 136 | − status: "pending", | |
| 137 | − createdAt: Date.now(), | |
| 138 | − }; | |
| 113 | +function loadState(): PersistedState { | |
| 114 | + if (!existsSync(STATE_PATH)) return { approvals: [] }; | |
| 115 | + | |
| 116 | + try { | |
| 117 | + const text = readFileSync(STATE_PATH, "utf8"); | |
| 118 | + const parsed = JSON.parse(text) as Partial<PersistedState>; | |
| 119 | + if (!parsed || !Array.isArray(parsed.approvals)) { | |
| 120 | + return { approvals: [] }; | |
| 121 | + } | |
| 122 | + | |
| 123 | + return { | |
| 124 | + approvals: parsed.approvals.filter((entry): entry is ApprovalItem => { | |
| 125 | + return ( | |
| 126 | + Boolean(entry) && | |
| 127 | + typeof entry.id === "string" && | |
| 128 | + typeof entry.action === "string" && | |
| 129 | + typeof entry.justification === "string" && | |
| 130 | + typeof entry.createdAt === "number" && | |
| 131 | + (entry.status === "pending" || entry.status === "approved" || entry.status === "denied") | |
| 132 | + ); | |
| 133 | + }), | |
| 134 | + }; | |
| 135 | + } catch { | |
| 136 | + return { approvals: [] }; | |
| 137 | + } | |
| 139 | 138 | } |
| 140 | 139 | |
| 141 | −function statusMarker(status: ApprovalStatus): string { | |
| 142 | − if (status === "approved") return "[approved]"; | |
| 143 | − if (status === "denied") return "[denied]"; | |
| 144 | − return "[pending]"; | |
| 140 | +function saveState(state: PersistedState): void { | |
| 141 | + const tempPath = `${STATE_PATH}.tmp`; | |
| 142 | + writeFileSync(tempPath, JSON.stringify(state, null, 2)); | |
| 143 | + renameSync(tempPath, STATE_PATH); | |
| 145 | 144 | } |
| 146 | 145 | |
| 147 | −function clearAndRender(session: string, approvals: ApprovalItem[], selected: number): void { | |
| 146 | +function findApproval(state: PersistedState, approvalId: string | null): ApprovalItem | null { | |
| 147 | + if (approvalId) { | |
| 148 | + return state.approvals.find((item) => item.id === approvalId) ?? null; | |
| 149 | + } | |
| 150 | + | |
| 151 | + return state.approvals.find((item) => item.status === "pending") ?? null; | |
| 152 | +} | |
| 153 | + | |
| 154 | +function decideApproval(approvalId: string, status: ApprovalStatus): ApprovalItem | null { | |
| 155 | + const state = loadState(); | |
| 156 | + const target = state.approvals.find((item) => item.id === approvalId); | |
| 157 | + if (!target || target.status !== "pending") return null; | |
| 158 | + | |
| 159 | + target.status = status; | |
| 160 | + target.decidedAt = Date.now(); | |
| 161 | + saveState(state); | |
| 162 | + return target; | |
| 163 | +} | |
| 164 | + | |
| 165 | +function renderInline(session: string, current: ApprovalItem | null): void { | |
| 148 | 166 | process.stdout.write("\x1Bc"); |
| 149 | − process.stdout.write("Approval Popup Demo\n"); | |
| 167 | + process.stdout.write("Approval Pane (MCP-driven)\n"); | |
| 150 | 168 | process.stdout.write(`Session: ${session}\n`); |
| 151 | − process.stdout.write("Controls: j/k move a approve d deny n new request q quit\n"); | |
| 152 | − process.stdout.write("tmux tip: Ctrl+b then o to switch panes (mouse is enabled).\n"); | |
| 153 | − process.stdout.write("-".repeat(72) + "\n"); | |
| 169 | + process.stdout.write("Controls: a approve d deny q quit\n"); | |
| 170 | + process.stdout.write("This pane only appears when an MCP approval is pending.\n"); | |
| 171 | + process.stdout.write("-".repeat(84) + "\n"); | |
| 154 | 172 | |
| 155 | − if (approvals.length === 0) { | |
| 156 | − process.stdout.write("No approvals. Press n to enqueue one.\n"); | |
| 173 | + if (!current) { | |
| 174 | + process.stdout.write("No pending approval. Closing...\n"); | |
| 157 | 175 | return; |
| 158 | 176 | } |
| 159 | 177 | |
| 160 | − approvals.forEach((item, idx) => { | |
| 161 | − const pointer = idx === selected ? ">" : " "; | |
| 162 | − const time = new Date(item.createdAt).toLocaleTimeString(); | |
| 163 | − process.stdout.write(`${pointer} ${statusMarker(item.status)} ${item.id} ${item.title} (${time})\n`); | |
| 164 | − process.stdout.write(` ${item.details}\n`); | |
| 165 | − }); | |
| 178 | + const created = new Date(current.createdAt).toLocaleTimeString(); | |
| 179 | + process.stdout.write(`[pending] ${current.id}\n`); | |
| 180 | + process.stdout.write(`action: ${current.action}\n`); | |
| 181 | + process.stdout.write(`created: ${created}\n`); | |
| 182 | + if (current.justification.trim().length > 0) { | |
| 183 | + process.stdout.write(`justification: ${current.justification}\n`); | |
| 184 | + } | |
| 166 | 185 | } |
| 167 | 186 | |
| 168 | −async function runInlineUi(session: string): Promise<number> { | |
| 169 | − const approvals: ApprovalItem[] = [makeApproval(session, 0)]; | |
| 170 | − let selected = 0; | |
| 171 | − let sequence = 1; | |
| 172 | − | |
| 173 | − const enqueueTimer = setInterval(() => { | |
| 174 | − approvals.unshift(makeApproval(session, sequence)); | |
| 175 | − sequence += 1; | |
| 176 | − selected = 0; | |
| 177 | − clearAndRender(session, approvals, selected); | |
| 178 | − }, 12000); | |
| 187 | +async function runInlineUi(session: string, approvalId: string | null): Promise<number> { | |
| 188 | + let current = findApproval(loadState(), approvalId); | |
| 189 | + renderInline(session, current); | |
| 179 | 190 | |
| 180 | 191 | const cleanup = (): void => { |
| 181 | − clearInterval(enqueueTimer); | |
| 182 | 192 | if (process.stdin.isTTY) { |
| 183 | 193 | process.stdin.setRawMode(false); |
| 184 | 194 | } |
| 185 | 195 | process.stdin.pause(); |
| 186 | 196 | }; |
| 187 | 197 | |
| 188 | − clearAndRender(session, approvals, selected); | |
| 189 | − | |
| 190 | 198 | return await new Promise<number>((resolve) => { |
| 191 | 199 | if (process.stdin.isTTY) { |
| 192 | 200 | process.stdin.setRawMode(true); |
| @@ -200,59 +208,46 @@ async function runInlineUi(session: string): Promise<number> { · 7 unmodified lines | ||
| 200 | 208 | resolve(code); |
| 201 | 209 | }; |
| 202 | 210 | |
| 211 | + const poll = setInterval(() => { | |
| 212 | + current = findApproval(loadState(), approvalId); | |
| 213 | + if (!current) { | |
| 214 | + clearInterval(poll); | |
| 215 | + renderInline(session, null); | |
| 216 | + setTimeout(() => finish(0), 250); | |
| 217 | + return; | |
| 218 | + } | |
| 219 | + renderInline(session, current); | |
| 220 | + }, 500); | |
| 221 | + | |
| 203 | 222 | process.stdin.on("data", (chunk) => { |
| 204 | 223 | const key = typeof chunk === "string" ? chunk : chunk.toString("utf8"); |
| 205 | 224 | |
| 206 | 225 | if (key === "\u0003" || key === "q") { |
| 226 | + clearInterval(poll); | |
| 207 | 227 | finish(0); |
| 208 | 228 | return; |
| 209 | 229 | } |
| 210 | 230 | |
| 211 | − if (key === "j" || key === "\u001B[B") { | |
| 212 | − if (approvals.length > 0) { | |
| 213 | − selected = Math.min(approvals.length - 1, selected + 1); | |
| 214 | − } | |
| 215 | − clearAndRender(session, approvals, selected); | |
| 216 | − return; | |
| 217 | − } | |
| 218 | − | |
| 219 | − if (key === "k" || key === "\u001B[A") { | |
| 220 | − if (approvals.length > 0) { | |
| 221 | − selected = Math.max(0, selected - 1); | |
| 222 | − } | |
| 223 | − clearAndRender(session, approvals, selected); | |
| 224 | − return; | |
| 225 | − } | |
| 226 | − | |
| 227 | − if (key === "n") { | |
| 228 | − approvals.unshift(makeApproval(session, sequence)); | |
| 229 | − sequence += 1; | |
| 230 | − selected = 0; | |
| 231 | − clearAndRender(session, approvals, selected); | |
| 232 | − return; | |
| 233 | − } | |
| 234 | − | |
| 235 | − const current = approvals[selected]; | |
| 236 | − if (!current || current.status !== "pending") { | |
| 237 | − return; | |
| 238 | − } | |
| 231 | + if (!current) return; | |
| 239 | 232 | |
| 240 | 233 | if (key === "a") { |
| 241 | − current.status = "approved"; | |
| 242 | − clearAndRender(session, approvals, selected); | |
| 234 | + decideApproval(current.id, "approved"); | |
| 235 | + clearInterval(poll); | |
| 236 | + finish(0); | |
| 243 | 237 | return; |
| 244 | 238 | } |
| 245 | 239 | |
| 246 | 240 | if (key === "d") { |
| 247 | − current.status = "denied"; | |
| 248 | − clearAndRender(session, approvals, selected); | |
| 241 | + decideApproval(current.id, "denied"); | |
| 242 | + clearInterval(poll); | |
| 243 | + finish(0); | |
| 249 | 244 | } |
| 250 | 245 | }); |
| 251 | 246 | }); |
| 252 | 247 | } |
| 253 | 248 | |
| 254 | 249 | async function main(): Promise<number> { |
| 255 | − const { session, inline, tmuxSession, agentCommand } = parseArgs(process.argv.slice(2)); | |
| 250 | + const { session, inline, tmuxSession, agentCommand, approvalId } = parseArgs(process.argv.slice(2)); | |
| 256 | 251 | |
| 257 | 252 | if (!inline) { |
| 258 | 253 | if (!hasTmuxBinary()) { |
| @@ -261,25 +256,16 @@ async function main(): Promise<number> { · 2 unmodified lines | ||
| 261 | 256 | return 1; |
| 262 | 257 | } |
| 263 | 258 | |
| 264 | − const clients = getTmuxClients(); | |
| 265 | − const target = clients[0]; | |
| 266 | − if (target) { | |
| 267 | − await launchTmuxPopup(session, target); | |
| 268 | − return 0; | |
| 269 | − } | |
| 270 | − | |
| 271 | − console.log(`No active tmux clients. Launching '${agentCommand}' with approvals in session '${tmuxSession}'...`); | |
| 272 | − const started = await launchManagedTmuxWorkspace(session, tmuxSession, agentCommand); | |
| 273 | − if (started) { | |
| 274 | − return 0; | |
| 275 | − } | |
| 259 | + console.log(`Launching '${agentCommand}' in tmux session '${tmuxSession}'...`); | |
| 260 | + const started = await launchManagedTmuxWorkspace(tmuxSession, agentCommand); | |
| 261 | + if (started) return 0; | |
| 276 | 262 | |
| 277 | 263 | console.error("Unable to launch managed tmux workspace."); |
| 278 | − console.error("You can still run the inline demo with --inline."); | |
| 264 | + console.error("You can still run the inline pane with --inline."); | |
| 279 | 265 | return 1; |
| 280 | 266 | } |
| 281 | 267 | |
| 282 | − return await runInlineUi(session); | |
| 268 | + return await runInlineUi(session, approvalId); | |
| 283 | 269 | } |
| 284 | 270 | |
| 285 | 271 | const code = await main(); |
| @@ -1,30 +1,149 @@ | ||
| 1 | +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; | |
| 2 | +import path from "node:path"; | |
| 3 | + | |
| 1 | 4 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 2 | 5 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; |
| 3 | 6 | import { z } from "zod"; |
| 4 | 7 | |
| 5 | −type ApprovalDecision = "approved" | "denied"; | |
| 8 | +type ApprovalStatus = "pending" | "approved" | "denied"; | |
| 6 | 9 | |
| 7 | 10 | interface ApprovalRecord { |
| 8 | 11 | id: string; |
| 9 | 12 | action: string; |
| 10 | 13 | justification: string; |
| 11 | 14 | createdAt: number; |
| 12 | − decision: ApprovalDecision; | |
| 13 | − decidedAt: number; | |
| 15 | + status: ApprovalStatus; | |
| 16 | + decidedAt: number | null; | |
| 17 | +} | |
| 18 | + | |
| 19 | +interface PersistedState { | |
| 20 | + approvals: ApprovalRecord[]; | |
| 21 | +} | |
| 22 | + | |
| 23 | +const HOME_DIR = process.env.HOME ?? "."; | |
| 24 | +const DATA_DIR = path.join(HOME_DIR, ".executor-lite-approvals"); | |
| 25 | +const STATE_PATH = path.join(DATA_DIR, "mcp-state.json"); | |
| 26 | +const APPROVAL_UI_SCRIPT = path.resolve(import.meta.dir, "approval-client.ts"); | |
| 27 | + | |
| 28 | +mkdirSync(DATA_DIR, { recursive: true }); | |
| 29 | + | |
| 30 | +const state: PersistedState = loadState(); | |
| 31 | + | |
| 32 | +function shellQuote(value: string): string { | |
| 33 | + return `'${value.replace(/'/g, "'\\''")}'`; | |
| 34 | +} | |
| 35 | + | |
| 36 | +function loadState(): PersistedState { | |
| 37 | + if (!existsSync(STATE_PATH)) { | |
| 38 | + return { approvals: [] }; | |
| 39 | + } | |
| 40 | + | |
| 41 | + try { | |
| 42 | + const text = readFileSync(STATE_PATH, "utf8"); | |
| 43 | + const parsed = JSON.parse(text) as Partial<PersistedState>; | |
| 44 | + if (!parsed || !Array.isArray(parsed.approvals)) { | |
| 45 | + return { approvals: [] }; | |
| 46 | + } | |
| 47 | + | |
| 48 | + return { | |
| 49 | + approvals: parsed.approvals.filter((entry): entry is ApprovalRecord => { | |
| 50 | + return ( | |
| 51 | + Boolean(entry) && | |
| 52 | + typeof entry.id === "string" && | |
| 53 | + typeof entry.action === "string" && | |
| 54 | + typeof entry.justification === "string" && | |
| 55 | + typeof entry.createdAt === "number" && | |
| 56 | + (entry.status === "pending" || entry.status === "approved" || entry.status === "denied") | |
| 57 | + ); | |
| 58 | + }), | |
| 59 | + }; | |
| 60 | + } catch { | |
| 61 | + return { approvals: [] }; | |
| 62 | + } | |
| 14 | 63 | } |
| 15 | 64 | |
| 16 | −const approvals: ApprovalRecord[] = []; | |
| 65 | +function refreshStateFromDisk(): void { | |
| 66 | + const disk = loadState(); | |
| 67 | + state.approvals = disk.approvals; | |
| 68 | +} | |
| 69 | + | |
| 70 | +function saveState(): void { | |
| 71 | + const tempPath = `${STATE_PATH}.tmp`; | |
| 72 | + writeFileSync(tempPath, JSON.stringify(state, null, 2)); | |
| 73 | + renameSync(tempPath, STATE_PATH); | |
| 74 | +} | |
| 75 | + | |
| 76 | +function recordPendingApproval(action: string, justification: string): ApprovalRecord { | |
| 77 | + const entry: ApprovalRecord = { | |
| 78 | + id: `appr_${crypto.randomUUID().slice(0, 8)}`, | |
| 79 | + action, | |
| 80 | + justification, | |
| 81 | + createdAt: Date.now(), | |
| 82 | + status: "pending", | |
| 83 | + decidedAt: null, | |
| 84 | + }; | |
| 85 | + | |
| 86 | + state.approvals = [entry]; | |
| 87 | + saveState(); | |
| 88 | + return entry; | |
| 89 | +} | |
| 90 | + | |
| 91 | +function removeApproval(approvalId: string): void { | |
| 92 | + state.approvals = state.approvals.filter((item) => item.id !== approvalId); | |
| 93 | + saveState(); | |
| 94 | +} | |
| 95 | + | |
| 96 | +function markDeniedIfStillPending(approvalId: string): void { | |
| 97 | + const item = state.approvals.find((entry) => entry.id === approvalId); | |
| 98 | + if (!item || item.status !== "pending") return; | |
| 99 | + item.status = "denied"; | |
| 100 | + item.decidedAt = Date.now(); | |
| 101 | + saveState(); | |
| 102 | +} | |
| 103 | + | |
| 104 | +function maybeOpenApprovalPane(approvalId: string): void { | |
| 105 | + if (!process.env.TMUX) return; | |
| 106 | + | |
| 107 | + const command = `bun run ${shellQuote(APPROVAL_UI_SCRIPT)} --inline --session claude --approval-id ${shellQuote(approvalId)}`; | |
| 108 | + | |
| 109 | + const split = Bun.spawnSync({ | |
| 110 | + cmd: ["tmux", "split-window", "-v", "-p", "30", command], | |
| 111 | + stdin: "inherit", | |
| 112 | + stdout: "ignore", | |
| 113 | + stderr: "ignore", | |
| 114 | + }); | |
| 115 | + | |
| 116 | + if (split.exitCode !== 0) return; | |
| 117 | + | |
| 118 | + Bun.spawnSync({ | |
| 119 | + cmd: ["tmux", "select-pane", "-T", "Approvals"], | |
| 120 | + stdin: "inherit", | |
| 121 | + stdout: "ignore", | |
| 122 | + stderr: "ignore", | |
| 123 | + }); | |
| 124 | +} | |
| 125 | + | |
| 126 | +async function waitForDecision(approvalId: string, timeoutMs: number): Promise<ApprovalStatus | "timeout"> { | |
| 127 | + const deadline = Date.now() + timeoutMs; | |
| 128 | + | |
| 129 | + while (Date.now() < deadline) { | |
| 130 | + refreshStateFromDisk(); | |
| 131 | + const current = state.approvals.find((entry) => entry.id === approvalId); | |
| 132 | + if (current && current.status !== "pending") { | |
| 133 | + return current.status; | |
| 134 | + } | |
| 135 | + | |
| 136 | + await Bun.sleep(350); | |
| 137 | + } | |
| 138 | + | |
| 139 | + return "timeout"; | |
| 140 | +} | |
| 17 | 141 | |
| 18 | 142 | const mcp = new McpServer( |
| 19 | 143 | { name: "memory-approval-demo", version: "0.0.1" }, |
| 20 | 144 | { |
| 21 | 145 | capabilities: { |
| 22 | 146 | tools: {}, |
| 23 | − experimental: { | |
| 24 | − elicitation: { | |
| 25 | − form: {}, | |
| 26 | − }, | |
| 27 | − }, | |
| 28 | 147 | }, |
| 29 | 148 | }, |
| 30 | 149 | ); |
| @@ -32,102 +151,53 @@ const mcp = new McpServer( · 1 unmodified line | ||
| 32 | 151 | mcp.registerTool( |
| 33 | 152 | "dangerous_action", |
| 34 | 153 | { |
| 35 | − description: "Runs a fake dangerous action after explicit approval.", | |
| 154 | + description: "Queues a dangerous action and waits for sidecar approval from shared state.", | |
| 36 | 155 | inputSchema: { |
| 37 | 156 | action: z.string().min(1), |
| 38 | 157 | justification: z.string().optional(), |
| 158 | + approvalTimeoutMs: z.number().int().min(1_000).max(600_000).optional(), | |
| 39 | 159 | }, |
| 40 | 160 | }, |
| 41 | − async ({ action, justification }) => { | |
| 42 | − const approvalId = `appr_${crypto.randomUUID().slice(0, 8)}`; | |
| 43 | − | |
| 44 | − let decision: ApprovalDecision = "denied"; | |
| 45 | − | |
| 46 | − try { | |
| 47 | − const response = await mcp.server.elicitInput({ | |
| 48 | − mode: "form", | |
| 49 | − message: `Approve dangerous action '${action}'?`, | |
| 50 | − requestedSchema: { | |
| 51 | − type: "object", | |
| 52 | − properties: { | |
| 53 | − decision: { | |
| 54 | − type: "string", | |
| 55 | − oneOf: [ | |
| 56 | − { const: "approved", title: "Approve" }, | |
| 57 | − { const: "denied", title: "Deny" }, | |
| 58 | − ], | |
| 59 | − }, | |
| 60 | − }, | |
| 61 | − required: ["decision"], | |
| 62 | − }, | |
| 63 | − }); | |
| 64 | − | |
| 65 | − if (response.action === "accept" && response.content?.decision === "approved") { | |
| 66 | − decision = "approved"; | |
| 67 | − } | |
| 68 | − } catch (error) { | |
| 69 | − console.error("elicitation failed", error); | |
| 70 | − decision = "denied"; | |
| 71 | − } | |
| 161 | + async ({ action, justification, approvalTimeoutMs }) => { | |
| 162 | + refreshStateFromDisk(); | |
| 163 | + const pending = recordPendingApproval(action, justification ?? ""); | |
| 164 | + maybeOpenApprovalPane(pending.id); | |
| 165 | + | |
| 166 | + const timeoutMs = approvalTimeoutMs ?? 120_000; | |
| 167 | + const decision = await waitForDecision(pending.id, timeoutMs); | |
| 168 | + | |
| 169 | + refreshStateFromDisk(); | |
| 170 | + const current = state.approvals.find((item) => item.id === pending.id); | |
| 72 | 171 | |
| 73 | − const now = Date.now(); | |
| 74 | − approvals.unshift({ | |
| 75 | − id: approvalId, | |
| 76 | − action, | |
| 77 | − justification: justification ?? "", | |
| 78 | − createdAt: now, | |
| 79 | − decision, | |
| 80 | − decidedAt: now, | |
| 81 | − }); | |
| 82 | − | |
| 83 | − if (decision === "denied") { | |
| 172 | + if (decision === "approved" && current?.status === "approved") { | |
| 173 | + removeApproval(pending.id); | |
| 84 | 174 | return { |
| 85 | − content: [{ type: "text", text: `Denied '${action}'.` }], | |
| 175 | + content: [{ type: "text", text: `Approved and executed '${action}'.` }], | |
| 86 | 176 | structuredContent: { |
| 87 | − approvalId, | |
| 88 | − status: "denied", | |
| 177 | + approvalId: pending.id, | |
| 178 | + status: "approved", | |
| 89 | 179 | action, |
| 180 | + executed: true, | |
| 90 | 181 | }, |
| 91 | 182 | }; |
| 92 | 183 | } |
| 93 | 184 | |
| 185 | + if (decision === "timeout") { | |
| 186 | + markDeniedIfStillPending(pending.id); | |
| 187 | + } | |
| 188 | + | |
| 189 | + removeApproval(pending.id); | |
| 190 | + | |
| 94 | 191 | return { |
| 95 | − content: [{ type: "text", text: `Approved and executed '${action}'.` }], | |
| 192 | + content: [{ type: "text", text: `Denied '${action}' (${decision === "timeout" ? "approval timed out" : "approval denied"}).` }], | |
| 96 | 193 | structuredContent: { |
| 97 | − approvalId, | |
| 98 | − status: "approved", | |
| 194 | + approvalId: pending.id, | |
| 195 | + status: "denied", | |
| 99 | 196 | action, |
| 100 | − executed: true, | |
| 101 | 197 | }, |
| 102 | 198 | }; |
| 103 | 199 | }, |
| 104 | 200 | ); |
| 105 | 201 | |
| 106 | −mcp.registerTool( | |
| 107 | − "approval_history", | |
| 108 | − { | |
| 109 | − description: "Lists in-memory approval decisions for this MCP server process.", | |
| 110 | − inputSchema: { | |
| 111 | − limit: z.number().int().min(1).max(100).optional(), | |
| 112 | − }, | |
| 113 | − }, | |
| 114 | − async ({ limit }) => { | |
| 115 | − const max = limit ?? 20; | |
| 116 | − const items = approvals.slice(0, max).map((entry) => ({ | |
| 117 | − id: entry.id, | |
| 118 | − action: entry.action, | |
| 119 | − justification: entry.justification, | |
| 120 | − decision: entry.decision, | |
| 121 | − createdAt: entry.createdAt, | |
| 122 | − decidedAt: entry.decidedAt, | |
| 123 | − })); | |
| 124 | − | |
| 125 | − return { | |
| 126 | − content: [{ type: "text", text: `Returning ${items.length} approval entries.` }], | |
| 127 | − structuredContent: { items }, | |
| 128 | − }; | |
| 129 | − }, | |
| 130 | −); | |
| 131 | − | |
| 132 | 202 | const transport = new StdioServerTransport(); |
| 133 | 203 | await mcp.connect(transport); |
| @@ -0,0 +1 @@ | ||
| 1 | +.reference/ | |
| @@ -0,0 +1,4 @@ | ||
| 1 | +{ | |
| 2 | + "typescript.tsdk": "./node_modules/typescript/lib", | |
| 3 | + "typescript.enablePromptUseWorkspaceTsdk": true | |
| 4 | +} | |
| @@ -0,0 +1 @@ | ||
| 1 | +CLAUDE.md | |
| @@ -0,0 +1,17 @@ | ||
| 1 | +# v2 Agent Notes | |
| 2 | + | |
| 3 | +<!-- effect-solutions:start --> | |
| 4 | + | |
| 5 | +## Effect Best Practices | |
| 6 | + | |
| 7 | +**IMPORTANT:** Always consult effect-solutions before writing Effect code. | |
| 8 | + | |
| 9 | +1. Run `effect-solutions list` to see available guides | |
| 10 | +2. Run `effect-solutions show <topic>...` for relevant patterns (supports multiple topics) | |
| 11 | +3. Search `.reference/effect/` for real implementations (run `effect-solutions setup` first) | |
| 12 | + | |
| 13 | +Topics: quick-start, project-setup, tsconfig, basics, services-and-layers, data-modeling, error-handling, config, testing, cli. | |
| 14 | + | |
| 15 | +Never guess at Effect patterns - check the guide first. | |
| 16 | + | |
| 17 | +<!-- effect-solutions:end --> | |
| @@ -0,0 +1,369 @@ | ||
| 1 | +# Executor v2 Plan | |
| 2 | + | |
| 3 | +Reference implementations and inspiration for this plan: | |
| 4 | + `../AnswerOverflow` | |
| 5 | + `../quickhub` | |
| 6 | + `../create-epoch-app` | |
| 7 | + | |
| 8 | +## 1) Why v2 exists | |
| 9 | + | |
| 10 | +Executor today is optimized for enterprise/team correctness (workspaces, org flows, cloud control), but has too much friction for per-user adoption. | |
| 11 | + | |
| 12 | +v2 changes the default: | |
| 13 | + | |
| 14 | +- **First-run must be local and immediate** (`npx executor <agent>` works fast). | |
| 15 | +- **No database dependency** in local-lite mode. | |
| 16 | +- **Cloud/self-host are upgrade targets**, not prerequisites. | |
| 17 | +- **One architecture for all modes** via adapters and shared contracts. | |
| 18 | + | |
| 19 | +--- | |
| 20 | + | |
| 21 | +## 2) Product principles | |
| 22 | + | |
| 23 | +1. **Local-first** | |
| 24 | + - User gets value without login, org setup, or web onboarding. | |
| 25 | +2. **One mental model** | |
| 26 | + - Same commands, same execution semantics across local and remote targets. | |
| 27 | +3. **Schema-first** | |
| 28 | + - Effect Schema is the single source of truth for persisted/domain models. | |
| 29 | +4. **Execute-first tooling** | |
| 30 | + - Top-level model tool surface is minimal; code execution is primary. | |
| 31 | +5. **Progressive complexity** | |
| 32 | + - Advanced controls exist but are hidden until needed. | |
| 33 | + | |
| 34 | +--- | |
| 35 | + | |
| 36 | +## 3) Product modes and targets | |
| 37 | + | |
| 38 | +### Modes | |
| 39 | + | |
| 40 | +- **local-lite**: PM daemon + in-memory hot state + file persistence. No DB. | |
| 41 | +- **remote**: Convex-backed target (applies to both cloud and self-hosted). | |
| 42 | + | |
| 43 | +### Key simplification | |
| 44 | + | |
| 45 | +- `cloud` and `self-hosted` are not separate architecture branches. | |
| 46 | +- They are both **remote Convex targets**; only URL/auth/deployment ownership differs. | |
| 47 | + | |
| 48 | +--- | |
| 49 | + | |
| 50 | +## 4) Control + execution model | |
| 51 | + | |
| 52 | +### Control role (conceptual) | |
| 53 | + | |
| 54 | +Control includes source registry, policies, approvals/input workflow, identity/link state, and sync metadata. | |
| 55 | + | |
| 56 | +- In local-lite: handled by `apps/pm`. | |
| 57 | +- In remote: handled by Convex + web stack. | |
| 58 | + | |
| 59 | +### Execution role | |
| 60 | + | |
| 61 | +Execution remains the runtime engine that runs generated TypeScript and calls `tools.*` via proxy invocation. | |
| 62 | + | |
| 63 | +--- | |
| 64 | + | |
| 65 | +## 5) Monorepo shape (Executor-only) | |
| 66 | + | |
| 67 | +```text | |
| 68 | +v2/ | |
| 69 | + apps/ | |
| 70 | + cli/ | |
| 71 | + pm/ | |
| 72 | + web/ | |
| 73 | + convex/ | |
| 74 | + | |
| 75 | + packages/ | |
| 76 | + confect/ # imported from quickhub; Effect<->Convex schema bridge | |
| 77 | + schema/ # canonical Effect schemas | |
| 78 | + persistence-ports/ | |
| 79 | + persistence-local/ | |
| 80 | + persistence-convex/ | |
| 81 | + domain/ | |
| 82 | + approvals/ | |
| 83 | + source-manager/ | |
| 84 | + engine/ | |
| 85 | + mcp-gateway/ | |
| 86 | + oauth/ | |
| 87 | + secrets/ | |
| 88 | + sync/ | |
| 89 | + rpc/ | |
| 90 | + sdk/ | |
| 91 | + ai-sdk-adapter/ | |
| 92 | +``` | |
| 93 | + | |
| 94 | +--- | |
| 95 | + | |
| 96 | +## 6) Dependency order (high-level) | |
| 97 | + | |
| 98 | +```text | |
| 99 | +confect -> schema -> persistence-ports | |
| 100 | + | |
| 101 | +persistence-ports -> persistence-local | |
| 102 | +persistence-ports -> persistence-convex | |
| 103 | + | |
| 104 | +schema + persistence-ports + oauth + secrets -> domain | |
| 105 | + | |
| 106 | +domain -> approvals/source-manager/engine | |
| 107 | +approvals/source-manager/engine -> mcp-gateway | |
| 108 | + | |
| 109 | +domain/features -> rpc -> sdk -> ai-sdk-adapter | |
| 110 | + | |
| 111 | +apps/pm -> mcp-gateway + domain + persistence-local + rpc | |
| 112 | +apps/convex -> mcp-gateway + domain + persistence-convex + rpc | |
| 113 | +apps/cli/web -> sdk | |
| 114 | +``` | |
| 115 | + | |
| 116 | +Rules: | |
| 117 | +- `schema` has no dependency on feature/runtime packages. | |
| 118 | +- `domain` depends on interfaces, not concrete state adapters. | |
| 119 | +- apps are leaves; packages do not import apps. | |
| 120 | + | |
| 121 | +--- | |
| 122 | + | |
| 123 | +## 7) Local-lite PM architecture | |
| 124 | + | |
| 125 | +`apps/pm` is the local daemon and source of hot truth. | |
| 126 | + | |
| 127 | +Responsibilities: | |
| 128 | +- MCP endpoint surface | |
| 129 | +- source registration and status lifecycle | |
| 130 | +- approvals/input broker | |
| 131 | +- task orchestration and runtime execution coordination | |
| 132 | +- persistence (snapshot + event log) | |
| 133 | + | |
| 134 | +### Persistence model | |
| 135 | + | |
| 136 | +- Hot state: in-memory | |
| 137 | +- Durable: file-based | |
| 138 | + - `snapshot.json` | |
| 139 | + - `events.jsonl` (WAL) | |
| 140 | + - `artifacts/` (compiled tool manifests) | |
| 141 | + - secrets in secure store / dedicated protected file | |
| 142 | + | |
| 143 | +Crash/restart behavior: | |
| 144 | +- Rehydrate from snapshot + events. | |
| 145 | +- Rebuild derived indexes in memory. | |
| 146 | +- Mark previously running tasks terminally with restart reason. | |
| 147 | + | |
| 148 | +--- | |
| 149 | + | |
| 150 | +## 8) Data model strategy | |
| 151 | + | |
| 152 | +Effect Schema is canonical (`packages/schema`). | |
| 153 | + | |
| 154 | +Current scaffold includes: | |
| 155 | +- domain IDs (`src/ids.ts`) | |
| 156 | +- enums/common primitives | |
| 157 | +- model schemas: | |
| 158 | + - profile, workspace | |
| 159 | + - source, tool artifact | |
| 160 | + - credential ref, oauth token | |
| 161 | + - policy, approval | |
| 162 | + - task run, sync state | |
| 163 | + - event envelope | |
| 164 | +- snapshot/event schemas | |
| 165 | + | |
| 166 | +### Convex ID compatibility | |
| 167 | + | |
| 168 | +IDs are now intentionally split by layer: | |
| 169 | +- **Domain IDs** live in `packages/schema` as portable branded IDs. | |
| 170 | +- **Convex table IDs** live in `packages/persistence-convex/src/convex-ids.ts` via `Id("table")` from `@executor-v2/confect`. | |
| 171 | + | |
| 172 | +This keeps canonical schema backend-agnostic while preserving strict table-typed IDs inside the Convex adapter boundary. | |
| 173 | + | |
| 174 | +--- | |
| 175 | + | |
| 176 | +## 9) Tooling and MCP surface | |
| 177 | + | |
| 178 | +### Top-level model-facing tools | |
| 179 | + | |
| 180 | +Keep top-level surface minimal: | |
| 181 | +- `execute` (primary) | |
| 182 | +- optional diagnostics (`health`, `version`) only if needed | |
| 183 | + | |
| 184 | +### Configuration model | |
| 185 | + | |
| 186 | +Executor config is handled as tool calls from inside `execute` via built-in namespace: | |
| 187 | +- `tools.executor.sources.add(...)` | |
| 188 | +- `tools.executor.sources.list(...)` | |
| 189 | +- `tools.executor.sources.remove(...)` | |
| 190 | +- other `tools.executor.*` controls as needed | |
| 191 | + | |
| 192 | +No mandatory top-level `set_enabled` in v1 (remove/add is enough for initial UX). | |
| 193 | + | |
| 194 | +### Execution semantics | |
| 195 | + | |
| 196 | +- Agent writes TS using `tools.*`. | |
| 197 | +- Runtime proxy resolves tool calls. | |
| 198 | +- Credentials/policies/approvals are applied server-side during invocation. | |
| 199 | + | |
| 200 | +--- | |
| 201 | + | |
| 202 | +## 10) Dynamic source registration | |
| 203 | + | |
| 204 | +Goal: user can ask the agent to use a service; agent can register the source through Executor tooling. | |
| 205 | + | |
| 206 | +- Discovery itself may happen outside Executor (model/web). | |
| 207 | +- Registration happens via Executor control tools (`tools.executor.sources.add`). | |
| 208 | + | |
| 209 | +Registration flow states: | |
| 210 | +- `draft -> probing -> auth_required -> connected | error` | |
| 211 | + | |
| 212 | +Auth handling: | |
| 213 | +- MCP: dynamic auth/OAuth detection, client registration where needed | |
| 214 | +- OpenAPI: inspect security schemes, prompt for API key if required | |
| 215 | + | |
| 216 | +Secrets are entered through host UX, not model transcript. | |
| 217 | + | |
| 218 | +--- | |
| 219 | + | |
| 220 | +## 11) Approvals and elicitation/input | |
| 221 | + | |
| 222 | +Use one internal broker for human-required inputs: | |
| 223 | +- approval | |
| 224 | +- confirm | |
| 225 | +- form input | |
| 226 | +- secret input | |
| 227 | + | |
| 228 | +### Capability-aware delivery | |
| 229 | + | |
| 230 | +- If client supports MCP elicitation: inline prompt. | |
| 231 | +- If not: out-of-band path (tmux pane, local web, notifications). | |
| 232 | + | |
| 233 | +### Terminal UX | |
| 234 | + | |
| 235 | +For fullscreen terminal clients: | |
| 236 | +- tmux split/popup watcher | |
| 237 | +- auto-show when pending approvals appear | |
| 238 | +- auto-hide after queue drains (with debounce) | |
| 239 | + | |
| 240 | +### Important boundary | |
| 241 | + | |
| 242 | +Approval/input actions are **host UX callbacks**, not AI SDK model tools. | |
| 243 | + | |
| 244 | +--- | |
| 245 | + | |
| 246 | +## 12) AI SDK strategy | |
| 247 | + | |
| 248 | +API shape should be Executor session/adapters, not single-source helper APIs. | |
| 249 | + | |
| 250 | +### Adapter-first target design | |
| 251 | + | |
| 252 | +`Executor.create({ adapter })` with adapters such as: | |
| 253 | +- `inproc` (embedded runtime, no install) | |
| 254 | +- `pm` (local daemon transport) | |
| 255 | +- `remote` (Convex target transport) | |
| 256 | + | |
| 257 | +### Public SDK shape | |
| 258 | + | |
| 259 | +Primary operations: | |
| 260 | +- execute code | |
| 261 | +- register/list/remove sources | |
| 262 | +- stream run status/results | |
| 263 | + | |
| 264 | +Do not expose approval/input-response as model tools. | |
| 265 | +Use host callbacks (e.g. `onInputRequest`) instead. | |
| 266 | + | |
| 267 | +--- | |
| 268 | + | |
| 269 | +## 13) Security posture | |
| 270 | + | |
| 271 | +- PM binds to loopback by default. | |
| 272 | +- Secrets never pass through model args/transcripts. | |
| 273 | +- Credential references in state; secret values in secure store. | |
| 274 | +- Strict origin/token checks if hosted web talks to localhost target. | |
| 275 | +- Audit events store decisions/actions, not raw secret material. | |
| 276 | + | |
| 277 | +--- | |
| 278 | + | |
| 279 | +## 14) Upgrade and sync | |
| 280 | + | |
| 281 | +Primary path: | |
| 282 | +- `local-lite -> remote` | |
| 283 | + | |
| 284 | +Optional reverse: | |
| 285 | +- `remote -> local-lite` export/backup | |
| 286 | + | |
| 287 | +Sync principles: | |
| 288 | +- deterministic source identity | |
| 289 | +- idempotent imports | |
| 290 | +- explicit credential migration policy | |
| 291 | +- migration cursor/mapping persisted in local state | |
| 292 | + | |
| 293 | +--- | |
| 294 | + | |
| 295 | +## 15) Rollout phases | |
| 296 | + | |
| 297 | +### Phase 0: Architecture + contracts | |
| 298 | +- finalize package boundaries | |
| 299 | +- finalize schema model set and invariants | |
| 300 | +- define state/repository interfaces | |
| 301 | + | |
| 302 | +### Phase 1: Local-lite core | |
| 303 | +- PM daemon skeleton | |
| 304 | +- file persistence (snapshot + WAL) | |
| 305 | +- execute path with runtime proxy integration | |
| 306 | +- basic source add/list/remove through `tools.executor.*` | |
| 307 | + | |
| 308 | +### Phase 2: Input/approval UX | |
| 309 | +- InputBroker + pending state persistence | |
| 310 | +- tmux watcher + local web fallback | |
| 311 | +- capability-aware elicitation fallback paths | |
| 312 | + | |
| 313 | +### Phase 3: Remote parity | |
| 314 | +- persistence-convex adapter and Convex app wiring | |
| 315 | +- shared RPC contracts for pm/convex | |
| 316 | +- target switching in CLI/web | |
| 317 | + | |
| 318 | +### Phase 4: Sync + upgrades | |
| 319 | +- local->remote promotion pipeline | |
| 320 | +- idempotent mapping/cursor strategy | |
| 321 | +- conflict handling and dry-run reporting | |
| 322 | + | |
| 323 | +### Phase 5: AI SDK adapters | |
| 324 | +- inproc adapter | |
| 325 | +- pm transport adapter | |
| 326 | +- remote transport adapter | |
| 327 | +- host callback API for inputs | |
| 328 | + | |
| 329 | +--- | |
| 330 | + | |
| 331 | +## 16) Success criteria | |
| 332 | + | |
| 333 | +- First successful `npx executor <agent>` run with no login in <2 minutes. | |
| 334 | +- No required database for local-lite mode. | |
| 335 | +- Same execution semantics across local and remote targets. | |
| 336 | +- Agent can add sources and execute against them in one flow. | |
| 337 | +- Approval/input is usable in fullscreen terminal clients. | |
| 338 | + | |
| 339 | +--- | |
| 340 | + | |
| 341 | +## 17) Non-goals (initial) | |
| 342 | + | |
| 343 | +- Recreating all enterprise admin UX in local-lite. | |
| 344 | +- Exposing huge top-level tool catalogs to the model by default. | |
| 345 | +- Maintaining separate cloud vs self-host architecture branches. | |
| 346 | + | |
| 347 | +--- | |
| 348 | + | |
| 349 | +## 18) Current scaffold status | |
| 350 | + | |
| 351 | +Implemented in `v2` so far: | |
| 352 | +- monorepo app/package skeleton | |
| 353 | +- `confect` imported from quickhub | |
| 354 | +- schema package scaffold with Effect models | |
| 355 | +- domain ID groundwork in `schema/src/ids.ts` | |
| 356 | +- Convex table ID groundwork in `persistence-convex/src/convex-ids.ts` | |
| 357 | + | |
| 358 | +Not implemented yet: | |
| 359 | +- runtime wiring, adapters, rpc handlers, PM behavior, sync engine | |
| 360 | + | |
| 361 | +--- | |
| 362 | + | |
| 363 | +## 19) Immediate next steps | |
| 364 | + | |
| 365 | +1. Define `persistence-ports` interfaces against current schema models. | |
| 366 | +2. Implement minimal `persistence-local` snapshot + event log codec. | |
| 367 | +3. Wire a minimal `apps/pm` execute flow (single run lifecycle). | |
| 368 | +4. Add first `tools.executor.sources.add/list/remove` control operations. | |
| 369 | +5. Add basic InputBroker contract (without full UI). | |
| @@ -0,0 +1,2 @@ | ||
| 1 | + | |
| 2 | +.env.local | |
| @@ -1,3 +1,7 @@ | ||
| 1 | 1 | # app-convex |
| 2 | 2 | |
| 3 | 3 | Convex remote server app scaffold for Executor v2. |
| 4 | + | |
| 5 | +Current scaffold includes: | |
| 6 | +- Convex schema wiring at `convex/schema.ts`, sourced from `@executor-v2/persistence-convex` | |
| 7 | +- bare-minimum MCP HTTP endpoint with a stub tool at `convex/http.ts` and `convex/mcp.ts` | |
| @@ -0,0 +1,90 @@ | ||
| 1 | +# Welcome to your Convex functions directory! | |
| 2 | + | |
| 3 | +Write your Convex functions here. | |
| 4 | +See https://docs.convex.dev/functions for more. | |
| 5 | + | |
| 6 | +A query function that takes two arguments looks like: | |
| 7 | + | |
| 8 | +```ts | |
| 9 | +// convex/myFunctions.ts | |
| 10 | +import { query } from "./_generated/server"; | |
| 11 | +import { v } from "convex/values"; | |
| 12 | + | |
| 13 | +export const myQueryFunction = query({ | |
| 14 | + // Validators for arguments. | |
| 15 | + args: { | |
| 16 | + first: v.number(), | |
| 17 | + second: v.string(), | |
| 18 | + }, | |
| 19 | + | |
| 20 | + // Function implementation. | |
| 21 | + handler: async (ctx, args) => { | |
| 22 | + // Read the database as many times as you need here. | |
| 23 | + // See https://docs.convex.dev/database/reading-data. | |
| 24 | + const documents = await ctx.db.query("tablename").collect(); | |
| 25 | + | |
| 26 | + // Arguments passed from the client are properties of the args object. | |
| 27 | + console.log(args.first, args.second); | |
| 28 | + | |
| 29 | + // Write arbitrary JavaScript here: filter, aggregate, build derived data, | |
| 30 | + // remove non-public properties, or create new objects. | |
| 31 | + return documents; | |
| 32 | + }, | |
| 33 | +}); | |
| 34 | +``` | |
| 35 | + | |
| 36 | +Using this query function in a React component looks like: | |
| 37 | + | |
| 38 | +```ts | |
| 39 | +const data = useQuery(api.myFunctions.myQueryFunction, { | |
| 40 | + first: 10, | |
| 41 | + second: "hello", | |
| 42 | +}); | |
| 43 | +``` | |
| 44 | + | |
| 45 | +A mutation function looks like: | |
| 46 | + | |
| 47 | +```ts | |
| 48 | +// convex/myFunctions.ts | |
| 49 | +import { mutation } from "./_generated/server"; | |
| 50 | +import { v } from "convex/values"; | |
| 51 | + | |
| 52 | +export const myMutationFunction = mutation({ | |
| 53 | + // Validators for arguments. | |
| 54 | + args: { | |
| 55 | + first: v.string(), | |
| 56 | + second: v.string(), | |
| 57 | + }, | |
| 58 | + | |
| 59 | + // Function implementation. | |
| 60 | + handler: async (ctx, args) => { | |
| 61 | + // Insert or modify documents in the database here. | |
| 62 | + // Mutations can also read from the database like queries. | |
| 63 | + // See https://docs.convex.dev/database/writing-data. | |
| 64 | + const message = { body: args.first, author: args.second }; | |
| 65 | + const id = await ctx.db.insert("messages", message); | |
| 66 | + | |
| 67 | + // Optionally, return a value from your mutation. | |
| 68 | + return await ctx.db.get("messages", id); | |
| 69 | + }, | |
| 70 | +}); | |
| 71 | +``` | |
| 72 | + | |
| 73 | +Using this mutation function in a React component looks like: | |
| 74 | + | |
| 75 | +```ts | |
| 76 | +const mutation = useMutation(api.myFunctions.myMutationFunction); | |
| 77 | +function handleButtonPress() { | |
| 78 | + // fire and forget, the most common way to use mutations | |
| 79 | + mutation({ first: "Hello!", second: "me" }); | |
| 80 | + // OR | |
| 81 | + // use the result once the mutation has completed | |
| 82 | + mutation({ first: "Hello!", second: "me" }).then((result) => | |
| 83 | + console.log(result), | |
| 84 | + ); | |
| 85 | +} | |
| 86 | +``` | |
| 87 | + | |
| 88 | +Use the Convex CLI to push your functions to a deployment. See everything | |
| 89 | +the Convex CLI can do by running `npx convex -h` in your project root | |
| 90 | +directory. To learn more, launch the docs with `npx convex docs`. | |
| @@ -0,0 +1,51 @@ | ||
| 1 | +/* eslint-disable */ | |
| 2 | +/** | |
| 3 | + * Generated `api` utility. | |
| 4 | + * | |
| 5 | + * THIS CODE IS AUTOMATICALLY GENERATED. | |
| 6 | + * | |
| 7 | + * To regenerate, run `npx convex dev`. | |
| 8 | + * @module | |
| 9 | + */ | |
| 10 | + | |
| 11 | +import type * as http from "../http.js"; | |
| 12 | +import type * as mcp from "../mcp.js"; | |
| 13 | + | |
| 14 | +import type { | |
| 15 | + ApiFromModules, | |
| 16 | + FilterApi, | |
| 17 | + FunctionReference, | |
| 18 | +} from "convex/server"; | |
| 19 | + | |
| 20 | +declare const fullApi: ApiFromModules<{ | |
| 21 | + http: typeof http; | |
| 22 | + mcp: typeof mcp; | |
| 23 | +}>; | |
| 24 | + | |
| 25 | +/** | |
| 26 | + * A utility for referencing Convex functions in your app's public API. | |
| 27 | + * | |
| 28 | + * Usage: | |
| 29 | + * ```js | |
| 30 | + * const myFunctionReference = api.myModule.myFunction; | |
| 31 | + * ``` | |
| 32 | + */ | |
| 33 | +export declare const api: FilterApi< | |
| 34 | + typeof fullApi, | |
| 35 | + FunctionReference<any, "public"> | |
| 36 | +>; | |
| 37 | + | |
| 38 | +/** | |
| 39 | + * A utility for referencing Convex functions in your app's internal API. | |
| 40 | + * | |
| 41 | + * Usage: | |
| 42 | + * ```js | |
| 43 | + * const myFunctionReference = internal.myModule.myFunction; | |
| 44 | + * ``` | |
| 45 | + */ | |
| 46 | +export declare const internal: FilterApi< | |
| 47 | + typeof fullApi, | |
| 48 | + FunctionReference<any, "internal"> | |
| 49 | +>; | |
| 50 | + | |
| 51 | +export declare const components: {}; | |
| @@ -0,0 +1,23 @@ | ||
| 1 | +/* eslint-disable */ | |
| 2 | +/** | |
| 3 | + * Generated `api` utility. | |
| 4 | + * | |
| 5 | + * THIS CODE IS AUTOMATICALLY GENERATED. | |
| 6 | + * | |
| 7 | + * To regenerate, run `npx convex dev`. | |
| 8 | + * @module | |
| 9 | + */ | |
| 10 | + | |
| 11 | +import { anyApi, componentsGeneric } from "convex/server"; | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * A utility for referencing Convex functions in your app's API. | |
| 15 | + * | |
| 16 | + * Usage: | |
| 17 | + * ```js | |
| 18 | + * const myFunctionReference = api.myModule.myFunction; | |
| 19 | + * ``` | |
| 20 | + */ | |
| 21 | +export const api = anyApi; | |
| 22 | +export const internal = anyApi; | |
| 23 | +export const components = componentsGeneric(); | |
| @@ -0,0 +1,60 @@ | ||
| 1 | +/* eslint-disable */ | |
| 2 | +/** | |
| 3 | + * Generated data model types. | |
| 4 | + * | |
| 5 | + * THIS CODE IS AUTOMATICALLY GENERATED. | |
| 6 | + * | |
| 7 | + * To regenerate, run `npx convex dev`. | |
| 8 | + * @module | |
| 9 | + */ | |
| 10 | + | |
| 11 | +import type { | |
| 12 | + DataModelFromSchemaDefinition, | |
| 13 | + DocumentByName, | |
| 14 | + TableNamesInDataModel, | |
| 15 | + SystemTableNames, | |
| 16 | +} from "convex/server"; | |
| 17 | +import type { GenericId } from "convex/values"; | |
| 18 | +import schema from "../schema.js"; | |
| 19 | + | |
| 20 | +/** | |
| 21 | + * The names of all of your Convex tables. | |
| 22 | + */ | |
| 23 | +export type TableNames = TableNamesInDataModel<DataModel>; | |
| 24 | + | |
| 25 | +/** | |
| 26 | + * The type of a document stored in Convex. | |
| 27 | + * | |
| 28 | + * @typeParam TableName - A string literal type of the table name (like "users"). | |
| 29 | + */ | |
| 30 | +export type Doc<TableName extends TableNames> = DocumentByName< | |
| 31 | + DataModel, | |
| 32 | + TableName | |
| 33 | +>; | |
| 34 | + | |
| 35 | +/** | |
| 36 | + * An identifier for a document in Convex. | |
| 37 | + * | |
| 38 | + * Convex documents are uniquely identified by their `Id`, which is accessible | |
| 39 | + * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). | |
| 40 | + * | |
| 41 | + * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. | |
| 42 | + * | |
| 43 | + * IDs are just strings at runtime, but this type can be used to distinguish them from other | |
| 44 | + * strings when type checking. | |
| 45 | + * | |
| 46 | + * @typeParam TableName - A string literal type of the table name (like "users"). | |
| 47 | + */ | |
| 48 | +export type Id<TableName extends TableNames | SystemTableNames> = | |
| 49 | + GenericId<TableName>; | |
| 50 | + | |
| 51 | +/** | |
| 52 | + * A type describing your Convex data model. | |
| 53 | + * | |
| 54 | + * This type includes information about what tables you have, the type of | |
| 55 | + * documents stored in those tables, and the indexes defined on them. | |
| 56 | + * | |
| 57 | + * This type is used to parameterize methods like `queryGeneric` and | |
| 58 | + * `mutationGeneric` to make them type-safe. | |
| 59 | + */ | |
| 60 | +export type DataModel = DataModelFromSchemaDefinition<typeof schema>; | |
| @@ -0,0 +1,143 @@ | ||
| 1 | +/* eslint-disable */ | |
| 2 | +/** | |
| 3 | + * Generated utilities for implementing server-side Convex query and mutation functions. | |
| 4 | + * | |
| 5 | + * THIS CODE IS AUTOMATICALLY GENERATED. | |
| 6 | + * | |
| 7 | + * To regenerate, run `npx convex dev`. | |
| 8 | + * @module | |
| 9 | + */ | |
| 10 | + | |
| 11 | +import { | |
| 12 | + ActionBuilder, | |
| 13 | + HttpActionBuilder, | |
| 14 | + MutationBuilder, | |
| 15 | + QueryBuilder, | |
| 16 | + GenericActionCtx, | |
| 17 | + GenericMutationCtx, | |
| 18 | + GenericQueryCtx, | |
| 19 | + GenericDatabaseReader, | |
| 20 | + GenericDatabaseWriter, | |
| 21 | +} from "convex/server"; | |
| 22 | +import type { DataModel } from "./dataModel.js"; | |
| 23 | + | |
| 24 | +/** | |
| 25 | + * Define a query in this Convex app's public API. | |
| 26 | + * | |
| 27 | + * This function will be allowed to read your Convex database and will be accessible from the client. | |
| 28 | + * | |
| 29 | + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. | |
| 30 | + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. | |
| 31 | + */ | |
| 32 | +export declare const query: QueryBuilder<DataModel, "public">; | |
| 33 | + | |
| 34 | +/** | |
| 35 | + * Define a query that is only accessible from other Convex functions (but not from the client). | |
| 36 | + * | |
| 37 | + * This function will be allowed to read from your Convex database. It will not be accessible from the client. | |
| 38 | + * | |
| 39 | + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. | |
| 40 | + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. | |
| 41 | + */ | |
| 42 | +export declare const internalQuery: QueryBuilder<DataModel, "internal">; | |
| 43 | + | |
| 44 | +/** | |
| 45 | + * Define a mutation in this Convex app's public API. | |
| 46 | + * | |
| 47 | + * This function will be allowed to modify your Convex database and will be accessible from the client. | |
| 48 | + * | |
| 49 | + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. | |
| 50 | + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. | |
| 51 | + */ | |
| 52 | +export declare const mutation: MutationBuilder<DataModel, "public">; | |
| 53 | + | |
| 54 | +/** | |
| 55 | + * Define a mutation that is only accessible from other Convex functions (but not from the client). | |
| 56 | + * | |
| 57 | + * This function will be allowed to modify your Convex database. It will not be accessible from the client. | |
| 58 | + * | |
| 59 | + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. | |
| 60 | + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. | |
| 61 | + */ | |
| 62 | +export declare const internalMutation: MutationBuilder<DataModel, "internal">; | |
| 63 | + | |
| 64 | +/** | |
| 65 | + * Define an action in this Convex app's public API. | |
| 66 | + * | |
| 67 | + * An action is a function which can execute any JavaScript code, including non-deterministic | |
| 68 | + * code and code with side-effects, like calling third-party services. | |
| 69 | + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. | |
| 70 | + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. | |
| 71 | + * | |
| 72 | + * @param func - The action. It receives an {@link ActionCtx} as its first argument. | |
| 73 | + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. | |
| 74 | + */ | |
| 75 | +export declare const action: ActionBuilder<DataModel, "public">; | |
| 76 | + | |
| 77 | +/** | |
| 78 | + * Define an action that is only accessible from other Convex functions (but not from the client). | |
| 79 | + * | |
| 80 | + * @param func - The function. It receives an {@link ActionCtx} as its first argument. | |
| 81 | + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. | |
| 82 | + */ | |
| 83 | +export declare const internalAction: ActionBuilder<DataModel, "internal">; | |
| 84 | + | |
| 85 | +/** | |
| 86 | + * Define an HTTP action. | |
| 87 | + * | |
| 88 | + * The wrapped function will be used to respond to HTTP requests received | |
| 89 | + * by a Convex deployment if the requests matches the path and method where | |
| 90 | + * this action is routed. Be sure to route your httpAction in `convex/http.js`. | |
| 91 | + * | |
| 92 | + * @param func - The function. It receives an {@link ActionCtx} as its first argument | |
| 93 | + * and a Fetch API `Request` object as its second. | |
| 94 | + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. | |
| 95 | + */ | |
| 96 | +export declare const httpAction: HttpActionBuilder; | |
| 97 | + | |
| 98 | +/** | |
| 99 | + * A set of services for use within Convex query functions. | |
| 100 | + * | |
| 101 | + * The query context is passed as the first argument to any Convex query | |
| 102 | + * function run on the server. | |
| 103 | + * | |
| 104 | + * This differs from the {@link MutationCtx} because all of the services are | |
| 105 | + * read-only. | |
| 106 | + */ | |
| 107 | +export type QueryCtx = GenericQueryCtx<DataModel>; | |
| 108 | + | |
| 109 | +/** | |
| 110 | + * A set of services for use within Convex mutation functions. | |
| 111 | + * | |
| 112 | + * The mutation context is passed as the first argument to any Convex mutation | |
| 113 | + * function run on the server. | |
| 114 | + */ | |
| 115 | +export type MutationCtx = GenericMutationCtx<DataModel>; | |
| 116 | + | |
| 117 | +/** | |
| 118 | + * A set of services for use within Convex action functions. | |
| 119 | + * | |
| 120 | + * The action context is passed as the first argument to any Convex action | |
| 121 | + * function run on the server. | |
| 122 | + */ | |
| 123 | +export type ActionCtx = GenericActionCtx<DataModel>; | |
| 124 | + | |
| 125 | +/** | |
| 126 | + * An interface to read from the database within Convex query functions. | |
| 127 | + * | |
| 128 | + * The two entry points are {@link DatabaseReader.get}, which fetches a single | |
| 129 | + * document by its {@link Id}, or {@link DatabaseReader.query}, which starts | |
| 130 | + * building a query. | |
| 131 | + */ | |
| 132 | +export type DatabaseReader = GenericDatabaseReader<DataModel>; | |
| 133 | + | |
| 134 | +/** | |
| 135 | + * An interface to read from and write to the database within Convex mutation | |
| 136 | + * functions. | |
| 137 | + * | |
| 138 | + * Convex guarantees that all writes within a single mutation are | |
| 139 | + * executed atomically, so you never have to worry about partial writes leaving | |
| 140 | + * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) | |
| 141 | + * for the guarantees Convex provides your functions. | |
| 142 | + */ | |
| 143 | +export type DatabaseWriter = GenericDatabaseWriter<DataModel>; | |
| @@ -0,0 +1,93 @@ | ||
| 1 | +/* eslint-disable */ | |
| 2 | +/** | |
| 3 | + * Generated utilities for implementing server-side Convex query and mutation functions. | |
| 4 | + * | |
| 5 | + * THIS CODE IS AUTOMATICALLY GENERATED. | |
| 6 | + * | |
| 7 | + * To regenerate, run `npx convex dev`. | |
| 8 | + * @module | |
| 9 | + */ | |
| 10 | + | |
| 11 | +import { | |
| 12 | + actionGeneric, | |
| 13 | + httpActionGeneric, | |
| 14 | + queryGeneric, | |
| 15 | + mutationGeneric, | |
| 16 | + internalActionGeneric, | |
| 17 | + internalMutationGeneric, | |
| 18 | + internalQueryGeneric, | |
| 19 | +} from "convex/server"; | |
| 20 | + | |
| 21 | +/** | |
| 22 | + * Define a query in this Convex app's public API. | |
| 23 | + * | |
| 24 | + * This function will be allowed to read your Convex database and will be accessible from the client. | |
| 25 | + * | |
| 26 | + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. | |
| 27 | + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. | |
| 28 | + */ | |
| 29 | +export const query = queryGeneric; | |
| 30 | + | |
| 31 | +/** | |
| 32 | + * Define a query that is only accessible from other Convex functions (but not from the client). | |
| 33 | + * | |
| 34 | + * This function will be allowed to read from your Convex database. It will not be accessible from the client. | |
| 35 | + * | |
| 36 | + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. | |
| 37 | + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. | |
| 38 | + */ | |
| 39 | +export const internalQuery = internalQueryGeneric; | |
| 40 | + | |
| 41 | +/** | |
| 42 | + * Define a mutation in this Convex app's public API. | |
| 43 | + * | |
| 44 | + * This function will be allowed to modify your Convex database and will be accessible from the client. | |
| 45 | + * | |
| 46 | + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. | |
| 47 | + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. | |
| 48 | + */ | |
| 49 | +export const mutation = mutationGeneric; | |
| 50 | + | |
| 51 | +/** | |
| 52 | + * Define a mutation that is only accessible from other Convex functions (but not from the client). | |
| 53 | + * | |
| 54 | + * This function will be allowed to modify your Convex database. It will not be accessible from the client. | |
| 55 | + * | |
| 56 | + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. | |
| 57 | + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. | |
| 58 | + */ | |
| 59 | +export const internalMutation = internalMutationGeneric; | |
| 60 | + | |
| 61 | +/** | |
| 62 | + * Define an action in this Convex app's public API. | |
| 63 | + * | |
| 64 | + * An action is a function which can execute any JavaScript code, including non-deterministic | |
| 65 | + * code and code with side-effects, like calling third-party services. | |
| 66 | + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. | |
| 67 | + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. | |
| 68 | + * | |
| 69 | + * @param func - The action. It receives an {@link ActionCtx} as its first argument. | |
| 70 | + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. | |
| 71 | + */ | |
| 72 | +export const action = actionGeneric; | |
| 73 | + | |
| 74 | +/** | |
| 75 | + * Define an action that is only accessible from other Convex functions (but not from the client). | |
| 76 | + * | |
| 77 | + * @param func - The function. It receives an {@link ActionCtx} as its first argument. | |
| 78 | + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. | |
| 79 | + */ | |
| 80 | +export const internalAction = internalActionGeneric; | |
| 81 | + | |
| 82 | +/** | |
| 83 | + * Define an HTTP action. | |
| 84 | + * | |
| 85 | + * The wrapped function will be used to respond to HTTP requests received | |
| 86 | + * by a Convex deployment if the requests matches the path and method where | |
| 87 | + * this action is routed. Be sure to route your httpAction in `convex/http.js`. | |
| 88 | + * | |
| 89 | + * @param func - The function. It receives an {@link ActionCtx} as its first argument | |
| 90 | + * and a Fetch API `Request` object as its second. | |
| 91 | + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. | |
| 92 | + */ | |
| 93 | +export const httpAction = httpActionGeneric; | |
| @@ -0,0 +1,13 @@ | ||
| 1 | +import { httpRouter } from "convex/server"; | |
| 2 | +import { mcpHandler } from "./mcp"; | |
| 3 | + | |
| 4 | +const http = httpRouter(); | |
| 5 | + | |
| 6 | +http.route({ path: "/mcp", method: "POST", handler: mcpHandler }); | |
| 7 | +http.route({ path: "/mcp", method: "GET", handler: mcpHandler }); | |
| 8 | +http.route({ path: "/mcp", method: "DELETE", handler: mcpHandler }); | |
| 9 | +http.route({ path: "/v1/mcp", method: "POST", handler: mcpHandler }); | |
| 10 | +http.route({ path: "/v1/mcp", method: "GET", handler: mcpHandler }); | |
| 11 | +http.route({ path: "/v1/mcp", method: "DELETE", handler: mcpHandler }); | |
| 12 | + | |
| 13 | +export default http; | |
| @@ -0,0 +1,9 @@ | ||
| 1 | +import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway"; | |
| 2 | +import { httpAction } from "./_generated/server"; | |
| 3 | + | |
| 4 | +export const mcpHandler = httpAction(async (_ctx, request) => | |
| 5 | + handleMcpHttpRequest(request, { | |
| 6 | + target: "remote", | |
| 7 | + serverName: "executor-v2-convex", | |
| 8 | + serverVersion: "0.0.0", | |
| 9 | + })); | |
| @@ -0,0 +1,3 @@ | ||
| 1 | +import { executorConfectSchema } from "@executor-v2/persistence-convex"; | |
| 2 | + | |
| 3 | +export default executorConfectSchema.convexSchemaDefinition; | |
| @@ -0,0 +1,25 @@ | ||
| 1 | +{ | |
| 2 | + /* This TypeScript project config describes the environment that | |
| 3 | + * Convex functions run in and is used to typecheck them. | |
| 4 | + * You can modify it, but some settings are required to use Convex. | |
| 5 | + */ | |
| 6 | + "compilerOptions": { | |
| 7 | + /* These settings are not required by Convex and can be modified. */ | |
| 8 | + "allowJs": true, | |
| 9 | + "strict": true, | |
| 10 | + "moduleResolution": "Bundler", | |
| 11 | + "jsx": "react-jsx", | |
| 12 | + "skipLibCheck": true, | |
| 13 | + "allowSyntheticDefaultImports": true, | |
| 14 | + | |
| 15 | + /* These compiler options are required by Convex */ | |
| 16 | + "target": "ESNext", | |
| 17 | + "lib": ["ES2021", "dom"], | |
| 18 | + "forceConsistentCasingInFileNames": true, | |
| 19 | + "module": "ESNext", | |
| 20 | + "isolatedModules": true, | |
| 21 | + "noEmit": true | |
| 22 | + }, | |
| 23 | + "include": ["./**/*"], | |
| 24 | + "exclude": ["./_generated"] | |
| 25 | +} | |
| @@ -3,6 +3,13 @@ · 2 unmodified lines | ||
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | 5 | "scripts": { |
| 6 | − "typecheck": "bunx tsc --noEmit -p tsconfig.json" | |
| 6 | + "dev": "bunx convex dev", | |
| 7 | + "typecheck": "bunx tsc --noEmit -p tsconfig.json", | |
| 8 | + "convex:codegen": "bunx convex codegen" | |
| 9 | + }, | |
| 10 | + "dependencies": { | |
| 11 | + "@executor-v2/mcp-gateway": "workspace:*", | |
| 12 | + "@executor-v2/persistence-convex": "workspace:*", | |
| 13 | + "convex": "^1.31.7" | |
| 7 | 14 | } |
| 8 | 15 | } |
| @@ -1,4 +1,4 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "extends": "../../tsconfig.json", |
| 3 | − "include": ["src"] | |
| 3 | + "include": ["src", "convex"] | |
| 4 | 4 | } |
| @@ -1,3 +1,8 @@ | ||
| 1 | 1 | # app-pm |
| 2 | 2 | |
| 3 | 3 | Local Process Manager app scaffold for Executor v2. |
| 4 | + | |
| 5 | +Current scaffold includes: | |
| 6 | +- local MCP endpoint at `POST /mcp` | |
| 7 | +- health endpoint at `GET /healthz` | |
| 8 | +- MCP stub tool routing via `@executor-v2/mcp-gateway` | |
| @@ -3,6 +3,11 @@ · 2 unmodified lines | ||
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | 5 | "scripts": { |
| 6 | + "dev": "bun --watch src/main.ts", | |
| 7 | + "start": "bun src/main.ts", | |
| 6 | 8 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" |
| 9 | + }, | |
| 10 | + "dependencies": { | |
| 11 | + "@executor-v2/mcp-gateway": "workspace:*" | |
| 7 | 12 | } |
| 8 | 13 | } |
| @@ -0,0 +1,31 @@ | ||
| 1 | +import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway"; | |
| 2 | + | |
| 3 | +const port = Number(Bun.env.PORT ?? 8787); | |
| 4 | + | |
| 5 | +const handleMcp = async (request: Request): Promise<Response> => | |
| 6 | + handleMcpHttpRequest(request, { | |
| 7 | + target: "local", | |
| 8 | + serverName: "executor-v2-pm", | |
| 9 | + serverVersion: "0.0.0", | |
| 10 | + }); | |
| 11 | + | |
| 12 | +const server = Bun.serve({ | |
| 13 | + port, | |
| 14 | + routes: { | |
| 15 | + "/healthz": { | |
| 16 | + GET: () => Response.json({ ok: true, service: "pm" }, { status: 200 }), | |
| 17 | + }, | |
| 18 | + "/mcp": { | |
| 19 | + GET: handleMcp, | |
| 20 | + POST: handleMcp, | |
| 21 | + DELETE: handleMcp, | |
| 22 | + }, | |
| 23 | + "/v1/mcp": { | |
| 24 | + GET: handleMcp, | |
| 25 | + POST: handleMcp, | |
| 26 | + DELETE: handleMcp, | |
| 27 | + }, | |
| 28 | + }, | |
| 29 | +}); | |
| 30 | + | |
| 31 | +console.log(`executor-v2 PM listening on http://127.0.0.1:${server.port}`); | |
| @@ -1,4 +1,7 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "extends": "../../tsconfig.json", |
| 3 | + "compilerOptions": { | |
| 4 | + "types": ["bun"] | |
| 5 | + }, | |
| 3 | 6 | "include": ["src"] |
| 4 | 7 | } |
| @@ -0,0 +1,699 @@ | ||
| 1 | +{ | |
| 2 | + "lockfileVersion": 1, | |
| 3 | + "configVersion": 1, | |
| 4 | + "workspaces": { | |
| 5 | + "": { | |
| 6 | + "name": "executor-v2", | |
| 7 | + "devDependencies": { | |
| 8 | + "@effect/language-service": "^0.76.0", | |
| 9 | + "typescript": "^5.9.3", | |
| 10 | + }, | |
| 11 | + }, | |
| 12 | + "apps/cli": { | |
| 13 | + "name": "@executor-v2/app-cli", | |
| 14 | + }, | |
| 15 | + "apps/convex": { | |
| 16 | + "name": "@executor-v2/app-convex", | |
| 17 | + "dependencies": { | |
| 18 | + "@executor-v2/mcp-gateway": "workspace:*", | |
| 19 | + "@executor-v2/persistence-convex": "workspace:*", | |
| 20 | + "convex": "^1.31.7", | |
| 21 | + }, | |
| 22 | + }, | |
| 23 | + "apps/pm": { | |
| 24 | + "name": "@executor-v2/app-pm", | |
| 25 | + "dependencies": { | |
| 26 | + "@executor-v2/mcp-gateway": "workspace:*", | |
| 27 | + }, | |
| 28 | + }, | |
| 29 | + "apps/web": { | |
| 30 | + "name": "@executor-v2/app-web", | |
| 31 | + }, | |
| 32 | + "packages/ai-sdk-adapter": { | |
| 33 | + "name": "@executor-v2/ai-sdk-adapter", | |
| 34 | + }, | |
| 35 | + "packages/approvals": { | |
| 36 | + "name": "@executor-v2/approvals", | |
| 37 | + }, | |
| 38 | + "packages/confect": { | |
| 39 | + "name": "@executor-v2/confect", | |
| 40 | + "version": "0.0.0", | |
| 41 | + "dependencies": { | |
| 42 | + "@effect-atom/atom": "^0.4.11", | |
| 43 | + "@effect-atom/atom-react": "^0.4.4", | |
| 44 | + "@effect/platform": "^0.93.6", | |
| 45 | + "@effect/rpc": "^0.72.2", | |
| 46 | + "convex": "^1.32.0", | |
| 47 | + "effect": "^3.19.8", | |
| 48 | + "next": "^16.1.6", | |
| 49 | + }, | |
| 50 | + "devDependencies": { | |
| 51 | + "@effect/vitest": "^0.27.0", | |
| 52 | + "@types/node": "^20.19.9", | |
| 53 | + "typescript": "^5.9.2", | |
| 54 | + "vitest": "^3.2.4", | |
| 55 | + }, | |
| 56 | + }, | |
| 57 | + "packages/domain": { | |
| 58 | + "name": "@executor-v2/domain", | |
| 59 | + }, | |
| 60 | + "packages/engine": { | |
| 61 | + "name": "@executor-v2/engine", | |
| 62 | + }, | |
| 63 | + "packages/mcp-gateway": { | |
| 64 | + "name": "@executor-v2/mcp-gateway", | |
| 65 | + "dependencies": { | |
| 66 | + "@modelcontextprotocol/sdk": "^1.26.0", | |
| 67 | + "zod": "^4.3.6", | |
| 68 | + }, | |
| 69 | + }, | |
| 70 | + "packages/oauth": { | |
| 71 | + "name": "@executor-v2/oauth", | |
| 72 | + }, | |
| 73 | + "packages/rpc": { | |
| 74 | + "name": "@executor-v2/rpc", | |
| 75 | + }, | |
| 76 | + "packages/schema": { | |
| 77 | + "name": "@executor-v2/schema", | |
| 78 | + "dependencies": { | |
| 79 | + "effect": "^3.19.4", | |
| 80 | + }, | |
| 81 | + }, | |
| 82 | + "packages/sdk": { | |
| 83 | + "name": "@executor-v2/sdk", | |
| 84 | + }, | |
| 85 | + "packages/secrets": { | |
| 86 | + "name": "@executor-v2/secrets", | |
| 87 | + }, | |
| 88 | + "packages/source-manager": { | |
| 89 | + "name": "@executor-v2/source-manager", | |
| 90 | + "dependencies": { | |
| 91 | + "@executor-v2/persistence-ports": "workspace:*", | |
| 92 | + "@executor-v2/schema": "workspace:*", | |
| 93 | + "effect": "^3.19.8", | |
| 94 | + }, | |
| 95 | + }, | |
| 96 | + "packages/state-contracts": { | |
| 97 | + "name": "@executor-v2/persistence-ports", | |
| 98 | + "dependencies": { | |
| 99 | + "@executor-v2/schema": "workspace:*", | |
| 100 | + "effect": "^3.19.8", | |
| 101 | + }, | |
| 102 | + }, | |
| 103 | + "packages/state-convex": { | |
| 104 | + "name": "@executor-v2/persistence-convex", | |
| 105 | + "dependencies": { | |
| 106 | + "@executor-v2/confect": "workspace:*", | |
| 107 | + "@executor-v2/persistence-ports": "workspace:*", | |
| 108 | + "@executor-v2/schema": "workspace:*", | |
| 109 | + "effect": "^3.19.8", | |
| 110 | + }, | |
| 111 | + }, | |
| 112 | + "packages/state-local-file": { | |
| 113 | + "name": "@executor-v2/persistence-local", | |
| 114 | + "dependencies": { | |
| 115 | + "@executor-v2/persistence-ports": "workspace:*", | |
| 116 | + "@executor-v2/schema": "workspace:*", | |
| 117 | + "effect": "^3.19.8", | |
| 118 | + }, | |
| 119 | + }, | |
| 120 | + "packages/sync": { | |
| 121 | + "name": "@executor-v2/sync", | |
| 122 | + }, | |
| 123 | + }, | |
| 124 | + "packages": { | |
| 125 | + "@effect-atom/atom": ["@effect-atom/atom@0.4.13", "", { "peerDependencies": { "@effect/experimental": "^0.57.0", "@effect/platform": "^0.93.0", "@effect/rpc": "^0.72.1", "effect": "^3.19.0" } }, "sha512-wYCPDg/vN2IWTqxho7kTTSivh//V2TSAcdkQvLXMJWww8YzhcCGmCiDpT6cefyNXvHj+qZrqYB54mrUqG6EYCA=="], | |
| 126 | + | |
| 127 | + "@effect-atom/atom-react": ["@effect-atom/atom-react@0.4.6", "", { "dependencies": { "@effect-atom/atom": "^0.4.13" }, "peerDependencies": { "effect": "^3.19", "react": ">=18 <20", "scheduler": "*" } }, "sha512-ZvncuSQsCcUrsddD00JcKDMraPge0AiDMGiJqKc7ProvagCVrxB+Emy5uHM0l0SLGUx4X56bFE0d1tp9Z/Bf+g=="], | |
| 128 | + | |
| 129 | + "@effect/experimental": ["@effect/experimental@0.57.11", "", { "dependencies": { "uuid": "^11.0.3" }, "peerDependencies": { "@effect/platform": "^0.93.6", "effect": "^3.19.9", "ioredis": "^5", "lmdb": "^3" }, "optionalPeers": ["ioredis", "lmdb"] }, "sha512-M5uug3Drs/gyTHLfA+XzcIZQGUEV/Jn5yi1POki4oZswhpzNmsVTHl4THpxAordRKwa5lFvTSlsRP684YH7pSw=="], | |
| 130 | + | |
| 131 | + "@effect/language-service": ["@effect/language-service@0.76.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-tCFQjdvdeqSx+j+QCA9dbz5K4BJtsvIknH8sHft8pTT8ClmVr7uAghD7TcufCtDOZQunXRXyKqQZzErduqhSYQ=="], | |
| 132 | + | |
| 133 | + "@effect/platform": ["@effect/platform@0.93.8", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.19.12" } }, "sha512-xTEy6fyTy4ijmFC3afKgtvYtn/JyPoIov4ZSUWJZUv3VeOcUPNGrrqG6IJlWkXs3NhvSywKv7wc1kw3epCQVZw=="], | |
| 134 | + | |
| 135 | + "@effect/rpc": ["@effect/rpc@0.72.2", "", { "dependencies": { "msgpackr": "^1.11.4" }, "peerDependencies": { "@effect/platform": "^0.93.3", "effect": "^3.19.5" } }, "sha512-BmTXybXCOq96D2r9mvSW/YdiTQs5CStnd4II+lfVKrMr3pMNERKLZ2LG37Tfm4Sy3Q8ire6IVVKO/CN+VR0uQQ=="], | |
| 136 | + | |
| 137 | + "@effect/vitest": ["@effect/vitest@0.27.0", "", { "peerDependencies": { "effect": "^3.19.0", "vitest": "^3.2.0" } }, "sha512-8bM7n9xlMUYw9GqPIVgXFwFm2jf27m/R7psI64PGpwU5+26iwyxp9eAXEsfT5S6lqztYfpQQ1Ubp5o6HfNYzJQ=="], | |
| 138 | + | |
| 139 | + "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], | |
| 140 | + | |
| 141 | + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="], | |
| 142 | + | |
| 143 | + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="], | |
| 144 | + | |
| 145 | + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ=="], | |
| 146 | + | |
| 147 | + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.0", "", { "os": "android", "cpu": "x64" }, "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q=="], | |
| 148 | + | |
| 149 | + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg=="], | |
| 150 | + | |
| 151 | + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g=="], | |
| 152 | + | |
| 153 | + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw=="], | |
| 154 | + | |
| 155 | + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g=="], | |
| 156 | + | |
| 157 | + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ=="], | |
| 158 | + | |
| 159 | + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ=="], | |
| 160 | + | |
| 161 | + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw=="], | |
| 162 | + | |
| 163 | + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg=="], | |
| 164 | + | |
| 165 | + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg=="], | |
| 166 | + | |
| 167 | + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA=="], | |
| 168 | + | |
| 169 | + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ=="], | |
| 170 | + | |
| 171 | + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w=="], | |
| 172 | + | |
| 173 | + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw=="], | |
| 174 | + | |
| 175 | + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w=="], | |
| 176 | + | |
| 177 | + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.0", "", { "os": "none", "cpu": "x64" }, "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA=="], | |
| 178 | + | |
| 179 | + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ=="], | |
| 180 | + | |
| 181 | + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A=="], | |
| 182 | + | |
| 183 | + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA=="], | |
| 184 | + | |
| 185 | + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA=="], | |
| 186 | + | |
| 187 | + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg=="], | |
| 188 | + | |
| 189 | + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ=="], | |
| 190 | + | |
| 191 | + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="], | |
| 192 | + | |
| 193 | + "@executor-v2/ai-sdk-adapter": ["@executor-v2/ai-sdk-adapter@workspace:packages/ai-sdk-adapter"], | |
| 194 | + | |
| 195 | + "@executor-v2/app-cli": ["@executor-v2/app-cli@workspace:apps/cli"], | |
| 196 | + | |
| 197 | + "@executor-v2/app-convex": ["@executor-v2/app-convex@workspace:apps/convex"], | |
| 198 | + | |
| 199 | + "@executor-v2/app-pm": ["@executor-v2/app-pm@workspace:apps/pm"], | |
| 200 | + | |
| 201 | + "@executor-v2/app-web": ["@executor-v2/app-web@workspace:apps/web"], | |
| 202 | + | |
| 203 | + "@executor-v2/approvals": ["@executor-v2/approvals@workspace:packages/approvals"], | |
| 204 | + | |
| 205 | + "@executor-v2/confect": ["@executor-v2/confect@workspace:packages/confect"], | |
| 206 | + | |
| 207 | + "@executor-v2/domain": ["@executor-v2/domain@workspace:packages/domain"], | |
| 208 | + | |
| 209 | + "@executor-v2/engine": ["@executor-v2/engine@workspace:packages/engine"], | |
| 210 | + | |
| 211 | + "@executor-v2/mcp-gateway": ["@executor-v2/mcp-gateway@workspace:packages/mcp-gateway"], | |
| 212 | + | |
| 213 | + "@executor-v2/oauth": ["@executor-v2/oauth@workspace:packages/oauth"], | |
| 214 | + | |
| 215 | + "@executor-v2/persistence-convex": ["@executor-v2/persistence-convex@workspace:packages/state-convex"], | |
| 216 | + | |
| 217 | + "@executor-v2/persistence-local": ["@executor-v2/persistence-local@workspace:packages/state-local-file"], | |
| 218 | + | |
| 219 | + "@executor-v2/persistence-ports": ["@executor-v2/persistence-ports@workspace:packages/state-contracts"], | |
| 220 | + | |
| 221 | + "@executor-v2/rpc": ["@executor-v2/rpc@workspace:packages/rpc"], | |
| 222 | + | |
| 223 | + "@executor-v2/schema": ["@executor-v2/schema@workspace:packages/schema"], | |
| 224 | + | |
| 225 | + "@executor-v2/sdk": ["@executor-v2/sdk@workspace:packages/sdk"], | |
| 226 | + | |
| 227 | + "@executor-v2/secrets": ["@executor-v2/secrets@workspace:packages/secrets"], | |
| 228 | + | |
| 229 | + "@executor-v2/source-manager": ["@executor-v2/source-manager@workspace:packages/source-manager"], | |
| 230 | + | |
| 231 | + "@executor-v2/sync": ["@executor-v2/sync@workspace:packages/sync"], | |
| 232 | + | |
| 233 | + "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], | |
| 234 | + | |
| 235 | + "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], | |
| 236 | + | |
| 237 | + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], | |
| 238 | + | |
| 239 | + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], | |
| 240 | + | |
| 241 | + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], | |
| 242 | + | |
| 243 | + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], | |
| 244 | + | |
| 245 | + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], | |
| 246 | + | |
| 247 | + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], | |
| 248 | + | |
| 249 | + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], | |
| 250 | + | |
| 251 | + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], | |
| 252 | + | |
| 253 | + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], | |
| 254 | + | |
| 255 | + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], | |
| 256 | + | |
| 257 | + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], | |
| 258 | + | |
| 259 | + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], | |
| 260 | + | |
| 261 | + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], | |
| 262 | + | |
| 263 | + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], | |
| 264 | + | |
| 265 | + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], | |
| 266 | + | |
| 267 | + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], | |
| 268 | + | |
| 269 | + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], | |
| 270 | + | |
| 271 | + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], | |
| 272 | + | |
| 273 | + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], | |
| 274 | + | |
| 275 | + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], | |
| 276 | + | |
| 277 | + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], | |
| 278 | + | |
| 279 | + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], | |
| 280 | + | |
| 281 | + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], | |
| 282 | + | |
| 283 | + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], | |
| 284 | + | |
| 285 | + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], | |
| 286 | + | |
| 287 | + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], | |
| 288 | + | |
| 289 | + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], | |
| 290 | + | |
| 291 | + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], | |
| 292 | + | |
| 293 | + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="], | |
| 294 | + | |
| 295 | + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="], | |
| 296 | + | |
| 297 | + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="], | |
| 298 | + | |
| 299 | + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], | |
| 300 | + | |
| 301 | + "@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="], | |
| 302 | + | |
| 303 | + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw=="], | |
| 304 | + | |
| 305 | + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ=="], | |
| 306 | + | |
| 307 | + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw=="], | |
| 308 | + | |
| 309 | + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ=="], | |
| 310 | + | |
| 311 | + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ=="], | |
| 312 | + | |
| 313 | + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg=="], | |
| 314 | + | |
| 315 | + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw=="], | |
| 316 | + | |
| 317 | + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="], | |
| 318 | + | |
| 319 | + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], | |
| 320 | + | |
| 321 | + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], | |
| 322 | + | |
| 323 | + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="], | |
| 324 | + | |
| 325 | + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="], | |
| 326 | + | |
| 327 | + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="], | |
| 328 | + | |
| 329 | + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="], | |
| 330 | + | |
| 331 | + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="], | |
| 332 | + | |
| 333 | + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="], | |
| 334 | + | |
| 335 | + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="], | |
| 336 | + | |
| 337 | + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="], | |
| 338 | + | |
| 339 | + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="], | |
| 340 | + | |
| 341 | + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="], | |
| 342 | + | |
| 343 | + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="], | |
| 344 | + | |
| 345 | + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="], | |
| 346 | + | |
| 347 | + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="], | |
| 348 | + | |
| 349 | + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="], | |
| 350 | + | |
| 351 | + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="], | |
| 352 | + | |
| 353 | + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="], | |
| 354 | + | |
| 355 | + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="], | |
| 356 | + | |
| 357 | + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="], | |
| 358 | + | |
| 359 | + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="], | |
| 360 | + | |
| 361 | + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="], | |
| 362 | + | |
| 363 | + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="], | |
| 364 | + | |
| 365 | + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="], | |
| 366 | + | |
| 367 | + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="], | |
| 368 | + | |
| 369 | + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], | |
| 370 | + | |
| 371 | + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], | |
| 372 | + | |
| 373 | + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], | |
| 374 | + | |
| 375 | + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], | |
| 376 | + | |
| 377 | + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], | |
| 378 | + | |
| 379 | + "@types/node": ["@types/node@20.19.33", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw=="], | |
| 380 | + | |
| 381 | + "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], | |
| 382 | + | |
| 383 | + "@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="], | |
| 384 | + | |
| 385 | + "@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], | |
| 386 | + | |
| 387 | + "@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="], | |
| 388 | + | |
| 389 | + "@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="], | |
| 390 | + | |
| 391 | + "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], | |
| 392 | + | |
| 393 | + "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], | |
| 394 | + | |
| 395 | + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], | |
| 396 | + | |
| 397 | + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], | |
| 398 | + | |
| 399 | + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], | |
| 400 | + | |
| 401 | + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], | |
| 402 | + | |
| 403 | + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], | |
| 404 | + | |
| 405 | + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], | |
| 406 | + | |
| 407 | + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], | |
| 408 | + | |
| 409 | + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], | |
| 410 | + | |
| 411 | + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], | |
| 412 | + | |
| 413 | + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], | |
| 414 | + | |
| 415 | + "caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="], | |
| 416 | + | |
| 417 | + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], | |
| 418 | + | |
| 419 | + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], | |
| 420 | + | |
| 421 | + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], | |
| 422 | + | |
| 423 | + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], | |
| 424 | + | |
| 425 | + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], | |
| 426 | + | |
| 427 | + "convex": ["convex@1.32.0", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-5FlajdLpW75pdLS+/CgGH5H6yeRuA+ru50AKJEYbJpmyILUS+7fdTvsdTaQ7ZFXMv0gE8mX4S+S3AtJ94k0mfw=="], | |
| 428 | + | |
| 429 | + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], | |
| 430 | + | |
| 431 | + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], | |
| 432 | + | |
| 433 | + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], | |
| 434 | + | |
| 435 | + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], | |
| 436 | + | |
| 437 | + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], | |
| 438 | + | |
| 439 | + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], | |
| 440 | + | |
| 441 | + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], | |
| 442 | + | |
| 443 | + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], | |
| 444 | + | |
| 445 | + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], | |
| 446 | + | |
| 447 | + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], | |
| 448 | + | |
| 449 | + "effect": ["effect@3.19.19", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-Yc8U/SVXo2dHnaP7zNBlAo83h/nzSJpi7vph6Hzyl4ulgMBIgPmz3UzOjb9sBgpFE00gC0iETR244sfXDNLHRg=="], | |
| 450 | + | |
| 451 | + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], | |
| 452 | + | |
| 453 | + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], | |
| 454 | + | |
| 455 | + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], | |
| 456 | + | |
| 457 | + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], | |
| 458 | + | |
| 459 | + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], | |
| 460 | + | |
| 461 | + "esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], | |
| 462 | + | |
| 463 | + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], | |
| 464 | + | |
| 465 | + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], | |
| 466 | + | |
| 467 | + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], | |
| 468 | + | |
| 469 | + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], | |
| 470 | + | |
| 471 | + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], | |
| 472 | + | |
| 473 | + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], | |
| 474 | + | |
| 475 | + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], | |
| 476 | + | |
| 477 | + "express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], | |
| 478 | + | |
| 479 | + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], | |
| 480 | + | |
| 481 | + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], | |
| 482 | + | |
| 483 | + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], | |
| 484 | + | |
| 485 | + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], | |
| 486 | + | |
| 487 | + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], | |
| 488 | + | |
| 489 | + "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], | |
| 490 | + | |
| 491 | + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], | |
| 492 | + | |
| 493 | + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], | |
| 494 | + | |
| 495 | + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], | |
| 496 | + | |
| 497 | + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], | |
| 498 | + | |
| 499 | + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], | |
| 500 | + | |
| 501 | + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], | |
| 502 | + | |
| 503 | + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], | |
| 504 | + | |
| 505 | + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], | |
| 506 | + | |
| 507 | + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], | |
| 508 | + | |
| 509 | + "hono": ["hono@4.12.2", "", {}, "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg=="], | |
| 510 | + | |
| 511 | + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], | |
| 512 | + | |
| 513 | + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], | |
| 514 | + | |
| 515 | + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], | |
| 516 | + | |
| 517 | + "ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="], | |
| 518 | + | |
| 519 | + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], | |
| 520 | + | |
| 521 | + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], | |
| 522 | + | |
| 523 | + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], | |
| 524 | + | |
| 525 | + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], | |
| 526 | + | |
| 527 | + "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], | |
| 528 | + | |
| 529 | + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], | |
| 530 | + | |
| 531 | + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], | |
| 532 | + | |
| 533 | + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], | |
| 534 | + | |
| 535 | + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], | |
| 536 | + | |
| 537 | + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], | |
| 538 | + | |
| 539 | + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], | |
| 540 | + | |
| 541 | + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], | |
| 542 | + | |
| 543 | + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], | |
| 544 | + | |
| 545 | + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], | |
| 546 | + | |
| 547 | + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], | |
| 548 | + | |
| 549 | + "msgpackr": ["msgpackr@1.11.8", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA=="], | |
| 550 | + | |
| 551 | + "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], | |
| 552 | + | |
| 553 | + "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], | |
| 554 | + | |
| 555 | + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], | |
| 556 | + | |
| 557 | + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], | |
| 558 | + | |
| 559 | + "next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="], | |
| 560 | + | |
| 561 | + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], | |
| 562 | + | |
| 563 | + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], | |
| 564 | + | |
| 565 | + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], | |
| 566 | + | |
| 567 | + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], | |
| 568 | + | |
| 569 | + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], | |
| 570 | + | |
| 571 | + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], | |
| 572 | + | |
| 573 | + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], | |
| 574 | + | |
| 575 | + "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], | |
| 576 | + | |
| 577 | + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], | |
| 578 | + | |
| 579 | + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], | |
| 580 | + | |
| 581 | + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], | |
| 582 | + | |
| 583 | + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], | |
| 584 | + | |
| 585 | + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], | |
| 586 | + | |
| 587 | + "postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], | |
| 588 | + | |
| 589 | + "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], | |
| 590 | + | |
| 591 | + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], | |
| 592 | + | |
| 593 | + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], | |
| 594 | + | |
| 595 | + "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], | |
| 596 | + | |
| 597 | + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], | |
| 598 | + | |
| 599 | + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], | |
| 600 | + | |
| 601 | + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], | |
| 602 | + | |
| 603 | + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], | |
| 604 | + | |
| 605 | + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], | |
| 606 | + | |
| 607 | + "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], | |
| 608 | + | |
| 609 | + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], | |
| 610 | + | |
| 611 | + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], | |
| 612 | + | |
| 613 | + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], | |
| 614 | + | |
| 615 | + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], | |
| 616 | + | |
| 617 | + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], | |
| 618 | + | |
| 619 | + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], | |
| 620 | + | |
| 621 | + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], | |
| 622 | + | |
| 623 | + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], | |
| 624 | + | |
| 625 | + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], | |
| 626 | + | |
| 627 | + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], | |
| 628 | + | |
| 629 | + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], | |
| 630 | + | |
| 631 | + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], | |
| 632 | + | |
| 633 | + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], | |
| 634 | + | |
| 635 | + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], | |
| 636 | + | |
| 637 | + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], | |
| 638 | + | |
| 639 | + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], | |
| 640 | + | |
| 641 | + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], | |
| 642 | + | |
| 643 | + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], | |
| 644 | + | |
| 645 | + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], | |
| 646 | + | |
| 647 | + "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], | |
| 648 | + | |
| 649 | + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], | |
| 650 | + | |
| 651 | + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], | |
| 652 | + | |
| 653 | + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], | |
| 654 | + | |
| 655 | + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], | |
| 656 | + | |
| 657 | + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], | |
| 658 | + | |
| 659 | + "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], | |
| 660 | + | |
| 661 | + "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], | |
| 662 | + | |
| 663 | + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], | |
| 664 | + | |
| 665 | + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], | |
| 666 | + | |
| 667 | + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], | |
| 668 | + | |
| 669 | + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], | |
| 670 | + | |
| 671 | + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], | |
| 672 | + | |
| 673 | + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], | |
| 674 | + | |
| 675 | + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], | |
| 676 | + | |
| 677 | + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], | |
| 678 | + | |
| 679 | + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], | |
| 680 | + | |
| 681 | + "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], | |
| 682 | + | |
| 683 | + "vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="], | |
| 684 | + | |
| 685 | + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], | |
| 686 | + | |
| 687 | + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], | |
| 688 | + | |
| 689 | + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], | |
| 690 | + | |
| 691 | + "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], | |
| 692 | + | |
| 693 | + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], | |
| 694 | + | |
| 695 | + "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], | |
| 696 | + | |
| 697 | + "vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], | |
| 698 | + } | |
| 699 | +} | |
| @@ -8,6 +8,11 @@ · 7 unmodified lines | ||
| 8 | 8 | "packages/*" |
| 9 | 9 | ], |
| 10 | 10 | "scripts": { |
| 11 | − "typecheck": "bunx tsc --noEmit" | |
| 11 | + "prepare": "effect-language-service patch", | |
| 12 | + "typecheck": "tsc --noEmit" | |
| 13 | + }, | |
| 14 | + "devDependencies": { | |
| 15 | + "@effect/language-service": "^0.76.0", | |
| 16 | + "typescript": "^5.9.3" | |
| 12 | 17 | } |
| 13 | 18 | } |
| @@ -1,5 +0,0 @@ | ||
| 1 | −# confect-core | |
| 2 | − | |
| 3 | −Effect schema helper primitives scaffold for Executor v2. | |
| 4 | − | |
| 5 | −Current scaffold includes basic helpers in `src/schema.ts`. | |
| @@ -1,14 +0,0 @@ | ||
| 1 | −{ | |
| 2 | − "name": "@executor-v2/confect-core", | |
| 3 | − "private": true, | |
| 4 | − "type": "module", | |
| 5 | − "exports": { | |
| 6 | − ".": "./src/index.ts" | |
| 7 | − }, | |
| 8 | − "scripts": { | |
| 9 | − "typecheck": "bunx tsc --noEmit -p tsconfig.json" | |
| 10 | − }, | |
| 11 | − "dependencies": { | |
| 12 | − "effect": "^3.19.4" | |
| 13 | − } | |
| 14 | −} | |
| @@ -1 +0,0 @@ | ||
| 1 | −export * from "./schema"; | |
| @@ -1,6 +0,0 @@ | ||
| 1 | −import { Schema } from "effect"; | |
| 2 | − | |
| 3 | −export const TimestampMsSchema = Schema.Number; | |
| 4 | − | |
| 5 | −export const makeIdSchema = (brandName: string) => | |
| 6 | − Schema.String.pipe(Schema.brand(brandName)); | |
| @@ -1,4 +0,0 @@ | ||
| 1 | −{ | |
| 2 | − "extends": "../../tsconfig.json", | |
| 3 | − "include": ["src"] | |
| 4 | −} | |
| @@ -0,0 +1,35 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@executor-v2/confect", | |
| 3 | + "version": "0.0.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "exports": { | |
| 7 | + ".": "./src/index.ts", | |
| 8 | + "./schema": "./src/schema.ts", | |
| 9 | + "./validators": "./src/validators.ts", | |
| 10 | + "./ctx": "./src/ctx.ts", | |
| 11 | + "./client": "./src/client.ts", | |
| 12 | + "./rpc": "./src/rpc/index.ts", | |
| 13 | + "./testing": "./src/testing.ts" | |
| 14 | + }, | |
| 15 | + "scripts": { | |
| 16 | + "test": "vitest run --no-watch", | |
| 17 | + "test:watch": "vitest", | |
| 18 | + "typecheck": "bunx tsc --noEmit -p tsconfig.json" | |
| 19 | + }, | |
| 20 | + "dependencies": { | |
| 21 | + "@effect-atom/atom": "^0.4.11", | |
| 22 | + "@effect-atom/atom-react": "^0.4.4", | |
| 23 | + "@effect/platform": "^0.93.6", | |
| 24 | + "@effect/rpc": "^0.72.2", | |
| 25 | + "convex": "^1.32.0", | |
| 26 | + "effect": "^3.19.8", | |
| 27 | + "next": "^16.1.6" | |
| 28 | + }, | |
| 29 | + "devDependencies": { | |
| 30 | + "@effect/vitest": "^0.27.0", | |
| 31 | + "@types/node": "^20.19.9", | |
| 32 | + "typescript": "^5.9.2", | |
| 33 | + "vitest": "^3.2.4" | |
| 34 | + } | |
| 35 | +} | |
| @@ -0,0 +1,160 @@ | ||
| 1 | +import type { FunctionReference, FunctionReturnType } from "convex/server"; | |
| 2 | +import { | |
| 3 | + ConvexClient as ConvexClientImpl, | |
| 4 | + ConvexHttpClient, | |
| 5 | +} from "convex/browser"; | |
| 6 | +import { Context, Effect, Layer, Stream } from "effect"; | |
| 7 | + | |
| 8 | +export interface ConvexRequestMetadata { | |
| 9 | + readonly headers?: Readonly<Record<string, string>>; | |
| 10 | +} | |
| 11 | + | |
| 12 | +export interface ConvexClientService { | |
| 13 | + query<Query extends FunctionReference<"query">>( | |
| 14 | + query: Query, | |
| 15 | + args: Query["_args"], | |
| 16 | + requestMetadata?: ConvexRequestMetadata, | |
| 17 | + ): Effect.Effect<FunctionReturnType<Query>>; | |
| 18 | + | |
| 19 | + mutation<Mutation extends FunctionReference<"mutation">>( | |
| 20 | + mutation: Mutation, | |
| 21 | + args: Mutation["_args"], | |
| 22 | + requestMetadata?: ConvexRequestMetadata, | |
| 23 | + ): Effect.Effect<FunctionReturnType<Mutation>>; | |
| 24 | + | |
| 25 | + action<Action extends FunctionReference<"action">>( | |
| 26 | + action: Action, | |
| 27 | + args: Action["_args"], | |
| 28 | + requestMetadata?: ConvexRequestMetadata, | |
| 29 | + ): Effect.Effect<FunctionReturnType<Action>>; | |
| 30 | + | |
| 31 | + subscribe<Query extends FunctionReference<"query">>( | |
| 32 | + query: Query, | |
| 33 | + args: Query["_args"], | |
| 34 | + ): Stream.Stream<FunctionReturnType<Query>>; | |
| 35 | +} | |
| 36 | + | |
| 37 | +export class ConvexClient extends Context.Tag("@confect/ConvexClient")< | |
| 38 | + ConvexClient, | |
| 39 | + ConvexClientService | |
| 40 | +>() {} | |
| 41 | + | |
| 42 | +export const ConvexClientLayer = ( | |
| 43 | + url: string, | |
| 44 | +): Layer.Layer<ConvexClient> => { | |
| 45 | + const wsClient = new ConvexClientImpl(url); | |
| 46 | + | |
| 47 | + // Shared HTTP client instance — no custom headers that would trigger CORS | |
| 48 | + // preflight failures in browsers. Telemetry context is already embedded | |
| 49 | + // in the payload by `withOptionalRpcTelemetryContext`, so transport-level | |
| 50 | + // headers are not needed for browser clients. | |
| 51 | + const httpClient = new ConvexHttpClient(url); | |
| 52 | + | |
| 53 | + const syncAuth = () => { | |
| 54 | + const auth = wsClient.getAuth(); | |
| 55 | + if (auth !== undefined) { | |
| 56 | + httpClient.setAuth(auth.token); | |
| 57 | + } | |
| 58 | + }; | |
| 59 | + | |
| 60 | + const service: ConvexClientService = { | |
| 61 | + query: <Query extends FunctionReference<"query">>( | |
| 62 | + query: Query, | |
| 63 | + args: Query["_args"], | |
| 64 | + _requestMetadata?: ConvexRequestMetadata, | |
| 65 | + ): Effect.Effect<FunctionReturnType<Query>> => | |
| 66 | + Effect.promise(() => { | |
| 67 | + syncAuth(); | |
| 68 | + return httpClient.query(query, args); | |
| 69 | + }), | |
| 70 | + | |
| 71 | + mutation: <Mutation extends FunctionReference<"mutation">>( | |
| 72 | + mutation: Mutation, | |
| 73 | + args: Mutation["_args"], | |
| 74 | + _requestMetadata?: ConvexRequestMetadata, | |
| 75 | + ): Effect.Effect<FunctionReturnType<Mutation>> => | |
| 76 | + Effect.promise(() => { | |
| 77 | + syncAuth(); | |
| 78 | + return httpClient.mutation(mutation, args); | |
| 79 | + }), | |
| 80 | + | |
| 81 | + action: <Action extends FunctionReference<"action">>( | |
| 82 | + action: Action, | |
| 83 | + args: Action["_args"], | |
| 84 | + _requestMetadata?: ConvexRequestMetadata, | |
| 85 | + ): Effect.Effect<FunctionReturnType<Action>> => | |
| 86 | + Effect.promise(() => { | |
| 87 | + syncAuth(); | |
| 88 | + return httpClient.action(action, args); | |
| 89 | + }), | |
| 90 | + | |
| 91 | + subscribe: <Query extends FunctionReference<"query">>( | |
| 92 | + query: Query, | |
| 93 | + args: Query["_args"], | |
| 94 | + ): Stream.Stream<FunctionReturnType<Query>> => | |
| 95 | + Stream.async<FunctionReturnType<Query>>((emit) => { | |
| 96 | + const unsubscribe = wsClient.onUpdate(query, args, (result) => { | |
| 97 | + emit.single(result); | |
| 98 | + }); | |
| 99 | + return Effect.sync(() => { | |
| 100 | + unsubscribe(); | |
| 101 | + }); | |
| 102 | + }), | |
| 103 | + }; | |
| 104 | + | |
| 105 | + return Layer.succeed(ConvexClient, service); | |
| 106 | +}; | |
| 107 | + | |
| 108 | +/** | |
| 109 | + * Server-only layer that uses only `ConvexHttpClient` (no WebSocket). | |
| 110 | + * Safe for use in Next.js server components, route handlers, and server actions. | |
| 111 | + * Subscriptions are not supported and will fail with a defect if called. | |
| 112 | + */ | |
| 113 | +export const ConvexHttpClientLayer = ( | |
| 114 | + url: string, | |
| 115 | + config: { | |
| 116 | + readonly authToken?: string; | |
| 117 | + } = {}, | |
| 118 | +): Layer.Layer<ConvexClient> => { | |
| 119 | + const httpClient = new ConvexHttpClient(url); | |
| 120 | + if (config.authToken !== undefined && config.authToken.length > 0) { | |
| 121 | + httpClient.setAuth(config.authToken); | |
| 122 | + } | |
| 123 | + | |
| 124 | + const service: ConvexClientService = { | |
| 125 | + query: <Query extends FunctionReference<"query">>( | |
| 126 | + query: Query, | |
| 127 | + args: Query["_args"], | |
| 128 | + _requestMetadata?: ConvexRequestMetadata, | |
| 129 | + ): Effect.Effect<FunctionReturnType<Query>> => | |
| 130 | + Effect.promise(() => httpClient.query(query, args)), | |
| 131 | + | |
| 132 | + mutation: <Mutation extends FunctionReference<"mutation">>( | |
| 133 | + mutation: Mutation, | |
| 134 | + args: Mutation["_args"], | |
| 135 | + _requestMetadata?: ConvexRequestMetadata, | |
| 136 | + ): Effect.Effect<FunctionReturnType<Mutation>> => | |
| 137 | + Effect.promise(() => httpClient.mutation(mutation, args)), | |
| 138 | + | |
| 139 | + action: <Action extends FunctionReference<"action">>( | |
| 140 | + action: Action, | |
| 141 | + args: Action["_args"], | |
| 142 | + _requestMetadata?: ConvexRequestMetadata, | |
| 143 | + ): Effect.Effect<FunctionReturnType<Action>> => | |
| 144 | + Effect.promise(() => httpClient.action(action, args)), | |
| 145 | + | |
| 146 | + subscribe: <Query extends FunctionReference<"query">>( | |
| 147 | + _query: Query, | |
| 148 | + _args: Query["_args"], | |
| 149 | + ): Stream.Stream<FunctionReturnType<Query>> => | |
| 150 | + Stream.fromEffect( | |
| 151 | + Effect.die( | |
| 152 | + new Error( | |
| 153 | + "ConvexHttpClientLayer does not support subscriptions. Use ConvexClientLayer (with WebSocket) for real-time subscriptions.", | |
| 154 | + ), | |
| 155 | + ), | |
| 156 | + ), | |
| 157 | + }; | |
| 158 | + | |
| 159 | + return Layer.succeed(ConvexClient, service); | |
| 160 | +}; | |
| @@ -0,0 +1,683 @@ | ||
| 1 | +import type { | |
| 2 | + FunctionReference, | |
| 3 | + FunctionReturnType, | |
| 4 | + GenericDatabaseReader, | |
| 5 | + GenericDatabaseWriter, | |
| 6 | + GenericMutationCtx, | |
| 7 | + GenericQueryCtx, | |
| 8 | + GenericActionCtx, | |
| 9 | + OptionalRestArgs, | |
| 10 | + PaginationOptions, | |
| 11 | + PaginationResult, | |
| 12 | + Expression, | |
| 13 | + FilterBuilder, | |
| 14 | + IndexRange, | |
| 15 | + SearchFilter, | |
| 16 | + GenericDataModel, | |
| 17 | + GenericDocument, | |
| 18 | + GenericTableInfo, | |
| 19 | + WithoutSystemFields, | |
| 20 | + Scheduler, | |
| 21 | +} from "convex/server"; | |
| 22 | +import type { GenericId, Value } from "convex/values"; | |
| 23 | +import * as Chunk from "effect/Chunk"; | |
| 24 | +import * as Context from "effect/Context"; | |
| 25 | +import * as Effect from "effect/Effect"; | |
| 26 | +import { pipe } from "effect/Function"; | |
| 27 | +import * as Option from "effect/Option"; | |
| 28 | +import * as Schema from "effect/Schema"; | |
| 29 | +import * as Stream from "effect/Stream"; | |
| 30 | + | |
| 31 | +import type { | |
| 32 | + GenericConfectSchema, | |
| 33 | + TableNamesInSchema, | |
| 34 | + DocumentFromTable, | |
| 35 | + EncodedDocumentFromTable, | |
| 36 | +} from "./schema"; | |
| 37 | + | |
| 38 | +export type { | |
| 39 | + GenericConfectSchema, | |
| 40 | + TableNamesInSchema, | |
| 41 | + DocumentFromTable, | |
| 42 | + EncodedDocumentFromTable, | |
| 43 | +}; | |
| 44 | + | |
| 45 | +/** | |
| 46 | + * A loosely-typed index range builder that allows chaining `.eq`, `.gt`, `.gte`, `.lt`, `.lte` | |
| 47 | + * with string field names and `Value` values. This is the type exposed to Confect consumers since | |
| 48 | + * Confect erases table-specific index type information. | |
| 49 | + * | |
| 50 | + * Unlike `IndexRangeBuilder<GenericDocument, string[], number>` (where `.eq()` resolves to plain | |
| 51 | + * `IndexRange` due to tuple-length arithmetic on `string[]`), this interface is self-referential: | |
| 52 | + * `.eq()` always returns `LooseIndexRangeBuilder`, allowing multi-field `.eq().eq()` chains. | |
| 53 | + */ | |
| 54 | +export interface LooseIndexRangeBuilder extends IndexRange { | |
| 55 | + eq(fieldName: string, value: Value): LooseIndexRangeBuilder; | |
| 56 | + gt(fieldName: string, value: Value): LooseIndexRangeBuilder; | |
| 57 | + gte(fieldName: string, value: Value): LooseIndexRangeBuilder; | |
| 58 | + lt(fieldName: string, value: Value): IndexRange; | |
| 59 | + lte(fieldName: string, value: Value): IndexRange; | |
| 60 | +} | |
| 61 | + | |
| 62 | +type TableSchemas<Tables extends GenericConfectSchema> = { | |
| 63 | + [TableName in TableNamesInSchema<Tables>]: Schema.Schema< | |
| 64 | + DocumentFromTable<Tables, TableName>, | |
| 65 | + EncodedDocumentFromTable<Tables, TableName> | |
| 66 | + >; | |
| 67 | +}; | |
| 68 | + | |
| 69 | +interface ConfectQuery< | |
| 70 | + Tables extends GenericConfectSchema, | |
| 71 | + TableName extends TableNamesInSchema<Tables>, | |
| 72 | +> { | |
| 73 | + filter( | |
| 74 | + predicate: (q: FilterBuilder<GenericTableInfo>) => Expression<boolean>, | |
| 75 | + ): ConfectQuery<Tables, TableName>; | |
| 76 | + | |
| 77 | + order(order: "asc" | "desc"): ConfectOrderedQuery<Tables, TableName>; | |
| 78 | + | |
| 79 | + paginate( | |
| 80 | + paginationOpts: PaginationOptions, | |
| 81 | + ): Effect.Effect<PaginationResult<DocumentFromTable<Tables, TableName>>>; | |
| 82 | + | |
| 83 | + collect(): Effect.Effect<Array<DocumentFromTable<Tables, TableName>>>; | |
| 84 | + | |
| 85 | + take(n: number): Effect.Effect<Array<DocumentFromTable<Tables, TableName>>>; | |
| 86 | + | |
| 87 | + first(): Effect.Effect<Option.Option<DocumentFromTable<Tables, TableName>>>; | |
| 88 | + | |
| 89 | + unique(): Effect.Effect<Option.Option<DocumentFromTable<Tables, TableName>>, NotUniqueError>; | |
| 90 | + | |
| 91 | + stream(): Stream.Stream<DocumentFromTable<Tables, TableName>>; | |
| 92 | +} | |
| 93 | + | |
| 94 | +interface ConfectOrderedQuery< | |
| 95 | + Tables extends GenericConfectSchema, | |
| 96 | + TableName extends TableNamesInSchema<Tables>, | |
| 97 | +> extends Omit<ConfectQuery<Tables, TableName>, "order"> {} | |
| 98 | + | |
| 99 | +export class NotUniqueError extends Schema.TaggedError<NotUniqueError>()( | |
| 100 | + "NotUniqueError", | |
| 101 | + {}, | |
| 102 | +) {} | |
| 103 | + | |
| 104 | +interface QueryLike { | |
| 105 | + [Symbol.asyncIterator](): AsyncIterator<Record<string, Value>>; | |
| 106 | + filter(predicate: (q: FilterBuilder<GenericTableInfo>) => Expression<boolean>): QueryLike; | |
| 107 | + order(order: "asc" | "desc"): QueryLike; | |
| 108 | + paginate(opts: PaginationOptions): Promise<PaginationResult<Record<string, Value>>>; | |
| 109 | +} | |
| 110 | + | |
| 111 | +interface QueryInitializerLike extends QueryLike { | |
| 112 | + fullTableScan(): QueryLike; | |
| 113 | + withIndex(indexName: string, indexRange?: (q: LooseIndexRangeBuilder) => IndexRange): QueryLike; | |
| 114 | + withSearchIndex(indexName: string, searchFilter: (q: unknown) => SearchFilter): QueryLike; | |
| 115 | +} | |
| 116 | + | |
| 117 | +class ConfectQueryImpl< | |
| 118 | + Tables extends GenericConfectSchema, | |
| 119 | + TableName extends TableNamesInSchema<Tables>, | |
| 120 | +> implements ConfectQuery<Tables, TableName> | |
| 121 | +{ | |
| 122 | + constructor( | |
| 123 | + private readonly q: QueryLike, | |
| 124 | + private readonly tableSchema: Schema.Schema< | |
| 125 | + DocumentFromTable<Tables, TableName>, | |
| 126 | + EncodedDocumentFromTable<Tables, TableName> | |
| 127 | + >, | |
| 128 | + private readonly tableName: TableName, | |
| 129 | + ) {} | |
| 130 | + | |
| 131 | + private decode(doc: Record<string, Value>): DocumentFromTable<Tables, TableName> { | |
| 132 | + return Schema.decodeUnknownSync(this.tableSchema)(doc); | |
| 133 | + } | |
| 134 | + | |
| 135 | + filter( | |
| 136 | + predicate: (q: FilterBuilder<GenericTableInfo>) => Expression<boolean>, | |
| 137 | + ): ConfectQuery<Tables, TableName> { | |
| 138 | + return new ConfectQueryImpl( | |
| 139 | + this.q.filter(predicate), | |
| 140 | + this.tableSchema, | |
| 141 | + this.tableName, | |
| 142 | + ); | |
| 143 | + } | |
| 144 | + | |
| 145 | + order(order: "asc" | "desc"): ConfectOrderedQuery<Tables, TableName> { | |
| 146 | + return new ConfectQueryImpl( | |
| 147 | + this.q.order(order), | |
| 148 | + this.tableSchema, | |
| 149 | + this.tableName, | |
| 150 | + ); | |
| 151 | + } | |
| 152 | + | |
| 153 | + paginate( | |
| 154 | + paginationOpts: PaginationOptions, | |
| 155 | + ): Effect.Effect<PaginationResult<DocumentFromTable<Tables, TableName>>> { | |
| 156 | + return pipe( | |
| 157 | + Effect.promise(() => this.q.paginate(paginationOpts)), | |
| 158 | + Effect.map((result) => ({ | |
| 159 | + ...result, | |
| 160 | + page: result.page.map((doc) => this.decode(doc)), | |
| 161 | + })), | |
| 162 | + ); | |
| 163 | + } | |
| 164 | + | |
| 165 | + collect(): Effect.Effect<Array<DocumentFromTable<Tables, TableName>>> { | |
| 166 | + return pipe( | |
| 167 | + this.stream(), | |
| 168 | + Stream.runCollect, | |
| 169 | + Effect.map((chunk) => Chunk.toArray(chunk)), | |
| 170 | + ); | |
| 171 | + } | |
| 172 | + | |
| 173 | + take(n: number): Effect.Effect<Array<DocumentFromTable<Tables, TableName>>> { | |
| 174 | + return pipe( | |
| 175 | + this.stream(), | |
| 176 | + Stream.take(n), | |
| 177 | + Stream.runCollect, | |
| 178 | + Effect.map((chunk) => Chunk.toArray(chunk)), | |
| 179 | + ); | |
| 180 | + } | |
| 181 | + | |
| 182 | + first(): Effect.Effect<Option.Option<DocumentFromTable<Tables, TableName>>> { | |
| 183 | + return pipe(this.stream(), Stream.runHead); | |
| 184 | + } | |
| 185 | + | |
| 186 | + unique(): Effect.Effect<Option.Option<DocumentFromTable<Tables, TableName>>, NotUniqueError> { | |
| 187 | + return pipe( | |
| 188 | + this.stream(), | |
| 189 | + Stream.take(2), | |
| 190 | + Stream.runCollect, | |
| 191 | + Effect.flatMap((chunk) => { | |
| 192 | + if (Chunk.size(chunk) > 1) { | |
| 193 | + return Effect.fail(new NotUniqueError()); | |
| 194 | + } | |
| 195 | + return Effect.succeed(Chunk.get(chunk, 0)); | |
| 196 | + }), | |
| 197 | + ); | |
| 198 | + } | |
| 199 | + | |
| 200 | + stream(): Stream.Stream<DocumentFromTable<Tables, TableName>> { | |
| 201 | + return pipe( | |
| 202 | + Stream.fromAsyncIterable(this.q, (e) => e as Error), | |
| 203 | + Stream.map((doc) => this.decode(doc)), | |
| 204 | + Stream.orDie, | |
| 205 | + ); | |
| 206 | + } | |
| 207 | +} | |
| 208 | + | |
| 209 | +interface ConfectQueryInitializer< | |
| 210 | + Tables extends GenericConfectSchema, | |
| 211 | + TableName extends TableNamesInSchema<Tables>, | |
| 212 | +> extends ConfectQuery<Tables, TableName> { | |
| 213 | + fullTableScan(): ConfectQuery<Tables, TableName>; | |
| 214 | + | |
| 215 | + withIndex<IndexName extends string>( | |
| 216 | + indexName: IndexName, | |
| 217 | + indexRange?: (q: LooseIndexRangeBuilder) => IndexRange, | |
| 218 | + ): ConfectQuery<Tables, TableName>; | |
| 219 | + | |
| 220 | + withSearchIndex<IndexName extends string>( | |
| 221 | + indexName: IndexName, | |
| 222 | + searchFilter: (q: unknown) => SearchFilter, | |
| 223 | + ): ConfectOrderedQuery<Tables, TableName>; | |
| 224 | +} | |
| 225 | + | |
| 226 | +class ConfectQueryInitializerImpl< | |
| 227 | + Tables extends GenericConfectSchema, | |
| 228 | + TableName extends TableNamesInSchema<Tables>, | |
| 229 | +> implements ConfectQueryInitializer<Tables, TableName> | |
| 230 | +{ | |
| 231 | + constructor( | |
| 232 | + private readonly q: QueryInitializerLike, | |
| 233 | + private readonly tableSchema: Schema.Schema< | |
| 234 | + DocumentFromTable<Tables, TableName>, | |
| 235 | + EncodedDocumentFromTable<Tables, TableName> | |
| 236 | + >, | |
| 237 | + private readonly tableName: TableName, | |
| 238 | + ) {} | |
| 239 | + | |
| 240 | + fullTableScan(): ConfectQuery<Tables, TableName> { | |
| 241 | + return new ConfectQueryImpl( | |
| 242 | + this.q.fullTableScan(), | |
| 243 | + this.tableSchema, | |
| 244 | + this.tableName, | |
| 245 | + ); | |
| 246 | + } | |
| 247 | + | |
| 248 | + withIndex<IndexName extends string>( | |
| 249 | + indexName: IndexName, | |
| 250 | + indexRange?: (q: LooseIndexRangeBuilder) => IndexRange, | |
| 251 | + ): ConfectQuery<Tables, TableName> { | |
| 252 | + return new ConfectQueryImpl( | |
| 253 | + this.q.withIndex(indexName, indexRange), | |
| 254 | + this.tableSchema, | |
| 255 | + this.tableName, | |
| 256 | + ); | |
| 257 | + } | |
| 258 | + | |
| 259 | + withSearchIndex<IndexName extends string>( | |
| 260 | + indexName: IndexName, | |
| 261 | + searchFilter: (q: unknown) => SearchFilter, | |
| 262 | + ): ConfectOrderedQuery<Tables, TableName> { | |
| 263 | + return new ConfectQueryImpl( | |
| 264 | + this.q.withSearchIndex(indexName, searchFilter), | |
| 265 | + this.tableSchema, | |
| 266 | + this.tableName, | |
| 267 | + ); | |
| 268 | + } | |
| 269 | + | |
| 270 | + filter( | |
| 271 | + predicate: (q: FilterBuilder<GenericTableInfo>) => Expression<boolean>, | |
| 272 | + ): ConfectQuery<Tables, TableName> { | |
| 273 | + return this.fullTableScan().filter(predicate); | |
| 274 | + } | |
| 275 | + | |
| 276 | + order(order: "asc" | "desc"): ConfectOrderedQuery<Tables, TableName> { | |
| 277 | + return this.fullTableScan().order(order); | |
| 278 | + } | |
| 279 | + | |
| 280 | + paginate( | |
| 281 | + paginationOpts: PaginationOptions, | |
| 282 | + ): Effect.Effect<PaginationResult<DocumentFromTable<Tables, TableName>>> { | |
| 283 | + return this.fullTableScan().paginate(paginationOpts); | |
| 284 | + } | |
| 285 | + | |
| 286 | + collect(): Effect.Effect<Array<DocumentFromTable<Tables, TableName>>> { | |
| 287 | + return this.fullTableScan().collect(); | |
| 288 | + } | |
| 289 | + | |
| 290 | + take(n: number): Effect.Effect<Array<DocumentFromTable<Tables, TableName>>> { | |
| 291 | + return this.fullTableScan().take(n); | |
| 292 | + } | |
| 293 | + | |
| 294 | + first(): Effect.Effect<Option.Option<DocumentFromTable<Tables, TableName>>> { | |
| 295 | + return this.fullTableScan().first(); | |
| 296 | + } | |
| 297 | + | |
| 298 | + unique(): Effect.Effect<Option.Option<DocumentFromTable<Tables, TableName>>, NotUniqueError> { | |
| 299 | + return this.fullTableScan().unique(); | |
| 300 | + } | |
| 301 | + | |
| 302 | + stream(): Stream.Stream<DocumentFromTable<Tables, TableName>> { | |
| 303 | + return this.fullTableScan().stream(); | |
| 304 | + } | |
| 305 | +} | |
| 306 | + | |
| 307 | +export interface ConfectDatabaseReader<Tables extends GenericConfectSchema> { | |
| 308 | + query<TableName extends TableNamesInSchema<Tables>>( | |
| 309 | + tableName: TableName, | |
| 310 | + ): ConfectQueryInitializer<Tables, TableName>; | |
| 311 | + | |
| 312 | + get<TableName extends TableNamesInSchema<Tables>>( | |
| 313 | + id: GenericId<TableName>, | |
| 314 | + ): Effect.Effect<Option.Option<DocumentFromTable<Tables, TableName>>>; | |
| 315 | + | |
| 316 | + normalizeId<TableName extends TableNamesInSchema<Tables>>( | |
| 317 | + tableName: TableName, | |
| 318 | + id: string, | |
| 319 | + ): Option.Option<GenericId<TableName>>; | |
| 320 | +} | |
| 321 | + | |
| 322 | +export interface ConfectDatabaseWriter<Tables extends GenericConfectSchema> | |
| 323 | + extends ConfectDatabaseReader<Tables> { | |
| 324 | + insert<TableName extends TableNamesInSchema<Tables>>( | |
| 325 | + table: TableName, | |
| 326 | + value: WithoutSystemFields<DocumentFromTable<Tables, TableName>>, | |
| 327 | + ): Effect.Effect<GenericId<TableName>>; | |
| 328 | + | |
| 329 | + patch<TableName extends TableNamesInSchema<Tables>>( | |
| 330 | + id: GenericId<TableName>, | |
| 331 | + value: Partial<WithoutSystemFields<DocumentFromTable<Tables, TableName>>>, | |
| 332 | + ): Effect.Effect<void>; | |
| 333 | + | |
| 334 | + replace<TableName extends TableNamesInSchema<Tables>>( | |
| 335 | + id: GenericId<TableName>, | |
| 336 | + value: WithoutSystemFields<DocumentFromTable<Tables, TableName>>, | |
| 337 | + ): Effect.Effect<void>; | |
| 338 | + | |
| 339 | + delete(id: GenericId<string>): Effect.Effect<void>; | |
| 340 | +} | |
| 341 | + | |
| 342 | +class ConfectDatabaseReaderImpl<Tables extends GenericConfectSchema> | |
| 343 | + implements ConfectDatabaseReader<Tables> | |
| 344 | +{ | |
| 345 | + constructor( | |
| 346 | + protected readonly db: GenericDatabaseReader<GenericDataModel>, | |
| 347 | + protected readonly tableSchemas: TableSchemas<Tables>, | |
| 348 | + ) {} | |
| 349 | + | |
| 350 | + protected decode<TableName extends TableNamesInSchema<Tables>>( | |
| 351 | + tableName: TableName, | |
| 352 | + doc: Record<string, Value>, | |
| 353 | + ): DocumentFromTable<Tables, TableName> { | |
| 354 | + return Schema.decodeUnknownSync(this.tableSchemas[tableName])(doc); | |
| 355 | + } | |
| 356 | + | |
| 357 | + query<TableName extends TableNamesInSchema<Tables>>( | |
| 358 | + tableName: TableName, | |
| 359 | + ): ConfectQueryInitializer<Tables, TableName> { | |
| 360 | + return new ConfectQueryInitializerImpl( | |
| 361 | + this.db.query(tableName) as unknown as QueryInitializerLike, | |
| 362 | + this.tableSchemas[tableName], | |
| 363 | + tableName, | |
| 364 | + ); | |
| 365 | + } | |
| 366 | + | |
| 367 | + get<TableName extends TableNamesInSchema<Tables>>( | |
| 368 | + id: GenericId<TableName>, | |
| 369 | + ): Effect.Effect<Option.Option<DocumentFromTable<Tables, TableName>>> { | |
| 370 | + return pipe( | |
| 371 | + Effect.promise(() => this.db.get(id)), | |
| 372 | + Effect.map((doc) => { | |
| 373 | + if (doc === null) { | |
| 374 | + return Option.none<DocumentFromTable<Tables, TableName>>(); | |
| 375 | + } | |
| 376 | + const tableName = this.getTableNameFromId(id); | |
| 377 | + if (Option.isNone(tableName)) { | |
| 378 | + return Option.none<DocumentFromTable<Tables, TableName>>(); | |
| 379 | + } | |
| 380 | + return Option.some(this.decode(tableName.value, doc as Record<string, Value>)); | |
| 381 | + }), | |
| 382 | + ); | |
| 383 | + } | |
| 384 | + | |
| 385 | + normalizeId<TableName extends TableNamesInSchema<Tables>>( | |
| 386 | + tableName: TableName, | |
| 387 | + id: string, | |
| 388 | + ): Option.Option<GenericId<TableName>> { | |
| 389 | + const normalized = this.db.normalizeId(tableName, id); | |
| 390 | + return Option.fromNullable(normalized); | |
| 391 | + } | |
| 392 | + | |
| 393 | + private getTableNameFromId<TableName extends TableNamesInSchema<Tables>>( | |
| 394 | + id: GenericId<TableName>, | |
| 395 | + ): Option.Option<TableName> { | |
| 396 | + for (const tableName of Object.keys(this.tableSchemas)) { | |
| 397 | + const normalized = this.db.normalizeId(tableName, id); | |
| 398 | + if (normalized !== null) { | |
| 399 | + return Option.some(tableName as TableName); | |
| 400 | + } | |
| 401 | + } | |
| 402 | + return Option.none(); | |
| 403 | + } | |
| 404 | +} | |
| 405 | + | |
| 406 | +class ConfectDatabaseWriterImpl<Tables extends GenericConfectSchema> | |
| 407 | + extends ConfectDatabaseReaderImpl<Tables> | |
| 408 | + implements ConfectDatabaseWriter<Tables> | |
| 409 | +{ | |
| 410 | + constructor( | |
| 411 | + protected override readonly db: GenericDatabaseWriter<GenericDataModel>, | |
| 412 | + tableSchemas: TableSchemas<Tables>, | |
| 413 | + ) { | |
| 414 | + super(db, tableSchemas); | |
| 415 | + } | |
| 416 | + | |
| 417 | + insert<TableName extends TableNamesInSchema<Tables>>( | |
| 418 | + table: TableName, | |
| 419 | + value: WithoutSystemFields<DocumentFromTable<Tables, TableName>>, | |
| 420 | + ): Effect.Effect<GenericId<TableName>> { | |
| 421 | + return Effect.promise(() => | |
| 422 | + (this.db as GenericDatabaseWriter<GenericDataModel>).insert( | |
| 423 | + table, | |
| 424 | + value as Record<string, Value>, | |
| 425 | + ), | |
| 426 | + ) as Effect.Effect<GenericId<TableName>>; | |
| 427 | + } | |
| 428 | + | |
| 429 | + patch<TableName extends TableNamesInSchema<Tables>>( | |
| 430 | + id: GenericId<TableName>, | |
| 431 | + value: Partial<WithoutSystemFields<DocumentFromTable<Tables, TableName>>>, | |
| 432 | + ): Effect.Effect<void> { | |
| 433 | + return Effect.promise(() => | |
| 434 | + (this.db as GenericDatabaseWriter<GenericDataModel>).patch( | |
| 435 | + id, | |
| 436 | + value as Record<string, Value>, | |
| 437 | + ), | |
| 438 | + ); | |
| 439 | + } | |
| 440 | + | |
| 441 | + replace<TableName extends TableNamesInSchema<Tables>>( | |
| 442 | + id: GenericId<TableName>, | |
| 443 | + value: WithoutSystemFields<DocumentFromTable<Tables, TableName>>, | |
| 444 | + ): Effect.Effect<void> { | |
| 445 | + return Effect.promise(() => | |
| 446 | + (this.db as GenericDatabaseWriter<GenericDataModel>).replace( | |
| 447 | + id, | |
| 448 | + value as Record<string, Value>, | |
| 449 | + ), | |
| 450 | + ); | |
| 451 | + } | |
| 452 | + | |
| 453 | + delete(id: GenericId<string>): Effect.Effect<void> { | |
| 454 | + return Effect.promise(() => | |
| 455 | + (this.db as GenericDatabaseWriter<GenericDataModel>).delete(id), | |
| 456 | + ); | |
| 457 | + } | |
| 458 | +} | |
| 459 | + | |
| 460 | +export interface ConfectAuth { | |
| 461 | + getUserIdentity(): Effect.Effect<Option.Option<{ | |
| 462 | + tokenIdentifier: string; | |
| 463 | + subject: string; | |
| 464 | + issuer: string; | |
| 465 | + [key: string]: unknown; | |
| 466 | + }>>; | |
| 467 | +} | |
| 468 | + | |
| 469 | +class ConfectAuthImpl implements ConfectAuth { | |
| 470 | + constructor(private readonly auth: { getUserIdentity: () => Promise<unknown> }) {} | |
| 471 | + | |
| 472 | + getUserIdentity(): Effect.Effect<Option.Option<{ | |
| 473 | + tokenIdentifier: string; | |
| 474 | + subject: string; | |
| 475 | + issuer: string; | |
| 476 | + [key: string]: unknown; | |
| 477 | + }>> { | |
| 478 | + return pipe( | |
| 479 | + Effect.promise(() => this.auth.getUserIdentity()), | |
| 480 | + Effect.map((identity) => | |
| 481 | + identity | |
| 482 | + ? Option.some( | |
| 483 | + identity as { | |
| 484 | + tokenIdentifier: string; | |
| 485 | + subject: string; | |
| 486 | + issuer: string; | |
| 487 | + [key: string]: unknown; | |
| 488 | + }, | |
| 489 | + ) | |
| 490 | + : Option.none(), | |
| 491 | + ), | |
| 492 | + ); | |
| 493 | + } | |
| 494 | +} | |
| 495 | + | |
| 496 | +/** | |
| 497 | + * Unwrap `ExitEncoded<A, E, D>` to just `A` at the type level. | |
| 498 | + * | |
| 499 | + * Confect-registered functions return `ExitEncoded` on the wire, so | |
| 500 | + * `FunctionReturnType<Ref>` resolves to `ExitEncoded<A, E, D>`. | |
| 501 | + * This utility type extracts the success value `A` so that | |
| 502 | + * `yield* ctx.runQuery(...)` gives you the logical return type. | |
| 503 | + * | |
| 504 | + * For non-Confect functions (plain Convex), the type passes through unchanged. | |
| 505 | + */ | |
| 506 | +type UnwrapExitEncoded<T> = | |
| 507 | + // Use Extract to isolate the Success branch of the ExitEncoded union | |
| 508 | + Extract<T, { readonly _tag: "Success" }> extends { | |
| 509 | + readonly _tag: "Success"; | |
| 510 | + readonly value: infer A; | |
| 511 | + } | |
| 512 | + ? A | |
| 513 | + : T; | |
| 514 | + | |
| 515 | +export interface ConfectQueryCtx<Tables extends GenericConfectSchema> { | |
| 516 | + runQuery<Query extends FunctionReference<"query", "public" | "internal">>( | |
| 517 | + query: Query, | |
| 518 | + ...args: OptionalRestArgs<Query> | |
| 519 | + ): Effect.Effect<UnwrapExitEncoded<FunctionReturnType<Query>>>; | |
| 520 | + | |
| 521 | + db: ConfectDatabaseReader<Tables>; | |
| 522 | + auth: ConfectAuth; | |
| 523 | + | |
| 524 | + /** | |
| 525 | + * The raw Convex query context, useful for passing to Convex components | |
| 526 | + * (e.g. `@convex-dev/aggregate`) that expect standard Convex context objects. | |
| 527 | + */ | |
| 528 | + rawCtx: GenericQueryCtx<GenericDataModel>; | |
| 529 | +} | |
| 530 | + | |
| 531 | +export interface ConfectMutationCtx<Tables extends GenericConfectSchema> { | |
| 532 | + runQuery<Query extends FunctionReference<"query", "public" | "internal">>( | |
| 533 | + query: Query, | |
| 534 | + ...args: OptionalRestArgs<Query> | |
| 535 | + ): Effect.Effect<UnwrapExitEncoded<FunctionReturnType<Query>>>; | |
| 536 | + | |
| 537 | + runMutation<Mutation extends FunctionReference<"mutation", "public" | "internal">>( | |
| 538 | + mutation: Mutation, | |
| 539 | + ...args: OptionalRestArgs<Mutation> | |
| 540 | + ): Effect.Effect<UnwrapExitEncoded<FunctionReturnType<Mutation>>>; | |
| 541 | + | |
| 542 | + /** Raw Convex scheduler for scheduling functions from mutations. */ | |
| 543 | + scheduler: Scheduler; | |
| 544 | + | |
| 545 | + db: ConfectDatabaseWriter<Tables>; | |
| 546 | + auth: ConfectAuth; | |
| 547 | + | |
| 548 | + /** | |
| 549 | + * The raw Convex mutation context, useful for passing to Convex components | |
| 550 | + * (e.g. `@convex-dev/aggregate`) that expect standard Convex context objects. | |
| 551 | + */ | |
| 552 | + rawCtx: GenericMutationCtx<GenericDataModel>; | |
| 553 | +} | |
| 554 | + | |
| 555 | +export interface ConfectActionCtx<Tables extends GenericConfectSchema> { | |
| 556 | + runQuery<Query extends FunctionReference<"query", "public" | "internal">>( | |
| 557 | + query: Query, | |
| 558 | + ...args: OptionalRestArgs<Query> | |
| 559 | + ): Effect.Effect<UnwrapExitEncoded<FunctionReturnType<Query>>>; | |
| 560 | + | |
| 561 | + runMutation<Mutation extends FunctionReference<"mutation", "public" | "internal">>( | |
| 562 | + mutation: Mutation, | |
| 563 | + ...args: OptionalRestArgs<Mutation> | |
| 564 | + ): Effect.Effect<UnwrapExitEncoded<FunctionReturnType<Mutation>>>; | |
| 565 | + | |
| 566 | + runAction<Action extends FunctionReference<"action", "public" | "internal">>( | |
| 567 | + action: Action, | |
| 568 | + ...args: OptionalRestArgs<Action> | |
| 569 | + ): Effect.Effect<UnwrapExitEncoded<FunctionReturnType<Action>>>; | |
| 570 | + | |
| 571 | + scheduler: Scheduler; | |
| 572 | + auth: ConfectAuth; | |
| 573 | +} | |
| 574 | + | |
| 575 | +export const ConfectQueryCtx = <Tables extends GenericConfectSchema>() => | |
| 576 | + Context.GenericTag<ConfectQueryCtx<Tables>>("@confect/ConfectQueryCtx"); | |
| 577 | + | |
| 578 | +export const ConfectMutationCtx = <Tables extends GenericConfectSchema>() => | |
| 579 | + Context.GenericTag<ConfectMutationCtx<Tables>>("@confect/ConfectMutationCtx"); | |
| 580 | + | |
| 581 | +export const ConfectActionCtx = <Tables extends GenericConfectSchema>() => | |
| 582 | + Context.GenericTag<ConfectActionCtx<Tables>>("@confect/ConfectActionCtx"); | |
| 583 | + | |
| 584 | +export const makeQueryCtx = <Tables extends GenericConfectSchema>( | |
| 585 | + ctx: GenericQueryCtx<GenericDataModel>, | |
| 586 | + tableSchemas: TableSchemas<Tables>, | |
| 587 | +): ConfectQueryCtx<Tables> => ({ | |
| 588 | + runQuery: <Query extends FunctionReference<"query", "public" | "internal">>( | |
| 589 | + query: Query, | |
| 590 | + ...args: OptionalRestArgs<Query> | |
| 591 | + ) => | |
| 592 | + Effect.promise(() => ctx.runQuery(query, ...args)).pipe( | |
| 593 | + Effect.flatMap(unwrapIfExitEncoded), | |
| 594 | + ), | |
| 595 | + db: new ConfectDatabaseReaderImpl(ctx.db, tableSchemas), | |
| 596 | + auth: new ConfectAuthImpl(ctx.auth), | |
| 597 | + rawCtx: ctx, | |
| 598 | +}); | |
| 599 | + | |
| 600 | +export const makeMutationCtx = <Tables extends GenericConfectSchema>( | |
| 601 | + ctx: GenericMutationCtx<GenericDataModel>, | |
| 602 | + tableSchemas: TableSchemas<Tables>, | |
| 603 | +): ConfectMutationCtx<Tables> => ({ | |
| 604 | + runQuery: <Query extends FunctionReference<"query", "public" | "internal">>( | |
| 605 | + query: Query, | |
| 606 | + ...args: OptionalRestArgs<Query> | |
| 607 | + ) => | |
| 608 | + Effect.promise(() => ctx.runQuery(query, ...args)).pipe( | |
| 609 | + Effect.flatMap(unwrapIfExitEncoded), | |
| 610 | + ), | |
| 611 | + runMutation: <Mutation extends FunctionReference<"mutation", "public" | "internal">>( | |
| 612 | + mutation: Mutation, | |
| 613 | + ...args: OptionalRestArgs<Mutation> | |
| 614 | + ) => | |
| 615 | + Effect.promise(() => ctx.runMutation(mutation, ...args)).pipe( | |
| 616 | + Effect.flatMap(unwrapIfExitEncoded), | |
| 617 | + ), | |
| 618 | + scheduler: ctx.scheduler, | |
| 619 | + db: new ConfectDatabaseWriterImpl(ctx.db, tableSchemas), | |
| 620 | + auth: new ConfectAuthImpl(ctx.auth), | |
| 621 | + rawCtx: ctx, | |
| 622 | +}); | |
| 623 | + | |
| 624 | +/** | |
| 625 | + * Detect and unwrap Confect ExitEncoded results from cross-function calls. | |
| 626 | + * | |
| 627 | + * When a Confect action/mutation/query calls another Confect function via | |
| 628 | + * `ctx.runQuery`/`ctx.runMutation`/`ctx.runAction`, the raw Convex runtime | |
| 629 | + * returns the function's wire format — which for Confect functions is | |
| 630 | + * `ExitEncoded<Success, Error>` (a tagged union `{_tag: "Success", value}` | | |
| 631 | + * `{_tag: "Failure", cause}`). | |
| 632 | + * | |
| 633 | + * Without unwrapping, `yield* ctx.runQuery(...)` gives you the raw ExitEncoded | |
| 634 | + * object instead of the logical success value, forcing users to manually check | |
| 635 | + * `_tag` and extract `.value`. This helper makes cross-function calls | |
| 636 | + * transparent: Success values are extracted, Failure causes become Effect failures. | |
| 637 | + */ | |
| 638 | +const unwrapIfExitEncoded = <T>(result: T): Effect.Effect<T> => { | |
| 639 | + if ( | |
| 640 | + typeof result === "object" && | |
| 641 | + result !== null && | |
| 642 | + "_tag" in result | |
| 643 | + ) { | |
| 644 | + const tagged = result as { _tag: string; value?: unknown; cause?: unknown }; | |
| 645 | + if (tagged._tag === "Success") { | |
| 646 | + return Effect.succeed(tagged.value as T); | |
| 647 | + } | |
| 648 | + if (tagged._tag === "Failure") { | |
| 649 | + // Re-throw as an Effect failure so the caller's error channel catches it | |
| 650 | + return Effect.die(tagged.cause); | |
| 651 | + } | |
| 652 | + } | |
| 653 | + // Not an ExitEncoded — return as-is (e.g. plain Convex functions) | |
| 654 | + return Effect.succeed(result); | |
| 655 | +}; | |
| 656 | + | |
| 657 | +export const makeActionCtx = <Tables extends GenericConfectSchema>( | |
| 658 | + ctx: GenericActionCtx<GenericDataModel>, | |
| 659 | +): ConfectActionCtx<Tables> => ({ | |
| 660 | + runQuery: <Query extends FunctionReference<"query", "public" | "internal">>( | |
| 661 | + query: Query, | |
| 662 | + ...args: OptionalRestArgs<Query> | |
| 663 | + ) => | |
| 664 | + Effect.promise(() => ctx.runQuery(query, ...args)).pipe( | |
| 665 | + Effect.flatMap(unwrapIfExitEncoded), | |
| 666 | + ), | |
| 667 | + runMutation: <Mutation extends FunctionReference<"mutation", "public" | "internal">>( | |
| 668 | + mutation: Mutation, | |
| 669 | + ...args: OptionalRestArgs<Mutation> | |
| 670 | + ) => | |
| 671 | + Effect.promise(() => ctx.runMutation(mutation, ...args)).pipe( | |
| 672 | + Effect.flatMap(unwrapIfExitEncoded), | |
| 673 | + ), | |
| 674 | + runAction: <Action extends FunctionReference<"action", "public" | "internal">>( | |
| 675 | + action: Action, | |
| 676 | + ...args: OptionalRestArgs<Action> | |
| 677 | + ) => | |
| 678 | + Effect.promise(() => ctx.runAction(action, ...args)).pipe( | |
| 679 | + Effect.flatMap(unwrapIfExitEncoded), | |
| 680 | + ), | |
| 681 | + scheduler: ctx.scheduler, | |
| 682 | + auth: new ConfectAuthImpl(ctx.auth), | |
| 683 | +}); | |
| @@ -0,0 +1,43 @@ | ||
| 1 | +export { | |
| 2 | + defineSchema, | |
| 3 | + defineTable, | |
| 4 | + type ConfectSchemaDefinition, | |
| 5 | + type ConfectTableDefinition, | |
| 6 | + type GenericConfectSchema, | |
| 7 | + type TableNamesInSchema, | |
| 8 | + type DocumentFromTable, | |
| 9 | + type EncodedDocumentFromTable, | |
| 10 | + type ConfectDataModel, | |
| 11 | + type TablesFromSchemaDefinition, | |
| 12 | +} from "./schema"; | |
| 13 | + | |
| 14 | +export { | |
| 15 | + schemaToValidator, | |
| 16 | + schemaToObjectValidator, | |
| 17 | + Id, | |
| 18 | + UnsupportedSchemaError, | |
| 19 | + TopLevelMustBeObjectError, | |
| 20 | + IndexSignaturesNotSupportedError, | |
| 21 | +} from "./validators"; | |
| 22 | + | |
| 23 | +export { | |
| 24 | + NotUniqueError, | |
| 25 | + ConfectQueryCtx, | |
| 26 | + ConfectMutationCtx, | |
| 27 | + ConfectActionCtx, | |
| 28 | + makeQueryCtx, | |
| 29 | + makeMutationCtx, | |
| 30 | + makeActionCtx, | |
| 31 | + type ConfectDatabaseReader, | |
| 32 | + type ConfectDatabaseWriter, | |
| 33 | + type ConfectAuth, | |
| 34 | + type LooseIndexRangeBuilder, | |
| 35 | +} from "./ctx"; | |
| 36 | + | |
| 37 | +export { ConvexClient, ConvexClientLayer, ConvexHttpClientLayer, type ConvexClientService } from "./client"; | |
| 38 | + | |
| 39 | +export { | |
| 40 | + Cursor, | |
| 41 | + PaginationOptionsSchema, | |
| 42 | + PaginationResultSchema, | |
| 43 | +} from "./pagination"; | |
| @@ -0,0 +1,19 @@ | ||
| 1 | +import * as Schema from "effect/Schema"; | |
| 2 | + | |
| 3 | +export const Cursor = Schema.String.pipe(Schema.brand("Cursor")); | |
| 4 | +export type Cursor = typeof Cursor.Type; | |
| 5 | + | |
| 6 | +export const PaginationOptionsSchema = Schema.Struct({ | |
| 7 | + cursor: Schema.NullOr(Cursor), | |
| 8 | + numItems: Schema.Number, | |
| 9 | +}); | |
| 10 | +export type PaginationOptionsSchema = typeof PaginationOptionsSchema.Type; | |
| 11 | + | |
| 12 | +export const PaginationResultSchema = <Item extends Schema.Schema.AnyNoContext>( | |
| 13 | + item: Item, | |
| 14 | +) => | |
| 15 | + Schema.Struct({ | |
| 16 | + page: Schema.Array(item), | |
| 17 | + isDone: Schema.Boolean, | |
| 18 | + continueCursor: Cursor, | |
| 19 | + }); | |
| @@ -0,0 +1,776 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { Effect, Layer, Option, Schema, Stream } from "effect"; | |
| 3 | +import { Atom, Registry, Result } from "@effect-atom/atom"; | |
| 4 | +import { createRpcClient, createServerRpcQuery, RpcDefectError } from "./client"; | |
| 5 | +import { createRpcFactory, makeRpcModule } from "./server"; | |
| 6 | +import { defineTable, defineSchema } from "../schema"; | |
| 7 | +import { ConvexClient, type ConvexClientService } from "../client"; | |
| 8 | + | |
| 9 | +const testSchema = defineSchema({ | |
| 10 | + guestbook: defineTable( | |
| 11 | + Schema.Struct({ | |
| 12 | + author: Schema.String, | |
| 13 | + message: Schema.String, | |
| 14 | + }), | |
| 15 | + ), | |
| 16 | +}); | |
| 17 | + | |
| 18 | +const factory = createRpcFactory({ schema: testSchema }); | |
| 19 | + | |
| 20 | +// Define endpoints | |
| 21 | +const addEndpoint = factory.mutation({ | |
| 22 | + payload: { author: Schema.String, message: Schema.String }, | |
| 23 | + success: Schema.String, | |
| 24 | +}); | |
| 25 | +addEndpoint.implement((payload) => | |
| 26 | + Effect.gen(function* () { | |
| 27 | + return `Added message from ${payload.author}`; | |
| 28 | + }), | |
| 29 | +); | |
| 30 | + | |
| 31 | +const listEndpoint = factory.query({ | |
| 32 | + success: Schema.Array(Schema.Struct({ author: Schema.String, message: Schema.String })), | |
| 33 | +}); | |
| 34 | +listEndpoint.implement(() => | |
| 35 | + Effect.gen(function* () { | |
| 36 | + return [{ author: "Alice", message: "Hello" }]; | |
| 37 | + }), | |
| 38 | +); | |
| 39 | + | |
| 40 | +const getEndpoint = factory.query({ | |
| 41 | + payload: { id: Schema.String }, | |
| 42 | + success: Schema.Struct({ author: Schema.String, message: Schema.String }), | |
| 43 | + error: Schema.Struct({ _tag: Schema.Literal("NotFound") }), | |
| 44 | +}); | |
| 45 | +getEndpoint.implement((payload) => | |
| 46 | + Effect.gen(function* () { | |
| 47 | + if (payload.id === "not-found") { | |
| 48 | + return yield* Effect.fail({ _tag: "NotFound" as const }); | |
| 49 | + } | |
| 50 | + return { author: "Bob", message: "Test" }; | |
| 51 | + }), | |
| 52 | +); | |
| 53 | + | |
| 54 | +const sendNotificationEndpoint = factory.action({ | |
| 55 | + payload: { userId: Schema.String }, | |
| 56 | + success: Schema.Struct({ sent: Schema.Boolean }), | |
| 57 | +}); | |
| 58 | +sendNotificationEndpoint.implement((_payload) => | |
| 59 | + Effect.gen(function* () { | |
| 60 | + return { sent: true }; | |
| 61 | + }), | |
| 62 | +); | |
| 63 | + | |
| 64 | +const guestbookModule = makeRpcModule({ | |
| 65 | + add: addEndpoint, | |
| 66 | + list: listEndpoint, | |
| 67 | + get: getEndpoint, | |
| 68 | + sendNotification: sendNotificationEndpoint, | |
| 69 | +}); | |
| 70 | + | |
| 71 | +describe("RPC Client", () => { | |
| 72 | + describe("createRpcClient", () => { | |
| 73 | + it("creates client with runtime property", () => { | |
| 74 | + const mockApi = { | |
| 75 | + add: guestbookModule.handlers.add, | |
| 76 | + list: guestbookModule.handlers.list, | |
| 77 | + get: guestbookModule.handlers.get, | |
| 78 | + sendNotification: guestbookModule.handlers.sendNotification, | |
| 79 | + }; | |
| 80 | + | |
| 81 | + const client = createRpcClient<typeof guestbookModule>( | |
| 82 | + mockApi as never, | |
| 83 | + { url: "https://test.convex.cloud" }, | |
| 84 | + ); | |
| 85 | + | |
| 86 | + expect(client.runtime).toBeDefined(); | |
| 87 | + expect(typeof client.runtime.atom).toBe("function"); | |
| 88 | + expect(typeof client.runtime.fn).toBe("function"); | |
| 89 | + expect(typeof client.runtime.pull).toBe("function"); | |
| 90 | + }); | |
| 91 | + | |
| 92 | + it("creates typed endpoint proxies for each module endpoint", () => { | |
| 93 | + const mockApi = { | |
| 94 | + add: guestbookModule.handlers.add, | |
| 95 | + list: guestbookModule.handlers.list, | |
| 96 | + get: guestbookModule.handlers.get, | |
| 97 | + sendNotification: guestbookModule.handlers.sendNotification, | |
| 98 | + }; | |
| 99 | + | |
| 100 | + const client = createRpcClient<typeof guestbookModule>( | |
| 101 | + mockApi as never, | |
| 102 | + { url: "https://test.convex.cloud" }, | |
| 103 | + ); | |
| 104 | + | |
| 105 | + expect(client.add).toBeDefined(); | |
| 106 | + expect(client.list).toBeDefined(); | |
| 107 | + expect(client.get).toBeDefined(); | |
| 108 | + expect(client.sendNotification).toBeDefined(); | |
| 109 | + }); | |
| 110 | + | |
| 111 | + it("mutation endpoints expose mutate AtomResultFn", () => { | |
| 112 | + const mockApi = { add: guestbookModule.handlers.add }; | |
| 113 | + const client = createRpcClient<typeof guestbookModule>( | |
| 114 | + mockApi as never, | |
| 115 | + { url: "https://test.convex.cloud" }, | |
| 116 | + ); | |
| 117 | + | |
| 118 | + expect(client.add.mutate).toBeDefined(); | |
| 119 | + expect(typeof client.add.mutate).toBe("object"); | |
| 120 | + }); | |
| 121 | + | |
| 122 | + it("query endpoints expose query and subscription functions", () => { | |
| 123 | + const mockApi = { list: guestbookModule.handlers.list }; | |
| 124 | + const client = createRpcClient<typeof guestbookModule>( | |
| 125 | + mockApi as never, | |
| 126 | + { url: "https://test.convex.cloud" }, | |
| 127 | + ); | |
| 128 | + | |
| 129 | + expect(typeof client.list.query).toBe("function"); | |
| 130 | + expect(typeof client.list.queryEffect).toBe("function"); | |
| 131 | + expect(typeof client.list.queryPromise).toBe("function"); | |
| 132 | + expect(typeof client.list.subscription).toBe("function"); | |
| 133 | + }); | |
| 134 | + | |
| 135 | + it("action endpoints expose call AtomResultFn", () => { | |
| 136 | + const mockApi = { sendNotification: guestbookModule.handlers.sendNotification }; | |
| 137 | + const client = createRpcClient<typeof guestbookModule>( | |
| 138 | + mockApi as never, | |
| 139 | + { url: "https://test.convex.cloud" }, | |
| 140 | + ); | |
| 141 | + | |
| 142 | + expect(client.sendNotification.call).toBeDefined(); | |
| 143 | + expect(typeof client.sendNotification.callAsQuery).toBe("function"); | |
| 144 | + expect(typeof client.sendNotification.call).toBe("object"); | |
| 145 | + expect(typeof client.sendNotification.callEffect).toBe("function"); | |
| 146 | + expect(typeof client.sendNotification.callPromise).toBe("function"); | |
| 147 | + }); | |
| 148 | + | |
| 149 | + it("action callAsQuery returns atom for given payload", () => { | |
| 150 | + const mockApi = { sendNotification: guestbookModule.handlers.sendNotification }; | |
| 151 | + const client = createRpcClient<typeof guestbookModule>( | |
| 152 | + mockApi as never, | |
| 153 | + { url: "https://test.convex.cloud" }, | |
| 154 | + ); | |
| 155 | + | |
| 156 | + const atom = client.sendNotification.callAsQuery({ userId: "user-1" }); | |
| 157 | + expect(atom).toBeDefined(); | |
| 158 | + expect(Atom.isAtom(atom)).toBe(true); | |
| 159 | + }); | |
| 160 | + | |
| 161 | + it("mutation endpoints expose imperative mutateEffect and mutatePromise", () => { | |
| 162 | + const mockApi = { add: guestbookModule.handlers.add }; | |
| 163 | + const client = createRpcClient<typeof guestbookModule>( | |
| 164 | + mockApi as never, | |
| 165 | + { url: "https://test.convex.cloud" }, | |
| 166 | + ); | |
| 167 | + | |
| 168 | + expect(typeof client.add.mutateEffect).toBe("function"); | |
| 169 | + expect(typeof client.add.mutatePromise).toBe("function"); | |
| 170 | + }); | |
| 171 | + | |
| 172 | + it("caches endpoint proxies (same reference on repeated access)", () => { | |
| 173 | + const mockApi = { add: guestbookModule.handlers.add }; | |
| 174 | + const client = createRpcClient<typeof guestbookModule>( | |
| 175 | + mockApi as never, | |
| 176 | + { url: "https://test.convex.cloud" }, | |
| 177 | + ); | |
| 178 | + | |
| 179 | + const first = client.add; | |
| 180 | + const second = client.add; | |
| 181 | + expect(first).toBe(second); | |
| 182 | + }); | |
| 183 | + | |
| 184 | + it("query function returns atom for given payload", () => { | |
| 185 | + const mockApi = { get: guestbookModule.handlers.get }; | |
| 186 | + const client = createRpcClient<typeof guestbookModule>( | |
| 187 | + mockApi as never, | |
| 188 | + { url: "https://test.convex.cloud" }, | |
| 189 | + ); | |
| 190 | + | |
| 191 | + const atom = client.get.query({ id: "123" }); | |
| 192 | + expect(atom).toBeDefined(); | |
| 193 | + expect(Atom.isAtom(atom)).toBe(true); | |
| 194 | + }); | |
| 195 | + | |
| 196 | + it("subscription function returns atom for given payload", () => { | |
| 197 | + const mockApi = { list: guestbookModule.handlers.list }; | |
| 198 | + const client = createRpcClient<typeof guestbookModule>( | |
| 199 | + mockApi as never, | |
| 200 | + { url: "https://test.convex.cloud" }, | |
| 201 | + ); | |
| 202 | + | |
| 203 | + const atom = client.list.subscription({}); | |
| 204 | + expect(atom).toBeDefined(); | |
| 205 | + expect(Atom.isAtom(atom)).toBe(true); | |
| 206 | + }); | |
| 207 | + | |
| 208 | + it("query can be disabled until caller is ready", () => { | |
| 209 | + let queryCalls = 0; | |
| 210 | + const service: ConvexClientService = { | |
| 211 | + query: () => { | |
| 212 | + queryCalls += 1; | |
| 213 | + return Effect.die("query should be disabled"); | |
| 214 | + }, | |
| 215 | + mutation: () => Effect.die("not implemented"), | |
| 216 | + action: () => Effect.die("not implemented"), | |
| 217 | + subscribe: () => Stream.empty, | |
| 218 | + }; | |
| 219 | + const layer = Layer.succeed(ConvexClient, service); | |
| 220 | + | |
| 221 | + const mockApi = { list: guestbookModule.handlers.list }; | |
| 222 | + const client = createRpcClient<typeof guestbookModule>( | |
| 223 | + mockApi as never, | |
| 224 | + { url: "https://test.convex.cloud", layer }, | |
| 225 | + ); | |
| 226 | + | |
| 227 | + const atom = client.list.query({}, { enabled: false }); | |
| 228 | + const registry = Registry.make(); | |
| 229 | + const value = registry.get(atom); | |
| 230 | + | |
| 231 | + expect(Result.isInitial(value)).toBe(true); | |
| 232 | + expect(queryCalls).toBe(0); | |
| 233 | + | |
| 234 | + const unmount = registry.mount(atom); | |
| 235 | + expect(queryCalls).toBe(0); | |
| 236 | + unmount(); | |
| 237 | + registry.dispose(); | |
| 238 | + }); | |
| 239 | + | |
| 240 | + it("subscription can be disabled until caller is ready", () => { | |
| 241 | + let subscribeCalls = 0; | |
| 242 | + const service: ConvexClientService = { | |
| 243 | + query: () => Effect.die("not implemented"), | |
| 244 | + mutation: () => Effect.die("not implemented"), | |
| 245 | + action: () => Effect.die("not implemented"), | |
| 246 | + subscribe: () => { | |
| 247 | + subscribeCalls += 1; | |
| 248 | + return Stream.empty; | |
| 249 | + }, | |
| 250 | + }; | |
| 251 | + const layer = Layer.succeed(ConvexClient, service); | |
| 252 | + | |
| 253 | + const mockApi = { list: guestbookModule.handlers.list }; | |
| 254 | + const client = createRpcClient<typeof guestbookModule>( | |
| 255 | + mockApi as never, | |
| 256 | + { url: "https://test.convex.cloud", layer }, | |
| 257 | + ); | |
| 258 | + | |
| 259 | + const atom = client.list.subscription({}, { enabled: false }); | |
| 260 | + const registry = Registry.make(); | |
| 261 | + const value = registry.get(atom); | |
| 262 | + | |
| 263 | + expect(Result.isInitial(value)).toBe(true); | |
| 264 | + expect(subscribeCalls).toBe(0); | |
| 265 | + | |
| 266 | + const unmount = registry.mount(atom); | |
| 267 | + expect(subscribeCalls).toBe(0); | |
| 268 | + unmount(); | |
| 269 | + registry.dispose(); | |
| 270 | + }); | |
| 271 | + }); | |
| 272 | + | |
| 273 | + describe("RpcDefectError", () => { | |
| 274 | + it("has correct _tag", () => { | |
| 275 | + const error = new RpcDefectError({ defect: "test" }); | |
| 276 | + expect(error._tag).toBe("RpcDefectError"); | |
| 277 | + }); | |
| 278 | + | |
| 279 | + it("stores string defect", () => { | |
| 280 | + const error = new RpcDefectError({ defect: "Unexpected server crash" }); | |
| 281 | + expect(error.defect).toBe("Unexpected server crash"); | |
| 282 | + }); | |
| 283 | + | |
| 284 | + it("stores complex object defect", () => { | |
| 285 | + const complexDefect = { | |
| 286 | + code: "INTERNAL_ERROR", | |
| 287 | + message: "Database connection failed", | |
| 288 | + stack: "Error at line 42...", | |
| 289 | + metadata: { requestId: "abc123" }, | |
| 290 | + }; | |
| 291 | + const error = new RpcDefectError({ defect: complexDefect }); | |
| 292 | + expect(error.defect).toEqual(complexDefect); | |
| 293 | + }); | |
| 294 | + | |
| 295 | + it("is instanceof Error", () => { | |
| 296 | + const error = new RpcDefectError({ defect: "test" }); | |
| 297 | + expect(error).toBeInstanceOf(Error); | |
| 298 | + }); | |
| 299 | + }); | |
| 300 | + | |
| 301 | + describe("type safety (compile-time checks)", () => { | |
| 302 | + it("client.add.mutate is typed as AtomResultFn", () => { | |
| 303 | + const mockApi = { add: guestbookModule.handlers.add }; | |
| 304 | + const client = createRpcClient<typeof guestbookModule>( | |
| 305 | + mockApi as never, | |
| 306 | + { url: "https://test.convex.cloud" }, | |
| 307 | + ); | |
| 308 | + | |
| 309 | + expect(client.add.mutate).toBeDefined(); | |
| 310 | + expect(Atom.isAtom(client.add.mutate)).toBe(true); | |
| 311 | + }); | |
| 312 | + | |
| 313 | + it("client.get.query payload is typed from module definition", () => { | |
| 314 | + const mockApi = { get: guestbookModule.handlers.get }; | |
| 315 | + const client = createRpcClient<typeof guestbookModule>( | |
| 316 | + mockApi as never, | |
| 317 | + { url: "https://test.convex.cloud" }, | |
| 318 | + ); | |
| 319 | + | |
| 320 | + const atom = client.get.query({ id: "123" }); | |
| 321 | + expect(atom).toBeDefined(); | |
| 322 | + }); | |
| 323 | + | |
| 324 | + it("client.sendNotification.call is typed as AtomResultFn", () => { | |
| 325 | + const mockApi = { sendNotification: guestbookModule.handlers.sendNotification }; | |
| 326 | + const client = createRpcClient<typeof guestbookModule>( | |
| 327 | + mockApi as never, | |
| 328 | + { url: "https://test.convex.cloud" }, | |
| 329 | + ); | |
| 330 | + | |
| 331 | + expect(client.sendNotification.call).toBeDefined(); | |
| 332 | + expect(Atom.isAtom(client.sendNotification.call)).toBe(true); | |
| 333 | + }); | |
| 334 | + }); | |
| 335 | +}); | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | +// --------------------------------------------------------------------------- | |
| 340 | +// Error round-trip helpers (server encode → client decode) | |
| 341 | +// --------------------------------------------------------------------------- | |
| 342 | + | |
| 343 | +import { Cause, Chunk, Exit, FiberId } from "effect"; | |
| 344 | +import type { ExitEncoded } from "./server"; | |
| 345 | + | |
| 346 | +class NotFoundError extends Schema.TaggedError<NotFoundError>()( | |
| 347 | + "NotFoundError", | |
| 348 | + { id: Schema.String }, | |
| 349 | +) {} | |
| 350 | + | |
| 351 | +class ForbiddenError extends Schema.TaggedError<ForbiddenError>()( | |
| 352 | + "ForbiddenError", | |
| 353 | + {}, | |
| 354 | +) {} | |
| 355 | + | |
| 356 | +const createServerEncoder = ( | |
| 357 | + successSchema: Schema.Schema.AnyNoContext, | |
| 358 | + errorSchema: Schema.Schema.AnyNoContext | undefined, | |
| 359 | +) => { | |
| 360 | + const exitSchemaVal = Schema.Exit({ | |
| 361 | + success: successSchema as Schema.Schema<unknown, unknown, never>, | |
| 362 | + failure: (errorSchema ?? Schema.Never) as Schema.Schema<unknown, unknown, never>, | |
| 363 | + defect: Schema.Defect, | |
| 364 | + }); | |
| 365 | + return Schema.encodeSync(exitSchemaVal) as ( | |
| 366 | + exit: Exit.Exit<unknown, unknown>, | |
| 367 | + ) => ExitEncoded; | |
| 368 | +}; | |
| 369 | + | |
| 370 | +type CauseEncoded = | |
| 371 | + | { readonly _tag: "Empty" } | |
| 372 | + | { readonly _tag: "Fail"; readonly error: unknown } | |
| 373 | + | { readonly _tag: "Die"; readonly defect: unknown } | |
| 374 | + | { readonly _tag: "Interrupt"; readonly fiberId: unknown } | |
| 375 | + | { | |
| 376 | + readonly _tag: "Sequential"; | |
| 377 | + readonly left: CauseEncoded; | |
| 378 | + readonly right: CauseEncoded; | |
| 379 | + } | |
| 380 | + | { | |
| 381 | + readonly _tag: "Parallel"; | |
| 382 | + readonly left: CauseEncoded; | |
| 383 | + readonly right: CauseEncoded; | |
| 384 | + }; | |
| 385 | + | |
| 386 | +const decodeCauseTest = (encoded: CauseEncoded): Cause.Cause<unknown> => { | |
| 387 | + switch (encoded._tag) { | |
| 388 | + case "Empty": | |
| 389 | + return Cause.empty; | |
| 390 | + case "Fail": | |
| 391 | + return Cause.fail(encoded.error); | |
| 392 | + case "Die": | |
| 393 | + return Cause.die(encoded.defect); | |
| 394 | + case "Interrupt": | |
| 395 | + return Cause.interrupt(FiberId.none); | |
| 396 | + case "Sequential": | |
| 397 | + return Cause.sequential( | |
| 398 | + decodeCauseTest(encoded.left), | |
| 399 | + decodeCauseTest(encoded.right), | |
| 400 | + ); | |
| 401 | + case "Parallel": | |
| 402 | + return Cause.parallel( | |
| 403 | + decodeCauseTest(encoded.left), | |
| 404 | + decodeCauseTest(encoded.right), | |
| 405 | + ); | |
| 406 | + } | |
| 407 | +}; | |
| 408 | + | |
| 409 | +const decodeExitTest = (encoded: ExitEncoded): Exit.Exit<unknown, unknown> => { | |
| 410 | + if (!encoded || typeof encoded !== "object" || !("_tag" in encoded)) { | |
| 411 | + return Exit.fail( | |
| 412 | + new RpcDefectError({ | |
| 413 | + defect: `Unexpected RPC response: ${JSON.stringify(encoded)}`, | |
| 414 | + }), | |
| 415 | + ); | |
| 416 | + } | |
| 417 | + if (encoded._tag === "Success") { | |
| 418 | + return Exit.succeed(encoded.value); | |
| 419 | + } | |
| 420 | + if (encoded._tag !== "Failure" || !encoded.cause) { | |
| 421 | + return Exit.fail( | |
| 422 | + new RpcDefectError({ | |
| 423 | + defect: `Unexpected exit tag: ${String(encoded._tag)}`, | |
| 424 | + }), | |
| 425 | + ); | |
| 426 | + } | |
| 427 | + const cause = decodeCauseTest(encoded.cause as CauseEncoded); | |
| 428 | + const failureOption = Cause.failureOption(cause); | |
| 429 | + if (Option.isSome(failureOption)) { | |
| 430 | + return Exit.fail(failureOption.value); | |
| 431 | + } | |
| 432 | + const defects = Cause.defects(cause); | |
| 433 | + if (Chunk.isNonEmpty(defects)) { | |
| 434 | + return Exit.fail( | |
| 435 | + new RpcDefectError({ defect: Chunk.unsafeHead(defects) }), | |
| 436 | + ); | |
| 437 | + } | |
| 438 | + return Exit.fail(new RpcDefectError({ defect: "Empty cause" })); | |
| 439 | +}; | |
| 440 | + | |
| 441 | +describe("Error round-trip (server encode → client decode)", () => { | |
| 442 | + it("TaggedError _tag survives server encode → JSON → client decode", () => { | |
| 443 | + const encodeExit = createServerEncoder( | |
| 444 | + Schema.String, | |
| 445 | + Schema.Union(NotFoundError, ForbiddenError), | |
| 446 | + ); | |
| 447 | + | |
| 448 | + const serverExit = Exit.fail(new NotFoundError({ id: "test-123" })); | |
| 449 | + const encoded = encodeExit(serverExit); | |
| 450 | + | |
| 451 | + // Simulate JSON round-trip (Convex sends JSON over the wire) | |
| 452 | + const wire = JSON.parse(JSON.stringify(encoded)); | |
| 453 | + | |
| 454 | + const clientExit = decodeExitTest(wire); | |
| 455 | + | |
| 456 | + expect(Exit.isFailure(clientExit)).toBe(true); | |
| 457 | + if (Exit.isFailure(clientExit)) { | |
| 458 | + const error = Cause.failureOption(clientExit.cause); | |
| 459 | + expect(Option.isSome(error)).toBe(true); | |
| 460 | + if (Option.isSome(error)) { | |
| 461 | + const err = error.value as Record<string, unknown>; | |
| 462 | + expect(err._tag).toBe("NotFoundError"); | |
| 463 | + expect(err.id).toBe("test-123"); | |
| 464 | + } | |
| 465 | + } | |
| 466 | + }); | |
| 467 | + | |
| 468 | + it("Union TaggedError _tag is preserved for second variant", () => { | |
| 469 | + const encodeExit = createServerEncoder( | |
| 470 | + Schema.String, | |
| 471 | + Schema.Union(NotFoundError, ForbiddenError), | |
| 472 | + ); | |
| 473 | + | |
| 474 | + const serverExit = Exit.fail(new ForbiddenError()); | |
| 475 | + const encoded = encodeExit(serverExit); | |
| 476 | + const wire = JSON.parse(JSON.stringify(encoded)); | |
| 477 | + const clientExit = decodeExitTest(wire); | |
| 478 | + | |
| 479 | + expect(Exit.isFailure(clientExit)).toBe(true); | |
| 480 | + if (Exit.isFailure(clientExit)) { | |
| 481 | + const error = Cause.failureOption(clientExit.cause); | |
| 482 | + expect(Option.isSome(error)).toBe(true); | |
| 483 | + if (Option.isSome(error)) { | |
| 484 | + const err = error.value as Record<string, unknown>; | |
| 485 | + expect(err._tag).toBe("ForbiddenError"); | |
| 486 | + } | |
| 487 | + } | |
| 488 | + }); | |
| 489 | + | |
| 490 | + it("defect (Effect.die) is wrapped in RpcDefectError", () => { | |
| 491 | + const encodeExit = createServerEncoder(Schema.String, undefined); | |
| 492 | + | |
| 493 | + const serverExit = Exit.die("GitHub API error: 403 Forbidden"); | |
| 494 | + const encoded = encodeExit(serverExit); | |
| 495 | + const wire = JSON.parse(JSON.stringify(encoded)); | |
| 496 | + const clientExit = decodeExitTest(wire); | |
| 497 | + | |
| 498 | + expect(Exit.isFailure(clientExit)).toBe(true); | |
| 499 | + if (Exit.isFailure(clientExit)) { | |
| 500 | + const error = Cause.failureOption(clientExit.cause); | |
| 501 | + expect(Option.isSome(error)).toBe(true); | |
| 502 | + if (Option.isSome(error)) { | |
| 503 | + expect(error.value).toBeInstanceOf(RpcDefectError); | |
| 504 | + } | |
| 505 | + } | |
| 506 | + }); | |
| 507 | +}); | |
| 508 | + | |
| 509 | +// --------------------------------------------------------------------------- | |
| 510 | +// Server RPC query deduplication tests | |
| 511 | +// --------------------------------------------------------------------------- | |
| 512 | + | |
| 513 | +/** | |
| 514 | + * Creates a mock ConvexHttpClientLayer that tracks how many actual query calls | |
| 515 | + * are made. Each query resolves after a short delay to simulate network latency, | |
| 516 | + * allowing us to test that concurrent calls are deduplicated. | |
| 517 | + */ | |
| 518 | +const createMockHttpLayer = () => { | |
| 519 | + let callCount = 0; | |
| 520 | + const callPayloads: Array<unknown> = []; | |
| 521 | + | |
| 522 | + const service: ConvexClientService = { | |
| 523 | + query: <Q extends import("convex/server").FunctionReference<"query">>( | |
| 524 | + _query: Q, | |
| 525 | + args: Q["_args"], | |
| 526 | + ) => { | |
| 527 | + callCount += 1; | |
| 528 | + callPayloads.push(args); | |
| 529 | + // Simulate network delay so concurrent calls overlap | |
| 530 | + return Effect.promise( | |
| 531 | + () => | |
| 532 | + new Promise<import("convex/server").FunctionReturnType<Q>>((resolve) => { | |
| 533 | + setTimeout( | |
| 534 | + () => | |
| 535 | + resolve({ | |
| 536 | + _tag: "Success", | |
| 537 | + value: { data: "test-result" }, | |
| 538 | + } as import("convex/server").FunctionReturnType<Q>), | |
| 539 | + 50, | |
| 540 | + ); | |
| 541 | + }), | |
| 542 | + ); | |
| 543 | + }, | |
| 544 | + mutation: () => Effect.die("not implemented"), | |
| 545 | + action: () => Effect.die("not implemented"), | |
| 546 | + subscribe: () => Stream.empty, | |
| 547 | + }; | |
| 548 | + | |
| 549 | + const layer = Layer.succeed(ConvexClient, service); | |
| 550 | + | |
| 551 | + return { | |
| 552 | + layer, | |
| 553 | + getCallCount: () => callCount, | |
| 554 | + getCallPayloads: () => callPayloads, | |
| 555 | + resetCounts: () => { | |
| 556 | + callCount = 0; | |
| 557 | + callPayloads.length = 0; | |
| 558 | + }, | |
| 559 | + }; | |
| 560 | +}; | |
| 561 | + | |
| 562 | +describe("Server RPC query deduplication", () => { | |
| 563 | + it("concurrent queryPromise calls with identical payload only fire one request", async () => { | |
| 564 | + const mock = createMockHttpLayer(); | |
| 565 | + | |
| 566 | + const mockApi = { | |
| 567 | + list: guestbookModule.handlers.list, | |
| 568 | + }; | |
| 569 | + | |
| 570 | + const serverClient = createServerRpcQuery<typeof guestbookModule>( | |
| 571 | + mockApi as never, | |
| 572 | + { url: "https://test.convex.cloud", layer: mock.layer }, | |
| 573 | + ); | |
| 574 | + | |
| 575 | + // Fire 3 concurrent calls with the same payload | |
| 576 | + const [r1, r2, r3] = await Promise.all([ | |
| 577 | + serverClient.list.queryPromise({}), | |
| 578 | + serverClient.list.queryPromise({}), | |
| 579 | + serverClient.list.queryPromise({}), | |
| 580 | + ]); | |
| 581 | + | |
| 582 | + // All should return the same result | |
| 583 | + expect(r1).toEqual({ data: "test-result" }); | |
| 584 | + expect(r2).toEqual({ data: "test-result" }); | |
| 585 | + expect(r3).toEqual({ data: "test-result" }); | |
| 586 | + | |
| 587 | + // Only ONE actual query should have been made | |
| 588 | + expect(mock.getCallCount()).toBe(1); | |
| 589 | + }); | |
| 590 | + | |
| 591 | + it("concurrent calls with DIFFERENT payloads fire separate requests", async () => { | |
| 592 | + const mock = createMockHttpLayer(); | |
| 593 | + | |
| 594 | + const mockApi = { | |
| 595 | + get: guestbookModule.handlers.get, | |
| 596 | + }; | |
| 597 | + | |
| 598 | + const serverClient = createServerRpcQuery<typeof guestbookModule>( | |
| 599 | + mockApi as never, | |
| 600 | + { url: "https://test.convex.cloud", layer: mock.layer }, | |
| 601 | + ); | |
| 602 | + | |
| 603 | + const [r1, r2] = await Promise.all([ | |
| 604 | + serverClient.get.queryPromise({ id: "a" }), | |
| 605 | + serverClient.get.queryPromise({ id: "b" }), | |
| 606 | + ]); | |
| 607 | + | |
| 608 | + // Both resolve | |
| 609 | + expect(r1).toEqual({ data: "test-result" }); | |
| 610 | + expect(r2).toEqual({ data: "test-result" }); | |
| 611 | + | |
| 612 | + // Two different payloads = two separate requests | |
| 613 | + expect(mock.getCallCount()).toBe(2); | |
| 614 | + }); | |
| 615 | + | |
| 616 | + it("scopes server query cache by auth token when configured", async () => { | |
| 617 | + const mock = createMockHttpLayer(); | |
| 618 | + | |
| 619 | + const mockApi = { | |
| 620 | + list: guestbookModule.handlers.list, | |
| 621 | + }; | |
| 622 | + | |
| 623 | + let token = "token-a"; | |
| 624 | + | |
| 625 | + const serverClient = createServerRpcQuery<typeof guestbookModule>( | |
| 626 | + mockApi as never, | |
| 627 | + { | |
| 628 | + url: "https://test.convex.cloud", | |
| 629 | + layer: mock.layer, | |
| 630 | + getAuthToken: async () => token, | |
| 631 | + }, | |
| 632 | + ); | |
| 633 | + | |
| 634 | + await serverClient.list.queryPromise({}); | |
| 635 | + await serverClient.list.queryPromise({}); | |
| 636 | + expect(mock.getCallCount()).toBe(1); | |
| 637 | + | |
| 638 | + token = "token-b"; | |
| 639 | + await serverClient.list.queryPromise({}); | |
| 640 | + expect(mock.getCallCount()).toBe(2); | |
| 641 | + }); | |
| 642 | + | |
| 643 | + it("sequential calls after TTL expiry fire fresh requests", async () => { | |
| 644 | + const mock = createMockHttpLayer(); | |
| 645 | + | |
| 646 | + const mockApi = { | |
| 647 | + list: guestbookModule.handlers.list, | |
| 648 | + }; | |
| 649 | + | |
| 650 | + const serverClient = createServerRpcQuery<typeof guestbookModule>( | |
| 651 | + mockApi as never, | |
| 652 | + { url: "https://test.convex.cloud", layer: mock.layer }, | |
| 653 | + ); | |
| 654 | + | |
| 655 | + // First call | |
| 656 | + const r1 = await serverClient.list.queryPromise({}); | |
| 657 | + expect(r1).toEqual({ data: "test-result" }); | |
| 658 | + | |
| 659 | + // Second call happens immediately (within TTL) — should be cached | |
| 660 | + const r2 = await serverClient.list.queryPromise({}); | |
| 661 | + expect(r2).toEqual({ data: "test-result" }); | |
| 662 | + | |
| 663 | + // Within the TTL window, both calls share the same cached result | |
| 664 | + // The Cache coalesces in-flight requests AND caches completed results | |
| 665 | + // for the TTL duration, so sequential calls within TTL only fire once. | |
| 666 | + expect(mock.getCallCount()).toBe(1); | |
| 667 | + }); | |
| 668 | + | |
| 669 | + it("deduplication works when first call rejects", async () => { | |
| 670 | + let callCount = 0; | |
| 671 | + | |
| 672 | + const failingService: ConvexClientService = { | |
| 673 | + query: <Q extends import("convex/server").FunctionReference<"query">>( | |
| 674 | + _query: Q, | |
| 675 | + _args: Q["_args"], | |
| 676 | + ) => { | |
| 677 | + callCount += 1; | |
| 678 | + return Effect.promise( | |
| 679 | + () => | |
| 680 | + new Promise<import("convex/server").FunctionReturnType<Q>>((resolve) => { | |
| 681 | + setTimeout( | |
| 682 | + () => | |
| 683 | + resolve({ | |
| 684 | + _tag: "Failure", | |
| 685 | + cause: { | |
| 686 | + _tag: "Fail", | |
| 687 | + error: { _tag: "SomeError" }, | |
| 688 | + }, | |
| 689 | + } as import("convex/server").FunctionReturnType<Q>), | |
| 690 | + 50, | |
| 691 | + ); | |
| 692 | + }), | |
| 693 | + ); | |
| 694 | + }, | |
| 695 | + mutation: () => Effect.die("not implemented"), | |
| 696 | + action: () => Effect.die("not implemented"), | |
| 697 | + subscribe: () => Stream.empty, | |
| 698 | + }; | |
| 699 | + | |
| 700 | + const failingLayer = Layer.succeed(ConvexClient, failingService); | |
| 701 | + | |
| 702 | + const mockApi = { | |
| 703 | + list: guestbookModule.handlers.list, | |
| 704 | + }; | |
| 705 | + | |
| 706 | + const serverClient = createServerRpcQuery<typeof guestbookModule>( | |
| 707 | + mockApi as never, | |
| 708 | + { url: "https://test.convex.cloud", layer: failingLayer }, | |
| 709 | + ); | |
| 710 | + | |
| 711 | + // All 3 should reject | |
| 712 | + const results = await Promise.allSettled([ | |
| 713 | + serverClient.list.queryPromise({}), | |
| 714 | + serverClient.list.queryPromise({}), | |
| 715 | + serverClient.list.queryPromise({}), | |
| 716 | + ]); | |
| 717 | + | |
| 718 | + expect(results[0].status).toBe("rejected"); | |
| 719 | + expect(results[1].status).toBe("rejected"); | |
| 720 | + expect(results[2].status).toBe("rejected"); | |
| 721 | + | |
| 722 | + // Only ONE actual call | |
| 723 | + expect(callCount).toBe(1); | |
| 724 | + }); | |
| 725 | +}); | |
| 726 | + | |
| 727 | +describe("RPC Module", () => { | |
| 728 | + describe("makeRpcModule", () => { | |
| 729 | + it("creates module with handlers object", () => { | |
| 730 | + expect(guestbookModule.handlers).toBeDefined(); | |
| 731 | + expect(guestbookModule.handlers.add).toBeDefined(); | |
| 732 | + expect(guestbookModule.handlers.list).toBeDefined(); | |
| 733 | + expect(guestbookModule.handlers.get).toBeDefined(); | |
| 734 | + expect(guestbookModule.handlers.sendNotification).toBeDefined(); | |
| 735 | + }); | |
| 736 | + | |
| 737 | + it("creates module with rpcs object", () => { | |
| 738 | + expect(guestbookModule.rpcs).toBeDefined(); | |
| 739 | + expect(guestbookModule.rpcs.add).toBeDefined(); | |
| 740 | + expect(guestbookModule.rpcs.list).toBeDefined(); | |
| 741 | + expect(guestbookModule.rpcs.get).toBeDefined(); | |
| 742 | + expect(guestbookModule.rpcs.sendNotification).toBeDefined(); | |
| 743 | + }); | |
| 744 | + | |
| 745 | + it("creates module with group for @effect/rpc compatibility", () => { | |
| 746 | + expect(guestbookModule.group).toBeDefined(); | |
| 747 | + }); | |
| 748 | + | |
| 749 | + it("endpoint objects are accessible directly on module", () => { | |
| 750 | + expect(guestbookModule.add).toBeDefined(); | |
| 751 | + expect(guestbookModule.add._tag).toBe("add"); | |
| 752 | + expect(guestbookModule.list._tag).toBe("list"); | |
| 753 | + expect(guestbookModule.get._tag).toBe("get"); | |
| 754 | + expect(guestbookModule.sendNotification._tag).toBe("sendNotification"); | |
| 755 | + }); | |
| 756 | + | |
| 757 | + it("handlers are Convex registered functions", () => { | |
| 758 | + expect(typeof guestbookModule.handlers.add).toBe("function"); | |
| 759 | + expect(typeof guestbookModule.handlers.list).toBe("function"); | |
| 760 | + }); | |
| 761 | + }); | |
| 762 | + | |
| 763 | + describe("createRpcFactory", () => { | |
| 764 | + it("creates factory with query, mutation, action methods", () => { | |
| 765 | + expect(typeof factory.query).toBe("function"); | |
| 766 | + expect(typeof factory.mutation).toBe("function"); | |
| 767 | + expect(typeof factory.action).toBe("function"); | |
| 768 | + }); | |
| 769 | + | |
| 770 | + it("creates factory with internal variants", () => { | |
| 771 | + expect(typeof factory.internalQuery).toBe("function"); | |
| 772 | + expect(typeof factory.internalMutation).toBe("function"); | |
| 773 | + expect(typeof factory.internalAction).toBe("function"); | |
| 774 | + }); | |
| 775 | + }); | |
| 776 | +}); | |
| @@ -0,0 +1,1105 @@ | ||
| 1 | +import type { Rpc } from "@effect/rpc"; | |
| 2 | +import type { | |
| 3 | + FunctionReference, | |
| 4 | + RegisteredQuery, | |
| 5 | + RegisteredMutation, | |
| 6 | + RegisteredAction, | |
| 7 | +} from "convex/server"; | |
| 8 | +import { Atom, Result } from "@effect-atom/atom"; | |
| 9 | +import { Result as ResultModule, useAtomValue } from "@effect-atom/atom-react"; | |
| 10 | +import * as Cause from "effect/Cause"; | |
| 11 | +import { Cache, Chunk, Data, Duration, Effect, Exit, FiberId, Layer, Option, Stream } from "effect"; | |
| 12 | + | |
| 13 | +import { | |
| 14 | + ConvexClient, | |
| 15 | + ConvexClientLayer, | |
| 16 | + ConvexHttpClientLayer, | |
| 17 | + type ConvexRequestMetadata, | |
| 18 | +} from "../client"; | |
| 19 | +import type { AnyRpcModule, ExitEncoded, RpcEndpoint } from "./server"; | |
| 20 | +import { | |
| 21 | + makeRpcTransportHeaders, | |
| 22 | + type RpcClientKind, | |
| 23 | + withOptionalRpcTelemetryContext, | |
| 24 | +} from "./telemetry"; | |
| 25 | + | |
| 26 | +export class RpcDefectError extends Data.TaggedError("RpcDefectError")<{ | |
| 27 | + readonly defect: unknown; | |
| 28 | +}> { | |
| 29 | + get message(): string { | |
| 30 | + return `RpcDefectError: ${extractDefectMessage(this.defect)}`; | |
| 31 | + } | |
| 32 | +} | |
| 33 | + | |
| 34 | +/** Best-effort extraction of a human-readable message from an opaque defect. */ | |
| 35 | +const extractDefectMessage = (defect: unknown): string => { | |
| 36 | + if (defect === null || defect === undefined) return "Unknown defect"; | |
| 37 | + if (typeof defect === "string") return defect; | |
| 38 | + if (defect instanceof Error) return defect.message; | |
| 39 | + if (typeof defect === "object") { | |
| 40 | + const obj = defect as Record<string, unknown>; | |
| 41 | + // TaggedError / Data.TaggedError | |
| 42 | + if (typeof obj._tag === "string" && typeof obj.message === "string") { | |
| 43 | + return `[${obj._tag}] ${obj.message}`; | |
| 44 | + } | |
| 45 | + if (typeof obj._tag === "string") return obj._tag; | |
| 46 | + if (typeof obj.message === "string") return obj.message; | |
| 47 | + try { | |
| 48 | + return JSON.stringify(defect); | |
| 49 | + } catch { | |
| 50 | + return String(defect); | |
| 51 | + } | |
| 52 | + } | |
| 53 | + return String(defect); | |
| 54 | +}; | |
| 55 | + | |
| 56 | +type EndpointPayload<E> = E extends RpcEndpoint<infer _Tag, infer R, infer _ConvexFn> | |
| 57 | + ? Rpc.Payload<R> | |
| 58 | + : never; | |
| 59 | + | |
| 60 | +type EndpointSuccess<E> = E extends RpcEndpoint<infer _Tag, infer R, infer _ConvexFn> | |
| 61 | + ? Rpc.Success<R> | |
| 62 | + : never; | |
| 63 | + | |
| 64 | +type EndpointError<E> = E extends RpcEndpoint<infer _Tag, infer R, infer _ConvexFn> | |
| 65 | + ? Rpc.Error<R> | |
| 66 | + : never; | |
| 67 | + | |
| 68 | +type EndpointKind<E> = E extends RpcEndpoint<infer _Tag, infer _R, infer ConvexFn> | |
| 69 | + ? ConvexFn extends RegisteredQuery<infer _V, infer _A, infer _R> | |
| 70 | + ? "query" | |
| 71 | + : ConvexFn extends RegisteredMutation<infer _V, infer _A, infer _R> | |
| 72 | + ? "mutation" | |
| 73 | + : ConvexFn extends RegisteredAction<infer _V, infer _A, infer _R> | |
| 74 | + ? "action" | |
| 75 | + : never | |
| 76 | + : never; | |
| 77 | + | |
| 78 | +type IsPaginatedResult<T> = T extends { | |
| 79 | + page: ReadonlyArray<infer _Item>; | |
| 80 | + isDone: boolean; | |
| 81 | + continueCursor: string; | |
| 82 | +} | |
| 83 | + ? true | |
| 84 | + : false; | |
| 85 | + | |
| 86 | +type ExtractPageItem<T> = T extends { | |
| 87 | + page: ReadonlyArray<infer Item>; | |
| 88 | + isDone: boolean; | |
| 89 | + continueCursor: string; | |
| 90 | +} | |
| 91 | + ? Item | |
| 92 | + : never; | |
| 93 | + | |
| 94 | +type IsPaginatedPayload<T> = T extends { | |
| 95 | + cursor: string | null; | |
| 96 | + numItems: number; | |
| 97 | +} | |
| 98 | + ? true | |
| 99 | + : false; | |
| 100 | + | |
| 101 | +/** | |
| 102 | + * Extra payload fields for a paginated endpoint – everything except the | |
| 103 | + * pagination primitives (`cursor` and `numItems`) that the framework manages. | |
| 104 | + */ | |
| 105 | +type PaginatedExtraPayload<Payload> = Omit<Payload, "cursor" | "numItems">; | |
| 106 | + | |
| 107 | +type PaginatedArgs<Extra> = keyof Extra extends never | |
| 108 | + ? [numItems: number] | |
| 109 | + : [numItems: number, payload: Extra]; | |
| 110 | + | |
| 111 | +export interface RpcReadOptions { | |
| 112 | + readonly enabled?: boolean; | |
| 113 | +} | |
| 114 | + | |
| 115 | +export type RpcQueryClient<Payload, Success, Error> = { | |
| 116 | + query: ( | |
| 117 | + payload: Payload, | |
| 118 | + options?: RpcReadOptions, | |
| 119 | + ) => Atom.Atom<Result.Result<Success, Error | RpcDefectError>>; | |
| 120 | + queryEffect: (payload: Payload) => Effect.Effect<Success, Error | RpcDefectError>; | |
| 121 | + queryPromise: (payload: Payload) => Promise<Success>; | |
| 122 | + subscription: ( | |
| 123 | + payload: Payload, | |
| 124 | + options?: RpcReadOptions, | |
| 125 | + ) => Atom.Atom<Result.Result<Success, Error | RpcDefectError>>; | |
| 126 | +} & (IsPaginatedResult<Success> extends true | |
| 127 | + ? IsPaginatedPayload<Payload> extends true | |
| 128 | + ? { | |
| 129 | + paginated: ( | |
| 130 | + ...args: PaginatedArgs<PaginatedExtraPayload<Payload>> | |
| 131 | + ) => Atom.Writable< | |
| 132 | + Atom.PullResult<ExtractPageItem<Success>, Error | RpcDefectError>, | |
| 133 | + void | |
| 134 | + >; | |
| 135 | + } | |
| 136 | + : {} | |
| 137 | + : {}); | |
| 138 | + | |
| 139 | +export type RpcMutationClient<Payload, Success, Error> = { | |
| 140 | + mutate: Atom.AtomResultFn<Payload, Success, Error | RpcDefectError>; | |
| 141 | + mutateEffect: (payload: Payload) => Effect.Effect<Success, Error | RpcDefectError>; | |
| 142 | + mutatePromise: (payload: Payload) => Promise<Success>; | |
| 143 | +}; | |
| 144 | + | |
| 145 | +export type RpcActionClient<Payload, Success, Error> = { | |
| 146 | + call: Atom.AtomResultFn<Payload, Success, Error | RpcDefectError>; | |
| 147 | + callEffect: (payload: Payload) => Effect.Effect<Success, Error | RpcDefectError>; | |
| 148 | + callPromise: (payload: Payload) => Promise<Success>; | |
| 149 | + callAsQuery: ( | |
| 150 | + payload: Payload, | |
| 151 | + ) => Atom.Atom<Result.Result<Success, Error | RpcDefectError>>; | |
| 152 | +}; | |
| 153 | + | |
| 154 | +type DecorateEndpoint<E, Shared extends Record<string, unknown> = {}> = | |
| 155 | + EndpointKind<E> extends "query" | |
| 156 | + ? RpcQueryClient< | |
| 157 | + Omit<EndpointPayload<E>, keyof Shared>, | |
| 158 | + EndpointSuccess<E>, | |
| 159 | + EndpointError<E> | |
| 160 | + > | |
| 161 | + : EndpointKind<E> extends "mutation" | |
| 162 | + ? RpcMutationClient< | |
| 163 | + Omit<EndpointPayload<E>, keyof Shared>, | |
| 164 | + EndpointSuccess<E>, | |
| 165 | + EndpointError<E> | |
| 166 | + > | |
| 167 | + : EndpointKind<E> extends "action" | |
| 168 | + ? RpcActionClient< | |
| 169 | + Omit<EndpointPayload<E>, keyof Shared>, | |
| 170 | + EndpointSuccess<E>, | |
| 171 | + EndpointError<E> | |
| 172 | + > | |
| 173 | + : never; | |
| 174 | + | |
| 175 | +type EndpointsRecord = Record<string, RpcEndpoint<string, Rpc.Any, unknown>>; | |
| 176 | + | |
| 177 | +export type RpcModuleClientMethods<TEndpoints extends EndpointsRecord, Shared extends Record<string, unknown> = {}> = { | |
| 178 | + readonly [K in keyof TEndpoints]: DecorateEndpoint<TEndpoints[K], Shared>; | |
| 179 | +}; | |
| 180 | + | |
| 181 | +export interface RpcModuleClientConfig { | |
| 182 | + readonly url: string; | |
| 183 | + readonly layer?: Layer.Layer<ConvexClient>; | |
| 184 | + readonly enablePayloadTelemetryFallback?: boolean; | |
| 185 | +} | |
| 186 | + | |
| 187 | +type ConvexApiModule = Record<string, FunctionReference<"query" | "mutation" | "action">>; | |
| 188 | + | |
| 189 | +type DecorateModuleEndpoints<TModule extends AnyRpcModule, Shared extends Record<string, unknown>> = { | |
| 190 | + [K in keyof TModule]: TModule[K] extends RpcEndpoint<string, Rpc.Any, unknown> | |
| 191 | + ? DecorateEndpoint<TModule[K], Shared> | |
| 192 | + : never; | |
| 193 | +}; | |
| 194 | + | |
| 195 | +type ExtractDecoratedEndpoints<TModule extends AnyRpcModule, Shared extends Record<string, unknown>> = | |
| 196 | + Pick<DecorateModuleEndpoints<TModule, Shared>, { | |
| 197 | + [K in keyof TModule]: TModule[K] extends RpcEndpoint<string, Rpc.Any, unknown> ? K : never; | |
| 198 | + }[keyof TModule]>; | |
| 199 | + | |
| 200 | +export type RpcModuleClient< | |
| 201 | + TModule extends AnyRpcModule, | |
| 202 | + Shared extends Record<string, unknown> = {}, | |
| 203 | +> = { | |
| 204 | + readonly runtime: Atom.AtomRuntime<ConvexClient>; | |
| 205 | +} & ExtractDecoratedEndpoints<TModule, Shared>; | |
| 206 | + | |
| 207 | +type CauseEncoded<E = unknown, D = unknown> = | |
| 208 | + | { readonly _tag: "Empty" } | |
| 209 | + | { readonly _tag: "Fail"; readonly error: E } | |
| 210 | + | { readonly _tag: "Die"; readonly defect: D } | |
| 211 | + | { readonly _tag: "Interrupt"; readonly fiberId: unknown } | |
| 212 | + | { readonly _tag: "Sequential"; readonly left: CauseEncoded<E, D>; readonly right: CauseEncoded<E, D> } | |
| 213 | + | { readonly _tag: "Parallel"; readonly left: CauseEncoded<E, D>; readonly right: CauseEncoded<E, D> }; | |
| 214 | + | |
| 215 | +const decodeCause = (encoded: CauseEncoded): Cause.Cause<unknown> => { | |
| 216 | + switch (encoded._tag) { | |
| 217 | + case "Empty": | |
| 218 | + return Cause.empty; | |
| 219 | + case "Fail": | |
| 220 | + return Cause.fail(encoded.error); | |
| 221 | + case "Die": | |
| 222 | + return Cause.die(encoded.defect); | |
| 223 | + case "Interrupt": | |
| 224 | + return Cause.interrupt(FiberId.none); | |
| 225 | + case "Sequential": | |
| 226 | + return Cause.sequential(decodeCause(encoded.left), decodeCause(encoded.right)); | |
| 227 | + case "Parallel": | |
| 228 | + return Cause.parallel(decodeCause(encoded.left), decodeCause(encoded.right)); | |
| 229 | + } | |
| 230 | +}; | |
| 231 | + | |
| 232 | +const decodeExit = (encoded: ExitEncoded): Exit.Exit<unknown, unknown> => { | |
| 233 | + // Safety: if the response isn't a valid ExitEncoded, wrap as defect | |
| 234 | + if ( | |
| 235 | + !encoded || | |
| 236 | + typeof encoded !== "object" || | |
| 237 | + !("_tag" in encoded) | |
| 238 | + ) { | |
| 239 | + return Exit.fail( | |
| 240 | + new RpcDefectError({ defect: `Unexpected RPC response: ${JSON.stringify(encoded)}` }), | |
| 241 | + ); | |
| 242 | + } | |
| 243 | + if (encoded._tag === "Success") { | |
| 244 | + return Exit.succeed(encoded.value); | |
| 245 | + } | |
| 246 | + if (encoded._tag !== "Failure" || !encoded.cause) { | |
| 247 | + return Exit.fail( | |
| 248 | + new RpcDefectError({ defect: `Unexpected exit tag: ${String(encoded._tag)}` }), | |
| 249 | + ); | |
| 250 | + } | |
| 251 | + const cause = decodeCause(encoded.cause); | |
| 252 | + const failureOption = Cause.failureOption(cause); | |
| 253 | + if (Option.isSome(failureOption)) { | |
| 254 | + return Exit.fail(failureOption.value); | |
| 255 | + } | |
| 256 | + const defects = Cause.defects(cause); | |
| 257 | + if (Chunk.isNonEmpty(defects)) { | |
| 258 | + return Exit.fail(new RpcDefectError({ defect: Chunk.unsafeHead(defects) })); | |
| 259 | + } | |
| 260 | + if (Cause.isInterrupted(cause)) { | |
| 261 | + return Exit.fail(new RpcDefectError({ defect: "Interrupted" })); | |
| 262 | + } | |
| 263 | + return Exit.fail(new RpcDefectError({ defect: "Empty cause" })); | |
| 264 | +}; | |
| 265 | + | |
| 266 | +const ensureNextRequestDataAccess = async () => { | |
| 267 | + if (typeof window !== "undefined") { | |
| 268 | + return; | |
| 269 | + } | |
| 270 | + | |
| 271 | + // `next/headers` exists only in Next.js server runtime. Attempt import and | |
| 272 | + // request binding opportunistically; skip in tests and non-Next environments. | |
| 273 | + try { | |
| 274 | + const { headers } = await import("next/headers"); | |
| 275 | + await headers(); | |
| 276 | + } catch { | |
| 277 | + return; | |
| 278 | + } | |
| 279 | +}; | |
| 280 | + | |
| 281 | +const withRpcClientSpan = <A>( | |
| 282 | + kind: RpcClientKind, | |
| 283 | + endpointTag: string, | |
| 284 | + payload: unknown, | |
| 285 | + enablePayloadTelemetryFallback: boolean, | |
| 286 | + run: ( | |
| 287 | + payloadWithTelemetry: unknown, | |
| 288 | + requestMetadata: ConvexRequestMetadata, | |
| 289 | + ) => Effect.Effect<A, unknown, ConvexClient>, | |
| 290 | +): Effect.Effect<A, unknown, ConvexClient> => | |
| 291 | + Effect.useSpan( | |
| 292 | + `rpc.client.${kind}.${endpointTag}`, | |
| 293 | + { | |
| 294 | + kind: "client", | |
| 295 | + captureStackTrace: false, | |
| 296 | + attributes: { | |
| 297 | + "rpc.system": "convex", | |
| 298 | + "rpc.method": endpointTag, | |
| 299 | + "rpc.confect.kind": kind, | |
| 300 | + }, | |
| 301 | + }, | |
| 302 | + (span) => { | |
| 303 | + const requestMetadata: ConvexRequestMetadata = { | |
| 304 | + headers: makeRpcTransportHeaders(span), | |
| 305 | + }; | |
| 306 | + return run( | |
| 307 | + withOptionalRpcTelemetryContext( | |
| 308 | + kind, | |
| 309 | + payload, | |
| 310 | + span, | |
| 311 | + enablePayloadTelemetryFallback, | |
| 312 | + ), | |
| 313 | + requestMetadata, | |
| 314 | + ); | |
| 315 | + }, | |
| 316 | + ); | |
| 317 | + | |
| 318 | +const createQueryEffect = ( | |
| 319 | + endpointTag: string, | |
| 320 | + convexFn: FunctionReference<"query">, | |
| 321 | + payload: unknown, | |
| 322 | + enablePayloadTelemetryFallback: boolean, | |
| 323 | +): Effect.Effect<unknown, unknown, ConvexClient> => | |
| 324 | + withRpcClientSpan( | |
| 325 | + "query", | |
| 326 | + endpointTag, | |
| 327 | + payload, | |
| 328 | + enablePayloadTelemetryFallback, | |
| 329 | + (payloadWithTelemetry, requestMetadata) => | |
| 330 | + Effect.gen(function* () { | |
| 331 | + const client = yield* ConvexClient; | |
| 332 | + const encodedExit = yield* client.query( | |
| 333 | + convexFn, | |
| 334 | + payloadWithTelemetry, | |
| 335 | + requestMetadata, | |
| 336 | + ); | |
| 337 | + const exit = decodeExit(encodedExit as ExitEncoded); | |
| 338 | + if (Exit.isSuccess(exit)) { | |
| 339 | + return exit.value; | |
| 340 | + } | |
| 341 | + return yield* exit; | |
| 342 | + }), | |
| 343 | + ); | |
| 344 | + | |
| 345 | +const createMutationEffect = ( | |
| 346 | + endpointTag: string, | |
| 347 | + convexFn: FunctionReference<"mutation">, | |
| 348 | + payload: unknown, | |
| 349 | + enablePayloadTelemetryFallback: boolean, | |
| 350 | +): Effect.Effect<unknown, unknown, ConvexClient> => | |
| 351 | + withRpcClientSpan( | |
| 352 | + "mutation", | |
| 353 | + endpointTag, | |
| 354 | + payload, | |
| 355 | + enablePayloadTelemetryFallback, | |
| 356 | + (payloadWithTelemetry, requestMetadata) => | |
| 357 | + Effect.gen(function* () { | |
| 358 | + const client = yield* ConvexClient; | |
| 359 | + const encodedExit = yield* client.mutation( | |
| 360 | + convexFn, | |
| 361 | + payloadWithTelemetry, | |
| 362 | + requestMetadata, | |
| 363 | + ); | |
| 364 | + const exit = decodeExit(encodedExit as ExitEncoded); | |
| 365 | + if (Exit.isSuccess(exit)) { | |
| 366 | + return exit.value; | |
| 367 | + } | |
| 368 | + return yield* exit; | |
| 369 | + }), | |
| 370 | + ); | |
| 371 | + | |
| 372 | +const createActionEffect = ( | |
| 373 | + endpointTag: string, | |
| 374 | + convexFn: FunctionReference<"action">, | |
| 375 | + payload: unknown, | |
| 376 | + enablePayloadTelemetryFallback: boolean, | |
| 377 | +): Effect.Effect<unknown, unknown, ConvexClient> => | |
| 378 | + withRpcClientSpan( | |
| 379 | + "action", | |
| 380 | + endpointTag, | |
| 381 | + payload, | |
| 382 | + enablePayloadTelemetryFallback, | |
| 383 | + (payloadWithTelemetry, requestMetadata) => | |
| 384 | + Effect.gen(function* () { | |
| 385 | + const client = yield* ConvexClient; | |
| 386 | + const encodedExit = yield* client.action( | |
| 387 | + convexFn, | |
| 388 | + payloadWithTelemetry, | |
| 389 | + requestMetadata, | |
| 390 | + ); | |
| 391 | + const exit = decodeExit(encodedExit as ExitEncoded); | |
| 392 | + if (Exit.isSuccess(exit)) { | |
| 393 | + return exit.value; | |
| 394 | + } | |
| 395 | + return yield* exit; | |
| 396 | + }), | |
| 397 | + ); | |
| 398 | + | |
| 399 | +const createQueryAtom = ( | |
| 400 | + runtime: Atom.AtomRuntime<ConvexClient>, | |
| 401 | + endpointTag: string, | |
| 402 | + convexFn: FunctionReference<"query">, | |
| 403 | + payload: unknown, | |
| 404 | + enablePayloadTelemetryFallback: boolean, | |
| 405 | +): Atom.Atom<Result.Result<unknown, unknown>> => { | |
| 406 | + return runtime.atom( | |
| 407 | + createQueryEffect( | |
| 408 | + endpointTag, | |
| 409 | + convexFn, | |
| 410 | + payload, | |
| 411 | + enablePayloadTelemetryFallback, | |
| 412 | + ), | |
| 413 | + ); | |
| 414 | +}; | |
| 415 | + | |
| 416 | +const createSubscriptionAtom = ( | |
| 417 | + runtime: Atom.AtomRuntime<ConvexClient>, | |
| 418 | + endpointTag: string, | |
| 419 | + convexFn: FunctionReference<"query">, | |
| 420 | + payload: unknown, | |
| 421 | + enablePayloadTelemetryFallback: boolean, | |
| 422 | +): Atom.Atom<Result.Result<unknown, unknown>> => { | |
| 423 | + return runtime.atom( | |
| 424 | + Stream.unwrap( | |
| 425 | + withRpcClientSpan( | |
| 426 | + "query", | |
| 427 | + endpointTag, | |
| 428 | + payload, | |
| 429 | + enablePayloadTelemetryFallback, | |
| 430 | + (payloadWithTelemetry, _requestMetadata) => | |
| 431 | + Effect.gen(function* () { | |
| 432 | + const client = yield* ConvexClient; | |
| 433 | + return client.subscribe(convexFn, payloadWithTelemetry).pipe( | |
| 434 | + Stream.mapEffect((encodedExit) => { | |
| 435 | + const exit = decodeExit(encodedExit as ExitEncoded); | |
| 436 | + if (Exit.isSuccess(exit)) { | |
| 437 | + return Effect.succeed(exit.value); | |
| 438 | + } | |
| 439 | + return exit; | |
| 440 | + }), | |
| 441 | + ); | |
| 442 | + }), | |
| 443 | + ), | |
| 444 | + ), | |
| 445 | + ); | |
| 446 | +}; | |
| 447 | + | |
| 448 | +const createActionQueryAtom = ( | |
| 449 | + runtime: Atom.AtomRuntime<ConvexClient>, | |
| 450 | + endpointTag: string, | |
| 451 | + convexFn: FunctionReference<"action">, | |
| 452 | + payload: unknown, | |
| 453 | + enablePayloadTelemetryFallback: boolean, | |
| 454 | +): Atom.Atom<Result.Result<unknown, unknown>> => { | |
| 455 | + return runtime.atom( | |
| 456 | + createActionEffect( | |
| 457 | + endpointTag, | |
| 458 | + convexFn, | |
| 459 | + payload, | |
| 460 | + enablePayloadTelemetryFallback, | |
| 461 | + ), | |
| 462 | + ); | |
| 463 | +}; | |
| 464 | + | |
| 465 | +const createMutationFn = ( | |
| 466 | + runtime: Atom.AtomRuntime<ConvexClient>, | |
| 467 | + endpointTag: string, | |
| 468 | + convexFn: FunctionReference<"mutation">, | |
| 469 | + getShared: () => Record<string, unknown>, | |
| 470 | + enablePayloadTelemetryFallback: boolean, | |
| 471 | +): Atom.AtomResultFn<unknown, unknown, unknown> => { | |
| 472 | + return runtime.fn<unknown>()( | |
| 473 | + Effect.fnUntraced(function* (payload) { | |
| 474 | + const fullPayload = { ...getShared(), ...(payload as object) }; | |
| 475 | + return yield* createMutationEffect( | |
| 476 | + endpointTag, | |
| 477 | + convexFn, | |
| 478 | + fullPayload, | |
| 479 | + enablePayloadTelemetryFallback, | |
| 480 | + ); | |
| 481 | + }), | |
| 482 | + ); | |
| 483 | +}; | |
| 484 | + | |
| 485 | +const createActionFn = ( | |
| 486 | + runtime: Atom.AtomRuntime<ConvexClient>, | |
| 487 | + endpointTag: string, | |
| 488 | + convexFn: FunctionReference<"action">, | |
| 489 | + getShared: () => Record<string, unknown>, | |
| 490 | + enablePayloadTelemetryFallback: boolean, | |
| 491 | +): Atom.AtomResultFn<unknown, unknown, unknown> => { | |
| 492 | + return runtime.fn<unknown>()( | |
| 493 | + Effect.fnUntraced(function* (payload) { | |
| 494 | + const fullPayload = { ...getShared(), ...(payload as object) }; | |
| 495 | + return yield* createActionEffect( | |
| 496 | + endpointTag, | |
| 497 | + convexFn, | |
| 498 | + fullPayload, | |
| 499 | + enablePayloadTelemetryFallback, | |
| 500 | + ); | |
| 501 | + }), | |
| 502 | + ); | |
| 503 | +}; | |
| 504 | + | |
| 505 | +interface PaginatedResult<T> { | |
| 506 | + page: ReadonlyArray<T>; | |
| 507 | + isDone: boolean; | |
| 508 | + continueCursor: string; | |
| 509 | +} | |
| 510 | + | |
| 511 | +const createPaginatedAtom = ( | |
| 512 | + runtime: Atom.AtomRuntime<ConvexClient>, | |
| 513 | + endpointTag: string, | |
| 514 | + convexFn: FunctionReference<"query">, | |
| 515 | + getShared: () => Record<string, unknown>, | |
| 516 | + numItems: number, | |
| 517 | + extraPayload: Record<string, unknown>, | |
| 518 | + enablePayloadTelemetryFallback: boolean, | |
| 519 | +): Atom.Writable<Atom.PullResult<unknown, unknown>, void> => { | |
| 520 | + return runtime.pull( | |
| 521 | + Stream.paginateChunkEffect(null as string | null, (cursor) => | |
| 522 | + Effect.gen(function* () { | |
| 523 | + const fullPayload = { | |
| 524 | + ...getShared(), | |
| 525 | + ...extraPayload, | |
| 526 | + cursor, | |
| 527 | + numItems, | |
| 528 | + }; | |
| 529 | + const result = (yield* createQueryEffect( | |
| 530 | + endpointTag, | |
| 531 | + convexFn, | |
| 532 | + fullPayload, | |
| 533 | + enablePayloadTelemetryFallback, | |
| 534 | + )) as PaginatedResult<unknown>; | |
| 535 | + const nextCursor = result.isDone | |
| 536 | + ? Option.none<string | null>() | |
| 537 | + : Option.some(result.continueCursor); | |
| 538 | + | |
| 539 | + return [Chunk.fromIterable(result.page), nextCursor] as const; | |
| 540 | + }), | |
| 541 | + ), | |
| 542 | + ); | |
| 543 | +}; | |
| 544 | + | |
| 545 | +const noop = () => {}; | |
| 546 | + | |
| 547 | +const isRpcReadEnabled = (options?: RpcReadOptions): boolean => | |
| 548 | + options?.enabled ?? true; | |
| 549 | + | |
| 550 | +const makeDisabledResultAtom = <Success, Error>() => | |
| 551 | + Atom.make(Result.initial<Success, Error>()); | |
| 552 | + | |
| 553 | +export function createRpcClient< | |
| 554 | + TModule extends AnyRpcModule, | |
| 555 | + Shared extends Record<string, unknown> = {}, | |
| 556 | +>( | |
| 557 | + convexApi: ConvexApiModule, | |
| 558 | + config: RpcModuleClientConfig, | |
| 559 | + getShared: () => Shared = () => ({}) as Shared, | |
| 560 | +): RpcModuleClient<TModule, Shared> { | |
| 561 | + const baseLayer = config.layer ?? ConvexClientLayer(config.url); | |
| 562 | + const enablePayloadTelemetryFallback = | |
| 563 | + config.enablePayloadTelemetryFallback ?? true; | |
| 564 | + const runtime = Atom.runtime(baseLayer); | |
| 565 | + | |
| 566 | + const queryFamilies = new Map<string, (payload: unknown) => Atom.Atom<Result.Result<unknown, unknown>>>(); | |
| 567 | + const subscriptionFamilies = new Map<string, (payload: unknown) => Atom.Atom<Result.Result<unknown, unknown>>>(); | |
| 568 | + const actionQueryFamilies = new Map<string, (payload: unknown) => Atom.Atom<Result.Result<unknown, unknown>>>(); | |
| 569 | + const mutationFns = new Map<string, Atom.AtomResultFn<unknown, unknown, unknown>>(); | |
| 570 | + const actionFns = new Map<string, Atom.AtomResultFn<unknown, unknown, unknown>>(); | |
| 571 | + const paginatedFamilies = new Map<string, (numItems: number, extra: Record<string, unknown>) => Atom.Writable<Atom.PullResult<unknown, unknown>, void>>(); | |
| 572 | + | |
| 573 | + const getQueryFamily = (tag: string) => { | |
| 574 | + let family = queryFamilies.get(tag); | |
| 575 | + if (!family) { | |
| 576 | + const convexFn = convexApi[tag] as FunctionReference<"query">; | |
| 577 | + family = Atom.family((p: unknown) => { | |
| 578 | + const fullPayload = { ...getShared(), ...(p as object) }; | |
| 579 | + return createQueryAtom( | |
| 580 | + runtime, | |
| 581 | + tag, | |
| 582 | + convexFn, | |
| 583 | + fullPayload, | |
| 584 | + enablePayloadTelemetryFallback, | |
| 585 | + ); | |
| 586 | + }); | |
| 587 | + queryFamilies.set(tag, family); | |
| 588 | + } | |
| 589 | + return family; | |
| 590 | + }; | |
| 591 | + | |
| 592 | + const getSubscriptionFamily = (tag: string) => { | |
| 593 | + let family = subscriptionFamilies.get(tag); | |
| 594 | + if (!family) { | |
| 595 | + const convexFn = convexApi[tag] as FunctionReference<"query">; | |
| 596 | + family = Atom.family((p: unknown) => { | |
| 597 | + const fullPayload = { ...getShared(), ...(p as object) }; | |
| 598 | + return createSubscriptionAtom( | |
| 599 | + runtime, | |
| 600 | + tag, | |
| 601 | + convexFn, | |
| 602 | + fullPayload, | |
| 603 | + enablePayloadTelemetryFallback, | |
| 604 | + ); | |
| 605 | + }); | |
| 606 | + subscriptionFamilies.set(tag, family); | |
| 607 | + } | |
| 608 | + return family; | |
| 609 | + }; | |
| 610 | + | |
| 611 | + const getMutationFn = (tag: string) => { | |
| 612 | + let fn = mutationFns.get(tag); | |
| 613 | + if (!fn) { | |
| 614 | + const convexFn = convexApi[tag] as FunctionReference<"mutation">; | |
| 615 | + fn = createMutationFn( | |
| 616 | + runtime, | |
| 617 | + tag, | |
| 618 | + convexFn, | |
| 619 | + getShared, | |
| 620 | + enablePayloadTelemetryFallback, | |
| 621 | + ); | |
| 622 | + mutationFns.set(tag, fn); | |
| 623 | + } | |
| 624 | + return fn; | |
| 625 | + }; | |
| 626 | + | |
| 627 | + const getActionFn = (tag: string) => { | |
| 628 | + let fn = actionFns.get(tag); | |
| 629 | + if (!fn) { | |
| 630 | + const convexFn = convexApi[tag] as FunctionReference<"action">; | |
| 631 | + fn = createActionFn( | |
| 632 | + runtime, | |
| 633 | + tag, | |
| 634 | + convexFn, | |
| 635 | + getShared, | |
| 636 | + enablePayloadTelemetryFallback, | |
| 637 | + ); | |
| 638 | + actionFns.set(tag, fn); | |
| 639 | + } | |
| 640 | + return fn; | |
| 641 | + }; | |
| 642 | + | |
| 643 | + const getActionQueryFamily = (tag: string) => { | |
| 644 | + let family = actionQueryFamilies.get(tag); | |
| 645 | + if (!family) { | |
| 646 | + const convexFn = convexApi[tag] as FunctionReference<"action">; | |
| 647 | + family = Atom.family((p: unknown) => { | |
| 648 | + const fullPayload = { ...getShared(), ...(p as object) }; | |
| 649 | + return createActionQueryAtom( | |
| 650 | + runtime, | |
| 651 | + tag, | |
| 652 | + convexFn, | |
| 653 | + fullPayload, | |
| 654 | + enablePayloadTelemetryFallback, | |
| 655 | + ); | |
| 656 | + }); | |
| 657 | + actionQueryFamilies.set(tag, family); | |
| 658 | + } | |
| 659 | + return family; | |
| 660 | + }; | |
| 661 | + | |
| 662 | + const getPaginatedFamily = (tag: string) => { | |
| 663 | + let cached = paginatedFamilies.get(tag); | |
| 664 | + if (!cached) { | |
| 665 | + const convexFn = convexApi[tag] as FunctionReference<"query">; | |
| 666 | + // Atom.family takes a single arg; use a stable JSON key so that | |
| 667 | + // identical (numItems + extra) combinations share the same atom. | |
| 668 | + const atomFamily = Atom.family((key: string) => { | |
| 669 | + const { numItems, extra } = JSON.parse(key) as { | |
| 670 | + numItems: number; | |
| 671 | + extra: Record<string, unknown>; | |
| 672 | + }; | |
| 673 | + return createPaginatedAtom( | |
| 674 | + runtime, | |
| 675 | + tag, | |
| 676 | + convexFn, | |
| 677 | + getShared, | |
| 678 | + numItems, | |
| 679 | + extra, | |
| 680 | + enablePayloadTelemetryFallback, | |
| 681 | + ); | |
| 682 | + }); | |
| 683 | + cached = (numItems: number, extra: Record<string, unknown>) => | |
| 684 | + atomFamily(JSON.stringify({ numItems, extra })); | |
| 685 | + paginatedFamilies.set(tag, cached); | |
| 686 | + } | |
| 687 | + return cached; | |
| 688 | + }; | |
| 689 | + | |
| 690 | + const endpointProxyCache = new Map<string, unknown>(); | |
| 691 | + | |
| 692 | + const proxy = new Proxy(noop, { | |
| 693 | + get(_target, prop) { | |
| 694 | + if (prop === "runtime") { | |
| 695 | + return runtime; | |
| 696 | + } | |
| 697 | + if (prop === "then") { | |
| 698 | + return undefined; | |
| 699 | + } | |
| 700 | + if (typeof prop !== "string") { | |
| 701 | + return undefined; | |
| 702 | + } | |
| 703 | + | |
| 704 | + let endpointProxy = endpointProxyCache.get(prop); | |
| 705 | + if (!endpointProxy) { | |
| 706 | + const disabledResultAtom = makeDisabledResultAtom< | |
| 707 | + never, | |
| 708 | + RpcDefectError | |
| 709 | + >(); | |
| 710 | + | |
| 711 | + const queryEffect = (payload: unknown) => { | |
| 712 | + const convexFn = convexApi[prop] as FunctionReference<"query">; | |
| 713 | + const fullPayload = { ...getShared(), ...(payload as object) }; | |
| 714 | + return createQueryEffect( | |
| 715 | + prop, | |
| 716 | + convexFn, | |
| 717 | + fullPayload, | |
| 718 | + enablePayloadTelemetryFallback, | |
| 719 | + ).pipe( | |
| 720 | + Effect.provide(baseLayer), | |
| 721 | + ); | |
| 722 | + }; | |
| 723 | + | |
| 724 | + const mutateEffect = (payload: unknown) => { | |
| 725 | + const convexFn = convexApi[prop] as FunctionReference<"mutation">; | |
| 726 | + const fullPayload = { ...getShared(), ...(payload as object) }; | |
| 727 | + return createMutationEffect( | |
| 728 | + prop, | |
| 729 | + convexFn, | |
| 730 | + fullPayload, | |
| 731 | + enablePayloadTelemetryFallback, | |
| 732 | + ).pipe( | |
| 733 | + Effect.provide(baseLayer), | |
| 734 | + ); | |
| 735 | + }; | |
| 736 | + | |
| 737 | + const callEffect = (payload: unknown) => { | |
| 738 | + const convexFn = convexApi[prop] as FunctionReference<"action">; | |
| 739 | + const fullPayload = { ...getShared(), ...(payload as object) }; | |
| 740 | + return createActionEffect( | |
| 741 | + prop, | |
| 742 | + convexFn, | |
| 743 | + fullPayload, | |
| 744 | + enablePayloadTelemetryFallback, | |
| 745 | + ).pipe( | |
| 746 | + Effect.provide(baseLayer), | |
| 747 | + ); | |
| 748 | + }; | |
| 749 | + | |
| 750 | + endpointProxy = { | |
| 751 | + query: (payload: unknown, options?: RpcReadOptions) => | |
| 752 | + isRpcReadEnabled(options) | |
| 753 | + ? getQueryFamily(prop)(payload) | |
| 754 | + : disabledResultAtom, | |
| 755 | + queryEffect, | |
| 756 | + queryPromise: (payload: unknown) => Effect.runPromise(queryEffect(payload)), | |
| 757 | + subscription: (payload: unknown, options?: RpcReadOptions) => | |
| 758 | + isRpcReadEnabled(options) | |
| 759 | + ? getSubscriptionFamily(prop)(payload) | |
| 760 | + : disabledResultAtom, | |
| 761 | + mutate: getMutationFn(prop), | |
| 762 | + mutateEffect, | |
| 763 | + mutatePromise: (payload: unknown) => Effect.runPromise(mutateEffect(payload)), | |
| 764 | + call: getActionFn(prop), | |
| 765 | + callAsQuery: (payload: unknown) => getActionQueryFamily(prop)(payload), | |
| 766 | + callEffect, | |
| 767 | + callPromise: (payload: unknown) => Effect.runPromise(callEffect(payload)), | |
| 768 | + paginated: (numItems: number, extra: Record<string, unknown> = {}) => getPaginatedFamily(prop)(numItems, extra), | |
| 769 | + }; | |
| 770 | + endpointProxyCache.set(prop, endpointProxy); | |
| 771 | + } | |
| 772 | + return endpointProxy; | |
| 773 | + }, | |
| 774 | + }); | |
| 775 | + | |
| 776 | + return proxy as unknown as RpcModuleClient<TModule, Shared>; | |
| 777 | +} | |
| 778 | + | |
| 779 | +// --------------------------------------------------------------------------- | |
| 780 | +// Server-side RPC query client (HTTP-only, no WebSocket, no atoms) | |
| 781 | +// --------------------------------------------------------------------------- | |
| 782 | + | |
| 783 | +type ServerQueryEndpoint<Payload, Success> = { | |
| 784 | + readonly queryPromise: (payload: Payload) => Promise<Success>; | |
| 785 | +}; | |
| 786 | + | |
| 787 | +type ServerDecorateEndpoint<E, Shared extends Record<string, unknown> = {}> = | |
| 788 | + EndpointKind<E> extends "query" | |
| 789 | + ? ServerQueryEndpoint< | |
| 790 | + Omit<EndpointPayload<E>, keyof Shared>, | |
| 791 | + EndpointSuccess<E> | |
| 792 | + > | |
| 793 | + : never; | |
| 794 | + | |
| 795 | +export type ServerRpcModuleClient< | |
| 796 | + TModule extends AnyRpcModule, | |
| 797 | + Shared extends Record<string, unknown> = {}, | |
| 798 | +> = { | |
| 799 | + readonly [K in keyof TModule as TModule[K] extends RpcEndpoint<string, Rpc.Any, unknown> | |
| 800 | + ? EndpointKind<TModule[K]> extends "query" | |
| 801 | + ? K | |
| 802 | + : never | |
| 803 | + : never]: ServerDecorateEndpoint<TModule[K], Shared>; | |
| 804 | +}; | |
| 805 | + | |
| 806 | +export interface ServerRpcClientConfig { | |
| 807 | + readonly url: string; | |
| 808 | + /** | |
| 809 | + * Optional layer override (e.g. for testing). When provided, this layer is | |
| 810 | + * used instead of creating a `ConvexHttpClientLayer` from `url`. | |
| 811 | + */ | |
| 812 | + readonly layer?: Layer.Layer<ConvexClient>; | |
| 813 | + /** | |
| 814 | + * Optional auth token resolver for server-side requests. | |
| 815 | + * | |
| 816 | + * When provided, each `queryPromise` call resolves the current auth token | |
| 817 | + * and uses a token-authenticated HTTP client. Cache keys are also scoped by | |
| 818 | + * auth identity to avoid cross-user cache mixing. | |
| 819 | + */ | |
| 820 | + readonly getAuthToken?: () => Promise<string | null | undefined>; | |
| 821 | +} | |
| 822 | + | |
| 823 | +/** | |
| 824 | + * Create a server-side RPC client that only supports query promises. | |
| 825 | + * Uses `ConvexHttpClientLayer` (HTTP only, no WebSocket). | |
| 826 | + * Intended for use in Next.js server components and route handlers. | |
| 827 | + * | |
| 828 | + * Only query endpoints are exposed (mutations/actions are excluded). | |
| 829 | + * Each endpoint has a single method: `queryPromise(payload) => Promise<Success>`. | |
| 830 | + */ | |
| 831 | +/** | |
| 832 | + * Default TTL for the server-side query deduplication cache. | |
| 833 | + * | |
| 834 | + * During SSR multiple server components may call `queryPromise` with the | |
| 835 | + * same endpoint + payload concurrently. `Cache` automatically coalesces | |
| 836 | + * concurrent lookups for the same key into a single in-flight request. | |
| 837 | + * Completed results are kept for this duration so that near-simultaneous | |
| 838 | + * calls (within the same render pass) hit the cache rather than firing | |
| 839 | + * duplicate HTTP requests. The TTL is intentionally short to avoid | |
| 840 | + * serving stale data across unrelated requests on a long-lived server. | |
| 841 | + */ | |
| 842 | +const SERVER_QUERY_CACHE_TTL = Duration.seconds(5); | |
| 843 | + | |
| 844 | +/** | |
| 845 | + * Maximum number of distinct (endpoint + payload) entries kept in the | |
| 846 | + * server-side query cache. This is deliberately generous — each entry | |
| 847 | + * is small (just the resolved value) and expires quickly. | |
| 848 | + */ | |
| 849 | +const SERVER_QUERY_CACHE_CAPACITY = 256; | |
| 850 | + | |
| 851 | +export function createServerRpcQuery< | |
| 852 | + TModule extends AnyRpcModule, | |
| 853 | + Shared extends Record<string, unknown> = {}, | |
| 854 | +>( | |
| 855 | + convexApi: ConvexApiModule, | |
| 856 | + config: ServerRpcClientConfig, | |
| 857 | + getShared: () => Shared = () => ({}) as Shared, | |
| 858 | +): ServerRpcModuleClient<TModule, Shared> { | |
| 859 | + const defaultLayer = config.layer ?? ConvexHttpClientLayer(config.url); | |
| 860 | + | |
| 861 | + const authScopeByToken = new Map<string, string>(); | |
| 862 | + const authLayerByScope = new Map<string, Layer.Layer<ConvexClient>>(); | |
| 863 | + let nextAuthScopeId = 0; | |
| 864 | + | |
| 865 | + const AUTH_SCOPE_CACHE_LIMIT = 256; | |
| 866 | + | |
| 867 | + const resolveAuthToken = async (): Promise<string | null> => { | |
| 868 | + if (config.getAuthToken === undefined) { | |
| 869 | + return null; | |
| 870 | + } | |
| 871 | + | |
| 872 | + const token = await config.getAuthToken(); | |
| 873 | + if (token === null || token === undefined || token.length === 0) { | |
| 874 | + return null; | |
| 875 | + } | |
| 876 | + | |
| 877 | + const trimmed = token.trim(); | |
| 878 | + if (trimmed.length === 0) { | |
| 879 | + return null; | |
| 880 | + } | |
| 881 | + | |
| 882 | + if (trimmed.startsWith("Bearer ")) { | |
| 883 | + const bearerValue = trimmed.slice("Bearer ".length).trim(); | |
| 884 | + return bearerValue.length === 0 ? null : bearerValue; | |
| 885 | + } | |
| 886 | + | |
| 887 | + return trimmed; | |
| 888 | + }; | |
| 889 | + | |
| 890 | + const resetAuthScopeCaches = () => { | |
| 891 | + authScopeByToken.clear(); | |
| 892 | + authLayerByScope.clear(); | |
| 893 | + }; | |
| 894 | + | |
| 895 | + const getAuthScope = (token: string | null): string => { | |
| 896 | + if (token === null) { | |
| 897 | + return "anon"; | |
| 898 | + } | |
| 899 | + | |
| 900 | + const existingScope = authScopeByToken.get(token); | |
| 901 | + if (existingScope !== undefined) { | |
| 902 | + return existingScope; | |
| 903 | + } | |
| 904 | + | |
| 905 | + nextAuthScopeId += 1; | |
| 906 | + const scope = `auth-${nextAuthScopeId}`; | |
| 907 | + authScopeByToken.set(token, scope); | |
| 908 | + | |
| 909 | + if (authScopeByToken.size > AUTH_SCOPE_CACHE_LIMIT) { | |
| 910 | + resetAuthScopeCaches(); | |
| 911 | + authScopeByToken.set(token, scope); | |
| 912 | + } | |
| 913 | + | |
| 914 | + return scope; | |
| 915 | + }; | |
| 916 | + | |
| 917 | + const getLayerForScope = ( | |
| 918 | + authScope: string, | |
| 919 | + authToken: string | null, | |
| 920 | + ): Layer.Layer<ConvexClient> => { | |
| 921 | + if (config.layer !== undefined) { | |
| 922 | + return config.layer; | |
| 923 | + } | |
| 924 | + | |
| 925 | + const cachedLayer = authLayerByScope.get(authScope); | |
| 926 | + if (cachedLayer !== undefined) { | |
| 927 | + return cachedLayer; | |
| 928 | + } | |
| 929 | + | |
| 930 | + if (authToken === null) { | |
| 931 | + authLayerByScope.set(authScope, defaultLayer); | |
| 932 | + return defaultLayer; | |
| 933 | + } | |
| 934 | + | |
| 935 | + const nextLayer = ConvexHttpClientLayer(config.url, { authToken }); | |
| 936 | + authLayerByScope.set(authScope, nextLayer); | |
| 937 | + return nextLayer; | |
| 938 | + }; | |
| 939 | + | |
| 940 | + /** | |
| 941 | + * A single `Cache<string, unknown, unknown>` keyed by | |
| 942 | + * `json({ authScope, endpointName, payload })`. | |
| 943 | + * | |
| 944 | + * `Cache.make` returns an `Effect` — we run it once eagerly via | |
| 945 | + * `Effect.runSync` wrapped in a lazy singleton so it's created on first | |
| 946 | + * access. The lookup function is provided the layer so each cache miss | |
| 947 | + * fires exactly one HTTP request through the `ConvexHttpClientLayer`. | |
| 948 | + * | |
| 949 | + * Because Effect's `Cache` coalesces concurrent lookups for the same key, | |
| 950 | + * three concurrent calls to `queryPromise({ id: "x" })` result in a | |
| 951 | + * single Convex HTTP call. | |
| 952 | + */ | |
| 953 | + | |
| 954 | + // We store a Map of endpoint name → convex function ref so the lookup | |
| 955 | + // can resolve which function to call from just the cache key string. | |
| 956 | + const convexFnRegistry = new Map<string, FunctionReference<"query">>(); | |
| 957 | + | |
| 958 | + const cacheEffect = Cache.make({ | |
| 959 | + capacity: SERVER_QUERY_CACHE_CAPACITY, | |
| 960 | + timeToLive: SERVER_QUERY_CACHE_TTL, | |
| 961 | + lookup: (cacheKey: string) => { | |
| 962 | + const decodedKey = JSON.parse(cacheKey); | |
| 963 | + if (decodedKey === null || typeof decodedKey !== "object") { | |
| 964 | + return Effect.die(new Error("Invalid server RPC cache key")); | |
| 965 | + } | |
| 966 | + if (!("endpointName" in decodedKey) || !("payload" in decodedKey)) { | |
| 967 | + return Effect.die(new Error("Missing server RPC cache key fields")); | |
| 968 | + } | |
| 969 | + if (typeof decodedKey.endpointName !== "string") { | |
| 970 | + return Effect.die(new Error("Invalid server RPC endpoint cache key")); | |
| 971 | + } | |
| 972 | + if (!("authScope" in decodedKey) || typeof decodedKey.authScope !== "string") { | |
| 973 | + return Effect.die(new Error("Invalid server RPC auth scope cache key")); | |
| 974 | + } | |
| 975 | + | |
| 976 | + const endpointName = decodedKey.endpointName; | |
| 977 | + const authScope = decodedKey.authScope; | |
| 978 | + const fullPayload = decodedKey.payload; | |
| 979 | + const convexFn = convexFnRegistry.get(endpointName); | |
| 980 | + if (!convexFn) { | |
| 981 | + return Effect.die( | |
| 982 | + new Error(`No registered Convex function for endpoint "${endpointName}"`), | |
| 983 | + ); | |
| 984 | + } | |
| 985 | + | |
| 986 | + const requestLayer = authLayerByScope.get(authScope) ?? defaultLayer; | |
| 987 | + return Effect.provide( | |
| 988 | + createQueryEffect(endpointName, convexFn, fullPayload, false), | |
| 989 | + requestLayer, | |
| 990 | + ); | |
| 991 | + }, | |
| 992 | + }); | |
| 993 | + | |
| 994 | + const makeQueryCache = () => | |
| 995 | + Effect.runSync(Effect.provide(cacheEffect, defaultLayer)); | |
| 996 | + | |
| 997 | + let queryCache = Option.none<ReturnType<typeof makeQueryCache>>(); | |
| 998 | + | |
| 999 | + const getOrCreateQueryCache = () => { | |
| 1000 | + if (Option.isSome(queryCache)) { | |
| 1001 | + return queryCache.value; | |
| 1002 | + } | |
| 1003 | + | |
| 1004 | + const nextQueryCache = makeQueryCache(); | |
| 1005 | + queryCache = Option.some(nextQueryCache); | |
| 1006 | + return nextQueryCache; | |
| 1007 | + }; | |
| 1008 | + | |
| 1009 | + const endpointCache = new Map<string, ServerQueryEndpoint<unknown, unknown>>(); | |
| 1010 | + | |
| 1011 | + const getEndpoint = (prop: string): ServerQueryEndpoint<unknown, unknown> => { | |
| 1012 | + let endpoint = endpointCache.get(prop); | |
| 1013 | + if (!endpoint) { | |
| 1014 | + const convexFn = convexApi[prop]; | |
| 1015 | + if (!convexFn) { | |
| 1016 | + throw new Error(`No Convex function found for endpoint "${prop}"`); | |
| 1017 | + } | |
| 1018 | + convexFnRegistry.set(prop, convexFn as FunctionReference<"query">); | |
| 1019 | + | |
| 1020 | + endpoint = { | |
| 1021 | + queryPromise: async (payload: unknown) => { | |
| 1022 | + await ensureNextRequestDataAccess(); | |
| 1023 | + const authToken = await resolveAuthToken(); | |
| 1024 | + const authScope = getAuthScope(authToken); | |
| 1025 | + | |
| 1026 | + const payloadObject = | |
| 1027 | + payload !== null && typeof payload === "object" ? payload : {}; | |
| 1028 | + const fullPayload = { ...getShared(), ...payloadObject }; | |
| 1029 | + const cacheKey = JSON.stringify({ | |
| 1030 | + authScope, | |
| 1031 | + endpointName: prop, | |
| 1032 | + payload: fullPayload, | |
| 1033 | + }); | |
| 1034 | + getLayerForScope(authScope, authToken); | |
| 1035 | + | |
| 1036 | + return Effect.runPromise( | |
| 1037 | + Effect.gen(function* () { | |
| 1038 | + const queryCache = yield* Effect.sync(getOrCreateQueryCache); | |
| 1039 | + return yield* queryCache.get(cacheKey); | |
| 1040 | + }), | |
| 1041 | + ); | |
| 1042 | + }, | |
| 1043 | + }; | |
| 1044 | + endpointCache.set(prop, endpoint); | |
| 1045 | + } | |
| 1046 | + return endpoint; | |
| 1047 | + }; | |
| 1048 | + | |
| 1049 | + const proxy = new Proxy({} as Record<string, ServerQueryEndpoint<unknown, unknown>>, { | |
| 1050 | + get(_target, prop) { | |
| 1051 | + if (prop === "then") return undefined; | |
| 1052 | + if (typeof prop !== "string") return undefined; | |
| 1053 | + return getEndpoint(prop); | |
| 1054 | + }, | |
| 1055 | + }); | |
| 1056 | + | |
| 1057 | + return proxy as ServerRpcModuleClient<TModule, Shared>; | |
| 1058 | +} | |
| 1059 | + | |
| 1060 | +// --------------------------------------------------------------------------- | |
| 1061 | +// React hook: merge server-fetched data with real-time subscription | |
| 1062 | +// --------------------------------------------------------------------------- | |
| 1063 | + | |
| 1064 | +/** | |
| 1065 | + * Merge server-fetched initial data with a real-time Confect RPC subscription. | |
| 1066 | + * | |
| 1067 | + * Use this in client components that receive server data via `use(promise)` | |
| 1068 | + * (React Suspense) and also subscribe for live updates. The hook returns the | |
| 1069 | + * latest subscription value when available, falling back to `initialData`. | |
| 1070 | + * | |
| 1071 | + * No `useEffect`, no `useState` — pure derivation in the render path. | |
| 1072 | + * | |
| 1073 | + * The subscription atom from `client.X.subscription(...)` is typed as | |
| 1074 | + * `Atom<Result<unknown, unknown>>` because the RPC proxy loses generic info. | |
| 1075 | + * The type of `initialData` anchors the return type: both the server query | |
| 1076 | + * and the subscription call the same RPC endpoint, so the shapes match at | |
| 1077 | + * runtime. | |
| 1078 | + * | |
| 1079 | + * @example | |
| 1080 | + * ```tsx | |
| 1081 | + * const initialData = use(serverPromise); // suspends | |
| 1082 | + * const prAtom = useMemo(() => client.getPr.subscription({ ... }), [...]); | |
| 1083 | + * const pr = useSubscriptionWithInitial(prAtom, initialData); | |
| 1084 | + * ``` | |
| 1085 | + */ | |
| 1086 | +export function useSubscriptionWithInitial<T>( | |
| 1087 | + subscriptionAtom: Atom.Atom<Result.Result<unknown, unknown>>, | |
| 1088 | + initialData: T, | |
| 1089 | +): T { | |
| 1090 | + const result = useAtomValue(subscriptionAtom); | |
| 1091 | + | |
| 1092 | + if (ResultModule.isInitial(result)) { | |
| 1093 | + return initialData; | |
| 1094 | + } | |
| 1095 | + | |
| 1096 | + const valueOption = ResultModule.value(result); | |
| 1097 | + if (Option.isSome(valueOption)) { | |
| 1098 | + // Safe: the subscription and server query call the same RPC endpoint, | |
| 1099 | + // so the runtime value matches T. The proxy erases the generic, but | |
| 1100 | + // the hook restores type safety at its boundary. | |
| 1101 | + return valueOption.value as T; | |
| 1102 | + } | |
| 1103 | + | |
| 1104 | + return initialData; | |
| 1105 | +} | |
| @@ -0,0 +1,87 @@ | ||
| 1 | +import path from "node:path"; | |
| 2 | +import { fileURLToPath } from "node:url"; | |
| 3 | +import ts from "typescript"; | |
| 4 | +import { describe, expect, it } from "vitest"; | |
| 5 | + | |
| 6 | +const __dirname = path.dirname(fileURLToPath(import.meta.url)); | |
| 7 | +const packageRoot = path.resolve(__dirname, "../.."); | |
| 8 | + | |
| 9 | +const confectConfigPath = path.join(packageRoot, "tsconfig.json"); | |
| 10 | +const configFile = ts.readConfigFile(confectConfigPath, ts.sys.readFile); | |
| 11 | +const parsedConfig = ts.parseJsonConfigFileContent( | |
| 12 | + configFile.config, | |
| 13 | + ts.sys, | |
| 14 | + path.dirname(confectConfigPath), | |
| 15 | +); | |
| 16 | + | |
| 17 | +const testFile = path.join(packageRoot, "src/rpc/client.test.ts"); | |
| 18 | + | |
| 19 | +const program = ts.createProgram({ | |
| 20 | + rootNames: [testFile], | |
| 21 | + options: parsedConfig.options, | |
| 22 | +}); | |
| 23 | + | |
| 24 | +const checker = program.getTypeChecker(); | |
| 25 | +const sf = program.getSourceFile(testFile); | |
| 26 | + | |
| 27 | +function findNodeAtPosition( | |
| 28 | + node: ts.Node, | |
| 29 | + position: number, | |
| 30 | +): ts.Node | undefined { | |
| 31 | + if (position >= node.getStart() && position < node.getEnd()) { | |
| 32 | + for (const child of node.getChildren()) { | |
| 33 | + const found = findNodeAtPosition(child, position); | |
| 34 | + if (found) return found; | |
| 35 | + } | |
| 36 | + return node; | |
| 37 | + } | |
| 38 | + return undefined; | |
| 39 | +} | |
| 40 | + | |
| 41 | +function hasDeclarations(pattern: string, offset: number): boolean { | |
| 42 | + if (!sf) return false; | |
| 43 | + | |
| 44 | + const content = sf.getFullText(); | |
| 45 | + const idx = content.indexOf(pattern); | |
| 46 | + if (idx === -1) return false; | |
| 47 | + | |
| 48 | + const pos = idx + offset; | |
| 49 | + const node = findNodeAtPosition(sf, pos); | |
| 50 | + if (!node) return false; | |
| 51 | + | |
| 52 | + const sym = checker.getSymbolAtLocation(node); | |
| 53 | + if (!sym) return false; | |
| 54 | + | |
| 55 | + const decls = sym.getDeclarations(); | |
| 56 | + return decls !== undefined && decls.length > 0; | |
| 57 | +} | |
| 58 | + | |
| 59 | +describe("Go-to-Definition", () => { | |
| 60 | + it("should load the test file", () => { | |
| 61 | + expect(sf).toBeDefined(); | |
| 62 | + }); | |
| 63 | + | |
| 64 | + describe("module endpoints", () => { | |
| 65 | + it("guestbookModule.add has declarations", () => { | |
| 66 | + expect(hasDeclarations("guestbookModule.add", "guestbookModule.".length)).toBe(true); | |
| 67 | + }); | |
| 68 | + | |
| 69 | + it("guestbookModule.list has declarations", () => { | |
| 70 | + expect(hasDeclarations("guestbookModule.list", "guestbookModule.".length)).toBe(true); | |
| 71 | + }); | |
| 72 | + | |
| 73 | + it("guestbookModule.get has declarations", () => { | |
| 74 | + expect(hasDeclarations("guestbookModule.get", "guestbookModule.".length)).toBe(true); | |
| 75 | + }); | |
| 76 | + }); | |
| 77 | + | |
| 78 | + describe("client endpoints", () => { | |
| 79 | + it("client.add has declarations", () => { | |
| 80 | + expect(hasDeclarations("client.add", "client.".length)).toBe(true); | |
| 81 | + }); | |
| 82 | + | |
| 83 | + it("client.list has declarations", () => { | |
| 84 | + expect(hasDeclarations("client.list", "client.".length)).toBe(true); | |
| 85 | + }); | |
| 86 | + }); | |
| 87 | +}); | |
| @@ -0,0 +1,49 @@ | ||
| 1 | +export { | |
| 2 | + Rpc, | |
| 3 | + RpcGroup, | |
| 4 | + RpcMiddleware, | |
| 5 | + createRpcFactory, | |
| 6 | + makeRpcModule, | |
| 7 | + middleware, | |
| 8 | + exitSchema, | |
| 9 | + WrapperTypeId, | |
| 10 | + isWrapper, | |
| 11 | + wrap, | |
| 12 | + fork, | |
| 13 | + uninterruptible, | |
| 14 | + type RpcModule, | |
| 15 | + type AnyRpcModule, | |
| 16 | + type RpcEndpoint, | |
| 17 | + type UnbuiltRpcEndpoint, | |
| 18 | + type RpcFactoryConfig, | |
| 19 | + type RpcModuleOptions, | |
| 20 | + type InferRpc, | |
| 21 | + type InferFn, | |
| 22 | + type InferModuleEndpoints, | |
| 23 | + type ExitEncoded, | |
| 24 | + type RpcInfo, | |
| 25 | + type MiddlewareOptions, | |
| 26 | + type ConfectMiddlewareFn, | |
| 27 | + type MiddlewareImplementation, | |
| 28 | + type Wrapper, | |
| 29 | + type Handler, | |
| 30 | + type ToHandler, | |
| 31 | + type HandlersFrom, | |
| 32 | + type HandlersContext, | |
| 33 | +} from "./server"; | |
| 34 | + | |
| 35 | +export { | |
| 36 | + createRpcClient, | |
| 37 | + createServerRpcQuery, | |
| 38 | + useSubscriptionWithInitial, | |
| 39 | + RpcDefectError, | |
| 40 | + type RpcReadOptions, | |
| 41 | + type RpcModuleClient, | |
| 42 | + type RpcModuleClientConfig, | |
| 43 | + type RpcQueryClient, | |
| 44 | + type RpcMutationClient, | |
| 45 | + type RpcActionClient, | |
| 46 | + type RpcModuleClientMethods, | |
| 47 | + type ServerRpcModuleClient, | |
| 48 | + type ServerRpcClientConfig, | |
| 49 | +} from "./client"; | |
| @@ -0,0 +1,1239 @@ | ||
| 1 | +import { describe, expect, it, vi } from "vitest"; | |
| 2 | +import { Effect, Schema, Context, Layer } from "effect"; | |
| 3 | +import { Rpc, RpcMiddleware } from "@effect/rpc"; | |
| 4 | +import { | |
| 5 | + createRpcFactory, | |
| 6 | + makeRpcModule, | |
| 7 | + exitSchema, | |
| 8 | + fork, | |
| 9 | + uninterruptible, | |
| 10 | + isWrapper, | |
| 11 | + wrap, | |
| 12 | + type ExitEncoded, | |
| 13 | +} from "./server"; | |
| 14 | +import { defineSchema, defineTable } from "../schema"; | |
| 15 | + | |
| 16 | +const testSchema = defineSchema({ | |
| 17 | + items: defineTable( | |
| 18 | + Schema.Struct({ | |
| 19 | + name: Schema.String, | |
| 20 | + value: Schema.Number, | |
| 21 | + }), | |
| 22 | + ), | |
| 23 | +}); | |
| 24 | + | |
| 25 | +const createMockCtx = () => ({ | |
| 26 | + db: { | |
| 27 | + query: vi.fn(), | |
| 28 | + get: vi.fn(), | |
| 29 | + insert: vi.fn(), | |
| 30 | + patch: vi.fn(), | |
| 31 | + replace: vi.fn(), | |
| 32 | + delete: vi.fn(), | |
| 33 | + normalizeId: vi.fn(), | |
| 34 | + }, | |
| 35 | + auth: { | |
| 36 | + getUserIdentity: vi.fn().mockResolvedValue(null), | |
| 37 | + }, | |
| 38 | + runQuery: vi.fn(), | |
| 39 | + runMutation: vi.fn(), | |
| 40 | + runAction: vi.fn(), | |
| 41 | + scheduler: { | |
| 42 | + runAfter: vi.fn(), | |
| 43 | + runAt: vi.fn(), | |
| 44 | + }, | |
| 45 | +}); | |
| 46 | + | |
| 47 | +type ConvexHandlerInternal = (ctx: unknown, args: unknown) => Promise<ExitEncoded>; | |
| 48 | + | |
| 49 | +const getHandler = (handler: unknown): ConvexHandlerInternal => { | |
| 50 | + const h = handler as { _handler: (ctx: unknown, args: unknown) => Promise<ExitEncoded> }; | |
| 51 | + return h._handler; | |
| 52 | +}; | |
| 53 | + | |
| 54 | +type SuccessExit = { readonly _tag: "Success"; readonly value: unknown }; | |
| 55 | +type FailureExit = { readonly _tag: "Failure"; readonly cause: unknown }; | |
| 56 | + | |
| 57 | +const assertSuccess = (exit: ExitEncoded): SuccessExit => { | |
| 58 | + if (exit._tag !== "Success") { | |
| 59 | + throw new Error(`Expected Success exit, got ${exit._tag}`); | |
| 60 | + } | |
| 61 | + return exit as SuccessExit; | |
| 62 | +}; | |
| 63 | + | |
| 64 | +const assertFailure = (exit: ExitEncoded): FailureExit => { | |
| 65 | + if (exit._tag !== "Failure") { | |
| 66 | + throw new Error(`Expected Failure exit, got ${exit._tag}`); | |
| 67 | + } | |
| 68 | + return exit as FailureExit; | |
| 69 | +}; | |
| 70 | + | |
| 71 | +/** Helper: define + immediately implement an endpoint (convenience for tests) */ | |
| 72 | +const withImpl = <T extends { implement: (handler: (...args: ReadonlyArray<never>) => Effect.Effect<never, never, never>) => void }>( | |
| 73 | + endpoint: T, | |
| 74 | + handler: Parameters<T["implement"]>[0], | |
| 75 | +): T => { | |
| 76 | + endpoint.implement(handler); | |
| 77 | + return endpoint; | |
| 78 | +}; | |
| 79 | + | |
| 80 | +describe("RPC Server", () => { | |
| 81 | + describe("createRpcFactory", () => { | |
| 82 | + it("creates a factory with query, mutation, and action methods", () => { | |
| 83 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 84 | + | |
| 85 | + expect(factory.query).toBeDefined(); | |
| 86 | + expect(factory.mutation).toBeDefined(); | |
| 87 | + expect(factory.action).toBeDefined(); | |
| 88 | + expect(factory.internalQuery).toBeDefined(); | |
| 89 | + expect(factory.internalMutation).toBeDefined(); | |
| 90 | + expect(factory.internalAction).toBeDefined(); | |
| 91 | + }); | |
| 92 | + | |
| 93 | + describe("query endpoint", () => { | |
| 94 | + it("creates a query endpoint with success schema", () => { | |
| 95 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 96 | + | |
| 97 | + const endpoint = factory.query({ success: Schema.String }); | |
| 98 | + | |
| 99 | + expect(endpoint.__unbuilt).toBe(true); | |
| 100 | + expect(endpoint.kind).toBe("query"); | |
| 101 | + expect(endpoint.successSchema).toBe(Schema.String); | |
| 102 | + }); | |
| 103 | + | |
| 104 | + it("creates a query endpoint with payload", () => { | |
| 105 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 106 | + | |
| 107 | + const endpoint = factory.query({ | |
| 108 | + payload: { name: Schema.String }, | |
| 109 | + success: Schema.String, | |
| 110 | + }); | |
| 111 | + | |
| 112 | + expect(endpoint.__unbuilt).toBe(true); | |
| 113 | + expect(endpoint.payloadFields).toHaveProperty("name"); | |
| 114 | + }); | |
| 115 | + | |
| 116 | + it("creates a query endpoint with error schema", () => { | |
| 117 | + class CustomError extends Schema.TaggedError<CustomError>()( | |
| 118 | + "CustomError", | |
| 119 | + { message: Schema.String }, | |
| 120 | + ) {} | |
| 121 | + | |
| 122 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 123 | + | |
| 124 | + const endpoint = factory.query({ | |
| 125 | + success: Schema.String, | |
| 126 | + error: CustomError, | |
| 127 | + }); | |
| 128 | + | |
| 129 | + expect(endpoint.__unbuilt).toBe(true); | |
| 130 | + expect(endpoint.errorSchema).toBe(CustomError); | |
| 131 | + }); | |
| 132 | + }); | |
| 133 | + | |
| 134 | + describe("mutation endpoint", () => { | |
| 135 | + it("creates a mutation endpoint", () => { | |
| 136 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 137 | + | |
| 138 | + const endpoint = factory.mutation({ | |
| 139 | + payload: { value: Schema.Number }, | |
| 140 | + success: Schema.Boolean, | |
| 141 | + }); | |
| 142 | + | |
| 143 | + expect(endpoint.__unbuilt).toBe(true); | |
| 144 | + expect(endpoint.kind).toBe("mutation"); | |
| 145 | + }); | |
| 146 | + }); | |
| 147 | + | |
| 148 | + describe("action endpoint", () => { | |
| 149 | + it("creates an action endpoint", () => { | |
| 150 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 151 | + | |
| 152 | + const endpoint = factory.action({ success: Schema.Void }); | |
| 153 | + | |
| 154 | + expect(endpoint.__unbuilt).toBe(true); | |
| 155 | + expect(endpoint.kind).toBe("action"); | |
| 156 | + }); | |
| 157 | + }); | |
| 158 | + | |
| 159 | + describe("internal endpoints", () => { | |
| 160 | + it("creates internal query endpoint", () => { | |
| 161 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 162 | + | |
| 163 | + const endpoint = factory.internalQuery({ success: Schema.Number }); | |
| 164 | + | |
| 165 | + expect(endpoint.__unbuilt).toBe(true); | |
| 166 | + expect(endpoint.kind).toBe("internalQuery"); | |
| 167 | + }); | |
| 168 | + | |
| 169 | + it("creates internal mutation endpoint", () => { | |
| 170 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 171 | + | |
| 172 | + const endpoint = factory.internalMutation({ success: Schema.Void }); | |
| 173 | + | |
| 174 | + expect(endpoint.__unbuilt).toBe(true); | |
| 175 | + expect(endpoint.kind).toBe("internalMutation"); | |
| 176 | + }); | |
| 177 | + | |
| 178 | + it("creates internal action endpoint", () => { | |
| 179 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 180 | + | |
| 181 | + const endpoint = factory.internalAction({ success: Schema.String }); | |
| 182 | + | |
| 183 | + expect(endpoint.__unbuilt).toBe(true); | |
| 184 | + expect(endpoint.kind).toBe("internalAction"); | |
| 185 | + }); | |
| 186 | + }); | |
| 187 | + }); | |
| 188 | + | |
| 189 | + describe("makeRpcModule", () => { | |
| 190 | + it("builds endpoints with correct tags", () => { | |
| 191 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 192 | + | |
| 193 | + const getItems = factory.query({ success: Schema.Array(Schema.String) }); | |
| 194 | + getItems.implement(() => Effect.succeed([])); | |
| 195 | + | |
| 196 | + const addItem = factory.mutation({ | |
| 197 | + payload: { name: Schema.String }, | |
| 198 | + success: Schema.String, | |
| 199 | + }); | |
| 200 | + addItem.implement((args) => Effect.succeed(args.name)); | |
| 201 | + | |
| 202 | + const module = makeRpcModule({ getItems, addItem }); | |
| 203 | + | |
| 204 | + expect(module.getItems._tag).toBe("getItems"); | |
| 205 | + expect(module.addItem._tag).toBe("addItem"); | |
| 206 | + }); | |
| 207 | + | |
| 208 | + it("provides handlers for each endpoint", () => { | |
| 209 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 210 | + | |
| 211 | + const echo = factory.query({ | |
| 212 | + payload: { message: Schema.String }, | |
| 213 | + success: Schema.String, | |
| 214 | + }); | |
| 215 | + echo.implement((args) => Effect.succeed(args.message)); | |
| 216 | + | |
| 217 | + const module = makeRpcModule({ echo }); | |
| 218 | + | |
| 219 | + expect(module.handlers.echo).toBeDefined(); | |
| 220 | + }); | |
| 221 | + | |
| 222 | + it("provides rpcs for each endpoint", () => { | |
| 223 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 224 | + | |
| 225 | + const myQuery = factory.query({ success: Schema.Void }); | |
| 226 | + myQuery.implement(() => Effect.void); | |
| 227 | + | |
| 228 | + const module = makeRpcModule({ myQuery }); | |
| 229 | + | |
| 230 | + expect(module.rpcs.myQuery).toBeDefined(); | |
| 231 | + }); | |
| 232 | + | |
| 233 | + it("provides a group for all rpcs", () => { | |
| 234 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 235 | + | |
| 236 | + const first = factory.query({ success: Schema.String }); | |
| 237 | + first.implement(() => Effect.succeed("1")); | |
| 238 | + | |
| 239 | + const second = factory.query({ success: Schema.Number }); | |
| 240 | + second.implement(() => Effect.succeed(2)); | |
| 241 | + | |
| 242 | + const module = makeRpcModule({ first, second }); | |
| 243 | + | |
| 244 | + expect(module.group).toBeDefined(); | |
| 245 | + }); | |
| 246 | + }); | |
| 247 | + | |
| 248 | + describe("basePayload", () => { | |
| 249 | + it("merges base payload with endpoint payload", () => { | |
| 250 | + const factory = createRpcFactory({ | |
| 251 | + schema: testSchema, | |
| 252 | + basePayload: { | |
| 253 | + tenantId: Schema.String, | |
| 254 | + }, | |
| 255 | + }); | |
| 256 | + | |
| 257 | + const endpoint = factory.query({ | |
| 258 | + payload: { itemId: Schema.String }, | |
| 259 | + success: Schema.Void, | |
| 260 | + }); | |
| 261 | + endpoint.implement((args) => { | |
| 262 | + expect(args.tenantId).toBeDefined(); | |
| 263 | + expect(args.itemId).toBeDefined(); | |
| 264 | + return Effect.void; | |
| 265 | + }); | |
| 266 | + | |
| 267 | + expect(endpoint.payloadFields).toHaveProperty("tenantId"); | |
| 268 | + expect(endpoint.payloadFields).toHaveProperty("itemId"); | |
| 269 | + }); | |
| 270 | + }); | |
| 271 | + | |
| 272 | + describe("ExitEncoded format", () => { | |
| 273 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 274 | + | |
| 275 | + it("encodes success correctly", async () => { | |
| 276 | + const test = factory.query({ success: Schema.String }); | |
| 277 | + test.implement(() => Effect.succeed("hello")); | |
| 278 | + | |
| 279 | + const module = makeRpcModule({ test }); | |
| 280 | + | |
| 281 | + const ctx = createMockCtx(); | |
| 282 | + const handler = getHandler(module.handlers.test); | |
| 283 | + const result = await handler(ctx, {}); | |
| 284 | + | |
| 285 | + const success = assertSuccess(result); | |
| 286 | + expect(success.value).toBe("hello"); | |
| 287 | + }); | |
| 288 | + | |
| 289 | + it("encodes failure correctly", async () => { | |
| 290 | + class TestError extends Schema.TaggedError<TestError>()("TestError", { | |
| 291 | + code: Schema.Number, | |
| 292 | + }) {} | |
| 293 | + | |
| 294 | + const test = factory.query({ success: Schema.String, error: TestError }); | |
| 295 | + test.implement(() => Effect.fail(new TestError({ code: 500 }))); | |
| 296 | + | |
| 297 | + const module = makeRpcModule({ test }); | |
| 298 | + | |
| 299 | + const ctx = createMockCtx(); | |
| 300 | + const handler = getHandler(module.handlers.test); | |
| 301 | + const result = await handler(ctx, {}); | |
| 302 | + | |
| 303 | + const failure = assertFailure(result); | |
| 304 | + expect(failure.cause).toEqual({ | |
| 305 | + _tag: "Fail", | |
| 306 | + error: { _tag: "TestError", code: 500 }, | |
| 307 | + }); | |
| 308 | + }); | |
| 309 | + | |
| 310 | + it("encodes defects (thrown errors) correctly", async () => { | |
| 311 | + const test = factory.query({ success: Schema.Void }); | |
| 312 | + test.implement(() => Effect.die(new Error("unexpected error"))); | |
| 313 | + | |
| 314 | + const module = makeRpcModule({ test }); | |
| 315 | + | |
| 316 | + const ctx = createMockCtx(); | |
| 317 | + const handler = getHandler(module.handlers.test); | |
| 318 | + const result = await handler(ctx, {}); | |
| 319 | + | |
| 320 | + const failure = assertFailure(result); | |
| 321 | + expect(failure.cause).toMatchObject({ | |
| 322 | + _tag: "Die", | |
| 323 | + defect: { | |
| 324 | + name: "Error", | |
| 325 | + message: "unexpected error", | |
| 326 | + }, | |
| 327 | + }); | |
| 328 | + }); | |
| 329 | + | |
| 330 | + it("handles payload decode errors as defects", async () => { | |
| 331 | + const test = factory.query({ | |
| 332 | + payload: { num: Schema.Number }, | |
| 333 | + success: Schema.Void, | |
| 334 | + }); | |
| 335 | + test.implement(() => Effect.void); | |
| 336 | + | |
| 337 | + const module = makeRpcModule({ test }); | |
| 338 | + | |
| 339 | + const ctx = createMockCtx(); | |
| 340 | + const handler = getHandler(module.handlers.test); | |
| 341 | + const result = await handler(ctx, { num: "not a number" }); | |
| 342 | + | |
| 343 | + const failure = assertFailure(result); | |
| 344 | + expect(failure.cause).toHaveProperty("_tag", "Die"); | |
| 345 | + }); | |
| 346 | + }); | |
| 347 | + | |
| 348 | + describe("complex success/error schemas", () => { | |
| 349 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 350 | + | |
| 351 | + it("handles array success schema", async () => { | |
| 352 | + const ItemSchema = Schema.Struct({ | |
| 353 | + id: Schema.String, | |
| 354 | + name: Schema.String, | |
| 355 | + }); | |
| 356 | + | |
| 357 | + const getItems = factory.query({ success: Schema.Array(ItemSchema) }); | |
| 358 | + getItems.implement(() => | |
| 359 | + Effect.succeed([ | |
| 360 | + { id: "1", name: "Item 1" }, | |
| 361 | + { id: "2", name: "Item 2" }, | |
| 362 | + ]), | |
| 363 | + ); | |
| 364 | + | |
| 365 | + const module = makeRpcModule({ getItems }); | |
| 366 | + | |
| 367 | + const ctx = createMockCtx(); | |
| 368 | + const handler = getHandler(module.handlers.getItems); | |
| 369 | + const result = await handler(ctx, {}); | |
| 370 | + | |
| 371 | + const success = assertSuccess(result); | |
| 372 | + expect(success.value).toEqual([ | |
| 373 | + { id: "1", name: "Item 1" }, | |
| 374 | + { id: "2", name: "Item 2" }, | |
| 375 | + ]); | |
| 376 | + }); | |
| 377 | + | |
| 378 | + it("handles union error schema", async () => { | |
| 379 | + class NotFoundError extends Schema.TaggedError<NotFoundError>()( | |
| 380 | + "NotFoundError", | |
| 381 | + { id: Schema.String }, | |
| 382 | + ) {} | |
| 383 | + | |
| 384 | + class ForbiddenError extends Schema.TaggedError<ForbiddenError>()( | |
| 385 | + "ForbiddenError", | |
| 386 | + {}, | |
| 387 | + ) {} | |
| 388 | + | |
| 389 | + const ErrorSchema = Schema.Union(NotFoundError, ForbiddenError); | |
| 390 | + | |
| 391 | + const getItem = factory.query({ | |
| 392 | + payload: { id: Schema.String }, | |
| 393 | + success: Schema.Void, | |
| 394 | + error: ErrorSchema, | |
| 395 | + }); | |
| 396 | + getItem.implement((args) => | |
| 397 | + Effect.fail(new NotFoundError({ id: args.id })), | |
| 398 | + ); | |
| 399 | + | |
| 400 | + const module = makeRpcModule({ getItem }); | |
| 401 | + | |
| 402 | + const ctx = createMockCtx(); | |
| 403 | + const handler = getHandler(module.handlers.getItem); | |
| 404 | + const result = await handler(ctx, { id: "123" }); | |
| 405 | + | |
| 406 | + const failure = assertFailure(result); | |
| 407 | + expect(failure.cause).toEqual({ | |
| 408 | + _tag: "Fail", | |
| 409 | + error: { _tag: "NotFoundError", id: "123" }, | |
| 410 | + }); | |
| 411 | + }); | |
| 412 | + | |
| 413 | + it("handles optional fields in payload", async () => { | |
| 414 | + const search = factory.query({ | |
| 415 | + payload: { | |
| 416 | + query: Schema.String, | |
| 417 | + limit: Schema.optional(Schema.Number), | |
| 418 | + }, | |
| 419 | + success: Schema.Array(Schema.String), | |
| 420 | + }); | |
| 421 | + search.implement((args) => { | |
| 422 | + const limit = args.limit ?? 10; | |
| 423 | + return Effect.succeed( | |
| 424 | + Array(limit) | |
| 425 | + .fill(null) | |
| 426 | + .map((_, i) => `${args.query}-${i}`), | |
| 427 | + ); | |
| 428 | + }); | |
| 429 | + | |
| 430 | + const module = makeRpcModule({ search }); | |
| 431 | + | |
| 432 | + const ctx = createMockCtx(); | |
| 433 | + const handler = getHandler(module.handlers.search); | |
| 434 | + | |
| 435 | + const resultWithLimit = await handler(ctx, { | |
| 436 | + query: "test", | |
| 437 | + limit: 3, | |
| 438 | + }); | |
| 439 | + const successWithLimit = assertSuccess(resultWithLimit); | |
| 440 | + expect((successWithLimit.value as Array<string>).length).toBe(3); | |
| 441 | + | |
| 442 | + const resultWithoutLimit = await handler(ctx, { | |
| 443 | + query: "test", | |
| 444 | + }); | |
| 445 | + const successWithoutLimit = assertSuccess(resultWithoutLimit); | |
| 446 | + expect((successWithoutLimit.value as Array<string>).length).toBe(10); | |
| 447 | + }); | |
| 448 | + }); | |
| 449 | + | |
| 450 | + describe("middleware", () => { | |
| 451 | + class CurrentUser extends Context.Tag("CurrentUser")< | |
| 452 | + CurrentUser, | |
| 453 | + { id: string; name: string } | |
| 454 | + >() {} | |
| 455 | + | |
| 456 | + class AuthMiddleware extends RpcMiddleware.Tag<AuthMiddleware>()( | |
| 457 | + "AuthMiddleware", | |
| 458 | + { | |
| 459 | + provides: CurrentUser, | |
| 460 | + }, | |
| 461 | + ) {} | |
| 462 | + | |
| 463 | + it("provides service from middleware via Layer", async () => { | |
| 464 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 465 | + | |
| 466 | + const CurrentUserLive = Layer.succeed( | |
| 467 | + CurrentUser, | |
| 468 | + { id: "user-123", name: "Test User" }, | |
| 469 | + ); | |
| 470 | + | |
| 471 | + const whoami = factory.query({ success: Schema.String }); | |
| 472 | + whoami.implement(() => | |
| 473 | + Effect.gen(function* () { | |
| 474 | + const user = yield* CurrentUser; | |
| 475 | + return `Hello, ${user.name}!`; | |
| 476 | + }), | |
| 477 | + ); | |
| 478 | + | |
| 479 | + const module = makeRpcModule( | |
| 480 | + { | |
| 481 | + whoami: whoami.middleware(AuthMiddleware), | |
| 482 | + }, | |
| 483 | + { middlewares: CurrentUserLive }, | |
| 484 | + ); | |
| 485 | + | |
| 486 | + const ctx = createMockCtx(); | |
| 487 | + const handler = getHandler(module.handlers.whoami); | |
| 488 | + const result = await handler(ctx, {}); | |
| 489 | + | |
| 490 | + const success = assertSuccess(result); | |
| 491 | + expect(success.value).toBe("Hello, Test User!"); | |
| 492 | + }); | |
| 493 | + | |
| 494 | + it("handler can access payload and middleware services", async () => { | |
| 495 | + let receivedItemId: string | null = null; | |
| 496 | + | |
| 497 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 498 | + | |
| 499 | + const CurrentUserLive = Layer.succeed( | |
| 500 | + CurrentUser, | |
| 501 | + { id: "user-123", name: "Test User" }, | |
| 502 | + ); | |
| 503 | + | |
| 504 | + const test = factory.query({ | |
| 505 | + payload: { itemId: Schema.String }, | |
| 506 | + success: Schema.String, | |
| 507 | + }); | |
| 508 | + test.implement((args) => | |
| 509 | + Effect.gen(function* () { | |
| 510 | + receivedItemId = args.itemId; | |
| 511 | + const user = yield* CurrentUser; | |
| 512 | + return `${user.name} accessed ${args.itemId}`; | |
| 513 | + }), | |
| 514 | + ); | |
| 515 | + | |
| 516 | + const module = makeRpcModule( | |
| 517 | + { | |
| 518 | + test: test.middleware(AuthMiddleware), | |
| 519 | + }, | |
| 520 | + { middlewares: CurrentUserLive }, | |
| 521 | + ); | |
| 522 | + | |
| 523 | + const ctx = createMockCtx(); | |
| 524 | + const handler = getHandler(module.handlers.test); | |
| 525 | + const result = await handler(ctx, { itemId: "item-456" }); | |
| 526 | + | |
| 527 | + const success = assertSuccess(result); | |
| 528 | + expect(receivedItemId).toEqual("item-456"); | |
| 529 | + expect(success.value).toBe("Test User accessed item-456"); | |
| 530 | + }); | |
| 531 | + | |
| 532 | + it("layer failure is encoded correctly", async () => { | |
| 533 | + class AuthError extends Schema.TaggedError<AuthError>()("AuthError", { | |
| 534 | + reason: Schema.String, | |
| 535 | + }) {} | |
| 536 | + | |
| 537 | + class FailingAuthMiddleware extends RpcMiddleware.Tag<FailingAuthMiddleware>()( | |
| 538 | + "FailingAuthMiddleware", | |
| 539 | + { | |
| 540 | + provides: CurrentUser, | |
| 541 | + failure: AuthError, | |
| 542 | + }, | |
| 543 | + ) {} | |
| 544 | + | |
| 545 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 546 | + | |
| 547 | + const FailingAuthLive = Layer.fail(new AuthError({ reason: "Invalid token" })); | |
| 548 | + | |
| 549 | + const protectedEndpoint = factory.query({ | |
| 550 | + success: Schema.String, | |
| 551 | + error: AuthError, | |
| 552 | + }); | |
| 553 | + protectedEndpoint.implement(() => | |
| 554 | + Effect.gen(function* () { | |
| 555 | + yield* CurrentUser; | |
| 556 | + return "secret"; | |
| 557 | + }), | |
| 558 | + ); | |
| 559 | + | |
| 560 | + const module = makeRpcModule( | |
| 561 | + { | |
| 562 | + protected: protectedEndpoint.middleware(FailingAuthMiddleware), | |
| 563 | + }, | |
| 564 | + { middlewares: FailingAuthLive }, | |
| 565 | + ); | |
| 566 | + | |
| 567 | + const ctx = createMockCtx(); | |
| 568 | + const handler = getHandler(module.handlers.protected); | |
| 569 | + const result = await handler(ctx, {}); | |
| 570 | + | |
| 571 | + const failure = assertFailure(result); | |
| 572 | + expect(failure.cause).toMatchObject({ | |
| 573 | + _tag: "Fail", | |
| 574 | + error: { _tag: "AuthError", reason: "Invalid token" }, | |
| 575 | + }); | |
| 576 | + }); | |
| 577 | + | |
| 578 | + it("multiple services are provided via merged Layers", async () => { | |
| 579 | + const executionOrder: Array<string> = []; | |
| 580 | + | |
| 581 | + class Logger extends Context.Tag("Logger")<Logger, { log: (msg: string) => void }>() {} | |
| 582 | + | |
| 583 | + class LoggerMiddleware extends RpcMiddleware.Tag<LoggerMiddleware>()( | |
| 584 | + "LoggerMiddleware", | |
| 585 | + { | |
| 586 | + provides: Logger, | |
| 587 | + }, | |
| 588 | + ) {} | |
| 589 | + | |
| 590 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 591 | + | |
| 592 | + const CurrentUserLive = Layer.succeed( | |
| 593 | + CurrentUser, | |
| 594 | + { id: "user-123", name: "Test User" }, | |
| 595 | + ); | |
| 596 | + | |
| 597 | + const LoggerLive = Layer.succeed( | |
| 598 | + Logger, | |
| 599 | + { log: (msg: string) => executionOrder.push(`log:${msg}`) }, | |
| 600 | + ); | |
| 601 | + | |
| 602 | + const ServicesLive = Layer.mergeAll(CurrentUserLive, LoggerLive); | |
| 603 | + | |
| 604 | + const test = factory.query({ success: Schema.String }); | |
| 605 | + test.implement(() => | |
| 606 | + Effect.gen(function* () { | |
| 607 | + const user = yield* CurrentUser; | |
| 608 | + const logger = yield* Logger; | |
| 609 | + logger.log("accessed"); | |
| 610 | + return user.name; | |
| 611 | + }), | |
| 612 | + ); | |
| 613 | + | |
| 614 | + const module = makeRpcModule( | |
| 615 | + { | |
| 616 | + test: test | |
| 617 | + .middleware(AuthMiddleware) | |
| 618 | + .middleware(LoggerMiddleware), | |
| 619 | + }, | |
| 620 | + { middlewares: ServicesLive }, | |
| 621 | + ); | |
| 622 | + | |
| 623 | + const ctx = createMockCtx(); | |
| 624 | + const handler = getHandler(module.handlers.test); | |
| 625 | + const result = await handler(ctx, {}); | |
| 626 | + | |
| 627 | + const success = assertSuccess(result); | |
| 628 | + expect(success.value).toBe("Test User"); | |
| 629 | + expect(executionOrder).toContain("log:accessed"); | |
| 630 | + }); | |
| 631 | + | |
| 632 | + it("works without any middleware", async () => { | |
| 633 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 634 | + | |
| 635 | + const simple = factory.query({ success: Schema.Number }); | |
| 636 | + simple.implement(() => Effect.succeed(42)); | |
| 637 | + | |
| 638 | + const module = makeRpcModule({ simple }); | |
| 639 | + | |
| 640 | + const ctx = createMockCtx(); | |
| 641 | + const handler = getHandler(module.handlers.simple); | |
| 642 | + const result = await handler(ctx, {}); | |
| 643 | + | |
| 644 | + const success = assertSuccess(result); | |
| 645 | + expect(success.value).toBe(42); | |
| 646 | + }); | |
| 647 | + }); | |
| 648 | + | |
| 649 | + describe("security and error encoding", () => { | |
| 650 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 651 | + | |
| 652 | + it("does not expose error stack traces in defects", async () => { | |
| 653 | + const test = factory.query({ success: Schema.Void }); | |
| 654 | + test.implement(() => { | |
| 655 | + const error = new Error("Internal error with sensitive info"); | |
| 656 | + error.stack = "Error: Internal error\n at secret/path/to/file.ts:42:13"; | |
| 657 | + return Effect.die(error); | |
| 658 | + }); | |
| 659 | + | |
| 660 | + const module = makeRpcModule({ test }); | |
| 661 | + | |
| 662 | + const ctx = createMockCtx(); | |
| 663 | + const handler = getHandler(module.handlers.test); | |
| 664 | + const result = await handler(ctx, {}); | |
| 665 | + | |
| 666 | + const failure = assertFailure(result); | |
| 667 | + expect(failure.cause).toMatchObject({ | |
| 668 | + _tag: "Die", | |
| 669 | + defect: { | |
| 670 | + name: "Error", | |
| 671 | + message: "Internal error with sensitive info", | |
| 672 | + }, | |
| 673 | + }); | |
| 674 | + const causeStr = JSON.stringify(failure.cause); | |
| 675 | + expect(causeStr).not.toContain("stack"); | |
| 676 | + expect(causeStr).not.toContain("secret/path"); | |
| 677 | + }); | |
| 678 | + | |
| 679 | + it("serializes non-Error defects safely", async () => { | |
| 680 | + const test = factory.query({ success: Schema.Void }); | |
| 681 | + test.implement(() => Effect.die("raw string error")); | |
| 682 | + | |
| 683 | + const module = makeRpcModule({ test }); | |
| 684 | + | |
| 685 | + const ctx = createMockCtx(); | |
| 686 | + const handler = getHandler(module.handlers.test); | |
| 687 | + const result = await handler(ctx, {}); | |
| 688 | + | |
| 689 | + const failure = assertFailure(result); | |
| 690 | + expect(failure.cause).toMatchObject({ | |
| 691 | + _tag: "Die", | |
| 692 | + defect: "raw string error", | |
| 693 | + }); | |
| 694 | + }); | |
| 695 | + | |
| 696 | + it("serializes object defects as JSON strings", async () => { | |
| 697 | + const test = factory.query({ success: Schema.Void }); | |
| 698 | + test.implement(() => | |
| 699 | + Effect.die({ _tag: "CustomDefect", code: 500, details: "some details" }), | |
| 700 | + ); | |
| 701 | + | |
| 702 | + const module = makeRpcModule({ test }); | |
| 703 | + | |
| 704 | + const ctx = createMockCtx(); | |
| 705 | + const handler = getHandler(module.handlers.test); | |
| 706 | + const result = await handler(ctx, {}); | |
| 707 | + | |
| 708 | + const failure = assertFailure(result); | |
| 709 | + expect(failure.cause).toMatchObject({ | |
| 710 | + _tag: "Die", | |
| 711 | + }); | |
| 712 | + expect(typeof (failure.cause as { defect: unknown }).defect).toBe("string"); | |
| 713 | + expect((failure.cause as { defect: string }).defect).toContain("CustomDefect"); | |
| 714 | + }); | |
| 715 | + | |
| 716 | + it("encodes interrupt cause correctly", async () => { | |
| 717 | + const test = factory.query({ success: Schema.Void }); | |
| 718 | + test.implement(() => Effect.interrupt); | |
| 719 | + | |
| 720 | + const module = makeRpcModule({ test }); | |
| 721 | + | |
| 722 | + const ctx = createMockCtx(); | |
| 723 | + const handler = getHandler(module.handlers.test); | |
| 724 | + const result = await handler(ctx, {}); | |
| 725 | + | |
| 726 | + const failure = assertFailure(result); | |
| 727 | + expect(failure.cause).toHaveProperty("_tag", "Interrupt"); | |
| 728 | + }); | |
| 729 | + | |
| 730 | + it("encodes tagged errors with their schema", async () => { | |
| 731 | + class ValidationError extends Schema.TaggedError<ValidationError>()( | |
| 732 | + "ValidationError", | |
| 733 | + { | |
| 734 | + field: Schema.String, | |
| 735 | + message: Schema.String, | |
| 736 | + }, | |
| 737 | + ) {} | |
| 738 | + | |
| 739 | + const test = factory.query({ | |
| 740 | + success: Schema.Void, | |
| 741 | + error: ValidationError, | |
| 742 | + }); | |
| 743 | + test.implement(() => | |
| 744 | + Effect.fail(new ValidationError({ field: "email", message: "Invalid format" })), | |
| 745 | + ); | |
| 746 | + | |
| 747 | + const module = makeRpcModule({ test }); | |
| 748 | + | |
| 749 | + const ctx = createMockCtx(); | |
| 750 | + const handler = getHandler(module.handlers.test); | |
| 751 | + const result = await handler(ctx, {}); | |
| 752 | + | |
| 753 | + const failure = assertFailure(result); | |
| 754 | + expect(failure.cause).toMatchObject({ | |
| 755 | + _tag: "Fail", | |
| 756 | + error: { | |
| 757 | + _tag: "ValidationError", | |
| 758 | + field: "email", | |
| 759 | + message: "Invalid format", | |
| 760 | + }, | |
| 761 | + }); | |
| 762 | + }); | |
| 763 | + | |
| 764 | + it("handles thrown sync exceptions as defects", async () => { | |
| 765 | + const test = factory.query({ success: Schema.Void }); | |
| 766 | + test.implement(() => | |
| 767 | + Effect.sync(() => { | |
| 768 | + throw new Error("Sync throw"); | |
| 769 | + }), | |
| 770 | + ); | |
| 771 | + | |
| 772 | + const module = makeRpcModule({ test }); | |
| 773 | + | |
| 774 | + const ctx = createMockCtx(); | |
| 775 | + const handler = getHandler(module.handlers.test); | |
| 776 | + const result = await handler(ctx, {}); | |
| 777 | + | |
| 778 | + const failure = assertFailure(result); | |
| 779 | + expect(failure.cause).toMatchObject({ | |
| 780 | + _tag: "Die", | |
| 781 | + defect: { | |
| 782 | + name: "Error", | |
| 783 | + message: "Sync throw", | |
| 784 | + }, | |
| 785 | + }); | |
| 786 | + }); | |
| 787 | + | |
| 788 | + it("handles promise rejections as defects", async () => { | |
| 789 | + const test = factory.query({ success: Schema.Void }); | |
| 790 | + test.implement(() => | |
| 791 | + Effect.promise(() => Promise.reject(new Error("Promise rejection"))), | |
| 792 | + ); | |
| 793 | + | |
| 794 | + const module = makeRpcModule({ test }); | |
| 795 | + | |
| 796 | + const ctx = createMockCtx(); | |
| 797 | + const handler = getHandler(module.handlers.test); | |
| 798 | + const result = await handler(ctx, {}); | |
| 799 | + | |
| 800 | + const failure = assertFailure(result); | |
| 801 | + expect(failure.cause).toMatchObject({ | |
| 802 | + _tag: "Die", | |
| 803 | + defect: { | |
| 804 | + name: "Error", | |
| 805 | + message: "Promise rejection", | |
| 806 | + }, | |
| 807 | + }); | |
| 808 | + }); | |
| 809 | + | |
| 810 | + it("encodes nested error structures properly", async () => { | |
| 811 | + class OuterError extends Schema.TaggedError<OuterError>()("OuterError", { | |
| 812 | + inner: Schema.Struct({ | |
| 813 | + code: Schema.Number, | |
| 814 | + details: Schema.Array(Schema.String), | |
| 815 | + }), | |
| 816 | + }) {} | |
| 817 | + | |
| 818 | + const test = factory.query({ | |
| 819 | + success: Schema.Void, | |
| 820 | + error: OuterError, | |
| 821 | + }); | |
| 822 | + test.implement(() => | |
| 823 | + Effect.fail( | |
| 824 | + new OuterError({ | |
| 825 | + inner: { | |
| 826 | + code: 422, | |
| 827 | + details: ["Field 1 invalid", "Field 2 missing"], | |
| 828 | + }, | |
| 829 | + }), | |
| 830 | + ), | |
| 831 | + ); | |
| 832 | + | |
| 833 | + const module = makeRpcModule({ test }); | |
| 834 | + | |
| 835 | + const ctx = createMockCtx(); | |
| 836 | + const handler = getHandler(module.handlers.test); | |
| 837 | + const result = await handler(ctx, {}); | |
| 838 | + | |
| 839 | + const failure = assertFailure(result); | |
| 840 | + expect(failure.cause).toMatchObject({ | |
| 841 | + _tag: "Fail", | |
| 842 | + error: { | |
| 843 | + _tag: "OuterError", | |
| 844 | + inner: { | |
| 845 | + code: 422, | |
| 846 | + details: ["Field 1 invalid", "Field 2 missing"], | |
| 847 | + }, | |
| 848 | + }, | |
| 849 | + }); | |
| 850 | + }); | |
| 851 | + }); | |
| 852 | + | |
| 853 | + describe("@effect/rpc API compatibility", () => { | |
| 854 | + describe("exitSchema", () => { | |
| 855 | + it("returns exit schema for an RPC", () => { | |
| 856 | + const rpc = Rpc.make("TestRpc", { | |
| 857 | + payload: { id: Schema.String }, | |
| 858 | + success: Schema.Number, | |
| 859 | + error: Schema.String, | |
| 860 | + }); | |
| 861 | + | |
| 862 | + const schema = exitSchema(rpc); | |
| 863 | + expect(schema).toBeDefined(); | |
| 864 | + }); | |
| 865 | + }); | |
| 866 | + | |
| 867 | + describe("wrapper utilities", () => { | |
| 868 | + it("fork wraps an Effect", () => { | |
| 869 | + const effect = Effect.succeed(42); | |
| 870 | + const wrapped = fork(effect); | |
| 871 | + | |
| 872 | + expect(isWrapper(wrapped)).toBe(true); | |
| 873 | + expect(wrapped.fork).toBe(true); | |
| 874 | + expect(wrapped.value).toBe(effect); | |
| 875 | + }); | |
| 876 | + | |
| 877 | + it("uninterruptible wraps an Effect", () => { | |
| 878 | + const effect = Effect.succeed(42); | |
| 879 | + const wrapped = uninterruptible(effect); | |
| 880 | + | |
| 881 | + expect(isWrapper(wrapped)).toBe(true); | |
| 882 | + expect(wrapped.uninterruptible).toBe(true); | |
| 883 | + expect(wrapped.value).toBe(effect); | |
| 884 | + }); | |
| 885 | + | |
| 886 | + it("wrap with custom options", () => { | |
| 887 | + const effect = Effect.succeed(42); | |
| 888 | + const wrapped = wrap({ fork: true, uninterruptible: true })(effect); | |
| 889 | + | |
| 890 | + expect(isWrapper(wrapped)).toBe(true); | |
| 891 | + expect(wrapped.fork).toBe(true); | |
| 892 | + expect(wrapped.uninterruptible).toBe(true); | |
| 893 | + }); | |
| 894 | + | |
| 895 | + it("isWrapper returns false for non-wrappers", () => { | |
| 896 | + expect(isWrapper({})).toBe(false); | |
| 897 | + expect(isWrapper({ fork: true })).toBe(false); | |
| 898 | + }); | |
| 899 | + }); | |
| 900 | + | |
| 901 | + }); | |
| 902 | + | |
| 903 | + describe("middleware chaining on individual RPCs", () => { | |
| 904 | + it("allows chaining .middleware() on individual endpoints", () => { | |
| 905 | + class AuthMiddleware extends RpcMiddleware.Tag<AuthMiddleware>()( | |
| 906 | + "AuthMiddleware", | |
| 907 | + {}, | |
| 908 | + ) {} | |
| 909 | + | |
| 910 | + class LoggingMiddleware extends RpcMiddleware.Tag<LoggingMiddleware>()( | |
| 911 | + "LoggingMiddleware", | |
| 912 | + {}, | |
| 913 | + ) {} | |
| 914 | + | |
| 915 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 916 | + | |
| 917 | + const endpoint = factory | |
| 918 | + .query({ success: Schema.String }) | |
| 919 | + .middleware(AuthMiddleware) | |
| 920 | + .middleware(LoggingMiddleware); | |
| 921 | + | |
| 922 | + expect(endpoint.__unbuilt).toBe(true); | |
| 923 | + expect(endpoint.middlewares).toHaveLength(2); | |
| 924 | + expect(endpoint.middlewares[0]).toBe(AuthMiddleware); | |
| 925 | + expect(endpoint.middlewares[1]).toBe(LoggingMiddleware); | |
| 926 | + }); | |
| 927 | + }); | |
| 928 | + | |
| 929 | + describe("per-request middleware execution", () => { | |
| 930 | + class CurrentUser extends Context.Tag("CurrentUser")< | |
| 931 | + CurrentUser, | |
| 932 | + { id: string; name: string } | |
| 933 | + >() {} | |
| 934 | + | |
| 935 | + class AuthMiddleware extends RpcMiddleware.Tag<AuthMiddleware>()( | |
| 936 | + "AuthMiddleware", | |
| 937 | + { | |
| 938 | + provides: CurrentUser, | |
| 939 | + }, | |
| 940 | + ) {} | |
| 941 | + | |
| 942 | + it("executes middleware function per request", async () => { | |
| 943 | + const { middleware } = await import("./server"); | |
| 944 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 945 | + let callCount = 0; | |
| 946 | + | |
| 947 | + const authImpl = middleware(AuthMiddleware, (options) => { | |
| 948 | + callCount++; | |
| 949 | + return Effect.succeed({ id: `user-${callCount}`, name: `User ${callCount}` }); | |
| 950 | + }); | |
| 951 | + | |
| 952 | + const whoami = factory.query({ success: Schema.String }); | |
| 953 | + whoami.implement(() => | |
| 954 | + Effect.gen(function* () { | |
| 955 | + const user = yield* CurrentUser; | |
| 956 | + return user.name; | |
| 957 | + }), | |
| 958 | + ); | |
| 959 | + | |
| 960 | + const module = makeRpcModule( | |
| 961 | + { | |
| 962 | + whoami: whoami.middleware(AuthMiddleware), | |
| 963 | + }, | |
| 964 | + { middlewares: [authImpl] }, | |
| 965 | + ); | |
| 966 | + | |
| 967 | + const ctx = createMockCtx(); | |
| 968 | + const handler = getHandler(module.handlers.whoami); | |
| 969 | + | |
| 970 | + const result1 = await handler(ctx, {}); | |
| 971 | + const success1 = assertSuccess(result1); | |
| 972 | + expect(success1.value).toBe("User 1"); | |
| 973 | + | |
| 974 | + const result2 = await handler(ctx, {}); | |
| 975 | + const success2 = assertSuccess(result2); | |
| 976 | + expect(success2.value).toBe("User 2"); | |
| 977 | + | |
| 978 | + expect(callCount).toBe(2); | |
| 979 | + }); | |
| 980 | + | |
| 981 | + it("middleware receives payload", async () => { | |
| 982 | + const { middleware } = await import("./server"); | |
| 983 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 984 | + let receivedPayload: unknown = null; | |
| 985 | + | |
| 986 | + const authImpl = middleware(AuthMiddleware, (options) => { | |
| 987 | + receivedPayload = options.payload; | |
| 988 | + return Effect.succeed({ id: "user-1", name: "Test" }); | |
| 989 | + }); | |
| 990 | + | |
| 991 | + const test = factory.query({ | |
| 992 | + payload: { itemId: Schema.String }, | |
| 993 | + success: Schema.String, | |
| 994 | + }); | |
| 995 | + test.implement((args) => | |
| 996 | + Effect.gen(function* () { | |
| 997 | + const user = yield* CurrentUser; | |
| 998 | + return `${user.name} accessed ${args.itemId}`; | |
| 999 | + }), | |
| 1000 | + ); | |
| 1001 | + | |
| 1002 | + const module = makeRpcModule( | |
| 1003 | + { | |
| 1004 | + test: test.middleware(AuthMiddleware), | |
| 1005 | + }, | |
| 1006 | + { middlewares: [authImpl] }, | |
| 1007 | + ); | |
| 1008 | + | |
| 1009 | + const ctx = createMockCtx(); | |
| 1010 | + const handler = getHandler(module.handlers.test); | |
| 1011 | + await handler(ctx, { itemId: "item-456" }); | |
| 1012 | + | |
| 1013 | + expect(receivedPayload).toEqual({ itemId: "item-456" }); | |
| 1014 | + }); | |
| 1015 | + | |
| 1016 | + it("middleware receives rpc info", async () => { | |
| 1017 | + const { middleware } = await import("./server"); | |
| 1018 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 1019 | + let receivedRpcInfo: unknown = null; | |
| 1020 | + | |
| 1021 | + const authImpl = middleware(AuthMiddleware, (options) => { | |
| 1022 | + receivedRpcInfo = options.rpc; | |
| 1023 | + return Effect.succeed({ id: "user-1", name: "Test" }); | |
| 1024 | + }); | |
| 1025 | + | |
| 1026 | + const myEndpoint = factory.query({ success: Schema.String }); | |
| 1027 | + myEndpoint.implement(() => | |
| 1028 | + Effect.gen(function* () { | |
| 1029 | + yield* CurrentUser; | |
| 1030 | + return "ok"; | |
| 1031 | + }), | |
| 1032 | + ); | |
| 1033 | + | |
| 1034 | + const module = makeRpcModule( | |
| 1035 | + { | |
| 1036 | + myEndpoint: myEndpoint.middleware(AuthMiddleware), | |
| 1037 | + }, | |
| 1038 | + { middlewares: [authImpl] }, | |
| 1039 | + ); | |
| 1040 | + | |
| 1041 | + const ctx = createMockCtx(); | |
| 1042 | + const handler = getHandler(module.handlers.myEndpoint); | |
| 1043 | + await handler(ctx, {}); | |
| 1044 | + | |
| 1045 | + expect(receivedRpcInfo).toEqual({ tag: "myEndpoint", kind: "query" }); | |
| 1046 | + }); | |
| 1047 | + | |
| 1048 | + it("middleware receives convex context", async () => { | |
| 1049 | + const { middleware } = await import("./server"); | |
| 1050 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 1051 | + let receivedCtx: unknown = null; | |
| 1052 | + | |
| 1053 | + const authImpl = middleware(AuthMiddleware, (options) => { | |
| 1054 | + receivedCtx = options.ctx; | |
| 1055 | + return Effect.succeed({ id: "user-1", name: "Test" }); | |
| 1056 | + }); | |
| 1057 | + | |
| 1058 | + const test = factory.query({ success: Schema.String }); | |
| 1059 | + test.implement(() => | |
| 1060 | + Effect.gen(function* () { | |
| 1061 | + yield* CurrentUser; | |
| 1062 | + return "ok"; | |
| 1063 | + }), | |
| 1064 | + ); | |
| 1065 | + | |
| 1066 | + const module = makeRpcModule( | |
| 1067 | + { | |
| 1068 | + test: test.middleware(AuthMiddleware), | |
| 1069 | + }, | |
| 1070 | + { middlewares: [authImpl] }, | |
| 1071 | + ); | |
| 1072 | + | |
| 1073 | + const mockCtx = createMockCtx(); | |
| 1074 | + const handler = getHandler(module.handlers.test); | |
| 1075 | + await handler(mockCtx, {}); | |
| 1076 | + | |
| 1077 | + expect(receivedCtx).toBe(mockCtx); | |
| 1078 | + }); | |
| 1079 | + | |
| 1080 | + it("middleware can fail with typed error", async () => { | |
| 1081 | + const { middleware } = await import("./server"); | |
| 1082 | + | |
| 1083 | + class AuthError extends Schema.TaggedError<AuthError>()("AuthError", { | |
| 1084 | + reason: Schema.String, | |
| 1085 | + }) {} | |
| 1086 | + | |
| 1087 | + class FailingAuthMiddleware extends RpcMiddleware.Tag<FailingAuthMiddleware>()( | |
| 1088 | + "FailingAuthMiddleware", | |
| 1089 | + { | |
| 1090 | + provides: CurrentUser, | |
| 1091 | + failure: AuthError, | |
| 1092 | + }, | |
| 1093 | + ) {} | |
| 1094 | + | |
| 1095 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 1096 | + | |
| 1097 | + const authImpl = middleware(FailingAuthMiddleware, () => | |
| 1098 | + Effect.fail(new AuthError({ reason: "Invalid token" })), | |
| 1099 | + ); | |
| 1100 | + | |
| 1101 | + const protectedEndpoint = factory.query({ | |
| 1102 | + success: Schema.String, | |
| 1103 | + error: AuthError, | |
| 1104 | + }); | |
| 1105 | + protectedEndpoint.implement(() => | |
| 1106 | + Effect.gen(function* () { | |
| 1107 | + yield* CurrentUser; | |
| 1108 | + return "secret"; | |
| 1109 | + }), | |
| 1110 | + ); | |
| 1111 | + | |
| 1112 | + const module = makeRpcModule( | |
| 1113 | + { | |
| 1114 | + protected: protectedEndpoint.middleware(FailingAuthMiddleware), | |
| 1115 | + }, | |
| 1116 | + { middlewares: [authImpl] }, | |
| 1117 | + ); | |
| 1118 | + | |
| 1119 | + const ctx = createMockCtx(); | |
| 1120 | + const handler = getHandler(module.handlers.protected); | |
| 1121 | + const result = await handler(ctx, {}); | |
| 1122 | + | |
| 1123 | + const failure = assertFailure(result); | |
| 1124 | + expect(failure.cause).toMatchObject({ | |
| 1125 | + _tag: "Fail", | |
| 1126 | + error: { _tag: "AuthError", reason: "Invalid token" }, | |
| 1127 | + }); | |
| 1128 | + }); | |
| 1129 | + | |
| 1130 | + it("multiple per-request middlewares execute in order", async () => { | |
| 1131 | + const { middleware } = await import("./server"); | |
| 1132 | + const executionOrder: Array<string> = []; | |
| 1133 | + | |
| 1134 | + class Logger extends Context.Tag("Logger")<Logger, { log: (msg: string) => void }>() {} | |
| 1135 | + | |
| 1136 | + class LoggerMiddleware extends RpcMiddleware.Tag<LoggerMiddleware>()( | |
| 1137 | + "LoggerMiddleware", | |
| 1138 | + { provides: Logger }, | |
| 1139 | + ) {} | |
| 1140 | + | |
| 1141 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 1142 | + | |
| 1143 | + const authImpl = middleware(AuthMiddleware, () => { | |
| 1144 | + executionOrder.push("auth"); | |
| 1145 | + return Effect.succeed({ id: "user-1", name: "Test" }); | |
| 1146 | + }); | |
| 1147 | + | |
| 1148 | + const loggerImpl = middleware(LoggerMiddleware, () => { | |
| 1149 | + executionOrder.push("logger"); | |
| 1150 | + return Effect.succeed({ log: (msg: string) => executionOrder.push(`log:${msg}`) }); | |
| 1151 | + }); | |
| 1152 | + | |
| 1153 | + const test = factory.query({ success: Schema.String }); | |
| 1154 | + test.implement(() => | |
| 1155 | + Effect.gen(function* () { | |
| 1156 | + const user = yield* CurrentUser; | |
| 1157 | + const logger = yield* Logger; | |
| 1158 | + logger.log("handler"); | |
| 1159 | + return user.name; | |
| 1160 | + }), | |
| 1161 | + ); | |
| 1162 | + | |
| 1163 | + const module = makeRpcModule( | |
| 1164 | + { | |
| 1165 | + test: test | |
| 1166 | + .middleware(AuthMiddleware) | |
| 1167 | + .middleware(LoggerMiddleware), | |
| 1168 | + }, | |
| 1169 | + { middlewares: [authImpl, loggerImpl] }, | |
| 1170 | + ); | |
| 1171 | + | |
| 1172 | + const ctx = createMockCtx(); | |
| 1173 | + const handler = getHandler(module.handlers.test); | |
| 1174 | + await handler(ctx, {}); | |
| 1175 | + | |
| 1176 | + expect(executionOrder).toEqual(["auth", "logger", "log:handler"]); | |
| 1177 | + }); | |
| 1178 | + | |
| 1179 | + it("supports mixed static layer and per-request middleware", async () => { | |
| 1180 | + const { middleware } = await import("./server"); | |
| 1181 | + | |
| 1182 | + class StaticService extends Context.Tag("StaticService")< | |
| 1183 | + StaticService, | |
| 1184 | + { value: string } | |
| 1185 | + >() {} | |
| 1186 | + | |
| 1187 | + class StaticMiddleware extends RpcMiddleware.Tag<StaticMiddleware>()( | |
| 1188 | + "StaticMiddleware", | |
| 1189 | + { provides: StaticService }, | |
| 1190 | + ) {} | |
| 1191 | + | |
| 1192 | + const factory = createRpcFactory({ schema: testSchema }); | |
| 1193 | + let perRequestCallCount = 0; | |
| 1194 | + | |
| 1195 | + const authImpl = middleware(AuthMiddleware, () => { | |
| 1196 | + perRequestCallCount++; | |
| 1197 | + return Effect.succeed({ id: `user-${perRequestCallCount}`, name: `User ${perRequestCallCount}` }); | |
| 1198 | + }); | |
| 1199 | + | |
| 1200 | + const staticLayer = Layer.succeed(StaticService, { value: "static-value" }); | |
| 1201 | + | |
| 1202 | + const test = factory.query({ success: Schema.String }); | |
| 1203 | + test.implement(() => | |
| 1204 | + Effect.gen(function* () { | |
| 1205 | + const user = yield* CurrentUser; | |
| 1206 | + const static_ = yield* StaticService; | |
| 1207 | + return `${user.name}: ${static_.value}`; | |
| 1208 | + }), | |
| 1209 | + ); | |
| 1210 | + | |
| 1211 | + const module = makeRpcModule( | |
| 1212 | + { | |
| 1213 | + test: test | |
| 1214 | + .middleware(AuthMiddleware) | |
| 1215 | + .middleware(StaticMiddleware), | |
| 1216 | + }, | |
| 1217 | + { | |
| 1218 | + middlewares: { | |
| 1219 | + implementations: [authImpl], | |
| 1220 | + layer: staticLayer, | |
| 1221 | + }, | |
| 1222 | + }, | |
| 1223 | + ); | |
| 1224 | + | |
| 1225 | + const ctx = createMockCtx(); | |
| 1226 | + const handler = getHandler(module.handlers.test); | |
| 1227 | + | |
| 1228 | + const result1 = await handler(ctx, {}); | |
| 1229 | + const success1 = assertSuccess(result1); | |
| 1230 | + expect(success1.value).toBe("User 1: static-value"); | |
| 1231 | + | |
| 1232 | + const result2 = await handler(ctx, {}); | |
| 1233 | + const success2 = assertSuccess(result2); | |
| 1234 | + expect(success2.value).toBe("User 2: static-value"); | |
| 1235 | + | |
| 1236 | + expect(perRequestCallCount).toBe(2); | |
| 1237 | + }); | |
| 1238 | + }); | |
| 1239 | +}); | |
| @@ -0,0 +1,671 @@ | ||
| 1 | +import { Rpc, RpcGroup, RpcMiddleware } from "@effect/rpc"; | |
| 2 | +import type { | |
| 3 | + DefaultFunctionArgs, | |
| 4 | + RegisteredMutation, | |
| 5 | + RegisteredQuery, | |
| 6 | + RegisteredAction, | |
| 7 | + GenericQueryCtx, | |
| 8 | + GenericMutationCtx, | |
| 9 | + GenericActionCtx, | |
| 10 | + GenericDataModel, | |
| 11 | +} from "convex/server"; | |
| 12 | +import { | |
| 13 | + queryGeneric, | |
| 14 | + mutationGeneric, | |
| 15 | + actionGeneric, | |
| 16 | + internalQueryGeneric, | |
| 17 | + internalMutationGeneric, | |
| 18 | + internalActionGeneric, | |
| 19 | +} from "convex/server"; | |
| 20 | +import { v } from "convex/values"; | |
| 21 | +import * as Context from "effect/Context"; | |
| 22 | +import * as Effect from "effect/Effect"; | |
| 23 | +import * as Exit from "effect/Exit"; | |
| 24 | +import * as Layer from "effect/Layer"; | |
| 25 | +import * as Option from "effect/Option"; | |
| 26 | +import * as Schema from "effect/Schema"; | |
| 27 | + | |
| 28 | +import { | |
| 29 | + ConfectActionCtx, | |
| 30 | + ConfectMutationCtx, | |
| 31 | + ConfectQueryCtx, | |
| 32 | + makeQueryCtx, | |
| 33 | + makeMutationCtx, | |
| 34 | + makeActionCtx, | |
| 35 | + type GenericConfectSchema, | |
| 36 | + type TableNamesInSchema, | |
| 37 | + type DocumentFromTable, | |
| 38 | + type EncodedDocumentFromTable, | |
| 39 | +} from "../ctx"; | |
| 40 | +import type { ConfectSchemaDefinition } from "../schema"; | |
| 41 | +import { extractParentSpanFromPayload } from "./telemetry"; | |
| 42 | + | |
| 43 | +export { Rpc, RpcGroup, RpcMiddleware }; | |
| 44 | + | |
| 45 | +export { | |
| 46 | + WrapperTypeId, | |
| 47 | + type WrapperTypeId as WrapperTypeIdType, | |
| 48 | + type Wrapper, | |
| 49 | + isWrapper, | |
| 50 | + wrap, | |
| 51 | + fork, | |
| 52 | + uninterruptible, | |
| 53 | +} from "@effect/rpc/Rpc"; | |
| 54 | + | |
| 55 | +export type Handler<Tag extends string> = Rpc.Handler<Tag>; | |
| 56 | +export type ToHandler<R extends Rpc.Any> = Rpc.ToHandler<R>; | |
| 57 | +export type HandlersFrom<R extends Rpc.Any> = RpcGroup.HandlersFrom<R>; | |
| 58 | +export type HandlersContext<R extends Rpc.Any, Handlers> = RpcGroup.HandlersContext<R, Handlers>; | |
| 59 | + | |
| 60 | +type ConvexCtx = GenericQueryCtx<GenericDataModel> | GenericMutationCtx<GenericDataModel> | GenericActionCtx<GenericDataModel>; | |
| 61 | + | |
| 62 | +export interface RpcInfo { | |
| 63 | + readonly tag: string; | |
| 64 | + readonly kind: "query" | "mutation" | "action" | "internalQuery" | "internalMutation" | "internalAction"; | |
| 65 | +} | |
| 66 | + | |
| 67 | +export interface MiddlewareOptions { | |
| 68 | + readonly rpc: RpcInfo; | |
| 69 | + readonly payload: unknown; | |
| 70 | + readonly ctx: ConvexCtx; | |
| 71 | +} | |
| 72 | + | |
| 73 | +export interface ConfectMiddlewareFn<Service, E = never> { | |
| 74 | + (options: MiddlewareOptions): Effect.Effect<Service, E>; | |
| 75 | +} | |
| 76 | + | |
| 77 | +export interface MiddlewareImplementation<M extends RpcMiddleware.TagClassAny = RpcMiddleware.TagClassAny> { | |
| 78 | + readonly _tag: "MiddlewareImplementation"; | |
| 79 | + readonly middleware: M; | |
| 80 | + readonly impl: ConfectMiddlewareFn<unknown, unknown>; | |
| 81 | +} | |
| 82 | + | |
| 83 | +export const middleware = <M extends RpcMiddleware.TagClassAny>( | |
| 84 | + middlewareTag: M, | |
| 85 | + impl: M extends { readonly provides: Context.Tag<infer _Id, infer S> } | |
| 86 | + ? ConfectMiddlewareFn<S, M extends { readonly failure: Schema.Schema<infer E, infer _I, infer _R> } ? E : never> | |
| 87 | + : ConfectMiddlewareFn<void, M extends { readonly failure: Schema.Schema<infer E, infer _I, infer _R> } ? E : never>, | |
| 88 | +): MiddlewareImplementation<M> => ({ | |
| 89 | + _tag: "MiddlewareImplementation", | |
| 90 | + middleware: middlewareTag, | |
| 91 | + impl: impl as ConfectMiddlewareFn<unknown, unknown>, | |
| 92 | +}); | |
| 93 | + | |
| 94 | +type TableSchemas<Tables extends GenericConfectSchema> = { | |
| 95 | + [TableName in TableNamesInSchema<Tables>]: Schema.Schema< | |
| 96 | + DocumentFromTable<Tables, TableName>, | |
| 97 | + EncodedDocumentFromTable<Tables, TableName> | |
| 98 | + >; | |
| 99 | +}; | |
| 100 | + | |
| 101 | +const extractTableSchemas = <Tables extends GenericConfectSchema>( | |
| 102 | + tables: Tables, | |
| 103 | +): TableSchemas<Tables> => { | |
| 104 | + const result: Record<string, Schema.Schema.AnyNoContext> = {}; | |
| 105 | + for (const [tableName, tableDef] of Object.entries(tables)) { | |
| 106 | + result[tableName] = (tableDef as { documentSchema: Schema.Schema.AnyNoContext }).documentSchema; | |
| 107 | + } | |
| 108 | + return result as TableSchemas<Tables>; | |
| 109 | +}; | |
| 110 | + | |
| 111 | +export interface RpcEndpoint< | |
| 112 | + Tag extends string, | |
| 113 | + R extends Rpc.Any, | |
| 114 | + ConvexFn, | |
| 115 | +> { | |
| 116 | + readonly _tag: Tag; | |
| 117 | + readonly rpc: R; | |
| 118 | + readonly fn: ConvexFn; | |
| 119 | +} | |
| 120 | + | |
| 121 | +// --------------------------------------------------------------------------- | |
| 122 | +// Handler ref — mutable slot filled by .implement() | |
| 123 | +// --------------------------------------------------------------------------- | |
| 124 | + | |
| 125 | +type HandlerFn = (payload: never) => Effect.Effect<unknown, unknown, unknown>; | |
| 126 | + | |
| 127 | +interface HandlerRef { | |
| 128 | + current: HandlerFn | null; | |
| 129 | + tag: string | null; | |
| 130 | +} | |
| 131 | + | |
| 132 | +const createHandlerRef = (): HandlerRef => ({ current: null, tag: null }); | |
| 133 | + | |
| 134 | +// --------------------------------------------------------------------------- | |
| 135 | +// UnbuiltRpcEndpoint — definition only, no handler body | |
| 136 | +// --------------------------------------------------------------------------- | |
| 137 | + | |
| 138 | +/** | |
| 139 | + * An endpoint whose schema has been defined but whose handler may or may not | |
| 140 | + * be wired yet. Call `.implement(handler)` to provide the implementation. | |
| 141 | + * | |
| 142 | + * The handler is stored in a mutable ref so that the definition file (which | |
| 143 | + * `api.d.ts` imports) never needs to contain handler code. The implementation | |
| 144 | + * can live in a separate file, breaking circular type dependencies. | |
| 145 | + */ | |
| 146 | +export interface UnbuiltRpcEndpoint< | |
| 147 | + PayloadFields extends Schema.Struct.Fields, | |
| 148 | + Success extends Schema.Schema.AnyNoContext, | |
| 149 | + Error extends Schema.Schema.AnyNoContext | undefined, | |
| 150 | + ConvexFnType, | |
| 151 | + Middlewares extends ReadonlyArray<RpcMiddleware.TagClassAny> = [], | |
| 152 | +> { | |
| 153 | + readonly __unbuilt: true; | |
| 154 | + readonly kind: string; | |
| 155 | + readonly payloadFields: PayloadFields; | |
| 156 | + readonly successSchema: Success; | |
| 157 | + readonly errorSchema: Error | undefined; | |
| 158 | + readonly middlewares: Middlewares; | |
| 159 | + readonly build: (tag: string, middlewareConfig: MiddlewareConfig | undefined) => RpcEndpoint<string, Rpc.Any, ConvexFnType>; | |
| 160 | + middleware<M extends RpcMiddleware.TagClassAny>( | |
| 161 | + middleware: M, | |
| 162 | + ): UnbuiltRpcEndpoint<PayloadFields, Success, Error, ConvexFnType, [...Middlewares, M]>; | |
| 163 | + | |
| 164 | + /** | |
| 165 | + * Wire the handler implementation for this endpoint. | |
| 166 | + * | |
| 167 | + * Can be called from a separate file to avoid circular type dependencies | |
| 168 | + * with Convex's generated `api.d.ts`. | |
| 169 | + */ | |
| 170 | + implement( | |
| 171 | + handler: ( | |
| 172 | + payload: Schema.Struct.Type<PayloadFields>, | |
| 173 | + ) => Effect.Effect< | |
| 174 | + Schema.Schema.Type<Success>, | |
| 175 | + Error extends Schema.Schema.AnyNoContext ? Schema.Schema.Type<Error> : never, | |
| 176 | + unknown | |
| 177 | + >, | |
| 178 | + ): void; | |
| 179 | +} | |
| 180 | + | |
| 181 | +export interface RpcFactoryConfig< | |
| 182 | + ConfectSchema extends GenericConfectSchema, | |
| 183 | + BasePayload extends Schema.Struct.Fields = {}, | |
| 184 | + BaseMiddlewares extends ReadonlyArray<RpcMiddleware.TagClassAny> = [], | |
| 185 | +> { | |
| 186 | + readonly schema: ConfectSchemaDefinition<ConfectSchema>; | |
| 187 | + readonly basePayload?: BasePayload; | |
| 188 | + readonly baseMiddlewares?: BaseMiddlewares; | |
| 189 | +} | |
| 190 | + | |
| 191 | +export type ExitEncoded<A = unknown, E = unknown, D = unknown> = Schema.ExitEncoded<A, E, D>; | |
| 192 | + | |
| 193 | +type MiddlewareConfig = { | |
| 194 | + readonly implementations: ReadonlyArray<MiddlewareImplementation>; | |
| 195 | + readonly staticLayer?: Layer.Layer<unknown, unknown, never>; | |
| 196 | +}; | |
| 197 | + | |
| 198 | +const StaticLayerReady = Context.GenericTag<true>("@confect/StaticLayerReady"); | |
| 199 | + | |
| 200 | +const executeMiddlewares = ( | |
| 201 | + endpointMiddlewares: ReadonlyArray<RpcMiddleware.TagClassAny>, | |
| 202 | + middlewareImpls: ReadonlyArray<MiddlewareImplementation>, | |
| 203 | + options: MiddlewareOptions, | |
| 204 | +): Effect.Effect<Context.Context<never>, unknown, never> => { | |
| 205 | + if (endpointMiddlewares.length === 0 || middlewareImpls.length === 0) { | |
| 206 | + return Effect.succeed(Context.empty()); | |
| 207 | + } | |
| 208 | + | |
| 209 | + return Effect.gen(function* () { | |
| 210 | + let ctx: Context.Context<never> = Context.empty(); | |
| 211 | + | |
| 212 | + for (const endpointMiddleware of endpointMiddlewares) { | |
| 213 | + const middlewareImpl = middlewareImpls.find( | |
| 214 | + (candidate) => candidate.middleware === endpointMiddleware, | |
| 215 | + ); | |
| 216 | + if (!middlewareImpl) { | |
| 217 | + continue; | |
| 218 | + } | |
| 219 | + | |
| 220 | + const result = yield* middlewareImpl.impl(options); | |
| 221 | + const providesTag = middlewareImpl.middleware.provides; | |
| 222 | + if (providesTag) { | |
| 223 | + ctx = Context.add(ctx, providesTag, result) as Context.Context<never>; | |
| 224 | + } | |
| 225 | + } | |
| 226 | + | |
| 227 | + return ctx; | |
| 228 | + }); | |
| 229 | +}; | |
| 230 | + | |
| 231 | +type EndpointKind = "query" | "mutation" | "action" | "internalQuery" | "internalMutation" | "internalAction"; | |
| 232 | + | |
| 233 | +type ConvexRegistrar = (opts: { args: ReturnType<typeof v.any>; handler: (ctx: ConvexCtx, args: unknown) => Promise<ExitEncoded> }) => unknown; | |
| 234 | + | |
| 235 | +interface EndpointConfig<Ctx, ConvexFnType> { | |
| 236 | + readonly kind: EndpointKind; | |
| 237 | + readonly ctxTag: Context.Tag<Ctx, Ctx>; | |
| 238 | + readonly makeCtx: (rawCtx: ConvexCtx) => Ctx; | |
| 239 | + readonly registrar: ConvexRegistrar; | |
| 240 | + readonly _convexFnType?: ConvexFnType; | |
| 241 | +} | |
| 242 | + | |
| 243 | +export const createRpcFactory = < | |
| 244 | + ConfectSchema extends GenericConfectSchema, | |
| 245 | + BasePayload extends Schema.Struct.Fields = {}, | |
| 246 | + BaseMiddlewares extends ReadonlyArray<RpcMiddleware.TagClassAny> = [], | |
| 247 | +>( | |
| 248 | + config: RpcFactoryConfig<ConfectSchema, BasePayload, BaseMiddlewares>, | |
| 249 | +) => { | |
| 250 | + const tableSchemas = extractTableSchemas(config.schema.tables); | |
| 251 | + const basePayload = config.basePayload ?? ({} as BasePayload); | |
| 252 | + const baseMiddlewares = config.baseMiddlewares ?? ([] as unknown as BaseMiddlewares); | |
| 253 | + | |
| 254 | + type MergedPayload<P extends Schema.Struct.Fields> = BasePayload & P; | |
| 255 | + | |
| 256 | + const makeConvexHandler = < | |
| 257 | + PayloadFields extends Schema.Struct.Fields, | |
| 258 | + Success extends Schema.Schema.AnyNoContext, | |
| 259 | + Error extends Schema.Schema.AnyNoContext | undefined, | |
| 260 | + Ctx, | |
| 261 | + >( | |
| 262 | + endpointConfig: EndpointConfig<Ctx, unknown>, | |
| 263 | + tag: string, | |
| 264 | + payloadFields: PayloadFields, | |
| 265 | + successSchema: Success, | |
| 266 | + errorSchema: Error, | |
| 267 | + middlewareConfig: MiddlewareConfig | undefined, | |
| 268 | + endpointMiddlewares: ReadonlyArray<RpcMiddleware.TagClassAny>, | |
| 269 | + handlerRef: HandlerRef, | |
| 270 | + ) => { | |
| 271 | + const payloadSchema = Schema.Struct(payloadFields) as unknown as Schema.Schema<Schema.Struct.Type<PayloadFields>, Schema.Struct.Encoded<PayloadFields>, never>; | |
| 272 | + const decodePayload = Schema.decodeUnknownSync(payloadSchema); | |
| 273 | + | |
| 274 | + const exitSchemaVal = Schema.Exit({ | |
| 275 | + success: successSchema as Schema.Schema<unknown, unknown, never>, | |
| 276 | + failure: (errorSchema ?? Schema.Never) as Schema.Schema<unknown, unknown, never>, | |
| 277 | + defect: Schema.Defect, | |
| 278 | + }); | |
| 279 | + const encodeExit = Schema.encodeSync(exitSchemaVal) as (exit: Exit.Exit<unknown, unknown>) => ExitEncoded; | |
| 280 | + | |
| 281 | + const rpcInfo: RpcInfo = { tag, kind: endpointConfig.kind }; | |
| 282 | + | |
| 283 | + return async (rawCtx: ConvexCtx, args: unknown): Promise<ExitEncoded> => { | |
| 284 | + if (!handlerRef.current) { | |
| 285 | + throw new Error( | |
| 286 | + `Handler not implemented for "${tag}". Call .implement() on the endpoint definition.`, | |
| 287 | + ); | |
| 288 | + } | |
| 289 | + const handler = handlerRef.current; | |
| 290 | + const parentSpan = extractParentSpanFromPayload(args); | |
| 291 | + | |
| 292 | + let decodedArgs: Schema.Struct.Type<PayloadFields>; | |
| 293 | + try { | |
| 294 | + decodedArgs = decodePayload(args); | |
| 295 | + } catch (err) { | |
| 296 | + return encodeExit(Exit.die(err)); | |
| 297 | + } | |
| 298 | + | |
| 299 | + const middlewareOptions: MiddlewareOptions = { | |
| 300 | + rpc: rpcInfo, | |
| 301 | + payload: decodedArgs, | |
| 302 | + ctx: rawCtx, | |
| 303 | + }; | |
| 304 | + | |
| 305 | + const effect = Effect.gen(function* () { | |
| 306 | + const middlewareCtx = middlewareConfig?.implementations?.length | |
| 307 | + ? yield* executeMiddlewares( | |
| 308 | + endpointMiddlewares, | |
| 309 | + middlewareConfig.implementations, | |
| 310 | + middlewareOptions, | |
| 311 | + ) | |
| 312 | + : Context.empty(); | |
| 313 | + | |
| 314 | + const handlerEffect = handler(decodedArgs as never); | |
| 315 | + const withMiddlewareCtx = Effect.provide(handlerEffect, middlewareCtx); | |
| 316 | + const withConfectCtx = Effect.provideService( | |
| 317 | + withMiddlewareCtx as Effect.Effect<Schema.Schema.Type<Success>, unknown, Ctx>, | |
| 318 | + endpointConfig.ctxTag, | |
| 319 | + endpointConfig.makeCtx(rawCtx), | |
| 320 | + ); | |
| 321 | + | |
| 322 | + return yield* withConfectCtx; | |
| 323 | + }); | |
| 324 | + | |
| 325 | + const tracedEffect = Effect.withSpan( | |
| 326 | + effect, | |
| 327 | + `rpc.server.${endpointConfig.kind}.${tag}`, | |
| 328 | + { | |
| 329 | + kind: "server", | |
| 330 | + captureStackTrace: false, | |
| 331 | + parent: Option.getOrUndefined(parentSpan), | |
| 332 | + attributes: { | |
| 333 | + "rpc.system": "convex", | |
| 334 | + "rpc.method": tag, | |
| 335 | + "rpc.confect.kind": endpointConfig.kind, | |
| 336 | + }, | |
| 337 | + }, | |
| 338 | + ); | |
| 339 | + | |
| 340 | + const effectWithLayer = middlewareConfig?.staticLayer | |
| 341 | + ? Effect.gen(function* () { | |
| 342 | + yield* StaticLayerReady; | |
| 343 | + return yield* tracedEffect; | |
| 344 | + }).pipe( | |
| 345 | + Effect.provide( | |
| 346 | + Layer.mergeAll( | |
| 347 | + middlewareConfig.staticLayer, | |
| 348 | + Layer.succeed(StaticLayerReady, true), | |
| 349 | + ), | |
| 350 | + ), | |
| 351 | + ) | |
| 352 | + : tracedEffect; | |
| 353 | + | |
| 354 | + const exit = await Effect.runPromiseExit(effectWithLayer); | |
| 355 | + return encodeExit(exit); | |
| 356 | + }; | |
| 357 | + }; | |
| 358 | + | |
| 359 | + const createUnbuiltEndpoint = < | |
| 360 | + PayloadFields extends Schema.Struct.Fields, | |
| 361 | + Success extends Schema.Schema.AnyNoContext, | |
| 362 | + Error extends Schema.Schema.AnyNoContext | undefined, | |
| 363 | + ConvexFnType, | |
| 364 | + Middlewares extends ReadonlyArray<RpcMiddleware.TagClassAny>, | |
| 365 | + Ctx, | |
| 366 | + >( | |
| 367 | + endpointConfig: EndpointConfig<Ctx, ConvexFnType>, | |
| 368 | + mergedPayload: PayloadFields, | |
| 369 | + successSchema: Success, | |
| 370 | + errorSchema: Error | undefined, | |
| 371 | + middlewares: Middlewares, | |
| 372 | + handlerRef: HandlerRef, | |
| 373 | + ): UnbuiltRpcEndpoint<PayloadFields, Success, Error, ConvexFnType, Middlewares> => ({ | |
| 374 | + __unbuilt: true as const, | |
| 375 | + kind: endpointConfig.kind, | |
| 376 | + payloadFields: mergedPayload, | |
| 377 | + successSchema: successSchema, | |
| 378 | + errorSchema: errorSchema, | |
| 379 | + middlewares, | |
| 380 | + build: (tag, middlewareConfig): RpcEndpoint<string, Rpc.Any, ConvexFnType> => { | |
| 381 | + handlerRef.tag = tag; | |
| 382 | + const rpc = Rpc.make(tag, { | |
| 383 | + payload: mergedPayload, | |
| 384 | + success: successSchema, | |
| 385 | + error: errorSchema, | |
| 386 | + }); | |
| 387 | + const convexHandler = makeConvexHandler( | |
| 388 | + endpointConfig, | |
| 389 | + tag, | |
| 390 | + mergedPayload, | |
| 391 | + successSchema, | |
| 392 | + errorSchema, | |
| 393 | + middlewareConfig, | |
| 394 | + middlewares, | |
| 395 | + handlerRef, | |
| 396 | + ); | |
| 397 | + const fn = endpointConfig.registrar({ args: v.any(), handler: convexHandler }); | |
| 398 | + return { _tag: tag, rpc, fn: fn as ConvexFnType }; | |
| 399 | + }, | |
| 400 | + middleware<M extends RpcMiddleware.TagClassAny>( | |
| 401 | + middlewareTag: M, | |
| 402 | + ): UnbuiltRpcEndpoint<PayloadFields, Success, Error, ConvexFnType, [...Middlewares, M]> { | |
| 403 | + const newMiddlewares = [...middlewares, middlewareTag] as [...Middlewares, M]; | |
| 404 | + return createUnbuiltEndpoint( | |
| 405 | + endpointConfig, | |
| 406 | + mergedPayload, | |
| 407 | + successSchema, | |
| 408 | + errorSchema, | |
| 409 | + newMiddlewares, | |
| 410 | + handlerRef, | |
| 411 | + ); | |
| 412 | + }, | |
| 413 | + implement( | |
| 414 | + handler: ( | |
| 415 | + payload: Schema.Struct.Type<PayloadFields>, | |
| 416 | + ) => Effect.Effect< | |
| 417 | + Schema.Schema.Type<Success>, | |
| 418 | + Error extends Schema.Schema.AnyNoContext ? Schema.Schema.Type<Error> : never, | |
| 419 | + unknown | |
| 420 | + >, | |
| 421 | + ): void { | |
| 422 | + handlerRef.current = handler as HandlerFn; | |
| 423 | + }, | |
| 424 | + }); | |
| 425 | + | |
| 426 | + /** | |
| 427 | + * Create an endpoint definition method. The returned function takes only | |
| 428 | + * schemas (no handler). Call `.implement(handler)` on the result to wire | |
| 429 | + * the handler, potentially from a separate file. | |
| 430 | + */ | |
| 431 | + const createEndpointMethod = <Ctx, ConvexFnType>( | |
| 432 | + endpointConfig: EndpointConfig<Ctx, ConvexFnType>, | |
| 433 | + ) => < | |
| 434 | + PayloadFields extends Schema.Struct.Fields = {}, | |
| 435 | + Success extends Schema.Schema.AnyNoContext = typeof Schema.Void, | |
| 436 | + Error extends Schema.Schema.AnyNoContext | undefined = undefined, | |
| 437 | + >( | |
| 438 | + options: { | |
| 439 | + readonly payload?: PayloadFields; | |
| 440 | + readonly success: Success; | |
| 441 | + readonly error?: Error; | |
| 442 | + }, | |
| 443 | + ): UnbuiltRpcEndpoint<MergedPayload<PayloadFields>, Success, Error, ConvexFnType, BaseMiddlewares> => { | |
| 444 | + const mergedPayload = { ...basePayload, ...options.payload } as MergedPayload<PayloadFields>; | |
| 445 | + const handlerRef = createHandlerRef(); | |
| 446 | + return createUnbuiltEndpoint( | |
| 447 | + endpointConfig, | |
| 448 | + mergedPayload, | |
| 449 | + options.success, | |
| 450 | + options.error, | |
| 451 | + baseMiddlewares as unknown as BaseMiddlewares, | |
| 452 | + handlerRef, | |
| 453 | + ); | |
| 454 | + }; | |
| 455 | + | |
| 456 | + const queryConfig: EndpointConfig<ConfectQueryCtx<ConfectSchema>, RegisteredQuery<"public", DefaultFunctionArgs, Promise<ExitEncoded>>> = { | |
| 457 | + kind: "query", | |
| 458 | + ctxTag: ConfectQueryCtx<ConfectSchema>(), | |
| 459 | + makeCtx: (ctx) => makeQueryCtx(ctx as GenericQueryCtx<GenericDataModel>, tableSchemas), | |
| 460 | + registrar: queryGeneric, | |
| 461 | + }; | |
| 462 | + | |
| 463 | + const mutationConfig: EndpointConfig<ConfectMutationCtx<ConfectSchema>, RegisteredMutation<"public", DefaultFunctionArgs, Promise<ExitEncoded>>> = { | |
| 464 | + kind: "mutation", | |
| 465 | + ctxTag: ConfectMutationCtx<ConfectSchema>(), | |
| 466 | + makeCtx: (ctx) => makeMutationCtx(ctx as GenericMutationCtx<GenericDataModel>, tableSchemas), | |
| 467 | + registrar: mutationGeneric, | |
| 468 | + }; | |
| 469 | + | |
| 470 | + const actionConfig: EndpointConfig<ConfectActionCtx<ConfectSchema>, RegisteredAction<"public", DefaultFunctionArgs, Promise<ExitEncoded>>> = { | |
| 471 | + kind: "action", | |
| 472 | + ctxTag: ConfectActionCtx<ConfectSchema>(), | |
| 473 | + makeCtx: (ctx) => makeActionCtx(ctx as GenericActionCtx<GenericDataModel>), | |
| 474 | + registrar: actionGeneric, | |
| 475 | + }; | |
| 476 | + | |
| 477 | + const internalQueryConfig: EndpointConfig<ConfectQueryCtx<ConfectSchema>, RegisteredQuery<"internal", DefaultFunctionArgs, Promise<ExitEncoded>>> = { | |
| 478 | + kind: "internalQuery", | |
| 479 | + ctxTag: ConfectQueryCtx<ConfectSchema>(), | |
| 480 | + makeCtx: (ctx) => makeQueryCtx(ctx as GenericQueryCtx<GenericDataModel>, tableSchemas), | |
| 481 | + registrar: internalQueryGeneric, | |
| 482 | + }; | |
| 483 | + | |
| 484 | + const internalMutationConfig: EndpointConfig<ConfectMutationCtx<ConfectSchema>, RegisteredMutation<"internal", DefaultFunctionArgs, Promise<ExitEncoded>>> = { | |
| 485 | + kind: "internalMutation", | |
| 486 | + ctxTag: ConfectMutationCtx<ConfectSchema>(), | |
| 487 | + makeCtx: (ctx) => makeMutationCtx(ctx as GenericMutationCtx<GenericDataModel>, tableSchemas), | |
| 488 | + registrar: internalMutationGeneric, | |
| 489 | + }; | |
| 490 | + | |
| 491 | + const internalActionConfig: EndpointConfig<ConfectActionCtx<ConfectSchema>, RegisteredAction<"internal", DefaultFunctionArgs, Promise<ExitEncoded>>> = { | |
| 492 | + kind: "internalAction", | |
| 493 | + ctxTag: ConfectActionCtx<ConfectSchema>(), | |
| 494 | + makeCtx: (ctx) => makeActionCtx(ctx as GenericActionCtx<GenericDataModel>), | |
| 495 | + registrar: internalActionGeneric, | |
| 496 | + }; | |
| 497 | + | |
| 498 | + return { | |
| 499 | + query: createEndpointMethod(queryConfig), | |
| 500 | + mutation: createEndpointMethod(mutationConfig), | |
| 501 | + action: createEndpointMethod(actionConfig), | |
| 502 | + internalQuery: createEndpointMethod(internalQueryConfig), | |
| 503 | + internalMutation: createEndpointMethod(internalMutationConfig), | |
| 504 | + internalAction: createEndpointMethod(internalActionConfig), | |
| 505 | + }; | |
| 506 | +}; | |
| 507 | + | |
| 508 | +export type InferRpc<E> = E extends RpcEndpoint<infer _Tag, infer R, infer _ConvexFn> ? R : never; | |
| 509 | + | |
| 510 | +export type InferFn<E> = E extends RpcEndpoint<infer _Tag, infer _R, infer ConvexFn> ? ConvexFn : never; | |
| 511 | + | |
| 512 | +interface RpcModuleBase< | |
| 513 | + Endpoints extends Record<string, RpcEndpoint<string, Rpc.Any, unknown>>, | |
| 514 | +> { | |
| 515 | + readonly _def: { | |
| 516 | + readonly endpoints: Endpoints; | |
| 517 | + }; | |
| 518 | + readonly rpcs: { [K in keyof Endpoints]: InferRpc<Endpoints[K]> }; | |
| 519 | + readonly handlers: { [K in keyof Endpoints]: InferFn<Endpoints[K]> }; | |
| 520 | + readonly group: RpcGroup.RpcGroup<InferRpc<Endpoints[keyof Endpoints]>>; | |
| 521 | +} | |
| 522 | + | |
| 523 | +export type RpcModule< | |
| 524 | + Endpoints extends Record<string, RpcEndpoint<string, Rpc.Any, unknown>>, | |
| 525 | +> = RpcModuleBase<Endpoints> & Endpoints; | |
| 526 | + | |
| 527 | +export interface AnyRpcModule { | |
| 528 | + readonly _def: { | |
| 529 | + readonly endpoints: Record<string, RpcEndpoint<string, Rpc.Any, unknown>>; | |
| 530 | + }; | |
| 531 | + readonly rpcs: Record<string, Rpc.Any>; | |
| 532 | + readonly handlers: Record<string, unknown>; | |
| 533 | + readonly group: unknown; | |
| 534 | +} | |
| 535 | + | |
| 536 | +export type InferModuleEndpoints<M extends AnyRpcModule> = M["_def"]["endpoints"]; | |
| 537 | + | |
| 538 | +interface AnyUnbuiltEndpoint { | |
| 539 | + readonly __unbuilt: true; | |
| 540 | + readonly kind: string; | |
| 541 | + readonly payloadFields: Schema.Struct.Fields; | |
| 542 | + readonly successSchema: Schema.Schema.AnyNoContext; | |
| 543 | + readonly errorSchema: Schema.Schema.AnyNoContext | undefined; | |
| 544 | + readonly middlewares: ReadonlyArray<RpcMiddleware.TagClassAny>; | |
| 545 | + readonly build: (tag: string, middlewareConfig: MiddlewareConfig | undefined) => RpcEndpoint<string, Rpc.Any, unknown>; | |
| 546 | + middleware<M extends RpcMiddleware.TagClassAny>(middleware: M): AnyUnbuiltEndpoint; | |
| 547 | + // Variance note: `implement` sits in covariant position (method), its handler | |
| 548 | + // parameter is contravariant, and the handler's `payload` is again contravariant | |
| 549 | + // — making `payload` doubly contravariant = covariant overall. So for | |
| 550 | + // `AnyUnbuiltEndpoint` to be a proper supertype of all concrete endpoints, | |
| 551 | + // `payload` must be a supertype of all concrete payload types → `unknown`. | |
| 552 | + implement(handler: (payload: unknown) => Effect.Effect<unknown, unknown, unknown>): void; | |
| 553 | +} | |
| 554 | + | |
| 555 | +type BuiltEndpoint<K extends string, U> = U extends UnbuiltRpcEndpoint< | |
| 556 | + infer PayloadFields, | |
| 557 | + infer Success, | |
| 558 | + infer Error, | |
| 559 | + infer ConvexFnType, | |
| 560 | + infer _Middlewares | |
| 561 | +> | |
| 562 | + ? RpcEndpoint<K, Rpc.Rpc<K, Schema.Struct<PayloadFields>, Success, Error extends Schema.Schema.AnyNoContext ? Error : typeof Schema.Never, never>, ConvexFnType> | |
| 563 | + : never; | |
| 564 | + | |
| 565 | +type BuiltEndpoints<T extends Record<string, AnyUnbuiltEndpoint>> = { | |
| 566 | + [K in keyof T & string]: BuiltEndpoint<K, T[K]>; | |
| 567 | +}; | |
| 568 | + | |
| 569 | +const isUnbuilt = (value: unknown): value is AnyUnbuiltEndpoint => | |
| 570 | + typeof value === "object" && value !== null && "__unbuilt" in value && (value as { __unbuilt: unknown }).__unbuilt === true; | |
| 571 | + | |
| 572 | +type ExtractMiddlewares<T extends Record<string, AnyUnbuiltEndpoint>> = T[keyof T]["middlewares"][number]; | |
| 573 | + | |
| 574 | +type ExtractMiddlewareProvides<T extends RpcMiddleware.TagClassAny> = T extends { | |
| 575 | + readonly provides: Context.Tag<infer Id, infer _S>; | |
| 576 | +} | |
| 577 | + ? Id | |
| 578 | + : never; | |
| 579 | + | |
| 580 | +type ExtractAllMiddlewareProvides<T extends Record<string, AnyUnbuiltEndpoint>> = ExtractMiddlewareProvides<ExtractMiddlewares<T>>; | |
| 581 | + | |
| 582 | +type StaticMiddlewareLayer<Services> = | |
| 583 | + | Layer.Layer<Services, unknown, never> | |
| 584 | + | Layer.Layer<never, unknown, never>; | |
| 585 | + | |
| 586 | +export interface RpcModuleOptions<Services> { | |
| 587 | + readonly middlewares?: | |
| 588 | + | StaticMiddlewareLayer<Services> | |
| 589 | + | ReadonlyArray<MiddlewareImplementation> | |
| 590 | + | { | |
| 591 | + readonly implementations?: ReadonlyArray<MiddlewareImplementation>; | |
| 592 | + readonly layer?: StaticMiddlewareLayer<Services>; | |
| 593 | + }; | |
| 594 | +} | |
| 595 | + | |
| 596 | +const normalizeMiddlewareOptions = <Services>( | |
| 597 | + options: RpcModuleOptions<Services>["middlewares"], | |
| 598 | +): MiddlewareConfig | undefined => { | |
| 599 | + if (!options) { | |
| 600 | + return undefined; | |
| 601 | + } | |
| 602 | + | |
| 603 | + if (Array.isArray(options)) { | |
| 604 | + return { | |
| 605 | + implementations: options, | |
| 606 | + staticLayer: undefined, | |
| 607 | + }; | |
| 608 | + } | |
| 609 | + | |
| 610 | + if ("_tag" in options && options._tag === "MiddlewareImplementation") { | |
| 611 | + return { | |
| 612 | + implementations: [options as MiddlewareImplementation], | |
| 613 | + staticLayer: undefined, | |
| 614 | + }; | |
| 615 | + } | |
| 616 | + | |
| 617 | + if (Layer.isLayer(options)) { | |
| 618 | + return { | |
| 619 | + implementations: [], | |
| 620 | + staticLayer: options as Layer.Layer<unknown, unknown, never>, | |
| 621 | + }; | |
| 622 | + } | |
| 623 | + | |
| 624 | + const cfg = options as { | |
| 625 | + readonly implementations?: ReadonlyArray<MiddlewareImplementation>; | |
| 626 | + readonly layer?: StaticMiddlewareLayer<Services>; | |
| 627 | + }; | |
| 628 | + | |
| 629 | + return { | |
| 630 | + implementations: cfg.implementations ?? [], | |
| 631 | + staticLayer: cfg.layer as Layer.Layer<unknown, unknown, never> | undefined, | |
| 632 | + }; | |
| 633 | +}; | |
| 634 | + | |
| 635 | +export function makeRpcModule< | |
| 636 | + const T extends Record<string, AnyUnbuiltEndpoint>, | |
| 637 | +>( | |
| 638 | + unbuiltEndpoints: T, | |
| 639 | + options?: RpcModuleOptions<ExtractAllMiddlewareProvides<T>>, | |
| 640 | +): RpcModuleBase<BuiltEndpoints<T>> & { readonly [K in keyof T]: BuiltEndpoint<K & string, T[K]> } { | |
| 641 | + const rpcs = {} as Record<string, Rpc.Any>; | |
| 642 | + const handlers = {} as Record<string, unknown>; | |
| 643 | + const rpcList: Array<Rpc.Any> = []; | |
| 644 | + const builtEndpoints = {} as Record<string, RpcEndpoint<string, Rpc.Any, unknown>>; | |
| 645 | + | |
| 646 | + const middlewareConfig = normalizeMiddlewareOptions(options?.middlewares); | |
| 647 | + | |
| 648 | + for (const key of Object.keys(unbuiltEndpoints)) { | |
| 649 | + const unbuilt = unbuiltEndpoints[key]!; | |
| 650 | + if (!isUnbuilt(unbuilt)) { | |
| 651 | + throw new Error(`Expected unbuilt endpoint for key "${key}"`); | |
| 652 | + } | |
| 653 | + const endpoint = unbuilt.build(key, middlewareConfig); | |
| 654 | + builtEndpoints[key] = endpoint; | |
| 655 | + rpcs[key] = endpoint.rpc; | |
| 656 | + handlers[key] = endpoint.fn; | |
| 657 | + rpcList.push(endpoint.rpc); | |
| 658 | + } | |
| 659 | + | |
| 660 | + type Built = BuiltEndpoints<T>; | |
| 661 | + const module = { | |
| 662 | + _def: { endpoints: builtEndpoints }, | |
| 663 | + rpcs: rpcs as { [K in keyof Built]: InferRpc<Built[K]> }, | |
| 664 | + handlers: handlers as { [K in keyof Built]: InferFn<Built[K]> }, | |
| 665 | + group: RpcGroup.make(...rpcList) as unknown as RpcGroup.RpcGroup<InferRpc<Built[keyof Built]>>, | |
| 666 | + }; | |
| 667 | + | |
| 668 | + return Object.assign(module, builtEndpoints) as RpcModuleBase<Built> & { readonly [K in keyof T]: BuiltEndpoint<K & string, T[K]> }; | |
| 669 | +} | |
| 670 | + | |
| 671 | +export const exitSchema = Rpc.exitSchema; | |
| @@ -0,0 +1,112 @@ | ||
| 1 | +import { Effect, Option } from "effect"; | |
| 2 | +import { describe, expect, it } from "vitest"; | |
| 3 | +import { | |
| 4 | + convexRequestIdHeader, | |
| 5 | + extractParentSpanFromPayload, | |
| 6 | + makeRpcTelemetryContext, | |
| 7 | + makeRpcTransportHeaders, | |
| 8 | + rpcTelemetryContextField, | |
| 9 | + withOptionalRpcTelemetryContext, | |
| 10 | + withRpcTelemetryContext, | |
| 11 | +} from "./telemetry"; | |
| 12 | + | |
| 13 | +describe("RPC telemetry context", () => { | |
| 14 | + it("roundtrips span context through payload metadata", async () => { | |
| 15 | + const payloadWithTelemetry = await Effect.runPromise( | |
| 16 | + Effect.useSpan("rpc.client.test", { kind: "client" }, (span) => | |
| 17 | + Effect.succeed( | |
| 18 | + withRpcTelemetryContext( | |
| 19 | + { message: "hello" }, | |
| 20 | + makeRpcTelemetryContext(span), | |
| 21 | + ), | |
| 22 | + ), | |
| 23 | + ), | |
| 24 | + ); | |
| 25 | + | |
| 26 | + const parentSpan = extractParentSpanFromPayload(payloadWithTelemetry); | |
| 27 | + expect(Option.isSome(parentSpan)).toBe(true); | |
| 28 | + | |
| 29 | + if (Option.isSome(parentSpan)) { | |
| 30 | + expect(parentSpan.value.traceId).toHaveLength(32); | |
| 31 | + expect(parentSpan.value.spanId).toHaveLength(16); | |
| 32 | + } | |
| 33 | + }); | |
| 34 | + | |
| 35 | + it("returns none when payload has no telemetry context", () => { | |
| 36 | + const parentSpan = extractParentSpanFromPayload({ message: "hello" }); | |
| 37 | + expect(Option.isNone(parentSpan)).toBe(true); | |
| 38 | + }); | |
| 39 | + | |
| 40 | + it("returns none for malformed telemetry payload", () => { | |
| 41 | + const parentSpan = extractParentSpanFromPayload({ | |
| 42 | + confectTelemetryContext: { | |
| 43 | + traceparent: 123, | |
| 44 | + }, | |
| 45 | + }); | |
| 46 | + | |
| 47 | + expect(Option.isNone(parentSpan)).toBe(true); | |
| 48 | + }); | |
| 49 | + | |
| 50 | + it("creates transport headers with trace context and request id", async () => { | |
| 51 | + const headers = await Effect.runPromise( | |
| 52 | + Effect.useSpan("rpc.client.test", { kind: "client" }, (span) => | |
| 53 | + Effect.succeed(makeRpcTransportHeaders(span)), | |
| 54 | + ), | |
| 55 | + ); | |
| 56 | + | |
| 57 | + expect(headers.traceparent).toBeDefined(); | |
| 58 | + const requestId = headers[convexRequestIdHeader]; | |
| 59 | + expect(requestId).toBeDefined(); | |
| 60 | + if (requestId !== undefined) { | |
| 61 | + expect(requestId.length).toBeGreaterThan(0); | |
| 62 | + } | |
| 63 | + }); | |
| 64 | + | |
| 65 | + it("skips payload telemetry context when fallback is disabled", async () => { | |
| 66 | + const payloadWithTelemetry = await Effect.runPromise( | |
| 67 | + Effect.useSpan("rpc.client.test", { kind: "client" }, (span) => | |
| 68 | + Effect.succeed( | |
| 69 | + withOptionalRpcTelemetryContext( | |
| 70 | + "mutation", | |
| 71 | + { message: "hello" }, | |
| 72 | + span, | |
| 73 | + false, | |
| 74 | + ), | |
| 75 | + ), | |
| 76 | + ), | |
| 77 | + ); | |
| 78 | + | |
| 79 | + expect(payloadWithTelemetry).toEqual({ message: "hello" }); | |
| 80 | + }); | |
| 81 | + | |
| 82 | + it("adds payload telemetry context for mutations when fallback is enabled", async () => { | |
| 83 | + const payloadWithTelemetry = await Effect.runPromise( | |
| 84 | + Effect.useSpan("rpc.client.test", { kind: "client" }, (span) => | |
| 85 | + Effect.succeed( | |
| 86 | + withOptionalRpcTelemetryContext( | |
| 87 | + "mutation", | |
| 88 | + { message: "hello" }, | |
| 89 | + span, | |
| 90 | + true, | |
| 91 | + ), | |
| 92 | + ), | |
| 93 | + ), | |
| 94 | + ); | |
| 95 | + | |
| 96 | + expect(payloadWithTelemetry).toMatchObject({ message: "hello" }); | |
| 97 | + expect(payloadWithTelemetry).toHaveProperty(rpcTelemetryContextField); | |
| 98 | + }); | |
| 99 | + | |
| 100 | + it("adds payload telemetry context for queries when fallback is enabled", async () => { | |
| 101 | + const payloadWithTelemetry = await Effect.runPromise( | |
| 102 | + Effect.useSpan("rpc.client.test", { kind: "client" }, (span) => | |
| 103 | + Effect.succeed( | |
| 104 | + withOptionalRpcTelemetryContext("query", { page: 1 }, span, true), | |
| 105 | + ), | |
| 106 | + ), | |
| 107 | + ); | |
| 108 | + | |
| 109 | + expect(payloadWithTelemetry).toMatchObject({ page: 1 }); | |
| 110 | + expect(payloadWithTelemetry).toHaveProperty(rpcTelemetryContextField); | |
| 111 | + }); | |
| 112 | +}); | |
| @@ -0,0 +1,134 @@ | ||
| 1 | +import * as Headers from "@effect/platform/Headers"; | |
| 2 | +import * as HttpTraceContext from "@effect/platform/HttpTraceContext"; | |
| 3 | +import * as Option from "effect/Option"; | |
| 4 | +import type * as Tracer from "effect/Tracer"; | |
| 5 | + | |
| 6 | +export const rpcTelemetryContextField = "confectTelemetryContext"; | |
| 7 | +export const convexRequestIdHeader = "convex-request-id"; | |
| 8 | + | |
| 9 | +export type RpcClientKind = "query" | "mutation" | "action"; | |
| 10 | + | |
| 11 | +export interface RpcTelemetryContext { | |
| 12 | + readonly traceparent: string; | |
| 13 | + readonly b3?: string; | |
| 14 | + readonly tracestate?: string; | |
| 15 | +} | |
| 16 | + | |
| 17 | +const isRecord = (value: unknown): value is Record<string, unknown> => | |
| 18 | + typeof value === "object" && value !== null; | |
| 19 | + | |
| 20 | +const toStringValue = (value: unknown): Option.Option<string> => | |
| 21 | + typeof value === "string" ? Option.some(value) : Option.none(); | |
| 22 | + | |
| 23 | +const getRecordValue = ( | |
| 24 | + record: Record<string, unknown>, | |
| 25 | + key: string, | |
| 26 | +): Option.Option<string> => | |
| 27 | + Option.flatMap(Option.fromNullable(record[key]), toStringValue); | |
| 28 | + | |
| 29 | +export const makeRpcTelemetryContext = ( | |
| 30 | + span: Tracer.Span, | |
| 31 | +): RpcTelemetryContext => { | |
| 32 | + const headers = HttpTraceContext.toHeaders(span); | |
| 33 | + const traceparent = headers.traceparent; | |
| 34 | + const tracestate = headers.tracestate; | |
| 35 | + | |
| 36 | + if (traceparent === undefined) { | |
| 37 | + return { | |
| 38 | + traceparent: `00-${span.traceId}-${span.spanId}-${ | |
| 39 | + span.sampled ? "01" : "00" | |
| 40 | + }`, | |
| 41 | + tracestate, | |
| 42 | + }; | |
| 43 | + } | |
| 44 | + | |
| 45 | + if (headers.b3 === undefined) { | |
| 46 | + return { traceparent, tracestate }; | |
| 47 | + } | |
| 48 | + | |
| 49 | + return { | |
| 50 | + traceparent, | |
| 51 | + b3: headers.b3, | |
| 52 | + tracestate, | |
| 53 | + }; | |
| 54 | +}; | |
| 55 | + | |
| 56 | +export const makeRpcTransportHeaders = ( | |
| 57 | + span: Tracer.Span, | |
| 58 | +): Readonly<Record<string, string>> => { | |
| 59 | + const telemetryContext = makeRpcTelemetryContext(span); | |
| 60 | + const headers: Record<string, string> = { | |
| 61 | + traceparent: telemetryContext.traceparent, | |
| 62 | + [convexRequestIdHeader]: `${span.traceId}-${span.spanId}`, | |
| 63 | + }; | |
| 64 | + | |
| 65 | + if (telemetryContext.b3 !== undefined) { | |
| 66 | + headers.b3 = telemetryContext.b3; | |
| 67 | + } | |
| 68 | + | |
| 69 | + if (telemetryContext.tracestate !== undefined) { | |
| 70 | + headers.tracestate = telemetryContext.tracestate; | |
| 71 | + } | |
| 72 | + | |
| 73 | + return headers; | |
| 74 | +}; | |
| 75 | + | |
| 76 | +export const withOptionalRpcTelemetryContext = ( | |
| 77 | + _kind: RpcClientKind, | |
| 78 | + payload: unknown, | |
| 79 | + span: Tracer.Span, | |
| 80 | + enablePayloadFallback: boolean, | |
| 81 | +): unknown => { | |
| 82 | + if (!enablePayloadFallback) { | |
| 83 | + return payload; | |
| 84 | + } | |
| 85 | + | |
| 86 | + return withRpcTelemetryContext(payload, makeRpcTelemetryContext(span)); | |
| 87 | +}; | |
| 88 | + | |
| 89 | +export const withRpcTelemetryContext = ( | |
| 90 | + payload: unknown, | |
| 91 | + telemetryContext: RpcTelemetryContext, | |
| 92 | +): unknown => { | |
| 93 | + if (!isRecord(payload)) { | |
| 94 | + return payload; | |
| 95 | + } | |
| 96 | + | |
| 97 | + return { | |
| 98 | + ...payload, | |
| 99 | + [rpcTelemetryContextField]: telemetryContext, | |
| 100 | + }; | |
| 101 | +}; | |
| 102 | + | |
| 103 | +export const extractParentSpanFromPayload = ( | |
| 104 | + payload: unknown, | |
| 105 | +): Option.Option<Tracer.ExternalSpan> => { | |
| 106 | + if (!isRecord(payload)) { | |
| 107 | + return Option.none(); | |
| 108 | + } | |
| 109 | + | |
| 110 | + const rawTelemetryContext = payload[rpcTelemetryContextField]; | |
| 111 | + if (!isRecord(rawTelemetryContext)) { | |
| 112 | + return Option.none(); | |
| 113 | + } | |
| 114 | + | |
| 115 | + const traceparent = Option.getOrUndefined( | |
| 116 | + getRecordValue(rawTelemetryContext, "traceparent"), | |
| 117 | + ); | |
| 118 | + const b3 = Option.getOrUndefined(getRecordValue(rawTelemetryContext, "b3")); | |
| 119 | + const tracestate = Option.getOrUndefined( | |
| 120 | + getRecordValue(rawTelemetryContext, "tracestate"), | |
| 121 | + ); | |
| 122 | + | |
| 123 | + if (traceparent === undefined && b3 === undefined) { | |
| 124 | + return Option.none(); | |
| 125 | + } | |
| 126 | + | |
| 127 | + return HttpTraceContext.fromHeaders( | |
| 128 | + Headers.fromInput({ | |
| 129 | + traceparent, | |
| 130 | + b3, | |
| 131 | + tracestate, | |
| 132 | + }), | |
| 133 | + ); | |
| 134 | +}; | |
| @@ -0,0 +1,235 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { Schema } from "effect"; | |
| 3 | +import { defineTable, defineSchema, type ConfectTableDefinition } from "./schema"; | |
| 4 | + | |
| 5 | +describe("schema", () => { | |
| 6 | + describe("defineTable", () => { | |
| 7 | + it("creates a table definition from Effect Schema", () => { | |
| 8 | + const userTable = defineTable( | |
| 9 | + Schema.Struct({ | |
| 10 | + name: Schema.String, | |
| 11 | + email: Schema.String, | |
| 12 | + }), | |
| 13 | + ); | |
| 14 | + | |
| 15 | + expect(userTable).toBeDefined(); | |
| 16 | + expect(userTable.tableDefinition).toBeDefined(); | |
| 17 | + expect(userTable.tableSchema).toBeDefined(); | |
| 18 | + expect(userTable.documentSchema).toBeDefined(); | |
| 19 | + }); | |
| 20 | + | |
| 21 | + it("includes system fields in documentSchema", () => { | |
| 22 | + const postTable = defineTable( | |
| 23 | + Schema.Struct({ | |
| 24 | + title: Schema.String, | |
| 25 | + content: Schema.String, | |
| 26 | + }), | |
| 27 | + ); | |
| 28 | + | |
| 29 | + const doc = { | |
| 30 | + _id: "123", | |
| 31 | + _creationTime: Date.now(), | |
| 32 | + title: "Hello", | |
| 33 | + content: "World", | |
| 34 | + }; | |
| 35 | + | |
| 36 | + const result = Schema.decodeUnknownSync(postTable.documentSchema)(doc); | |
| 37 | + expect(result).toMatchObject({ | |
| 38 | + _id: "123", | |
| 39 | + title: "Hello", | |
| 40 | + content: "World", | |
| 41 | + }); | |
| 42 | + }); | |
| 43 | + | |
| 44 | + describe("index", () => { | |
| 45 | + it("adds an index to the table definition", () => { | |
| 46 | + const table = defineTable( | |
| 47 | + Schema.Struct({ | |
| 48 | + userId: Schema.String, | |
| 49 | + createdAt: Schema.Number, | |
| 50 | + }), | |
| 51 | + ).index("by_userId", ["userId"]); | |
| 52 | + | |
| 53 | + expect(table).toBeDefined(); | |
| 54 | + }); | |
| 55 | + | |
| 56 | + it("supports compound indexes", () => { | |
| 57 | + const table = defineTable( | |
| 58 | + Schema.Struct({ | |
| 59 | + userId: Schema.String, | |
| 60 | + status: Schema.String, | |
| 61 | + createdAt: Schema.Number, | |
| 62 | + }), | |
| 63 | + ).index("by_userId_and_status", ["userId", "status"]); | |
| 64 | + | |
| 65 | + expect(table).toBeDefined(); | |
| 66 | + }); | |
| 67 | + | |
| 68 | + it("allows chaining multiple indexes", () => { | |
| 69 | + const table = defineTable( | |
| 70 | + Schema.Struct({ | |
| 71 | + userId: Schema.String, | |
| 72 | + email: Schema.String, | |
| 73 | + }), | |
| 74 | + ) | |
| 75 | + .index("by_userId", ["userId"]) | |
| 76 | + .index("by_email", ["email"]); | |
| 77 | + | |
| 78 | + expect(table).toBeDefined(); | |
| 79 | + }); | |
| 80 | + }); | |
| 81 | + | |
| 82 | + describe("searchIndex", () => { | |
| 83 | + it("adds a search index to the table definition", () => { | |
| 84 | + const table = defineTable( | |
| 85 | + Schema.Struct({ | |
| 86 | + title: Schema.String, | |
| 87 | + content: Schema.String, | |
| 88 | + }), | |
| 89 | + ).searchIndex("search_content", { | |
| 90 | + searchField: "content", | |
| 91 | + filterFields: [], | |
| 92 | + }); | |
| 93 | + | |
| 94 | + expect(table).toBeDefined(); | |
| 95 | + }); | |
| 96 | + }); | |
| 97 | + | |
| 98 | + describe("vectorIndex", () => { | |
| 99 | + it("adds a vector index to the table definition", () => { | |
| 100 | + const table = defineTable( | |
| 101 | + Schema.Struct({ | |
| 102 | + embedding: Schema.Array(Schema.Number), | |
| 103 | + category: Schema.String, | |
| 104 | + }), | |
| 105 | + ).vectorIndex("by_embedding", { | |
| 106 | + vectorField: "embedding", | |
| 107 | + dimensions: 1536, | |
| 108 | + filterFields: ["category"], | |
| 109 | + }); | |
| 110 | + | |
| 111 | + expect(table).toBeDefined(); | |
| 112 | + }); | |
| 113 | + }); | |
| 114 | + }); | |
| 115 | + | |
| 116 | + describe("defineSchema", () => { | |
| 117 | + it("creates a schema definition from multiple tables", () => { | |
| 118 | + const schema = defineSchema({ | |
| 119 | + users: defineTable( | |
| 120 | + Schema.Struct({ | |
| 121 | + name: Schema.String, | |
| 122 | + email: Schema.String, | |
| 123 | + }), | |
| 124 | + ), | |
| 125 | + posts: defineTable( | |
| 126 | + Schema.Struct({ | |
| 127 | + title: Schema.String, | |
| 128 | + authorId: Schema.String, | |
| 129 | + }), | |
| 130 | + ), | |
| 131 | + }); | |
| 132 | + | |
| 133 | + expect(schema.tables.users).toBeDefined(); | |
| 134 | + expect(schema.tables.posts).toBeDefined(); | |
| 135 | + expect(schema.convexSchemaDefinition).toBeDefined(); | |
| 136 | + }); | |
| 137 | + | |
| 138 | + it("preserves table definitions in the tables property", () => { | |
| 139 | + const userTable = defineTable( | |
| 140 | + Schema.Struct({ | |
| 141 | + name: Schema.String, | |
| 142 | + }), | |
| 143 | + ); | |
| 144 | + | |
| 145 | + const schema = defineSchema({ | |
| 146 | + users: userTable, | |
| 147 | + }); | |
| 148 | + | |
| 149 | + expect(schema.tables.users).toBe(userTable); | |
| 150 | + }); | |
| 151 | + | |
| 152 | + it("creates convex-compatible schema definition", () => { | |
| 153 | + const schema = defineSchema({ | |
| 154 | + items: defineTable( | |
| 155 | + Schema.Struct({ | |
| 156 | + name: Schema.String, | |
| 157 | + quantity: Schema.Number, | |
| 158 | + }), | |
| 159 | + ), | |
| 160 | + }); | |
| 161 | + | |
| 162 | + expect(schema.convexSchemaDefinition).toBeDefined(); | |
| 163 | + expect(schema.convexSchemaDefinition.tables).toBeDefined(); | |
| 164 | + }); | |
| 165 | + | |
| 166 | + it("handles complex nested schemas", () => { | |
| 167 | + const schema = defineSchema({ | |
| 168 | + orders: defineTable( | |
| 169 | + Schema.Struct({ | |
| 170 | + items: Schema.Array( | |
| 171 | + Schema.Struct({ | |
| 172 | + productId: Schema.String, | |
| 173 | + quantity: Schema.Number, | |
| 174 | + price: Schema.Number, | |
| 175 | + }), | |
| 176 | + ), | |
| 177 | + status: Schema.Union( | |
| 178 | + Schema.Literal("pending"), | |
| 179 | + Schema.Literal("confirmed"), | |
| 180 | + Schema.Literal("shipped"), | |
| 181 | + Schema.Literal("delivered"), | |
| 182 | + ), | |
| 183 | + metadata: Schema.optional( | |
| 184 | + Schema.Struct({ | |
| 185 | + notes: Schema.String, | |
| 186 | + }), | |
| 187 | + ), | |
| 188 | + }), | |
| 189 | + ), | |
| 190 | + }); | |
| 191 | + | |
| 192 | + expect(schema.tables.orders).toBeDefined(); | |
| 193 | + }); | |
| 194 | + }); | |
| 195 | + | |
| 196 | + describe("type inference", () => { | |
| 197 | + it("correctly infers document type with system fields", () => { | |
| 198 | + const table = defineTable( | |
| 199 | + Schema.Struct({ | |
| 200 | + name: Schema.String, | |
| 201 | + age: Schema.Number, | |
| 202 | + }), | |
| 203 | + ); | |
| 204 | + | |
| 205 | + const validDoc = { | |
| 206 | + _id: "doc123", | |
| 207 | + _creationTime: 1234567890, | |
| 208 | + name: "Alice", | |
| 209 | + age: 30, | |
| 210 | + }; | |
| 211 | + | |
| 212 | + const decoded = Schema.decodeUnknownSync(table.documentSchema)(validDoc); | |
| 213 | + expect(decoded).toEqual(validDoc); | |
| 214 | + }); | |
| 215 | + | |
| 216 | + it("rejects documents missing required fields", () => { | |
| 217 | + const table = defineTable( | |
| 218 | + Schema.Struct({ | |
| 219 | + name: Schema.String, | |
| 220 | + required: Schema.String, | |
| 221 | + }), | |
| 222 | + ); | |
| 223 | + | |
| 224 | + const invalidDoc = { | |
| 225 | + _id: "doc123", | |
| 226 | + _creationTime: 1234567890, | |
| 227 | + name: "Alice", | |
| 228 | + }; | |
| 229 | + | |
| 230 | + expect(() => | |
| 231 | + Schema.decodeUnknownSync(table.documentSchema)(invalidDoc), | |
| 232 | + ).toThrow(); | |
| 233 | + }); | |
| 234 | + }); | |
| 235 | +}); | |
| @@ -0,0 +1,400 @@ | ||
| 1 | +import { | |
| 2 | + defineSchema as defineConvexSchema, | |
| 3 | + defineTable as defineConvexTable, | |
| 4 | + type GenericTableIndexes, | |
| 5 | + type GenericTableSearchIndexes, | |
| 6 | + type GenericTableVectorIndexes, | |
| 7 | + type IndexTiebreakerField, | |
| 8 | + type SchemaDefinition, | |
| 9 | + type SearchIndexConfig, | |
| 10 | + type TableDefinition, | |
| 11 | + type VectorIndexConfig, | |
| 12 | + type Expand, | |
| 13 | + type SystemFields, | |
| 14 | + type SystemIndexes, | |
| 15 | + type IdField, | |
| 16 | +} from "convex/server"; | |
| 17 | +import type { Validator } from "convex/values"; | |
| 18 | +import { Record, Schema } from "effect"; | |
| 19 | + | |
| 20 | +import { Id, schemaToObjectValidator } from "./validators"; | |
| 21 | + | |
| 22 | +/** | |
| 23 | + * Recursively removes `readonly` modifiers from all properties and arrays. | |
| 24 | + * | |
| 25 | + * Effect Schema produces readonly types (e.g. `readonly number[]`, `readonly` properties), | |
| 26 | + * but Convex's `GenericDocument` requires mutable `Value[]`. This utility bridges the gap | |
| 27 | + * at the type level — the runtime validators produced by `schemaToObjectValidator` are | |
| 28 | + * already Convex-native (mutable), so this only affects TypeScript inference. | |
| 29 | + */ | |
| 30 | +type DeepMutable<T> = | |
| 31 | + T extends ReadonlyArray<infer U> | |
| 32 | + ? Array<DeepMutable<U>> | |
| 33 | + : T extends object | |
| 34 | + ? { -readonly [K in keyof T]: DeepMutable<T[K]> } | |
| 35 | + : T; | |
| 36 | + | |
| 37 | +const SystemFieldsSchema = Schema.Struct({ | |
| 38 | + _id: Schema.String, | |
| 39 | + _creationTime: Schema.Number, | |
| 40 | +}); | |
| 41 | + | |
| 42 | +const createSystemFieldsSchema = <TableName extends string>(tableName: TableName) => | |
| 43 | + Schema.Struct({ | |
| 44 | + _id: Id(tableName), | |
| 45 | + _creationTime: Schema.Number, | |
| 46 | + }); | |
| 47 | + | |
| 48 | +export interface ConfectTableDefinition< | |
| 49 | + TableSchema extends Schema.Schema.AnyNoContext, | |
| 50 | + TableValidator extends Validator<unknown, "required" | "optional", string> = Validator< | |
| 51 | + DeepMutable<TableSchema["Encoded"]>, | |
| 52 | + "required", | |
| 53 | + string | |
| 54 | + >, | |
| 55 | + Indexes extends GenericTableIndexes = {}, | |
| 56 | + SearchIndexes extends GenericTableSearchIndexes = {}, | |
| 57 | + VectorIndexes extends GenericTableVectorIndexes = {}, | |
| 58 | +> { | |
| 59 | + tableDefinition: TableDefinition< | |
| 60 | + TableValidator, | |
| 61 | + Indexes, | |
| 62 | + SearchIndexes, | |
| 63 | + VectorIndexes | |
| 64 | + >; | |
| 65 | + tableSchema: TableSchema; | |
| 66 | + documentSchema: Schema.Schema.AnyNoContext; | |
| 67 | + | |
| 68 | + index< | |
| 69 | + IndexName extends string, | |
| 70 | + FirstFieldPath extends string, | |
| 71 | + RestFieldPaths extends string[], | |
| 72 | + >( | |
| 73 | + name: IndexName, | |
| 74 | + fields: [FirstFieldPath, ...RestFieldPaths], | |
| 75 | + ): ConfectTableDefinition< | |
| 76 | + TableSchema, | |
| 77 | + TableValidator, | |
| 78 | + Expand< | |
| 79 | + Indexes & | |
| 80 | + Record< | |
| 81 | + IndexName, | |
| 82 | + [FirstFieldPath, ...RestFieldPaths, IndexTiebreakerField] | |
| 83 | + > | |
| 84 | + >, | |
| 85 | + SearchIndexes, | |
| 86 | + VectorIndexes | |
| 87 | + >; | |
| 88 | + | |
| 89 | + searchIndex< | |
| 90 | + IndexName extends string, | |
| 91 | + SearchField extends string, | |
| 92 | + FilterFields extends string = never, | |
| 93 | + >( | |
| 94 | + name: IndexName, | |
| 95 | + indexConfig: Expand<SearchIndexConfig<SearchField, FilterFields>>, | |
| 96 | + ): ConfectTableDefinition< | |
| 97 | + TableSchema, | |
| 98 | + TableValidator, | |
| 99 | + Indexes, | |
| 100 | + Expand< | |
| 101 | + SearchIndexes & | |
| 102 | + Record< | |
| 103 | + IndexName, | |
| 104 | + { | |
| 105 | + searchField: SearchField; | |
| 106 | + filterFields: FilterFields; | |
| 107 | + } | |
| 108 | + > | |
| 109 | + >, | |
| 110 | + VectorIndexes | |
| 111 | + >; | |
| 112 | + | |
| 113 | + vectorIndex< | |
| 114 | + IndexName extends string, | |
| 115 | + VectorField extends string, | |
| 116 | + FilterFields extends string = never, | |
| 117 | + >( | |
| 118 | + name: IndexName, | |
| 119 | + indexConfig: Expand<VectorIndexConfig<VectorField, FilterFields>>, | |
| 120 | + ): ConfectTableDefinition< | |
| 121 | + TableSchema, | |
| 122 | + TableValidator, | |
| 123 | + Indexes, | |
| 124 | + SearchIndexes, | |
| 125 | + Expand< | |
| 126 | + VectorIndexes & | |
| 127 | + Record< | |
| 128 | + IndexName, | |
| 129 | + { | |
| 130 | + vectorField: VectorField; | |
| 131 | + dimensions: number; | |
| 132 | + filterFields: FilterFields; | |
| 133 | + } | |
| 134 | + > | |
| 135 | + > | |
| 136 | + >; | |
| 137 | +} | |
| 138 | + | |
| 139 | +class ConfectTableDefinitionImpl< | |
| 140 | + TableSchema extends Schema.Schema.AnyNoContext, | |
| 141 | + TableValidator extends Validator<unknown, "required" | "optional", string>, | |
| 142 | + Indexes extends GenericTableIndexes = {}, | |
| 143 | + SearchIndexes extends GenericTableSearchIndexes = {}, | |
| 144 | + VectorIndexes extends GenericTableVectorIndexes = {}, | |
| 145 | +> implements | |
| 146 | + ConfectTableDefinition< | |
| 147 | + TableSchema, | |
| 148 | + TableValidator, | |
| 149 | + Indexes, | |
| 150 | + SearchIndexes, | |
| 151 | + VectorIndexes | |
| 152 | + > | |
| 153 | +{ | |
| 154 | + tableSchema: TableSchema; | |
| 155 | + documentSchema: Schema.Schema.AnyNoContext; | |
| 156 | + tableDefinition: TableDefinition< | |
| 157 | + TableValidator, | |
| 158 | + Indexes, | |
| 159 | + SearchIndexes, | |
| 160 | + VectorIndexes | |
| 161 | + >; | |
| 162 | + | |
| 163 | + constructor(tableSchema: TableSchema) { | |
| 164 | + this.tableSchema = tableSchema; | |
| 165 | + this.documentSchema = Schema.extend(SystemFieldsSchema, tableSchema) as unknown as Schema.Schema.AnyNoContext; | |
| 166 | + const tableValidator = schemaToObjectValidator(tableSchema); | |
| 167 | + this.tableDefinition = defineConvexTable( | |
| 168 | + tableValidator, | |
| 169 | + ) as unknown as TableDefinition<TableValidator, Indexes, SearchIndexes, VectorIndexes>; | |
| 170 | + } | |
| 171 | + | |
| 172 | + index< | |
| 173 | + IndexName extends string, | |
| 174 | + FirstFieldPath extends string, | |
| 175 | + RestFieldPaths extends string[], | |
| 176 | + >( | |
| 177 | + name: IndexName, | |
| 178 | + fields: [FirstFieldPath, ...RestFieldPaths], | |
| 179 | + ): ConfectTableDefinition< | |
| 180 | + TableSchema, | |
| 181 | + TableValidator, | |
| 182 | + Expand< | |
| 183 | + Indexes & | |
| 184 | + Record< | |
| 185 | + IndexName, | |
| 186 | + [FirstFieldPath, ...RestFieldPaths, IndexTiebreakerField] | |
| 187 | + > | |
| 188 | + >, | |
| 189 | + SearchIndexes, | |
| 190 | + VectorIndexes | |
| 191 | + > { | |
| 192 | + this.tableDefinition = this.tableDefinition.index( | |
| 193 | + name, | |
| 194 | + fields as unknown as [string, ...string[]], | |
| 195 | + ) as unknown as TableDefinition<TableValidator, Indexes, SearchIndexes, VectorIndexes>; | |
| 196 | + return this as unknown as ConfectTableDefinition< | |
| 197 | + TableSchema, | |
| 198 | + TableValidator, | |
| 199 | + Expand< | |
| 200 | + Indexes & | |
| 201 | + Record< | |
| 202 | + IndexName, | |
| 203 | + [FirstFieldPath, ...RestFieldPaths, IndexTiebreakerField] | |
| 204 | + > | |
| 205 | + >, | |
| 206 | + SearchIndexes, | |
| 207 | + VectorIndexes | |
| 208 | + >; | |
| 209 | + } | |
| 210 | + | |
| 211 | + searchIndex< | |
| 212 | + IndexName extends string, | |
| 213 | + SearchField extends string, | |
| 214 | + FilterFields extends string = never, | |
| 215 | + >( | |
| 216 | + name: IndexName, | |
| 217 | + indexConfig: Expand<SearchIndexConfig<SearchField, FilterFields>>, | |
| 218 | + ): ConfectTableDefinition< | |
| 219 | + TableSchema, | |
| 220 | + TableValidator, | |
| 221 | + Indexes, | |
| 222 | + Expand< | |
| 223 | + SearchIndexes & | |
| 224 | + Record< | |
| 225 | + IndexName, | |
| 226 | + { | |
| 227 | + searchField: SearchField; | |
| 228 | + filterFields: FilterFields; | |
| 229 | + } | |
| 230 | + > | |
| 231 | + >, | |
| 232 | + VectorIndexes | |
| 233 | + > { | |
| 234 | + this.tableDefinition = this.tableDefinition.searchIndex( | |
| 235 | + name, | |
| 236 | + indexConfig as unknown as SearchIndexConfig<string, string>, | |
| 237 | + ) as unknown as TableDefinition<TableValidator, Indexes, SearchIndexes, VectorIndexes>; | |
| 238 | + return this as unknown as ConfectTableDefinition< | |
| 239 | + TableSchema, | |
| 240 | + TableValidator, | |
| 241 | + Indexes, | |
| 242 | + Expand< | |
| 243 | + SearchIndexes & | |
| 244 | + Record< | |
| 245 | + IndexName, | |
| 246 | + { | |
| 247 | + searchField: SearchField; | |
| 248 | + filterFields: FilterFields; | |
| 249 | + } | |
| 250 | + > | |
| 251 | + >, | |
| 252 | + VectorIndexes | |
| 253 | + >; | |
| 254 | + } | |
| 255 | + | |
| 256 | + vectorIndex< | |
| 257 | + IndexName extends string, | |
| 258 | + VectorField extends string, | |
| 259 | + FilterFields extends string = never, | |
| 260 | + >( | |
| 261 | + name: IndexName, | |
| 262 | + indexConfig: Expand<VectorIndexConfig<VectorField, FilterFields>>, | |
| 263 | + ): ConfectTableDefinition< | |
| 264 | + TableSchema, | |
| 265 | + TableValidator, | |
| 266 | + Indexes, | |
| 267 | + SearchIndexes, | |
| 268 | + Expand< | |
| 269 | + VectorIndexes & | |
| 270 | + Record< | |
| 271 | + IndexName, | |
| 272 | + { | |
| 273 | + vectorField: VectorField; | |
| 274 | + dimensions: number; | |
| 275 | + filterFields: FilterFields; | |
| 276 | + } | |
| 277 | + > | |
| 278 | + > | |
| 279 | + > { | |
| 280 | + this.tableDefinition = this.tableDefinition.vectorIndex( | |
| 281 | + name, | |
| 282 | + indexConfig as unknown as VectorIndexConfig<string, string>, | |
| 283 | + ) as unknown as TableDefinition<TableValidator, Indexes, SearchIndexes, VectorIndexes>; | |
| 284 | + return this as unknown as ConfectTableDefinition< | |
| 285 | + TableSchema, | |
| 286 | + TableValidator, | |
| 287 | + Indexes, | |
| 288 | + SearchIndexes, | |
| 289 | + Expand< | |
| 290 | + VectorIndexes & | |
| 291 | + Record< | |
| 292 | + IndexName, | |
| 293 | + { | |
| 294 | + vectorField: VectorField; | |
| 295 | + dimensions: number; | |
| 296 | + filterFields: FilterFields; | |
| 297 | + } | |
| 298 | + > | |
| 299 | + > | |
| 300 | + >; | |
| 301 | + } | |
| 302 | +} | |
| 303 | + | |
| 304 | +export const defineTable = <TableSchema extends Schema.Schema.AnyNoContext>( | |
| 305 | + tableSchema: TableSchema, | |
| 306 | +): ConfectTableDefinition<TableSchema> => { | |
| 307 | + return new ConfectTableDefinitionImpl(tableSchema); | |
| 308 | +}; | |
| 309 | + | |
| 310 | +export type GenericConfectSchema = { | |
| 311 | + [tableName: string]: ConfectTableDefinition<Schema.Schema.AnyNoContext>; | |
| 312 | +}; | |
| 313 | + | |
| 314 | +export interface ConfectSchemaDefinition< | |
| 315 | + Tables extends GenericConfectSchema, | |
| 316 | +> { | |
| 317 | + tables: Tables; | |
| 318 | + convexSchemaDefinition: SchemaDefinition< | |
| 319 | + { | |
| 320 | + [K in keyof Tables & string]: Tables[K]["tableDefinition"]; | |
| 321 | + }, | |
| 322 | + true | |
| 323 | + >; | |
| 324 | +} | |
| 325 | + | |
| 326 | +export const defineSchema = <Tables extends GenericConfectSchema>( | |
| 327 | + tables: Tables, | |
| 328 | +): ConfectSchemaDefinition<Tables> => { | |
| 329 | + for (const [tableName, table] of Object.entries(tables)) { | |
| 330 | + table.documentSchema = Schema.extend( | |
| 331 | + createSystemFieldsSchema(tableName), | |
| 332 | + table.tableSchema, | |
| 333 | + ); | |
| 334 | + } | |
| 335 | + | |
| 336 | + const convexTables = Record.map( | |
| 337 | + tables, | |
| 338 | + (table) => table.tableDefinition, | |
| 339 | + ) as { [K in keyof Tables & string]: Tables[K]["tableDefinition"] }; | |
| 340 | + | |
| 341 | + return { | |
| 342 | + tables, | |
| 343 | + convexSchemaDefinition: defineConvexSchema(convexTables), | |
| 344 | + }; | |
| 345 | +}; | |
| 346 | + | |
| 347 | +export type TableNamesInSchema<T extends GenericConfectSchema> = keyof T & string; | |
| 348 | + | |
| 349 | +export type DocumentFromTable< | |
| 350 | + Tables extends GenericConfectSchema, | |
| 351 | + TableName extends TableNamesInSchema<Tables>, | |
| 352 | +> = Expand< | |
| 353 | + IdField<TableName> & | |
| 354 | + SystemFields & | |
| 355 | + DeepMutable<Tables[TableName]["tableSchema"]["Type"]> | |
| 356 | +>; | |
| 357 | + | |
| 358 | +export type EncodedDocumentFromTable< | |
| 359 | + Tables extends GenericConfectSchema, | |
| 360 | + TableName extends TableNamesInSchema<Tables>, | |
| 361 | +> = Expand< | |
| 362 | + IdField<TableName> & | |
| 363 | + SystemFields & | |
| 364 | + DeepMutable<Tables[TableName]["tableSchema"]["Encoded"]> | |
| 365 | +>; | |
| 366 | + | |
| 367 | +export type ConfectDataModel<Tables extends GenericConfectSchema> = { | |
| 368 | + [TableName in TableNamesInSchema<Tables>]: { | |
| 369 | + document: DocumentFromTable<Tables, TableName>; | |
| 370 | + encodedDocument: EncodedDocumentFromTable<Tables, TableName>; | |
| 371 | + indexes: Tables[TableName]["tableDefinition"] extends TableDefinition< | |
| 372 | + infer _V, | |
| 373 | + infer I, | |
| 374 | + infer _S, | |
| 375 | + infer _Vec | |
| 376 | + > | |
| 377 | + ? Expand<I & SystemIndexes> | |
| 378 | + : SystemIndexes; | |
| 379 | + searchIndexes: Tables[TableName]["tableDefinition"] extends TableDefinition< | |
| 380 | + infer _V, | |
| 381 | + infer _I, | |
| 382 | + infer S, | |
| 383 | + infer _Vec | |
| 384 | + > | |
| 385 | + ? S | |
| 386 | + : {}; | |
| 387 | + vectorIndexes: Tables[TableName]["tableDefinition"] extends TableDefinition< | |
| 388 | + infer _V, | |
| 389 | + infer _I, | |
| 390 | + infer _S, | |
| 391 | + infer Vec | |
| 392 | + > | |
| 393 | + ? Vec | |
| 394 | + : {}; | |
| 395 | + }; | |
| 396 | +}; | |
| 397 | + | |
| 398 | +export type TablesFromSchemaDefinition< | |
| 399 | + S extends ConfectSchemaDefinition<GenericConfectSchema>, | |
| 400 | +> = S extends ConfectSchemaDefinition<infer T> ? T : never; | |
| @@ -0,0 +1,129 @@ | ||
| 1 | +import type { FunctionReference, FunctionReturnType, SchemaDefinition, GenericSchema } from "convex/server"; | |
| 2 | +import { Effect, Layer, Stream } from "effect"; | |
| 3 | +import { | |
| 4 | + ConvexClient, | |
| 5 | + type ConvexClientService, | |
| 6 | + type ConvexRequestMetadata, | |
| 7 | +} from "./client"; | |
| 8 | + | |
| 9 | +export interface ConvexTestInstance { | |
| 10 | + query<Query extends FunctionReference<"query">>( | |
| 11 | + query: Query, | |
| 12 | + args: Query["_args"], | |
| 13 | + ): Promise<FunctionReturnType<Query>>; | |
| 14 | + | |
| 15 | + mutation<Mutation extends FunctionReference<"mutation">>( | |
| 16 | + mutation: Mutation, | |
| 17 | + args: Mutation["_args"], | |
| 18 | + ): Promise<FunctionReturnType<Mutation>>; | |
| 19 | + | |
| 20 | + action<Action extends FunctionReference<"action">>( | |
| 21 | + action: Action, | |
| 22 | + args: Action["_args"], | |
| 23 | + ): Promise<FunctionReturnType<Action>>; | |
| 24 | + | |
| 25 | + onUpdate<Query extends FunctionReference<"query">>( | |
| 26 | + query: Query, | |
| 27 | + args: Query["_args"], | |
| 28 | + callback: (result: FunctionReturnType<Query>) => void, | |
| 29 | + ): () => void; | |
| 30 | + | |
| 31 | + run<Output>( | |
| 32 | + func: (ctx: { db: unknown; storage: unknown }) => Promise<Output>, | |
| 33 | + ): Promise<Output>; | |
| 34 | +} | |
| 35 | + | |
| 36 | +export const ConvexClientTestLayer = ( | |
| 37 | + testClient: ConvexTestInstance, | |
| 38 | +): Layer.Layer<ConvexClient> => { | |
| 39 | + const service: ConvexClientService = { | |
| 40 | + query: <Query extends FunctionReference<"query">>( | |
| 41 | + query: Query, | |
| 42 | + args: Query["_args"], | |
| 43 | + _requestMetadata?: ConvexRequestMetadata, | |
| 44 | + ): Effect.Effect<FunctionReturnType<Query>> => | |
| 45 | + Effect.promise(() => testClient.query(query, args)), | |
| 46 | + | |
| 47 | + mutation: <Mutation extends FunctionReference<"mutation">>( | |
| 48 | + mutation: Mutation, | |
| 49 | + args: Mutation["_args"], | |
| 50 | + _requestMetadata?: ConvexRequestMetadata, | |
| 51 | + ): Effect.Effect<FunctionReturnType<Mutation>> => | |
| 52 | + Effect.promise(() => testClient.mutation(mutation, args)), | |
| 53 | + | |
| 54 | + action: <Action extends FunctionReference<"action">>( | |
| 55 | + action: Action, | |
| 56 | + args: Action["_args"], | |
| 57 | + _requestMetadata?: ConvexRequestMetadata, | |
| 58 | + ): Effect.Effect<FunctionReturnType<Action>> => | |
| 59 | + Effect.promise(() => testClient.action(action, args)), | |
| 60 | + | |
| 61 | + subscribe: <Query extends FunctionReference<"query">>( | |
| 62 | + query: Query, | |
| 63 | + args: Query["_args"], | |
| 64 | + ): Stream.Stream<FunctionReturnType<Query>> => | |
| 65 | + Stream.async<FunctionReturnType<Query>>((emit) => { | |
| 66 | + const unsubscribe = testClient.onUpdate(query, args, (result) => { | |
| 67 | + emit.single(result); | |
| 68 | + }); | |
| 69 | + return Effect.sync(() => { | |
| 70 | + unsubscribe(); | |
| 71 | + }); | |
| 72 | + }), | |
| 73 | + }; | |
| 74 | + | |
| 75 | + return Layer.succeed(ConvexClient, service); | |
| 76 | +}; | |
| 77 | + | |
| 78 | +export interface ConvexTestFactory { | |
| 79 | + <SchemaDef extends SchemaDefinition<GenericSchema, boolean>>( | |
| 80 | + schema: SchemaDef, | |
| 81 | + modules?: Record<string, () => Promise<unknown>>, | |
| 82 | + ): ConvexTestInstance; | |
| 83 | +} | |
| 84 | + | |
| 85 | +export interface MakeTestLayerOptions<SchemaDef extends SchemaDefinition<GenericSchema, boolean>> { | |
| 86 | + readonly schema: SchemaDef; | |
| 87 | + readonly modules?: Record<string, () => Promise<unknown>>; | |
| 88 | + readonly convexTest: ConvexTestFactory; | |
| 89 | +} | |
| 90 | + | |
| 91 | +export interface ConvexTestLayer extends Layer.Layer<ConvexClient> { | |
| 92 | + readonly testClient: ConvexTestInstance; | |
| 93 | +} | |
| 94 | + | |
| 95 | +export const makeTestLayer = <SchemaDef extends SchemaDefinition<GenericSchema, boolean>>( | |
| 96 | + options: MakeTestLayerOptions<SchemaDef>, | |
| 97 | +): ConvexTestLayer => { | |
| 98 | + const testClient = options.convexTest(options.schema, options.modules); | |
| 99 | + const layer = ConvexClientTestLayer(testClient); | |
| 100 | + return Object.assign(layer, { testClient }); | |
| 101 | +}; | |
| 102 | + | |
| 103 | +export interface CreateTestContextOptions<SchemaDef extends SchemaDefinition<GenericSchema, boolean>> { | |
| 104 | + readonly schema: SchemaDef; | |
| 105 | + readonly modules?: Record<string, () => Promise<unknown>>; | |
| 106 | + readonly convexTest: ConvexTestFactory; | |
| 107 | +} | |
| 108 | + | |
| 109 | +export interface TestContext { | |
| 110 | + readonly layer: Layer.Layer<ConvexClient>; | |
| 111 | + readonly testClient: ConvexTestInstance; | |
| 112 | + readonly runEffect: <A, E>(effect: Effect.Effect<A, E, ConvexClient>) => Promise<A>; | |
| 113 | +} | |
| 114 | + | |
| 115 | +export const createTestContext = <SchemaDef extends SchemaDefinition<GenericSchema, boolean>>( | |
| 116 | + options: CreateTestContextOptions<SchemaDef>, | |
| 117 | +): TestContext => { | |
| 118 | + const testClient = options.convexTest(options.schema, options.modules); | |
| 119 | + const layer = ConvexClientTestLayer(testClient); | |
| 120 | + | |
| 121 | + const runEffect = <A, E>(effect: Effect.Effect<A, E, ConvexClient>): Promise<A> => | |
| 122 | + Effect.runPromise(Effect.provide(effect, layer)); | |
| 123 | + | |
| 124 | + return { | |
| 125 | + layer, | |
| 126 | + testClient, | |
| 127 | + runEffect, | |
| 128 | + }; | |
| 129 | +}; | |
| @@ -0,0 +1,247 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { Schema } from "effect"; | |
| 3 | +import { | |
| 4 | + schemaToValidator, | |
| 5 | + schemaToObjectValidator, | |
| 6 | + Id, | |
| 7 | + TopLevelMustBeObjectError, | |
| 8 | + IndexSignaturesNotSupportedError, | |
| 9 | +} from "./validators"; | |
| 10 | + | |
| 11 | +const getKind = (validator: unknown): string => | |
| 12 | + (validator as { kind: string }).kind; | |
| 13 | + | |
| 14 | +const getValue = (validator: unknown): unknown => | |
| 15 | + (validator as { value: unknown }).value; | |
| 16 | + | |
| 17 | +const getTableName = (validator: unknown): string => | |
| 18 | + (validator as { tableName: string }).tableName; | |
| 19 | + | |
| 20 | +const getIsOptional = (validator: unknown): string => | |
| 21 | + (validator as { isOptional: string }).isOptional; | |
| 22 | + | |
| 23 | +describe("validators", () => { | |
| 24 | + describe("schemaToValidator", () => { | |
| 25 | + describe("primitive types", () => { | |
| 26 | + it("converts Schema.String to v.string()", () => { | |
| 27 | + const validator = schemaToValidator(Schema.String); | |
| 28 | + expect(getKind(validator)).toBe("string"); | |
| 29 | + }); | |
| 30 | + | |
| 31 | + it("converts Schema.Number to v.float64()", () => { | |
| 32 | + const validator = schemaToValidator(Schema.Number); | |
| 33 | + expect(getKind(validator)).toBe("float64"); | |
| 34 | + }); | |
| 35 | + | |
| 36 | + it("converts Schema.Boolean to v.boolean()", () => { | |
| 37 | + const validator = schemaToValidator(Schema.Boolean); | |
| 38 | + expect(getKind(validator)).toBe("boolean"); | |
| 39 | + }); | |
| 40 | + | |
| 41 | + it("converts Schema.BigInt to v.int64()", () => { | |
| 42 | + const validator = schemaToValidator(Schema.BigIntFromSelf); | |
| 43 | + expect(getKind(validator)).toBe("int64"); | |
| 44 | + }); | |
| 45 | + | |
| 46 | + it("converts Schema.Null to v.null()", () => { | |
| 47 | + const validator = schemaToValidator(Schema.Null); | |
| 48 | + expect(getKind(validator)).toBe("null"); | |
| 49 | + }); | |
| 50 | + }); | |
| 51 | + | |
| 52 | + describe("literal types", () => { | |
| 53 | + it("converts string literal", () => { | |
| 54 | + const validator = schemaToValidator(Schema.Literal("hello")); | |
| 55 | + expect(getKind(validator)).toBe("literal"); | |
| 56 | + expect(getValue(validator)).toBe("hello"); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + it("converts number literal", () => { | |
| 60 | + const validator = schemaToValidator(Schema.Literal(42)); | |
| 61 | + expect(getKind(validator)).toBe("literal"); | |
| 62 | + expect(getValue(validator)).toBe(42); | |
| 63 | + }); | |
| 64 | + | |
| 65 | + it("converts boolean literal", () => { | |
| 66 | + const validator = schemaToValidator(Schema.Literal(true)); | |
| 67 | + expect(getKind(validator)).toBe("literal"); | |
| 68 | + expect(getValue(validator)).toBe(true); | |
| 69 | + }); | |
| 70 | + }); | |
| 71 | + | |
| 72 | + describe("object types", () => { | |
| 73 | + it("converts simple struct", () => { | |
| 74 | + const schema = Schema.Struct({ | |
| 75 | + name: Schema.String, | |
| 76 | + age: Schema.Number, | |
| 77 | + }); | |
| 78 | + const validator = schemaToValidator(schema); | |
| 79 | + expect(getKind(validator)).toBe("object"); | |
| 80 | + }); | |
| 81 | + | |
| 82 | + it("converts nested struct", () => { | |
| 83 | + const schema = Schema.Struct({ | |
| 84 | + user: Schema.Struct({ | |
| 85 | + name: Schema.String, | |
| 86 | + email: Schema.String, | |
| 87 | + }), | |
| 88 | + active: Schema.Boolean, | |
| 89 | + }); | |
| 90 | + const validator = schemaToValidator(schema); | |
| 91 | + expect(getKind(validator)).toBe("object"); | |
| 92 | + }); | |
| 93 | + | |
| 94 | + it("handles optional fields", () => { | |
| 95 | + const schema = Schema.Struct({ | |
| 96 | + required: Schema.String, | |
| 97 | + optional: Schema.optional(Schema.String), | |
| 98 | + }); | |
| 99 | + const validator = schemaToValidator(schema); | |
| 100 | + expect(getKind(validator)).toBe("object"); | |
| 101 | + }); | |
| 102 | + }); | |
| 103 | + | |
| 104 | + describe("array types", () => { | |
| 105 | + it("converts array of primitives", () => { | |
| 106 | + const schema = Schema.Array(Schema.String); | |
| 107 | + const validator = schemaToValidator(schema); | |
| 108 | + expect(getKind(validator)).toBe("array"); | |
| 109 | + }); | |
| 110 | + | |
| 111 | + it("converts array of objects", () => { | |
| 112 | + const schema = Schema.Array( | |
| 113 | + Schema.Struct({ | |
| 114 | + id: Schema.String, | |
| 115 | + value: Schema.Number, | |
| 116 | + }), | |
| 117 | + ); | |
| 118 | + const validator = schemaToValidator(schema); | |
| 119 | + expect(getKind(validator)).toBe("array"); | |
| 120 | + }); | |
| 121 | + }); | |
| 122 | + | |
| 123 | + describe("union types", () => { | |
| 124 | + it("converts union of primitives", () => { | |
| 125 | + const schema = Schema.Union(Schema.String, Schema.Number); | |
| 126 | + const validator = schemaToValidator(schema); | |
| 127 | + expect(getKind(validator)).toBe("union"); | |
| 128 | + }); | |
| 129 | + | |
| 130 | + it("converts union of literals", () => { | |
| 131 | + const schema = Schema.Union( | |
| 132 | + Schema.Literal("active"), | |
| 133 | + Schema.Literal("inactive"), | |
| 134 | + Schema.Literal("pending"), | |
| 135 | + ); | |
| 136 | + const validator = schemaToValidator(schema); | |
| 137 | + expect(getKind(validator)).toBe("union"); | |
| 138 | + }); | |
| 139 | + | |
| 140 | + it("converts nullable types", () => { | |
| 141 | + const schema = Schema.NullOr(Schema.String); | |
| 142 | + const validator = schemaToValidator(schema); | |
| 143 | + expect(getKind(validator)).toBe("union"); | |
| 144 | + }); | |
| 145 | + }); | |
| 146 | + | |
| 147 | + describe("Id type", () => { | |
| 148 | + it("converts Id schema to v.id()", () => { | |
| 149 | + const schema = Id("users"); | |
| 150 | + const validator = schemaToValidator(schema); | |
| 151 | + expect(getKind(validator)).toBe("id"); | |
| 152 | + expect(getTableName(validator)).toBe("users"); | |
| 153 | + }); | |
| 154 | + | |
| 155 | + it("works with different table names", () => { | |
| 156 | + const postsId = Id("posts"); | |
| 157 | + const commentsId = Id("comments"); | |
| 158 | + | |
| 159 | + const postsValidator = schemaToValidator(postsId); | |
| 160 | + const commentsValidator = schemaToValidator(commentsId); | |
| 161 | + | |
| 162 | + expect(getTableName(postsValidator)).toBe("posts"); | |
| 163 | + expect(getTableName(commentsValidator)).toBe("comments"); | |
| 164 | + }); | |
| 165 | + }); | |
| 166 | + | |
| 167 | + describe("refinement types", () => { | |
| 168 | + it("unwraps refinements to base type", () => { | |
| 169 | + const schema = Schema.String.pipe(Schema.minLength(1)); | |
| 170 | + const validator = schemaToValidator(schema); | |
| 171 | + expect(getKind(validator)).toBe("string"); | |
| 172 | + }); | |
| 173 | + | |
| 174 | + it("unwraps multiple refinements", () => { | |
| 175 | + const schema = Schema.Number.pipe(Schema.positive(), Schema.int()); | |
| 176 | + const validator = schemaToValidator(schema); | |
| 177 | + expect(getKind(validator)).toBe("float64"); | |
| 178 | + }); | |
| 179 | + }); | |
| 180 | + | |
| 181 | + describe("any/unknown types", () => { | |
| 182 | + it("converts Schema.Unknown to v.any()", () => { | |
| 183 | + const validator = schemaToValidator(Schema.Unknown); | |
| 184 | + expect(getKind(validator)).toBe("any"); | |
| 185 | + }); | |
| 186 | + | |
| 187 | + it("converts Schema.Any to v.any()", () => { | |
| 188 | + const validator = schemaToValidator(Schema.Any); | |
| 189 | + expect(getKind(validator)).toBe("any"); | |
| 190 | + }); | |
| 191 | + }); | |
| 192 | + }); | |
| 193 | + | |
| 194 | + describe("schemaToObjectValidator", () => { | |
| 195 | + it("extracts fields from struct schema", () => { | |
| 196 | + const schema = Schema.Struct({ | |
| 197 | + name: Schema.String, | |
| 198 | + count: Schema.Number, | |
| 199 | + }); | |
| 200 | + const fields = schemaToObjectValidator(schema); | |
| 201 | + | |
| 202 | + expect(fields.name).toBeDefined(); | |
| 203 | + expect(fields.count).toBeDefined(); | |
| 204 | + }); | |
| 205 | + | |
| 206 | + it("marks optional fields correctly", () => { | |
| 207 | + const schema = Schema.Struct({ | |
| 208 | + required: Schema.String, | |
| 209 | + optional: Schema.optional(Schema.Number), | |
| 210 | + }); | |
| 211 | + const fields = schemaToObjectValidator(schema); | |
| 212 | + | |
| 213 | + expect(getIsOptional(fields.required)).toBe("required"); | |
| 214 | + expect(getIsOptional(fields.optional)).toBe("optional"); | |
| 215 | + }); | |
| 216 | + | |
| 217 | + it("throws TopLevelMustBeObjectError for non-object schemas", () => { | |
| 218 | + const schema = Schema.String; | |
| 219 | + expect(() => schemaToObjectValidator(schema)).toThrow( | |
| 220 | + TopLevelMustBeObjectError, | |
| 221 | + ); | |
| 222 | + }); | |
| 223 | + | |
| 224 | + it("throws IndexSignaturesNotSupportedError for record types", () => { | |
| 225 | + const schema = Schema.Record({ | |
| 226 | + key: Schema.String, | |
| 227 | + value: Schema.Number, | |
| 228 | + }); | |
| 229 | + expect(() => schemaToObjectValidator(schema)).toThrow( | |
| 230 | + IndexSignaturesNotSupportedError, | |
| 231 | + ); | |
| 232 | + }); | |
| 233 | + }); | |
| 234 | + | |
| 235 | + describe("Id helper", () => { | |
| 236 | + it("creates a branded string schema", () => { | |
| 237 | + const UserId = Id("users"); | |
| 238 | + const decoded = Schema.decodeSync(UserId)("user_123"); | |
| 239 | + expect(decoded).toBe("user_123"); | |
| 240 | + }); | |
| 241 | + | |
| 242 | + it("includes table name in identifier annotation", () => { | |
| 243 | + const UserId = Id("users"); | |
| 244 | + expect(UserId.ast.annotations).toBeDefined(); | |
| 245 | + }); | |
| 246 | + }); | |
| 247 | +}); | |
| @@ -0,0 +1,217 @@ | ||
| 1 | +import { v } from "convex/values"; | |
| 2 | +import type { GenericId, Validator } from "convex/values"; | |
| 3 | +import { Data, Option, Schema, SchemaAST } from "effect"; | |
| 4 | + | |
| 5 | +const getIdTableName = (ast: SchemaAST.AST): Option.Option<string> => { | |
| 6 | + const annotations = ast.annotations; | |
| 7 | + const identifier = annotations[SchemaAST.IdentifierAnnotationId]; | |
| 8 | + if (typeof identifier === "string" && identifier.startsWith("Id<")) { | |
| 9 | + const match = identifier.match(/^Id<"?([^">]+)"?>$/); | |
| 10 | + if (match?.[1]) { | |
| 11 | + return Option.some(match[1]); | |
| 12 | + } | |
| 13 | + } | |
| 14 | + return Option.none(); | |
| 15 | +}; | |
| 16 | + | |
| 17 | +export class UnsupportedSchemaError extends Data.TaggedError("UnsupportedSchemaError")<{ | |
| 18 | + readonly schemaType: string; | |
| 19 | +}> {} | |
| 20 | + | |
| 21 | +export class TopLevelMustBeObjectError extends Data.TaggedError("TopLevelMustBeObjectError")<{}> {} | |
| 22 | + | |
| 23 | +export class IndexSignaturesNotSupportedError extends Data.TaggedError( | |
| 24 | + "IndexSignaturesNotSupportedError", | |
| 25 | +)<{}> {} | |
| 26 | + | |
| 27 | +type AnyValidator = Validator<unknown, "required" | "optional", string>; | |
| 28 | +type RequiredValidator = Validator<unknown, "required", string>; | |
| 29 | + | |
| 30 | +const compileAst = ( | |
| 31 | + ast: SchemaAST.AST, | |
| 32 | + isOptionalProperty = false, | |
| 33 | +): AnyValidator => { | |
| 34 | + switch (ast._tag) { | |
| 35 | + case "Literal": { | |
| 36 | + const literal = ast.literal; | |
| 37 | + if (literal === null) { | |
| 38 | + return v.null(); | |
| 39 | + } | |
| 40 | + if (typeof literal === "string" || typeof literal === "number" || typeof literal === "bigint" || typeof literal === "boolean") { | |
| 41 | + return v.literal(literal); | |
| 42 | + } | |
| 43 | + throw new UnsupportedSchemaError({ schemaType: "Literal" }); | |
| 44 | + } | |
| 45 | + | |
| 46 | + case "BooleanKeyword": | |
| 47 | + return v.boolean(); | |
| 48 | + | |
| 49 | + case "StringKeyword": { | |
| 50 | + const tableName = getIdTableName(ast); | |
| 51 | + if (Option.isSome(tableName)) { | |
| 52 | + return v.id(tableName.value); | |
| 53 | + } | |
| 54 | + return v.string(); | |
| 55 | + } | |
| 56 | + | |
| 57 | + case "NumberKeyword": | |
| 58 | + return v.float64(); | |
| 59 | + | |
| 60 | + case "BigIntKeyword": | |
| 61 | + return v.int64(); | |
| 62 | + | |
| 63 | + case "Union": | |
| 64 | + return handleUnion(ast, isOptionalProperty); | |
| 65 | + | |
| 66 | + case "TypeLiteral": | |
| 67 | + return handleTypeLiteral(ast); | |
| 68 | + | |
| 69 | + case "TupleType": | |
| 70 | + return handleTupleType(ast); | |
| 71 | + | |
| 72 | + case "UnknownKeyword": | |
| 73 | + case "AnyKeyword": | |
| 74 | + return v.any(); | |
| 75 | + | |
| 76 | + case "Refinement": | |
| 77 | + return compileAst(ast.from); | |
| 78 | + | |
| 79 | + case "Suspend": | |
| 80 | + return v.any(); | |
| 81 | + | |
| 82 | + case "Declaration": | |
| 83 | + return v.bytes(); | |
| 84 | + | |
| 85 | + default: | |
| 86 | + throw new UnsupportedSchemaError({ schemaType: ast._tag }); | |
| 87 | + } | |
| 88 | +}; | |
| 89 | + | |
| 90 | +const handleUnion = ( | |
| 91 | + unionAst: SchemaAST.Union, | |
| 92 | + isOptionalProperty: boolean, | |
| 93 | +): AnyValidator => { | |
| 94 | + const types = unionAst.types; | |
| 95 | + const filteredTypes = isOptionalProperty | |
| 96 | + ? types.filter((type) => !SchemaAST.isUndefinedKeyword(type)) | |
| 97 | + : types; | |
| 98 | + | |
| 99 | + const validators: Array<AnyValidator> = []; | |
| 100 | + for (const type of filteredTypes) { | |
| 101 | + validators.push(compileAst(type)); | |
| 102 | + } | |
| 103 | + | |
| 104 | + if (validators.length === 0) { | |
| 105 | + return v.any(); | |
| 106 | + } | |
| 107 | + if (validators.length === 1) { | |
| 108 | + return validators[0]!; | |
| 109 | + } | |
| 110 | + | |
| 111 | + const [first, second, ...rest] = validators; | |
| 112 | + return v.union( | |
| 113 | + first as RequiredValidator, | |
| 114 | + second as RequiredValidator, | |
| 115 | + ...(rest as Array<RequiredValidator>), | |
| 116 | + ); | |
| 117 | +}; | |
| 118 | + | |
| 119 | +const handleTypeLiteral = ( | |
| 120 | + typeLiteralAst: SchemaAST.TypeLiteral, | |
| 121 | +): AnyValidator => { | |
| 122 | + if (typeLiteralAst.indexSignatures.length > 0) { | |
| 123 | + throw new IndexSignaturesNotSupportedError(); | |
| 124 | + } | |
| 125 | + | |
| 126 | + const fields: Record<string, AnyValidator> = {}; | |
| 127 | + | |
| 128 | + for (const prop of typeLiteralAst.propertySignatures) { | |
| 129 | + if (typeof prop.name !== "string") { | |
| 130 | + continue; | |
| 131 | + } | |
| 132 | + | |
| 133 | + const validator = compileAst(prop.type, prop.isOptional); | |
| 134 | + fields[prop.name] = prop.isOptional ? v.optional(validator) : validator; | |
| 135 | + } | |
| 136 | + | |
| 137 | + return v.object(fields); | |
| 138 | +}; | |
| 139 | + | |
| 140 | +const handleTupleType = ( | |
| 141 | + tupleTypeAst: SchemaAST.TupleType, | |
| 142 | +): AnyValidator => { | |
| 143 | + const { elements, rest } = tupleTypeAst; | |
| 144 | + | |
| 145 | + if (elements.length === 0 && rest.length > 0) { | |
| 146 | + const itemValidator = compileAst(rest[0]!.type); | |
| 147 | + return v.array(itemValidator as RequiredValidator); | |
| 148 | + } | |
| 149 | + | |
| 150 | + if (elements.length > 0) { | |
| 151 | + const elementValidators: Array<AnyValidator> = []; | |
| 152 | + for (const el of elements) { | |
| 153 | + elementValidators.push(compileAst(el.type)); | |
| 154 | + } | |
| 155 | + | |
| 156 | + if (elementValidators.length === 1) { | |
| 157 | + return v.array(elementValidators[0] as RequiredValidator); | |
| 158 | + } | |
| 159 | + | |
| 160 | + const [first, second, ...restValidators] = elementValidators; | |
| 161 | + return v.array( | |
| 162 | + v.union( | |
| 163 | + first as RequiredValidator, | |
| 164 | + second as RequiredValidator, | |
| 165 | + ...(restValidators as Array<RequiredValidator>), | |
| 166 | + ), | |
| 167 | + ); | |
| 168 | + } | |
| 169 | + | |
| 170 | + return v.array(v.any()); | |
| 171 | +}; | |
| 172 | + | |
| 173 | +export const schemaToValidator = <S extends Schema.Schema.AnyNoContext>( | |
| 174 | + schema: S, | |
| 175 | +): Validator<S["Encoded"], "required", string> => { | |
| 176 | + const ast = Schema.encodedSchema(schema).ast; | |
| 177 | + return compileAst(ast) as Validator<S["Encoded"], "required", string>; | |
| 178 | +}; | |
| 179 | + | |
| 180 | +type IdSchema<TableName extends string> = Schema.Schema< | |
| 181 | + GenericId<TableName>, | |
| 182 | + string | |
| 183 | +>; | |
| 184 | + | |
| 185 | +export const Id: <TableName extends string>( | |
| 186 | + tableName: TableName, | |
| 187 | +) => IdSchema<TableName> = <TableName extends string>(tableName: TableName) => | |
| 188 | + Schema.String.annotations({ | |
| 189 | + identifier: `Id<${tableName}>`, | |
| 190 | + }) as unknown as IdSchema<TableName>; | |
| 191 | + | |
| 192 | +export const schemaToObjectValidator = <S extends Schema.Schema.AnyNoContext>( | |
| 193 | + schema: S, | |
| 194 | +): Record<string, Validator<unknown, "required" | "optional", string>> => { | |
| 195 | + const ast = Schema.encodedSchema(schema).ast; | |
| 196 | + | |
| 197 | + if (ast._tag !== "TypeLiteral") { | |
| 198 | + throw new TopLevelMustBeObjectError(); | |
| 199 | + } | |
| 200 | + | |
| 201 | + if (ast.indexSignatures.length > 0) { | |
| 202 | + throw new IndexSignaturesNotSupportedError(); | |
| 203 | + } | |
| 204 | + | |
| 205 | + const fields: Record<string, AnyValidator> = {}; | |
| 206 | + | |
| 207 | + for (const prop of ast.propertySignatures) { | |
| 208 | + if (typeof prop.name !== "string") { | |
| 209 | + continue; | |
| 210 | + } | |
| 211 | + | |
| 212 | + const validator = compileAst(prop.type, prop.isOptional); | |
| 213 | + fields[prop.name] = prop.isOptional ? v.optional(validator) : validator; | |
| 214 | + } | |
| 215 | + | |
| 216 | + return fields; | |
| 217 | +}; | |
| @@ -0,0 +1,8 @@ | ||
| 1 | +{ | |
| 2 | + "extends": "../../tsconfig.json", | |
| 3 | + "compilerOptions": { | |
| 4 | + "types": ["node"] | |
| 5 | + }, | |
| 6 | + "include": ["src/**/*"], | |
| 7 | + "exclude": ["node_modules", "dist"] | |
| 8 | +} | |
| @@ -0,0 +1,11 @@ | ||
| 1 | +import { defineConfig } from "vitest/config"; | |
| 2 | + | |
| 3 | +export default defineConfig({ | |
| 4 | + test: { | |
| 5 | + environment: "node", | |
| 6 | + benchmark: { | |
| 7 | + include: ["**/*.bench.ts"], | |
| 8 | + reporters: ["default"], | |
| 9 | + }, | |
| 10 | + }, | |
| 11 | +}); | |
| @@ -1,3 +1,8 @@ | ||
| 1 | 1 | # mcp-gateway |
| 2 | 2 | |
| 3 | −MCP gateway surface scaffold for Executor v2. | |
| 3 | +Bare-minimum MCP gateway for Executor v2. | |
| 4 | + | |
| 5 | +Current scaffold includes: | |
| 6 | +- MCP server built with `@modelcontextprotocol/sdk` in `src/server.ts` | |
| 7 | +- streamable HTTP request handling (`handleMcpHttpRequest`) | |
| 8 | +- one stub tool: `executor.ping` | |
| @@ -2,7 +2,14 @@ · 1 unmodified line | ||
| 2 | 2 | "name": "@executor-v2/mcp-gateway", |
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | + "exports": { | |
| 6 | + ".": "./src/index.ts" | |
| 7 | + }, | |
| 5 | 8 | "scripts": { |
| 6 | 9 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" |
| 10 | + }, | |
| 11 | + "dependencies": { | |
| 12 | + "@modelcontextprotocol/sdk": "^1.26.0", | |
| 13 | + "zod": "^4.3.6" | |
| 7 | 14 | } |
| 8 | 15 | } |
| @@ -0,0 +1 @@ | ||
| 1 | +export * from "./server"; | |
| @@ -0,0 +1,70 @@ | ||
| 1 | +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | |
| 2 | +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; | |
| 3 | +import { z } from "zod"; | |
| 4 | + | |
| 5 | +export type GatewayTarget = "local" | "remote"; | |
| 6 | + | |
| 7 | +export type McpGatewayOptions = { | |
| 8 | + target: GatewayTarget; | |
| 9 | + serverName?: string; | |
| 10 | + serverVersion?: string; | |
| 11 | +}; | |
| 12 | + | |
| 13 | +const DEFAULT_SERVER_NAME = "executor-v2"; | |
| 14 | +const DEFAULT_SERVER_VERSION = "0.0.0"; | |
| 15 | +const STUB_TOOL_NAME = "executor.ping"; | |
| 16 | + | |
| 17 | +const PingToolInput = z.object({ | |
| 18 | + message: z.string().optional(), | |
| 19 | +}); | |
| 20 | + | |
| 21 | +const createStubMcpServer = (options: McpGatewayOptions): McpServer => { | |
| 22 | + const mcp = new McpServer({ | |
| 23 | + name: options.serverName ?? DEFAULT_SERVER_NAME, | |
| 24 | + version: options.serverVersion ?? DEFAULT_SERVER_VERSION, | |
| 25 | + }); | |
| 26 | + | |
| 27 | + mcp.registerTool( | |
| 28 | + STUB_TOOL_NAME, | |
| 29 | + { | |
| 30 | + description: "Stub MCP tool that replies with pong", | |
| 31 | + inputSchema: PingToolInput, | |
| 32 | + }, | |
| 33 | + async (input: { message?: string }) => { | |
| 34 | + const text = input.message | |
| 35 | + ? `pong (${options.target}) - ${input.message}` | |
| 36 | + : `pong (${options.target})`; | |
| 37 | + | |
| 38 | + return { | |
| 39 | + content: [ | |
| 40 | + { | |
| 41 | + type: "text" as const, | |
| 42 | + text, | |
| 43 | + }, | |
| 44 | + ], | |
| 45 | + isError: false, | |
| 46 | + }; | |
| 47 | + }, | |
| 48 | + ); | |
| 49 | + | |
| 50 | + return mcp; | |
| 51 | +}; | |
| 52 | + | |
| 53 | +export const handleMcpHttpRequest = async ( | |
| 54 | + request: Request, | |
| 55 | + options: McpGatewayOptions, | |
| 56 | +): Promise<Response> => { | |
| 57 | + const transport = new WebStandardStreamableHTTPServerTransport({ | |
| 58 | + sessionIdGenerator: undefined, | |
| 59 | + enableJsonResponse: true, | |
| 60 | + }); | |
| 61 | + const mcp = createStubMcpServer(options); | |
| 62 | + | |
| 63 | + try { | |
| 64 | + await mcp.connect(transport); | |
| 65 | + return await transport.handleRequest(request); | |
| 66 | + } finally { | |
| 67 | + await transport.close().catch(() => undefined); | |
| 68 | + await mcp.close().catch(() => undefined); | |
| 69 | + } | |
| 70 | +}; | |
| @@ -3,7 +3,7 @@ · 2 unmodified lines | ||
| 3 | 3 | Canonical Effect schema package scaffold for Executor v2. |
| 4 | 4 | |
| 5 | 5 | Current scaffold includes: |
| 6 | −- branded IDs (`src/ids.ts`) | |
| 6 | +- branded domain IDs (`src/ids.ts`) | |
| 7 | 7 | - shared enums and primitives (`src/enums.ts`, `src/common.ts`) |
| 8 | 8 | - core model schemas in `src/models/*` |
| 9 | 9 | - local snapshot/event schemas (`src/snapshot.ts`) |
| @@ -2,7 +2,15 @@ · 1 unmodified line | ||
| 2 | 2 | "name": "@executor-v2/source-manager", |
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | + "exports": { | |
| 6 | + ".": "./src/index.ts" | |
| 7 | + }, | |
| 5 | 8 | "scripts": { |
| 6 | 9 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" |
| 10 | + }, | |
| 11 | + "dependencies": { | |
| 12 | + "@executor-v2/persistence-ports": "workspace:*", | |
| 13 | + "@executor-v2/schema": "workspace:*", | |
| 14 | + "effect": "^3.19.8" | |
| 7 | 15 | } |
| 8 | 16 | } |
| @@ -1,4 +1,7 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "extends": "../../tsconfig.json", |
| 3 | + "compilerOptions": { | |
| 4 | + "types": ["node"] | |
| 5 | + }, | |
| 3 | 6 | "include": ["src"] |
| 4 | 7 | } |
| @@ -1,3 +1,7 @@ | ||
| 1 | −# state-contracts | |
| 1 | +# persistence-ports | |
| 2 | 2 | |
| 3 | −Storage and repository interface scaffold for Executor v2. | |
| 3 | +Persistence port interface scaffold for Executor v2. | |
| 4 | + | |
| 5 | +Current scaffold includes: | |
| 6 | +- `ProfileStore` in `src/profiles.ts` | |
| 7 | +- `ToolArtifactStore` in `src/tool-artifacts.ts` | |
| @@ -1,8 +1,15 @@ | ||
| 1 | 1 | { |
| 2 | − "name": "@executor-v2/state-contracts", | |
| 2 | + "name": "@executor-v2/persistence-ports", | |
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | + "exports": { | |
| 6 | + ".": "./src/index.ts" | |
| 7 | + }, | |
| 5 | 8 | "scripts": { |
| 6 | 9 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" |
| 10 | + }, | |
| 11 | + "dependencies": { | |
| 12 | + "@executor-v2/schema": "workspace:*", | |
| 13 | + "effect": "^3.19.8" | |
| 7 | 14 | } |
| 8 | 15 | } |
| @@ -0,0 +1,2 @@ | ||
| 1 | +export type { ProfileStore } from "./profiles"; | |
| 2 | +export type { ToolArtifactStore } from "./tool-artifacts"; | |
| @@ -0,0 +1,7 @@ | ||
| 1 | +import type { Profile, ProfileId } from "@executor-v2/schema"; | |
| 2 | +import type * as Effect from "effect/Effect"; | |
| 3 | +import type * as Option from "effect/Option"; | |
| 4 | + | |
| 5 | +export interface ProfileStore { | |
| 6 | + getById(id: ProfileId): Effect.Effect<Option.Option<Profile>>; | |
| 7 | +} | |
| @@ -0,0 +1,12 @@ | ||
| 1 | +import type { SourceId, ToolArtifact, WorkspaceId } from "@executor-v2/schema"; | |
| 2 | +import type * as Effect from "effect/Effect"; | |
| 3 | +import type * as Option from "effect/Option"; | |
| 4 | + | |
| 5 | +export interface ToolArtifactStore { | |
| 6 | + getBySource( | |
| 7 | + workspaceId: WorkspaceId, | |
| 8 | + sourceId: SourceId, | |
| 9 | + ): Effect.Effect<Option.Option<ToolArtifact>>; | |
| 10 | + | |
| 11 | + upsert(artifact: ToolArtifact): Effect.Effect<void>; | |
| 12 | +} | |
| @@ -1,3 +1,8 @@ | ||
| 1 | −# state-convex | |
| 1 | +# persistence-convex | |
| 2 | 2 | |
| 3 | −Convex-backed state adapter scaffold for Executor v2. | |
| 3 | +Convex-backed persistence adapter scaffold for Executor v2. | |
| 4 | + | |
| 5 | +Current scaffold includes: | |
| 6 | +- Convex table-typed ID schemas in `src/convex-ids.ts` | |
| 7 | +- shared Convex schema projection in `src/schema.ts` | |
| 8 | +- first adapter implementation for `ProfileStore` in `src/profiles.ts` | |
| @@ -1,8 +1,17 @@ | ||
| 1 | 1 | { |
| 2 | − "name": "@executor-v2/state-convex", | |
| 2 | + "name": "@executor-v2/persistence-convex", | |
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | + "exports": { | |
| 6 | + ".": "./src/index.ts" | |
| 7 | + }, | |
| 5 | 8 | "scripts": { |
| 6 | 9 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" |
| 10 | + }, | |
| 11 | + "dependencies": { | |
| 12 | + "@executor-v2/confect": "workspace:*", | |
| 13 | + "@executor-v2/schema": "workspace:*", | |
| 14 | + "@executor-v2/persistence-ports": "workspace:*", | |
| 15 | + "effect": "^3.19.8" | |
| 7 | 16 | } |
| 8 | 17 | } |
| @@ -0,0 +1,25 @@ | ||
| 1 | +import { Id } from "@executor-v2/confect"; | |
| 2 | + | |
| 3 | +export const ProfileDocIdSchema = Id("profiles"); | |
| 4 | +export const WorkspaceDocIdSchema = Id("workspaces"); | |
| 5 | +export const SourceDocIdSchema = Id("sources"); | |
| 6 | +export const ToolArtifactDocIdSchema = Id("toolArtifacts"); | |
| 7 | +export const CredentialRefDocIdSchema = Id("credentialRefs"); | |
| 8 | +export const OAuthTokenDocIdSchema = Id("oauthTokens"); | |
| 9 | +export const PolicyDocIdSchema = Id("policies"); | |
| 10 | +export const ApprovalDocIdSchema = Id("approvals"); | |
| 11 | +export const TaskRunDocIdSchema = Id("taskRuns"); | |
| 12 | +export const SyncStateDocIdSchema = Id("syncStates"); | |
| 13 | +export const EventDocIdSchema = Id("events"); | |
| 14 | + | |
| 15 | +export type ProfileDocId = typeof ProfileDocIdSchema.Type; | |
| 16 | +export type WorkspaceDocId = typeof WorkspaceDocIdSchema.Type; | |
| 17 | +export type SourceDocId = typeof SourceDocIdSchema.Type; | |
| 18 | +export type ToolArtifactDocId = typeof ToolArtifactDocIdSchema.Type; | |
| 19 | +export type CredentialRefDocId = typeof CredentialRefDocIdSchema.Type; | |
| 20 | +export type OAuthTokenDocId = typeof OAuthTokenDocIdSchema.Type; | |
| 21 | +export type PolicyDocId = typeof PolicyDocIdSchema.Type; | |
| 22 | +export type ApprovalDocId = typeof ApprovalDocIdSchema.Type; | |
| 23 | +export type TaskRunDocId = typeof TaskRunDocIdSchema.Type; | |
| 24 | +export type SyncStateDocId = typeof SyncStateDocIdSchema.Type; | |
| 25 | +export type EventDocId = typeof EventDocIdSchema.Type; | |
| @@ -0,0 +1,3 @@ | ||
| 1 | +export * from "./convex-ids"; | |
| 2 | +export * from "./schema"; | |
| 3 | +export * from "./profiles"; | |
| @@ -0,0 +1,33 @@ | ||
| 1 | +import type { | |
| 2 | + ConfectQueryCtx, | |
| 3 | + DocumentFromTable, | |
| 4 | +} from "@executor-v2/confect"; | |
| 5 | +import { ProfileSchema, type Profile, type ProfileId } from "@executor-v2/schema"; | |
| 6 | +import type { ProfileStore } from "@executor-v2/persistence-ports"; | |
| 7 | +import * as Effect from "effect/Effect"; | |
| 8 | +import { pipe } from "effect/Function"; | |
| 9 | +import * as Option from "effect/Option"; | |
| 10 | +import * as Schema from "effect/Schema"; | |
| 11 | + | |
| 12 | +import type { ExecutorConfectTables } from "./schema"; | |
| 13 | + | |
| 14 | +type ProfileDocument = DocumentFromTable<ExecutorConfectTables, "profiles">; | |
| 15 | + | |
| 16 | +const toProfile = (document: ProfileDocument): Profile => { | |
| 17 | + const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...profile } = document; | |
| 18 | + return Schema.decodeUnknownSync(ProfileSchema)(profile); | |
| 19 | +}; | |
| 20 | + | |
| 21 | +export const makeConvexProfileStore = ( | |
| 22 | + ctx: ConfectQueryCtx<ExecutorConfectTables>, | |
| 23 | +): ProfileStore => ({ | |
| 24 | + getById: (id: ProfileId) => | |
| 25 | + pipe( | |
| 26 | + ctx.db | |
| 27 | + .query("profiles") | |
| 28 | + .withIndex("by_domainId", (q) => q.eq("id", id)) | |
| 29 | + .unique(), | |
| 30 | + Effect.orDie, | |
| 31 | + Effect.map(Option.map(toProfile)), | |
| 32 | + ), | |
| 33 | +}); | |
| @@ -0,0 +1,56 @@ | ||
| 1 | +import { defineSchema, defineTable, type TablesFromSchemaDefinition } from "@executor-v2/confect"; | |
| 2 | +import { | |
| 3 | + ApprovalSchema, | |
| 4 | + CredentialRefSchema, | |
| 5 | + EventEnvelopeSchema, | |
| 6 | + OAuthTokenSchema, | |
| 7 | + PolicySchema, | |
| 8 | + ProfileSchema, | |
| 9 | + SourceSchema, | |
| 10 | + SyncStateSchema, | |
| 11 | + TaskRunSchema, | |
| 12 | + ToolArtifactSchema, | |
| 13 | + WorkspaceSchema, | |
| 14 | +} from "@executor-v2/schema"; | |
| 15 | + | |
| 16 | +export const executorConfectSchema = defineSchema({ | |
| 17 | + profiles: defineTable(ProfileSchema).index("by_domainId", ["id"]), | |
| 18 | + workspaces: defineTable(WorkspaceSchema) | |
| 19 | + .index("by_domainId", ["id"]) | |
| 20 | + .index("by_profileId", ["profileId"]), | |
| 21 | + sources: defineTable(SourceSchema) | |
| 22 | + .index("by_domainId", ["id"]) | |
| 23 | + .index("by_workspaceId", ["workspaceId"]), | |
| 24 | + toolArtifacts: defineTable(ToolArtifactSchema) | |
| 25 | + .index("by_domainId", ["id"]) | |
| 26 | + .index("by_workspaceId", ["workspaceId"]) | |
| 27 | + .index("by_sourceId", ["sourceId"]), | |
| 28 | + credentialRefs: defineTable(CredentialRefSchema) | |
| 29 | + .index("by_domainId", ["id"]) | |
| 30 | + .index("by_workspaceId", ["workspaceId"]) | |
| 31 | + .index("by_sourceId", ["sourceId"]), | |
| 32 | + oauthTokens: defineTable(OAuthTokenSchema) | |
| 33 | + .index("by_domainId", ["id"]) | |
| 34 | + .index("by_workspaceId", ["workspaceId"]) | |
| 35 | + .index("by_sourceId", ["sourceId"]), | |
| 36 | + policies: defineTable(PolicySchema) | |
| 37 | + .index("by_domainId", ["id"]) | |
| 38 | + .index("by_workspaceId", ["workspaceId"]), | |
| 39 | + approvals: defineTable(ApprovalSchema) | |
| 40 | + .index("by_domainId", ["id"]) | |
| 41 | + .index("by_workspaceId", ["workspaceId"]) | |
| 42 | + .index("by_taskRunId", ["taskRunId"]), | |
| 43 | + taskRuns: defineTable(TaskRunSchema) | |
| 44 | + .index("by_domainId", ["id"]) | |
| 45 | + .index("by_workspaceId", ["workspaceId"]) | |
| 46 | + .index("by_sessionId", ["sessionId"]), | |
| 47 | + syncStates: defineTable(SyncStateSchema) | |
| 48 | + .index("by_domainId", ["id"]) | |
| 49 | + .index("by_workspaceId", ["workspaceId"]), | |
| 50 | + events: defineTable(EventEnvelopeSchema) | |
| 51 | + .index("by_domainId", ["id"]) | |
| 52 | + .index("by_workspaceId", ["workspaceId"]) | |
| 53 | + .index("by_workspaceId_sequence", ["workspaceId", "sequence"]), | |
| 54 | +}); | |
| 55 | + | |
| 56 | +export type ExecutorConfectTables = TablesFromSchemaDefinition<typeof executorConfectSchema>; | |
| @@ -1,3 +1,7 @@ | ||
| 1 | −# state-local-file | |
| 1 | +# persistence-local | |
| 2 | 2 | |
| 3 | −Local file-backed state adapter scaffold for Executor v2. | |
| 3 | +Local file-backed persistence adapter scaffold for Executor v2. | |
| 4 | + | |
| 5 | +Current scaffold includes: | |
| 6 | +- local file `ToolArtifactStore` implementation in `src/tool-artifact-store.ts` | |
| 7 | +- atomic JSON persistence for extracted tool artifacts | |
| @@ -1,8 +1,16 @@ | ||
| 1 | 1 | { |
| 2 | − "name": "@executor-v2/state-local-file", | |
| 2 | + "name": "@executor-v2/persistence-local", | |
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | + "exports": { | |
| 6 | + ".": "./src/index.ts" | |
| 7 | + }, | |
| 5 | 8 | "scripts": { |
| 6 | 9 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" |
| 10 | + }, | |
| 11 | + "dependencies": { | |
| 12 | + "@executor-v2/persistence-ports": "workspace:*", | |
| 13 | + "@executor-v2/schema": "workspace:*", | |
| 14 | + "effect": "^3.19.8" | |
| 7 | 15 | } |
| 8 | 16 | } |
| @@ -0,0 +1,7 @@ | ||
| 1 | +export { | |
| 2 | + LocalPersistenceError, | |
| 3 | + makeLocalToolArtifactStore, | |
| 4 | + makeSourceId, | |
| 5 | + makeWorkspaceId, | |
| 6 | + type LocalToolArtifactStoreOptions, | |
| 7 | +} from "./tool-artifact-store"; | |
| @@ -0,0 +1,138 @@ | ||
| 1 | +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; | |
| 2 | +import { dirname, resolve } from "node:path"; | |
| 3 | + | |
| 4 | +import type { ToolArtifactStore } from "@executor-v2/persistence-ports"; | |
| 5 | +import { | |
| 6 | + SourceIdSchema, | |
| 7 | + ToolArtifactSchema, | |
| 8 | + WorkspaceIdSchema, | |
| 9 | + type SourceId, | |
| 10 | + type ToolArtifact, | |
| 11 | + type WorkspaceId, | |
| 12 | +} from "@executor-v2/schema"; | |
| 13 | +import * as Effect from "effect/Effect"; | |
| 14 | +import { pipe } from "effect/Function"; | |
| 15 | +import * as Option from "effect/Option"; | |
| 16 | +import * as Schema from "effect/Schema"; | |
| 17 | + | |
| 18 | +export class LocalPersistenceError extends Schema.TaggedError<LocalPersistenceError>()( | |
| 19 | + "LocalPersistenceError", | |
| 20 | + { | |
| 21 | + operation: Schema.String, | |
| 22 | + filePath: Schema.String, | |
| 23 | + message: Schema.String, | |
| 24 | + }, | |
| 25 | +) {} | |
| 26 | + | |
| 27 | +const ToolArtifactListSchema = Schema.Array(ToolArtifactSchema); | |
| 28 | + | |
| 29 | +const decodeArtifacts = Schema.decodeUnknownSync(ToolArtifactListSchema); | |
| 30 | +const decodeWorkspaceId = Schema.decodeUnknownSync(WorkspaceIdSchema); | |
| 31 | +const decodeSourceId = Schema.decodeUnknownSync(SourceIdSchema); | |
| 32 | + | |
| 33 | +export type LocalToolArtifactStoreOptions = { | |
| 34 | + rootDir: string; | |
| 35 | +}; | |
| 36 | + | |
| 37 | +const defaultArtifactsFilePath = (rootDir: string): string => | |
| 38 | + resolve(rootDir, "tool-artifacts.json"); | |
| 39 | + | |
| 40 | +const toLocalPersistenceError = ( | |
| 41 | + operation: string, | |
| 42 | + filePath: string, | |
| 43 | + cause: unknown, | |
| 44 | +): LocalPersistenceError => | |
| 45 | + new LocalPersistenceError({ | |
| 46 | + operation, | |
| 47 | + filePath, | |
| 48 | + message: cause instanceof Error ? cause.message : String(cause), | |
| 49 | + }); | |
| 50 | + | |
| 51 | +const readArtifacts = (filePath: string): Effect.Effect<Array<ToolArtifact>, LocalPersistenceError> => | |
| 52 | + pipe( | |
| 53 | + Effect.tryPromise({ | |
| 54 | + try: () => readFile(filePath, "utf8"), | |
| 55 | + catch: (cause) => toLocalPersistenceError("read", filePath, cause), | |
| 56 | + }), | |
| 57 | + Effect.flatMap((raw) => | |
| 58 | + Effect.try({ | |
| 59 | + try: () => decodeArtifacts(JSON.parse(raw)), | |
| 60 | + catch: (cause) => toLocalPersistenceError("decode", filePath, cause), | |
| 61 | + }), | |
| 62 | + ), | |
| 63 | + Effect.catchTag("LocalPersistenceError", (error) => | |
| 64 | + error.operation === "read" && error.message.includes("ENOENT") | |
| 65 | + ? Effect.succeed([]) | |
| 66 | + : Effect.fail(error), | |
| 67 | + ), | |
| 68 | + ); | |
| 69 | + | |
| 70 | +const writeArtifacts = ( | |
| 71 | + filePath: string, | |
| 72 | + artifacts: Array<ToolArtifact>, | |
| 73 | +): Effect.Effect<void, LocalPersistenceError> => { | |
| 74 | + const tempPath = `${filePath}.tmp`; | |
| 75 | + return pipe( | |
| 76 | + Effect.tryPromise({ | |
| 77 | + try: () => mkdir(dirname(filePath), { recursive: true }), | |
| 78 | + catch: (cause) => toLocalPersistenceError("mkdir", filePath, cause), | |
| 79 | + }), | |
| 80 | + Effect.flatMap(() => | |
| 81 | + Effect.tryPromise({ | |
| 82 | + try: () => writeFile(tempPath, JSON.stringify(artifacts, null, 2), "utf8"), | |
| 83 | + catch: (cause) => toLocalPersistenceError("write", tempPath, cause), | |
| 84 | + }), | |
| 85 | + ), | |
| 86 | + Effect.flatMap(() => | |
| 87 | + Effect.tryPromise({ | |
| 88 | + try: () => rename(tempPath, filePath), | |
| 89 | + catch: (cause) => toLocalPersistenceError("rename", filePath, cause), | |
| 90 | + }), | |
| 91 | + ), | |
| 92 | + ); | |
| 93 | +}; | |
| 94 | + | |
| 95 | +export const makeLocalToolArtifactStore = ( | |
| 96 | + options: LocalToolArtifactStoreOptions, | |
| 97 | +): ToolArtifactStore => { | |
| 98 | + const filePath = defaultArtifactsFilePath(options.rootDir); | |
| 99 | + | |
| 100 | + return { | |
| 101 | + getBySource: (workspaceId: WorkspaceId, sourceId: SourceId) => | |
| 102 | + pipe( | |
| 103 | + readArtifacts(filePath), | |
| 104 | + Effect.map((artifacts) => { | |
| 105 | + const match = artifacts.find( | |
| 106 | + (artifact) => | |
| 107 | + artifact.workspaceId === workspaceId && artifact.sourceId === sourceId, | |
| 108 | + ); | |
| 109 | + return Option.fromNullable(match); | |
| 110 | + }), | |
| 111 | + ), | |
| 112 | + | |
| 113 | + upsert: (artifact: ToolArtifact) => | |
| 114 | + pipe( | |
| 115 | + readArtifacts(filePath), | |
| 116 | + Effect.map((artifacts) => { | |
| 117 | + const next = [...artifacts]; | |
| 118 | + const index = next.findIndex( | |
| 119 | + (current) => | |
| 120 | + current.workspaceId === artifact.workspaceId && | |
| 121 | + current.sourceId === artifact.sourceId, | |
| 122 | + ); | |
| 123 | + | |
| 124 | + if (index >= 0) { | |
| 125 | + next[index] = artifact; | |
| 126 | + } else { | |
| 127 | + next.push(artifact); | |
| 128 | + } | |
| 129 | + | |
| 130 | + return next; | |
| 131 | + }), | |
| 132 | + Effect.flatMap((next) => writeArtifacts(filePath, next)), | |
| 133 | + ), | |
| 134 | + }; | |
| 135 | +}; | |
| 136 | + | |
| 137 | +export const makeWorkspaceId = (value: string): WorkspaceId => decodeWorkspaceId(value); | |
| 138 | +export const makeSourceId = (value: string): SourceId => decodeSourceId(value); | |
| @@ -1,4 +1,7 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "extends": "../../tsconfig.json", |
| 3 | + "compilerOptions": { | |
| 4 | + "types": ["node"] | |
| 5 | + }, | |
| 3 | 6 | "include": ["src"] |
| 4 | 7 | } |
| @@ -3,13 +3,19 @@ · 2 unmodified lines | ||
| 3 | 3 | "target": "ES2023", |
| 4 | 4 | "module": "ESNext", |
| 5 | 5 | "moduleResolution": "Bundler", |
| 6 | + "moduleDetection": "force", | |
| 6 | 7 | "strict": true, |
| 7 | 8 | "noEmit": true, |
| 8 | 9 | "verbatimModuleSyntax": true, |
| 9 | 10 | "skipLibCheck": true, |
| 10 | 11 | "resolveJsonModule": true, |
| 11 | 12 | "allowSyntheticDefaultImports": true, |
| 12 | − "types": [] | |
| 13 | + "types": [], | |
| 14 | + "plugins": [ | |
| 15 | + { | |
| 16 | + "name": "@effect/language-service" | |
| 17 | + } | |
| 18 | + ] | |
| 13 | 19 | }, |
| 14 | 20 | "include": [ |
| 15 | 21 | "apps/**/*.ts", |