Unified · Split
| @@ -1,65 +1,69 @@ | |||
| 1 | import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; | ||
| 1 | import path from "node:path"; | 2 | import path from "node:path"; |
| 2 | 3 | ||
| 3 | type ApprovalStatus = "pending" | "approved" | "denied"; | 4 | type ApprovalStatus = "pending" | "approved" | "denied"; |
| 4 | 5 | ||
| 5 | interface ApprovalItem { | 6 | interface ApprovalItem { |
| 6 | id: string; | 7 | id: string; |
| 7 | title: string; | 8 | action: string; |
| 8 | details: string; | 9 | justification: string; |
| 9 | status: ApprovalStatus; | ||
| 10 | createdAt: number; | 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 } { | 15 | interface PersistedState { |
| 14 | const session = argv[0] && !argv[0].startsWith("-") ? argv[0] : "claude"; | 16 | approvals: ApprovalItem[]; |
| 15 | const inline = argv.includes("--inline"); | 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"); | 27 | const DEFAULT_MCP_CONFIG_PATH = path.resolve(import.meta.dir, "claude-mcp-demo.json"); |
| 18 | const tmuxSession = tmuxSessionFlagIndex >= 0 && argv[tmuxSessionFlagIndex + 1] | 28 | const HOME_DIR = process.env.HOME ?? "."; |
| 19 | ? String(argv[tmuxSessionFlagIndex + 1]) | 29 | const STATE_PATH = path.join(HOME_DIR, ".executor-lite-approvals", "mcp-state.json"); |
| 20 | : "executor-approvals"; | ||
| 21 | 30 | ||
| 22 | const agentCommandFlagIndex = argv.indexOf("--agent-cmd"); | 31 | mkdirSync(path.dirname(STATE_PATH), { recursive: true }); |
| 23 | const agentCommand = agentCommandFlagIndex >= 0 && argv[agentCommandFlagIndex + 1] | ||
| 24 | ? String(argv[agentCommandFlagIndex + 1]) | ||
| 25 | : session; | ||
| 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 { | 37 | function getFlag(argv: string[], name: string): string | null { |
| 31 | const result = Bun.spawnSync({ cmd: ["tmux", "-V"] }); | 38 | const index = argv.indexOf(name); |
| 32 | return result.exitCode === 0; | 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[] { | 44 | function defaultAgentCommand(session: string): string { |
| 36 | const result = Bun.spawnSync({ cmd: ["tmux", "list-clients", "-F", "#{client_name}"] }); | 45 | if (session === "claude") { |
| 37 | if (result.exitCode !== 0) return []; | 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) | 50 | return session; |
| 40 | .toString("utf8") | ||
| 41 | .split("\n") | ||
| 42 | .map((line) => line.trim()) | ||
| 43 | .filter((line) => line.length > 0 && line !== "no current client"); | ||
| 44 | } | 51 | } |
| 45 | 52 | ||
| 46 | async function launchTmuxPopup(session: string, clientTarget: string): Promise<void> { | 53 | function parseArgs(argv: string[]): ParsedArgs { |
| 47 | const scriptPath = path.resolve(import.meta.dir, "approval-client.ts"); | 54 | const positionalSession = argv[0] && !argv[0].startsWith("-") ? argv[0] : "claude"; |
| 48 | const command = `bun run ${scriptPath} ${session} --inline`; | 55 | const session = getFlag(argv, "--session") ?? positionalSession; |
| 49 | Bun.spawnSync({ | 56 | const inline = argv.includes("--inline"); |
| 50 | cmd: ["tmux", "display-popup", "-t", clientTarget, "-E", "-w", "82%", "-h", "70%", command], | ||
| 51 | stdin: "inherit", | ||
| 52 | stdout: "inherit", | ||
| 53 | stderr: "inherit", | ||
| 54 | }); | ||
| 55 | } | ||
| 56 | 57 | ||
| 57 | function shellQuote(value: string): string { | 58 | const tmuxSession = getFlag(argv, "--tmux-session") ?? "executor-approvals"; |
| 58 | return `'${value.replace(/'/g, "'\\''")}'`; | 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 { | 65 | function hasTmuxBinary(): boolean { |
| 62 | const result = Bun.spawnSync({ cmd: ["tmux", "has-session", "-t", tmuxSession] }); | 66 | const result = Bun.spawnSync({ cmd: ["tmux", "-V"] }); |
| 63 | return result.exitCode === 0; | 67 | return result.exitCode === 0; |
| 64 | } | 68 | } |
| 65 | 69 | ||
| @@ -73,32 +77,24 @@ function runTmuxCommand(cmd: string[]): boolean { · 7 unmodified lines | |||
| 73 | return result.exitCode === 0; | 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 | function ensureSessionUiOptions(tmuxSession: string): void { | 85 | function ensureSessionUiOptions(tmuxSession: string): void { |
| 77 | runTmuxCommand(["tmux", "set-option", "-t", tmuxSession, "mouse", "on"]); | 86 | runTmuxCommand(["tmux", "set-option", "-t", tmuxSession, "mouse", "on"]); |
| 78 | runTmuxCommand(["tmux", "set-option", "-t", tmuxSession, "pane-border-status", "top"]); | 87 | runTmuxCommand(["tmux", "set-option", "-t", tmuxSession, "pane-border-status", "top"]); |
| 79 | runTmuxCommand(["tmux", "set-option", "-t", tmuxSession, "pane-border-format", "#{pane_index}: #{pane_title}"]); | 88 | runTmuxCommand(["tmux", "set-option", "-t", tmuxSession, "pane-border-format", "#{pane_index}: #{pane_title}"]); |
| 80 | } | 89 | } |
| 81 | 90 | ||
| 82 | function setPaneTitles(windowTarget: string): void { | 91 | async function launchManagedTmuxWorkspace(tmuxSession: string, agentCommand: string): Promise<boolean> { |
| 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 | if (!tmuxSessionExists(tmuxSession)) { | 92 | if (!tmuxSessionExists(tmuxSession)) { |
| 92 | const created = runTmuxCommand(["tmux", "new-session", "-d", "-s", tmuxSession, agentCommand]); | 93 | const created = runTmuxCommand(["tmux", "new-session", "-d", "-s", tmuxSession, agentCommand]); |
| 93 | if (!created) return false; | 94 | if (!created) return false; |
| 94 | 95 | ||
| 95 | ensureSessionUiOptions(tmuxSession); | 96 | ensureSessionUiOptions(tmuxSession); |
| 96 | 97 | runTmuxCommand(["tmux", "select-pane", "-t", `${tmuxSession}:0.0`, "-T", "Claude"]); | |
| 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`]); | ||
| 102 | return runTmuxCommand(["tmux", "attach-session", "-t", tmuxSession]); | 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 | const createdWindow = runTmuxCommand(["tmux", "new-window", "-t", `${tmuxSession}:`, "-n", windowName, agentCommand]); | 105 | const createdWindow = runTmuxCommand(["tmux", "new-window", "-t", `${tmuxSession}:`, "-n", windowName, agentCommand]); |
| 110 | if (!createdWindow) return false; | 106 | if (!createdWindow) return false; |
| 111 | 107 | ||
| 112 | const splitWindow = runTmuxCommand(["tmux", "split-window", "-t", windowTarget, "-v", "-p", "30", approvalCommand]); | 108 | runTmuxCommand(["tmux", "select-pane", "-t", `${windowTarget}.0`, "-T", "Claude"]); |
| 113 | if (!splitWindow) return false; | 109 | runTmuxCommand(["tmux", "select-window", "-t", windowTarget]); |
| 114 | |||
| 115 | setPaneTitles(windowTarget); | ||
| 116 | runTmuxCommand(["tmux", "select-pane", "-t", `${windowTarget}.0`]); | ||
| 117 | return runTmuxCommand(["tmux", "attach-session", "-t", tmuxSession]); | 110 | return runTmuxCommand(["tmux", "attach-session", "-t", tmuxSession]); |
| 118 | } | 111 | } |
| 119 | 112 | ||
| 120 | function makeApproval(session: string, index: number): ApprovalItem { | 113 | function loadState(): PersistedState { |
| 121 | const actions = [ | 114 | if (!existsSync(STATE_PATH)) return { approvals: [] }; |
| 122 | "Write DNS record", | 115 | |
| 123 | "Delete production file", | 116 | try { |
| 124 | "Rotate API token", | 117 | const text = readFileSync(STATE_PATH, "utf8"); |
| 125 | "Deploy migration", | 118 | const parsed = JSON.parse(text) as Partial<PersistedState>; |
| 126 | "Run destructive cleanup", | 119 | if (!parsed || !Array.isArray(parsed.approvals)) { |
| 127 | ]; | 120 | return { approvals: [] }; |
| 128 | 121 | } | |
| 129 | const title = actions[index % actions.length] ?? "Tool action"; | 122 | |
| 130 | const id = `appr_${crypto.randomUUID().slice(0, 8)}`; | 123 | return { |
| 131 | 124 | approvals: parsed.approvals.filter((entry): entry is ApprovalItem => { | |
| 132 | return { | 125 | return ( |
| 133 | id, | 126 | Boolean(entry) && |
| 134 | title, | 127 | typeof entry.id === "string" && |
| 135 | details: `Session: ${session} -> tools.example.action()`, | 128 | typeof entry.action === "string" && |
| 136 | status: "pending", | 129 | typeof entry.justification === "string" && |
| 137 | createdAt: Date.now(), | 130 | typeof entry.createdAt === "number" && |
| 138 | }; | 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 { | 140 | function saveState(state: PersistedState): void { |
| 142 | if (status === "approved") return "[approved]"; | 141 | const tempPath = `${STATE_PATH}.tmp`; |
| 143 | if (status === "denied") return "[denied]"; | 142 | writeFileSync(tempPath, JSON.stringify(state, null, 2)); |
| 144 | return "[pending]"; | 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 | process.stdout.write("\x1Bc"); | 166 | process.stdout.write("\x1Bc"); |
| 149 | process.stdout.write("Approval Popup Demo\n"); | 167 | process.stdout.write("Approval Pane (MCP-driven)\n"); |
| 150 | process.stdout.write(`Session: ${session}\n`); | 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"); | 169 | process.stdout.write("Controls: a approve d deny q quit\n"); |
| 152 | process.stdout.write("tmux tip: Ctrl+b then o to switch panes (mouse is enabled).\n"); | 170 | process.stdout.write("This pane only appears when an MCP approval is pending.\n"); |
| 153 | process.stdout.write("-".repeat(72) + "\n"); | 171 | process.stdout.write("-".repeat(84) + "\n"); |
| 154 | 172 | ||
| 155 | if (approvals.length === 0) { | 173 | if (!current) { |
| 156 | process.stdout.write("No approvals. Press n to enqueue one.\n"); | 174 | process.stdout.write("No pending approval. Closing...\n"); |
| 157 | return; | 175 | return; |
| 158 | } | 176 | } |
| 159 | 177 | ||
| 160 | approvals.forEach((item, idx) => { | 178 | const created = new Date(current.createdAt).toLocaleTimeString(); |
| 161 | const pointer = idx === selected ? ">" : " "; | 179 | process.stdout.write(`[pending] ${current.id}\n`); |
| 162 | const time = new Date(item.createdAt).toLocaleTimeString(); | 180 | process.stdout.write(`action: ${current.action}\n`); |
| 163 | process.stdout.write(`${pointer} ${statusMarker(item.status)} ${item.id} ${item.title} (${time})\n`); | 181 | process.stdout.write(`created: ${created}\n`); |
| 164 | process.stdout.write(` ${item.details}\n`); | 182 | if (current.justification.trim().length > 0) { |
| 165 | }); | 183 | process.stdout.write(`justification: ${current.justification}\n`); |
| 184 | } | ||
| 166 | } | 185 | } |
| 167 | 186 | ||
| 168 | async function runInlineUi(session: string): Promise<number> { | 187 | async function runInlineUi(session: string, approvalId: string | null): Promise<number> { |
| 169 | const approvals: ApprovalItem[] = [makeApproval(session, 0)]; | 188 | let current = findApproval(loadState(), approvalId); |
| 170 | let selected = 0; | 189 | renderInline(session, current); |
| 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); | ||
| 179 | 190 | ||
| 180 | const cleanup = (): void => { | 191 | const cleanup = (): void => { |
| 181 | clearInterval(enqueueTimer); | ||
| 182 | if (process.stdin.isTTY) { | 192 | if (process.stdin.isTTY) { |
| 183 | process.stdin.setRawMode(false); | 193 | process.stdin.setRawMode(false); |
| 184 | } | 194 | } |
| 185 | process.stdin.pause(); | 195 | process.stdin.pause(); |
| 186 | }; | 196 | }; |
| 187 | 197 | ||
| 188 | clearAndRender(session, approvals, selected); | ||
| 189 | |||
| 190 | return await new Promise<number>((resolve) => { | 198 | return await new Promise<number>((resolve) => { |
| 191 | if (process.stdin.isTTY) { | 199 | if (process.stdin.isTTY) { |
| 192 | process.stdin.setRawMode(true); | 200 | process.stdin.setRawMode(true); |
| @@ -200,59 +208,46 @@ async function runInlineUi(session: string): Promise<number> { · 7 unmodified lines | |||
| 200 | resolve(code); | 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 | process.stdin.on("data", (chunk) => { | 222 | process.stdin.on("data", (chunk) => { |
| 204 | const key = typeof chunk === "string" ? chunk : chunk.toString("utf8"); | 223 | const key = typeof chunk === "string" ? chunk : chunk.toString("utf8"); |
| 205 | 224 | ||
| 206 | if (key === "\u0003" || key === "q") { | 225 | if (key === "\u0003" || key === "q") { |
| 226 | clearInterval(poll); | ||
| 207 | finish(0); | 227 | finish(0); |
| 208 | return; | 228 | return; |
| 209 | } | 229 | } |
| 210 | 230 | ||
| 211 | if (key === "j" || key === "\u001B[B") { | 231 | if (!current) return; |
| 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 | } | ||
| 239 | 232 | ||
| 240 | if (key === "a") { | 233 | if (key === "a") { |
| 241 | current.status = "approved"; | 234 | decideApproval(current.id, "approved"); |
| 242 | clearAndRender(session, approvals, selected); | 235 | clearInterval(poll); |
| 236 | finish(0); | ||
| 243 | return; | 237 | return; |
| 244 | } | 238 | } |
| 245 | 239 | ||
| 246 | if (key === "d") { | 240 | if (key === "d") { |
| 247 | current.status = "denied"; | 241 | decideApproval(current.id, "denied"); |
| 248 | clearAndRender(session, approvals, selected); | 242 | clearInterval(poll); |
| 243 | finish(0); | ||
| 249 | } | 244 | } |
| 250 | }); | 245 | }); |
| 251 | }); | 246 | }); |
| 252 | } | 247 | } |
| 253 | 248 | ||
| 254 | async function main(): Promise<number> { | 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 | if (!inline) { | 252 | if (!inline) { |
| 258 | if (!hasTmuxBinary()) { | 253 | if (!hasTmuxBinary()) { |
| @@ -261,25 +256,16 @@ async function main(): Promise<number> { · 2 unmodified lines | |||
| 261 | return 1; | 256 | return 1; |
| 262 | } | 257 | } |
| 263 | 258 | ||
| 264 | const clients = getTmuxClients(); | 259 | console.log(`Launching '${agentCommand}' in tmux session '${tmuxSession}'...`); |
| 265 | const target = clients[0]; | 260 | const started = await launchManagedTmuxWorkspace(tmuxSession, agentCommand); |
| 266 | if (target) { | 261 | if (started) return 0; |
| 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 | } | ||
| 276 | 262 | ||
| 277 | console.error("Unable to launch managed tmux workspace."); | 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 | return 1; | 265 | return 1; |
| 280 | } | 266 | } |
| 281 | 267 | ||
| 282 | return await runInlineUi(session); | 268 | return await runInlineUi(session, approvalId); |
| 283 | } | 269 | } |
| 284 | 270 | ||
| 285 | const code = await main(); | 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 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | 4 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 2 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; | 5 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; |
| 3 | import { z } from "zod"; | 6 | import { z } from "zod"; |
| 4 | 7 | ||
| 5 | type ApprovalDecision = "approved" | "denied"; | 8 | type ApprovalStatus = "pending" | "approved" | "denied"; |
| 6 | 9 | ||
| 7 | interface ApprovalRecord { | 10 | interface ApprovalRecord { |
| 8 | id: string; | 11 | id: string; |
| 9 | action: string; | 12 | action: string; |
| 10 | justification: string; | 13 | justification: string; |
| 11 | createdAt: number; | 14 | createdAt: number; |
| 12 | decision: ApprovalDecision; | 15 | status: ApprovalStatus; |
| 13 | decidedAt: number; | 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 | const mcp = new McpServer( | 142 | const mcp = new McpServer( |
| 19 | { name: "memory-approval-demo", version: "0.0.1" }, | 143 | { name: "memory-approval-demo", version: "0.0.1" }, |
| 20 | { | 144 | { |
| 21 | capabilities: { | 145 | capabilities: { |
| 22 | tools: {}, | 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 | mcp.registerTool( | 151 | mcp.registerTool( |
| 33 | "dangerous_action", | 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 | inputSchema: { | 155 | inputSchema: { |
| 37 | action: z.string().min(1), | 156 | action: z.string().min(1), |
| 38 | justification: z.string().optional(), | 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 }) => { | 161 | async ({ action, justification, approvalTimeoutMs }) => { |
| 42 | const approvalId = `appr_${crypto.randomUUID().slice(0, 8)}`; | 162 | refreshStateFromDisk(); |
| 43 | 163 | const pending = recordPendingApproval(action, justification ?? ""); | |
| 44 | let decision: ApprovalDecision = "denied"; | 164 | maybeOpenApprovalPane(pending.id); |
| 45 | 165 | ||
| 46 | try { | 166 | const timeoutMs = approvalTimeoutMs ?? 120_000; |
| 47 | const response = await mcp.server.elicitInput({ | 167 | const decision = await waitForDecision(pending.id, timeoutMs); |
| 48 | mode: "form", | 168 | |
| 49 | message: `Approve dangerous action '${action}'?`, | 169 | refreshStateFromDisk(); |
| 50 | requestedSchema: { | 170 | const current = state.approvals.find((item) => item.id === pending.id); |
| 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 | } | ||
| 72 | 171 | ||
| 73 | const now = Date.now(); | 172 | if (decision === "approved" && current?.status === "approved") { |
| 74 | approvals.unshift({ | 173 | removeApproval(pending.id); |
| 75 | id: approvalId, | ||
| 76 | action, | ||
| 77 | justification: justification ?? "", | ||
| 78 | createdAt: now, | ||
| 79 | decision, | ||
| 80 | decidedAt: now, | ||
| 81 | }); | ||
| 82 | |||
| 83 | if (decision === "denied") { | ||
| 84 | return { | 174 | return { |
| 85 | content: [{ type: "text", text: `Denied '${action}'.` }], | 175 | content: [{ type: "text", text: `Approved and executed '${action}'.` }], |
| 86 | structuredContent: { | 176 | structuredContent: { |
| 87 | approvalId, | 177 | approvalId: pending.id, |
| 88 | status: "denied", | 178 | status: "approved", |
| 89 | action, | 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 | return { | 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 | structuredContent: { | 193 | structuredContent: { |
| 97 | approvalId, | 194 | approvalId: pending.id, |
| 98 | status: "approved", | 195 | status: "denied", |
| 99 | action, | 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 | const transport = new StdioServerTransport(); | 202 | const transport = new StdioServerTransport(); |
| 133 | await mcp.connect(transport); | 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 | # app-convex | 1 | # app-convex |
| 2 | 2 | ||
| 3 | Convex remote server app scaffold for Executor v2. | 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 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "scripts": { | 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 | "extends": "../../tsconfig.json", | 2 | "extends": "../../tsconfig.json", |
| 3 | "include": ["src"] | 3 | "include": ["src", "convex"] |
| 4 | } | 4 | } |
| @@ -1,3 +1,8 @@ | |||
| 1 | # app-pm | 1 | # app-pm |
| 2 | 2 | ||
| 3 | Local Process Manager app scaffold for Executor v2. | 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 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "scripts": { | 5 | "scripts": { |
| 6 | "dev": "bun --watch src/main.ts", | ||
| 7 | "start": "bun src/main.ts", | ||
| 6 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | 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 | "extends": "../../tsconfig.json", | 2 | "extends": "../../tsconfig.json", |
| 3 | "compilerOptions": { | ||
| 4 | "types": ["bun"] | ||
| 5 | }, | ||
| 3 | "include": ["src"] | 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 | "packages/*" | 8 | "packages/*" |
| 9 | ], | 9 | ], |
| 10 | "scripts": { | 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 | # mcp-gateway | 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 | "name": "@executor-v2/mcp-gateway", | 2 | "name": "@executor-v2/mcp-gateway", |
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | ||
| 6 | ".": "./src/index.ts" | ||
| 7 | }, | ||
| 5 | "scripts": { | 8 | "scripts": { |
| 6 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | 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 | Canonical Effect schema package scaffold for Executor v2. | 3 | Canonical Effect schema package scaffold for Executor v2. |
| 4 | 4 | ||
| 5 | Current scaffold includes: | 5 | Current scaffold includes: |
| 6 | - branded IDs (`src/ids.ts`) | 6 | - branded domain IDs (`src/ids.ts`) |
| 7 | - shared enums and primitives (`src/enums.ts`, `src/common.ts`) | 7 | - shared enums and primitives (`src/enums.ts`, `src/common.ts`) |
| 8 | - core model schemas in `src/models/*` | 8 | - core model schemas in `src/models/*` |
| 9 | - local snapshot/event schemas (`src/snapshot.ts`) | 9 | - local snapshot/event schemas (`src/snapshot.ts`) |
| @@ -2,7 +2,15 @@ · 1 unmodified line | |||
| 2 | "name": "@executor-v2/source-manager", | 2 | "name": "@executor-v2/source-manager", |
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | ||
| 6 | ".": "./src/index.ts" | ||
| 7 | }, | ||
| 5 | "scripts": { | 8 | "scripts": { |
| 6 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | 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 | "extends": "../../tsconfig.json", | 2 | "extends": "../../tsconfig.json", |
| 3 | "compilerOptions": { | ||
| 4 | "types": ["node"] | ||
| 5 | }, | ||
| 3 | "include": ["src"] | 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 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | ||
| 6 | ".": "./src/index.ts" | ||
| 7 | }, | ||
| 5 | "scripts": { | 8 | "scripts": { |
| 6 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | 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 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | ||
| 6 | ".": "./src/index.ts" | ||
| 7 | }, | ||
| 5 | "scripts": { | 8 | "scripts": { |
| 6 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | 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 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | ||
| 6 | ".": "./src/index.ts" | ||
| 7 | }, | ||
| 5 | "scripts": { | 8 | "scripts": { |
| 6 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | 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 | "extends": "../../tsconfig.json", | 2 | "extends": "../../tsconfig.json", |
| 3 | "compilerOptions": { | ||
| 4 | "types": ["node"] | ||
| 5 | }, | ||
| 3 | "include": ["src"] | 6 | "include": ["src"] |
| 4 | } | 7 | } |
| @@ -3,13 +3,19 @@ · 2 unmodified lines | |||
| 3 | "target": "ES2023", | 3 | "target": "ES2023", |
| 4 | "module": "ESNext", | 4 | "module": "ESNext", |
| 5 | "moduleResolution": "Bundler", | 5 | "moduleResolution": "Bundler", |
| 6 | "moduleDetection": "force", | ||
| 6 | "strict": true, | 7 | "strict": true, |
| 7 | "noEmit": true, | 8 | "noEmit": true, |
| 8 | "verbatimModuleSyntax": true, | 9 | "verbatimModuleSyntax": true, |
| 9 | "skipLibCheck": true, | 10 | "skipLibCheck": true, |
| 10 | "resolveJsonModule": true, | 11 | "resolveJsonModule": true, |
| 11 | "allowSyntheticDefaultImports": true, | 12 | "allowSyntheticDefaultImports": true, |
| 12 | "types": [] | 13 | "types": [], |
| 14 | "plugins": [ | ||
| 15 | { | ||
| 16 | "name": "@effect/language-service" | ||
| 17 | } | ||
| 18 | ] | ||
| 13 | }, | 19 | }, |
| 14 | "include": [ | 20 | "include": [ |
| 15 | "apps/**/*.ts", | 21 | "apps/**/*.ts", |