Unified · Split
| @@ -0,0 +1,238 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect-atom/atom-react"; | |
| 4 | +import type { UpsertSourcePayload } from "@executor-v2/control-plane"; | |
| 5 | +import type { SourceId, SourceKind, WorkspaceId } from "@executor-v2/schema"; | |
| 6 | +import { useState, type FormEvent } from "react"; | |
| 7 | + | |
| 8 | +import { | |
| 9 | + optimisticRemoveSources, | |
| 10 | + optimisticSourcesByWorkspace, | |
| 11 | + optimisticUpsertSources, | |
| 12 | + removeSource, | |
| 13 | + sourcesByWorkspace, | |
| 14 | + sourcesPendingByWorkspace, | |
| 15 | + sourcesResultByWorkspace, | |
| 16 | + upsertSource, | |
| 17 | +} from "../lib/control-plane/atoms"; | |
| 18 | + | |
| 19 | +const kindOptions: ReadonlyArray<SourceKind> = [ | |
| 20 | + "openapi", | |
| 21 | + "mcp", | |
| 22 | + "graphql", | |
| 23 | + "internal", | |
| 24 | +]; | |
| 25 | + | |
| 26 | +const Page = () => { | |
| 27 | + const [workspaceIdInput, setWorkspaceIdInput] = useState("ws_demo"); | |
| 28 | + const [name, setName] = useState("Weather API"); | |
| 29 | + const [kind, setKind] = useState<SourceKind>("openapi"); | |
| 30 | + const [endpoint, setEndpoint] = useState("https://example.com/openapi.json"); | |
| 31 | + const [statusText, setStatusText] = useState<string | null>(null); | |
| 32 | + | |
| 33 | + const workspaceId = workspaceIdInput as WorkspaceId; | |
| 34 | + | |
| 35 | + const sources = useAtomValue(sourcesByWorkspace(workspaceId)); | |
| 36 | + const sourcesPending = useAtomValue(sourcesPendingByWorkspace(workspaceId)); | |
| 37 | + const refreshSources = useAtomRefresh(sourcesResultByWorkspace(workspaceId)); | |
| 38 | + | |
| 39 | + const runUpsertSource = useAtomSet(upsertSource, { mode: "promise" }); | |
| 40 | + const runRemoveSource = useAtomSet(removeSource, { mode: "promise" }); | |
| 41 | + const setOptimisticSources = useAtomSet(optimisticSourcesByWorkspace(workspaceId)); | |
| 42 | + | |
| 43 | + const handleWorkspaceChange = (value: string) => { | |
| 44 | + setWorkspaceIdInput(value); | |
| 45 | + setStatusText(null); | |
| 46 | + }; | |
| 47 | + | |
| 48 | + const handleAddSource = (event: FormEvent<HTMLFormElement>) => { | |
| 49 | + event.preventDefault(); | |
| 50 | + | |
| 51 | + if (sourcesPending) { | |
| 52 | + return; | |
| 53 | + } | |
| 54 | + | |
| 55 | + const sourceId = `src_${crypto.randomUUID()}` as SourceId; | |
| 56 | + | |
| 57 | + const payload: UpsertSourcePayload = { | |
| 58 | + id: sourceId, | |
| 59 | + name, | |
| 60 | + kind, | |
| 61 | + endpoint, | |
| 62 | + enabled: true, | |
| 63 | + configJson: "{}", | |
| 64 | + status: "draft", | |
| 65 | + sourceHash: null, | |
| 66 | + lastError: null, | |
| 67 | + }; | |
| 68 | + | |
| 69 | + const previousSources = sources.items; | |
| 70 | + const optimistic = optimisticUpsertSources(previousSources, workspaceId, payload); | |
| 71 | + | |
| 72 | + setOptimisticSources({ | |
| 73 | + items: optimistic.items, | |
| 74 | + pendingAck: { | |
| 75 | + kind: "upsert", | |
| 76 | + sourceId: optimistic.sourceId, | |
| 77 | + }, | |
| 78 | + }); | |
| 79 | + | |
| 80 | + void runUpsertSource({ | |
| 81 | + path: { workspaceId }, | |
| 82 | + payload, | |
| 83 | + }) | |
| 84 | + .then(() => { | |
| 85 | + setStatusText("Source saved."); | |
| 86 | + refreshSources(); | |
| 87 | + }) | |
| 88 | + .catch(() => { | |
| 89 | + setStatusText("Source save failed."); | |
| 90 | + setOptimisticSources(null); | |
| 91 | + refreshSources(); | |
| 92 | + }); | |
| 93 | + }; | |
| 94 | + | |
| 95 | + const handleRemoveSource = (sourceId: SourceId) => { | |
| 96 | + if (sourcesPending) { | |
| 97 | + return; | |
| 98 | + } | |
| 99 | + | |
| 100 | + const previousSources = sources.items; | |
| 101 | + const optimistic = optimisticRemoveSources(previousSources, sourceId); | |
| 102 | + | |
| 103 | + setOptimisticSources({ | |
| 104 | + items: optimistic.items, | |
| 105 | + pendingAck: { | |
| 106 | + kind: "remove", | |
| 107 | + sourceId: optimistic.sourceId, | |
| 108 | + }, | |
| 109 | + }); | |
| 110 | + | |
| 111 | + void runRemoveSource({ | |
| 112 | + path: { workspaceId, sourceId }, | |
| 113 | + }) | |
| 114 | + .then(() => { | |
| 115 | + setStatusText("Source removed."); | |
| 116 | + refreshSources(); | |
| 117 | + }) | |
| 118 | + .catch(() => { | |
| 119 | + setStatusText("Source removal failed."); | |
| 120 | + setOptimisticSources(null); | |
| 121 | + refreshSources(); | |
| 122 | + }); | |
| 123 | + }; | |
| 124 | + | |
| 125 | + return ( | |
| 126 | + <main> | |
| 127 | + <section className="shell"> | |
| 128 | + <header className="hero"> | |
| 129 | + <h1>Executor v2 Control Plane</h1> | |
| 130 | + <p> | |
| 131 | + Basic Next.js frontend wired to the shared Effect HttpApi client via | |
| 132 | + Effect Atom. | |
| 133 | + </p> | |
| 134 | + </header> | |
| 135 | + | |
| 136 | + <div className="grid"> | |
| 137 | + <section className="card"> | |
| 138 | + <h2>Workspace + Source</h2> | |
| 139 | + <form onSubmit={handleAddSource}> | |
| 140 | + <div className="field"> | |
| 141 | + <label htmlFor="workspace-id">Workspace ID</label> | |
| 142 | + <input | |
| 143 | + id="workspace-id" | |
| 144 | + value={workspaceIdInput} | |
| 145 | + onChange={(event) => handleWorkspaceChange(event.target.value)} | |
| 146 | + required | |
| 147 | + /> | |
| 148 | + </div> | |
| 149 | + | |
| 150 | + <div className="field"> | |
| 151 | + <label htmlFor="source-name">Name</label> | |
| 152 | + <input | |
| 153 | + id="source-name" | |
| 154 | + value={name} | |
| 155 | + onChange={(event) => setName(event.target.value)} | |
| 156 | + required | |
| 157 | + /> | |
| 158 | + </div> | |
| 159 | + | |
| 160 | + <div className="field"> | |
| 161 | + <label htmlFor="source-kind">Kind</label> | |
| 162 | + <select | |
| 163 | + id="source-kind" | |
| 164 | + value={kind} | |
| 165 | + onChange={(event) => setKind(event.target.value as SourceKind)} | |
| 166 | + > | |
| 167 | + {kindOptions.map((option) => ( | |
| 168 | + <option key={option} value={option}> | |
| 169 | + {option} | |
| 170 | + </option> | |
| 171 | + ))} | |
| 172 | + </select> | |
| 173 | + </div> | |
| 174 | + | |
| 175 | + <div className="field"> | |
| 176 | + <label htmlFor="source-endpoint">Endpoint</label> | |
| 177 | + <input | |
| 178 | + id="source-endpoint" | |
| 179 | + value={endpoint} | |
| 180 | + onChange={(event) => setEndpoint(event.target.value)} | |
| 181 | + required | |
| 182 | + /> | |
| 183 | + </div> | |
| 184 | + | |
| 185 | + <button className="primary" type="submit" disabled={sourcesPending}> | |
| 186 | + {sourcesPending ? "Saving..." : "Save Source"} | |
| 187 | + </button> | |
| 188 | + </form> | |
| 189 | + | |
| 190 | + {statusText ? <p className="status">{statusText}</p> : null} | |
| 191 | + </section> | |
| 192 | + | |
| 193 | + <section className="card"> | |
| 194 | + <h2>Sources</h2> | |
| 195 | + | |
| 196 | + {sources.state === "loading" ? ( | |
| 197 | + <p className="status">Loading sources...</p> | |
| 198 | + ) : null} | |
| 199 | + | |
| 200 | + {sources.state === "error" ? ( | |
| 201 | + <p className="status error">{sources.message}</p> | |
| 202 | + ) : null} | |
| 203 | + | |
| 204 | + {sources.state !== "loading" && sources.items.length === 0 ? ( | |
| 205 | + <p className="status">No sources yet in this workspace.</p> | |
| 206 | + ) : null} | |
| 207 | + | |
| 208 | + <div className="list"> | |
| 209 | + {sources.items.map((source) => ( | |
| 210 | + <article className="list-item" key={source.id}> | |
| 211 | + <header> | |
| 212 | + <div> | |
| 213 | + <strong>{source.name}</strong> | |
| 214 | + <div className="meta"> | |
| 215 | + {source.kind} · {source.status} | |
| 216 | + </div> | |
| 217 | + <div className="meta">{source.endpoint}</div> | |
| 218 | + </div> | |
| 219 | + <button | |
| 220 | + className="danger" | |
| 221 | + type="button" | |
| 222 | + onClick={() => handleRemoveSource(source.id)} | |
| 223 | + disabled={sourcesPending} | |
| 224 | + > | |
| 225 | + Remove | |
| 226 | + </button> | |
| 227 | + </header> | |
| 228 | + </article> | |
| 229 | + ))} | |
| 230 | + </div> | |
| 231 | + </section> | |
| 232 | + </div> | |
| 233 | + </section> | |
| 234 | + </main> | |
| 235 | + ); | |
| 236 | +}; | |
| 237 | + | |
| 238 | +export default Page; | |
| @@ -1,210 +1,12 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | −import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect-atom/atom-react"; | |
| 4 | −import type { UpsertSourcePayload } from "@executor-v2/control-plane"; | |
| 5 | −import type { SourceId, SourceKind, WorkspaceId } from "@executor-v2/schema"; | |
| 6 | −import { useEffect, useState, useTransition, type FormEvent } from "react"; | |
| 3 | +import dynamic from "next/dynamic"; | |
| 7 | 4 | |
| 8 | −import { | |
| 9 | − removeSource, | |
| 10 | − sourcesByWorkspace, | |
| 11 | − sourcesLoading, | |
| 12 | − sourcesResultByWorkspace, | |
| 13 | − sourcesResultDisabled, | |
| 14 | − upsertSource, | |
| 15 | −} from "../lib/control-plane/atoms"; | |
| 5 | +const ControlPlanePageContent = dynamic(() => import("./page-content"), { | |
| 6 | + ssr: false, | |
| 7 | + loading: () => null, | |
| 8 | +}); | |
| 16 | 9 | |
| 17 | −const kindOptions: ReadonlyArray<SourceKind> = [ | |
| 18 | − "openapi", | |
| 19 | − "mcp", | |
| 20 | − "graphql", | |
| 21 | − "internal", | |
| 22 | −]; | |
| 23 | − | |
| 24 | −const Page = () => { | |
| 25 | − const [workspaceIdInput, setWorkspaceIdInput] = useState("ws_demo"); | |
| 26 | − const [name, setName] = useState("Weather API"); | |
| 27 | − const [kind, setKind] = useState<SourceKind>("openapi"); | |
| 28 | − const [endpoint, setEndpoint] = useState("https://example.com/openapi.json"); | |
| 29 | − const [statusText, setStatusText] = useState<string | null>(null); | |
| 30 | − const [isPending, startTransition] = useTransition(); | |
| 31 | − const [isClient, setIsClient] = useState(false); | |
| 32 | − | |
| 33 | − useEffect(() => { | |
| 34 | − setIsClient(true); | |
| 35 | − }, []); | |
| 36 | − | |
| 37 | − const workspaceId = workspaceIdInput as WorkspaceId; | |
| 38 | − | |
| 39 | − const sources = useAtomValue( | |
| 40 | − isClient ? sourcesByWorkspace(workspaceId) : sourcesLoading, | |
| 41 | − ); | |
| 42 | − const refreshSources = useAtomRefresh( | |
| 43 | − isClient ? sourcesResultByWorkspace(workspaceId) : sourcesResultDisabled, | |
| 44 | − ); | |
| 45 | − | |
| 46 | − const runUpsertSource = useAtomSet(upsertSource, { mode: "promise" }); | |
| 47 | − const runRemoveSource = useAtomSet(removeSource, { mode: "promise" }); | |
| 48 | − | |
| 49 | − const handleAddSource = (event: FormEvent<HTMLFormElement>) => { | |
| 50 | − event.preventDefault(); | |
| 51 | − | |
| 52 | − const payload: UpsertSourcePayload = { | |
| 53 | − name, | |
| 54 | − kind, | |
| 55 | − endpoint, | |
| 56 | − enabled: true, | |
| 57 | − configJson: "{}", | |
| 58 | − status: "draft", | |
| 59 | − sourceHash: null, | |
| 60 | − lastError: null, | |
| 61 | − }; | |
| 62 | − | |
| 63 | − startTransition(() => { | |
| 64 | − void runUpsertSource({ | |
| 65 | − path: { workspaceId }, | |
| 66 | − payload, | |
| 67 | − }) | |
| 68 | − .then(() => { | |
| 69 | − setStatusText("Source saved."); | |
| 70 | − refreshSources(); | |
| 71 | − }) | |
| 72 | − .catch(() => { | |
| 73 | − setStatusText("Source save failed."); | |
| 74 | − }); | |
| 75 | − }); | |
| 76 | − }; | |
| 77 | − | |
| 78 | − const handleRemoveSource = (sourceId: SourceId) => { | |
| 79 | − startTransition(() => { | |
| 80 | − void runRemoveSource({ | |
| 81 | − path: { workspaceId, sourceId }, | |
| 82 | − }) | |
| 83 | − .then(() => { | |
| 84 | − setStatusText("Source removed."); | |
| 85 | − refreshSources(); | |
| 86 | − }) | |
| 87 | − .catch(() => { | |
| 88 | − setStatusText("Source removal failed."); | |
| 89 | − }); | |
| 90 | − }); | |
| 91 | − }; | |
| 92 | − | |
| 93 | − return ( | |
| 94 | − <main> | |
| 95 | − <section className="shell"> | |
| 96 | − <header className="hero"> | |
| 97 | − <h1>Executor v2 Control Plane</h1> | |
| 98 | − <p> | |
| 99 | − Basic Next.js frontend wired to the shared Effect HttpApi client via | |
| 100 | − Effect Atom. | |
| 101 | − </p> | |
| 102 | − </header> | |
| 103 | − | |
| 104 | − <div className="grid"> | |
| 105 | − <section className="card"> | |
| 106 | − <h2>Workspace + Source</h2> | |
| 107 | − <form onSubmit={handleAddSource}> | |
| 108 | − <div className="field"> | |
| 109 | − <label htmlFor="workspace-id">Workspace ID</label> | |
| 110 | − <input | |
| 111 | − id="workspace-id" | |
| 112 | − value={workspaceIdInput} | |
| 113 | − onChange={(event) => setWorkspaceIdInput(event.target.value)} | |
| 114 | − required | |
| 115 | − /> | |
| 116 | − </div> | |
| 117 | − | |
| 118 | − <div className="field"> | |
| 119 | − <label htmlFor="source-name">Name</label> | |
| 120 | − <input | |
| 121 | − id="source-name" | |
| 122 | − value={name} | |
| 123 | − onChange={(event) => setName(event.target.value)} | |
| 124 | − required | |
| 125 | − /> | |
| 126 | − </div> | |
| 127 | − | |
| 128 | − <div className="field"> | |
| 129 | − <label htmlFor="source-kind">Kind</label> | |
| 130 | − <select | |
| 131 | − id="source-kind" | |
| 132 | − value={kind} | |
| 133 | − onChange={(event) => setKind(event.target.value as SourceKind)} | |
| 134 | − > | |
| 135 | − {kindOptions.map((option) => ( | |
| 136 | − <option key={option} value={option}> | |
| 137 | − {option} | |
| 138 | − </option> | |
| 139 | − ))} | |
| 140 | − </select> | |
| 141 | − </div> | |
| 142 | − | |
| 143 | − <div className="field"> | |
| 144 | − <label htmlFor="source-endpoint">Endpoint</label> | |
| 145 | − <input | |
| 146 | − id="source-endpoint" | |
| 147 | − value={endpoint} | |
| 148 | − onChange={(event) => setEndpoint(event.target.value)} | |
| 149 | − required | |
| 150 | − /> | |
| 151 | − </div> | |
| 152 | − | |
| 153 | − <button className="primary" type="submit" disabled={isPending}> | |
| 154 | − {isPending ? "Saving..." : "Save Source"} | |
| 155 | − </button> | |
| 156 | − </form> | |
| 157 | − | |
| 158 | − {statusText ? <p className="status">{statusText}</p> : null} | |
| 159 | − </section> | |
| 160 | − | |
| 161 | − <section className="card"> | |
| 162 | − <h2>Sources</h2> | |
| 163 | − | |
| 164 | − {sources.state === "loading" ? ( | |
| 165 | − <p className="status">Loading sources...</p> | |
| 166 | − ) : null} | |
| 167 | − | |
| 168 | − {sources.state === "refreshing" ? ( | |
| 169 | − <p className="status">Refreshing sources...</p> | |
| 170 | − ) : null} | |
| 171 | − | |
| 172 | − {sources.state === "error" ? ( | |
| 173 | − <p className="status error">{sources.message}</p> | |
| 174 | − ) : null} | |
| 175 | − | |
| 176 | − {sources.state !== "loading" && sources.items.length === 0 ? ( | |
| 177 | − <p className="status">No sources yet in this workspace.</p> | |
| 178 | − ) : null} | |
| 179 | − | |
| 180 | − <div className="list"> | |
| 181 | − {sources.items.map((source) => ( | |
| 182 | − <article className="list-item" key={source.id}> | |
| 183 | − <header> | |
| 184 | − <div> | |
| 185 | − <strong>{source.name}</strong> | |
| 186 | − <div className="meta"> | |
| 187 | − {source.kind} · {source.status} | |
| 188 | − </div> | |
| 189 | − <div className="meta">{source.endpoint}</div> | |
| 190 | − </div> | |
| 191 | − <button | |
| 192 | − className="danger" | |
| 193 | − type="button" | |
| 194 | − onClick={() => handleRemoveSource(source.id)} | |
| 195 | − disabled={isPending} | |
| 196 | − > | |
| 197 | − Remove | |
| 198 | − </button> | |
| 199 | − </header> | |
| 200 | − </article> | |
| 201 | − ))} | |
| 202 | − </div> | |
| 203 | − </section> | |
| 204 | − </div> | |
| 205 | − </section> | |
| 206 | − </main> | |
| 207 | − ); | |
| 208 | −}; | |
| 10 | +const Page = () => <ControlPlanePageContent />; | |
| 209 | 11 | |
| 210 | 12 | export default Page; |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { Atom, Result } from "@effect-atom/atom"; |
| 2 | −import type { Source, WorkspaceId } from "@executor-v2/schema"; | |
| 2 | +import type { UpsertSourcePayload } from "@executor-v2/control-plane"; | |
| 3 | +import type { Source, SourceId, WorkspaceId } from "@executor-v2/schema"; | |
| 3 | 4 | import * as Cause from "effect/Cause"; |
| 4 | 5 | import * as Option from "effect/Option"; |
| 5 | 6 | |
| @@ -9,9 +10,19 @@ type SourcesResult = Result.Result<ReadonlyArray<Source>, unknown>; · 3 unmodified lines | ||
| 9 | 10 | |
| 10 | 11 | const emptySources: ReadonlyArray<Source> = []; |
| 11 | 12 | |
| 12 | −export const sourcesResultDisabled = Atom.make( | |
| 13 | − Result.initial<ReadonlyArray<Source>, unknown>(), | |
| 14 | −); | |
| 13 | +const sourceStoreKey = (source: Source): string => `${source.workspaceId}:${source.id}`; | |
| 14 | + | |
| 15 | +const sortSources = (sources: ReadonlyArray<Source>): Array<Source> => | |
| 16 | + [...sources].sort((left, right) => { | |
| 17 | + const leftName = left.name.toLowerCase(); | |
| 18 | + const rightName = right.name.toLowerCase(); | |
| 19 | + | |
| 20 | + if (leftName === rightName) { | |
| 21 | + return sourceStoreKey(left).localeCompare(sourceStoreKey(right)); | |
| 22 | + } | |
| 23 | + | |
| 24 | + return leftName.localeCompare(rightName); | |
| 25 | + }); | |
| 15 | 26 | |
| 16 | 27 | export const sourcesResultByWorkspace = Atom.family( |
| 17 | 28 | (workspaceId: WorkspaceId): Atom.Atom<SourcesResult> => |
| @@ -22,6 +33,25 @@ export const sourcesResultByWorkspace = Atom.family( · 4 unmodified lines | ||
| 22 | 33 | }) as Atom.Atom<SourcesResult>, |
| 23 | 34 | ); |
| 24 | 35 | |
| 36 | +type OptimisticPendingAck = | |
| 37 | + | { | |
| 38 | + kind: "upsert"; | |
| 39 | + sourceId: SourceId; | |
| 40 | + } | |
| 41 | + | { | |
| 42 | + kind: "remove"; | |
| 43 | + sourceId: SourceId; | |
| 44 | + }; | |
| 45 | + | |
| 46 | +type OptimisticSources = { | |
| 47 | + items: ReadonlyArray<Source>; | |
| 48 | + pendingAck: OptimisticPendingAck; | |
| 49 | +}; | |
| 50 | + | |
| 51 | +export const optimisticSourcesByWorkspace = Atom.family((workspaceId: WorkspaceId) => | |
| 52 | + Atom.make<OptimisticSources | null>(null), | |
| 53 | +); | |
| 54 | + | |
| 25 | 55 | export const upsertSource = controlPlaneClient.mutation("sources", "upsert"); |
| 26 | 56 | export const removeSource = controlPlaneClient.mutation("sources", "remove"); |
| 27 | 57 | |
| @@ -37,37 +67,131 @@ export type SourcesState = · 9 unmodified lines | ||
| 37 | 67 | message: string; |
| 38 | 68 | } |
| 39 | 69 | | { |
| 40 | − state: "ready" | "refreshing"; | |
| 70 | + state: "ready"; | |
| 41 | 71 | items: ReadonlyArray<Source>; |
| 42 | 72 | message: null; |
| 43 | 73 | }; |
| 44 | 74 | |
| 45 | −export const sourcesLoading = Atom.make<SourcesState>({ | |
| 46 | − state: "loading", | |
| 47 | − items: emptySources, | |
| 48 | − message: null, | |
| 75 | +const sourceStateFromResult = (result: SourcesResult): SourcesState => | |
| 76 | + Result.match(result, { | |
| 77 | + onInitial: () => ({ | |
| 78 | + state: "loading", | |
| 79 | + items: emptySources, | |
| 80 | + message: null, | |
| 81 | + }), | |
| 82 | + onFailure: (failure) => ({ | |
| 83 | + state: "error", | |
| 84 | + items: Option.getOrElse(Result.value(result), () => emptySources), | |
| 85 | + message: Cause.pretty(failure.cause), | |
| 86 | + }), | |
| 87 | + onSuccess: (success) => ({ | |
| 88 | + state: "ready", | |
| 89 | + items: success.value, | |
| 90 | + message: null, | |
| 91 | + }), | |
| 92 | + }); | |
| 93 | + | |
| 94 | +const isAcknowledged = ( | |
| 95 | + serverSources: ReadonlyArray<Source>, | |
| 96 | + pendingAck: OptimisticPendingAck, | |
| 97 | +): boolean => { | |
| 98 | + const hasSource = serverSources.some((source) => source.id === pendingAck.sourceId); | |
| 99 | + return pendingAck.kind === "upsert" ? hasSource : !hasSource; | |
| 100 | +}; | |
| 101 | + | |
| 102 | +export const optimisticUpsertSources = ( | |
| 103 | + currentSources: ReadonlyArray<Source>, | |
| 104 | + workspaceId: WorkspaceId, | |
| 105 | + payload: UpsertSourcePayload, | |
| 106 | +): { | |
| 107 | + sourceId: SourceId; | |
| 108 | + items: ReadonlyArray<Source>; | |
| 109 | +} => { | |
| 110 | + const sourceId = payload.id as SourceId; | |
| 111 | + const existing = currentSources.find((source) => source.id === sourceId); | |
| 112 | + const now = Date.now(); | |
| 113 | + | |
| 114 | + const source: Source = { | |
| 115 | + id: sourceId, | |
| 116 | + workspaceId, | |
| 117 | + name: payload.name, | |
| 118 | + kind: payload.kind, | |
| 119 | + endpoint: payload.endpoint, | |
| 120 | + status: payload.status ?? "draft", | |
| 121 | + enabled: payload.enabled ?? true, | |
| 122 | + configJson: payload.configJson ?? "{}", | |
| 123 | + sourceHash: payload.sourceHash ?? null, | |
| 124 | + lastError: payload.lastError ?? null, | |
| 125 | + createdAt: existing?.createdAt ?? now, | |
| 126 | + updatedAt: now, | |
| 127 | + }; | |
| 128 | + | |
| 129 | + const nextSources = currentSources.filter((item) => item.id !== sourceId); | |
| 130 | + return { | |
| 131 | + sourceId, | |
| 132 | + items: sortSources([...nextSources, source]), | |
| 133 | + }; | |
| 134 | +}; | |
| 135 | + | |
| 136 | +export const optimisticRemoveSources = ( | |
| 137 | + currentSources: ReadonlyArray<Source>, | |
| 138 | + sourceId: SourceId, | |
| 139 | +): { | |
| 140 | + sourceId: SourceId; | |
| 141 | + items: ReadonlyArray<Source>; | |
| 142 | +} => ({ | |
| 143 | + sourceId, | |
| 144 | + items: currentSources.filter((source) => source.id !== sourceId), | |
| 49 | 145 | }); |
| 50 | 146 | |
| 147 | +export const sourcesPendingByWorkspace = Atom.family((workspaceId: WorkspaceId) => | |
| 148 | + Atom.make((get): boolean => { | |
| 149 | + const optimistic = get(optimisticSourcesByWorkspace(workspaceId)); | |
| 150 | + | |
| 151 | + if (optimistic === null) { | |
| 152 | + return false; | |
| 153 | + } | |
| 154 | + | |
| 155 | + const serverResult = get(sourcesResultByWorkspace(workspaceId)); | |
| 156 | + | |
| 157 | + return Result.match(serverResult, { | |
| 158 | + onInitial: () => true, | |
| 159 | + onFailure: () => true, | |
| 160 | + onSuccess: (success) => | |
| 161 | + !isAcknowledged(success.value, optimistic.pendingAck), | |
| 162 | + }); | |
| 163 | + }), | |
| 164 | +); | |
| 165 | + | |
| 51 | 166 | export const sourcesByWorkspace = Atom.family((workspaceId: WorkspaceId) => |
| 52 | 167 | Atom.make((get): SourcesState => { |
| 53 | − const result = get(sourcesResultByWorkspace(workspaceId)); | |
| 168 | + const serverResult = get(sourcesResultByWorkspace(workspaceId)); | |
| 169 | + const serverState = sourceStateFromResult(serverResult); | |
| 170 | + const optimistic = get(optimisticSourcesByWorkspace(workspaceId)); | |
| 54 | 171 | |
| 55 | − return Result.match(result, { | |
| 172 | + if (optimistic === null) { | |
| 173 | + return serverState; | |
| 174 | + } | |
| 175 | + | |
| 176 | + return Result.match(serverResult, { | |
| 56 | 177 | onInitial: () => ({ |
| 57 | − state: "loading", | |
| 58 | − items: emptySources, | |
| 178 | + state: "ready", | |
| 179 | + items: optimistic.items, | |
| 59 | 180 | message: null, |
| 60 | 181 | }), |
| 61 | − onFailure: (failure) => ({ | |
| 62 | − state: "error", | |
| 63 | − items: Option.getOrElse(Result.value(result), () => emptySources), | |
| 64 | − message: Cause.pretty(failure.cause), | |
| 65 | − }), | |
| 66 | − onSuccess: (success) => ({ | |
| 67 | − state: success.waiting ? "refreshing" : "ready", | |
| 68 | − items: success.value, | |
| 182 | + onFailure: () => ({ | |
| 183 | + state: "ready", | |
| 184 | + items: optimistic.items, | |
| 69 | 185 | message: null, |
| 70 | 186 | }), |
| 187 | + onSuccess: (success) => | |
| 188 | + isAcknowledged(success.value, optimistic.pendingAck) | |
| 189 | + ? serverState | |
| 190 | + : { | |
| 191 | + state: "ready", | |
| 192 | + items: optimistic.items, | |
| 193 | + message: null, | |
| 194 | + }, | |
| 71 | 195 | }); |
| 72 | 196 | }), |
| 73 | 197 | ); |
| @@ -1,5 +1,12 @@ | ||
| 1 | 1 | import { createControlPlaneAtomClient } from "@executor-v2/control-plane"; |
| 2 | 2 | |
| 3 | +const controlPlaneBaseUrl = | |
| 4 | + typeof window === "undefined" | |
| 5 | + ? process.env.CONTROL_PLANE_SERVER_BASE_URL ?? | |
| 6 | + process.env.NEXT_PUBLIC_CONTROL_PLANE_BASE_URL ?? | |
| 7 | + "http://127.0.0.1:3000/api/control-plane" | |
| 8 | + : process.env.NEXT_PUBLIC_CONTROL_PLANE_BASE_URL ?? "/api/control-plane"; | |
| 9 | + | |
| 3 | 10 | export const controlPlaneClient = createControlPlaneAtomClient({ |
| 4 | − baseUrl: process.env.NEXT_PUBLIC_CONTROL_PLANE_BASE_URL ?? "/api/control-plane", | |
| 11 | + baseUrl: controlPlaneBaseUrl, | |
| 5 | 12 | }); |
| @@ -6,7 +6,7 @@ · 5 unmodified lines | ||
| 6 | 6 | "dev": "next dev --port 3000", |
| 7 | 7 | "build": "next build", |
| 8 | 8 | "start": "next start --port 3000", |
| 9 | − "typecheck": "bunx tsc --noEmit -p tsconfig.json" | |
| 9 | + "typecheck": "next typegen && bunx tsc --noEmit -p tsconfig.json" | |
| 10 | 10 | }, |
| 11 | 11 | "dependencies": { |
| 12 | 12 | "@effect-atom/atom": "^0.4.11", |
| @@ -7,7 +7,6 @@ · 6 unmodified lines | ||
| 7 | 7 | "esnext" |
| 8 | 8 | ], |
| 9 | 9 | "jsx": "preserve", |
| 10 | − "incremental": true, | |
| 11 | 10 | "plugins": [ |
| 12 | 11 | { |
| 13 | 12 | "name": "next" |
| @@ -20,12 +19,13 @@ · 6 unmodified lines | ||
| 20 | 19 | "node" |
| 21 | 20 | ], |
| 22 | 21 | "allowJs": true, |
| 23 | − "esModuleInterop": true | |
| 22 | + "esModuleInterop": true, | |
| 23 | + "incremental": true | |
| 24 | 24 | }, |
| 25 | 25 | "include": [ |
| 26 | − "next-env.d.ts", | |
| 27 | 26 | "**/*.ts", |
| 28 | 27 | "**/*.tsx", |
| 28 | + "next-env.d.ts", | |
| 29 | 29 | ".next/types/**/*.ts" |
| 30 | 30 | ], |
| 31 | 31 | "exclude": [ |