History

optimistic sources

Rhys Sullivan committed 8 months ago

Commit f56bdb51 parent ef56bd56 files · +401 −230Browse files

Unified · Split

6 files
  1. v2/apps/web/app/page-content.tsx +238 −0
  2. v2/apps/web/app/page.tsx +6 −204
  3. v2/apps/web/lib/control-plane/atoms.ts +145 −21
  4. v2/apps/web/lib/control-plane/client.ts +8 −1
  5. v2/apps/web/package.json +1 −1
  6. v2/apps/web/tsconfig.json +3 −3
v2/apps/web/app/page-content.tsx+238−0
@@ -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;
v2/apps/web/app/page.tsx+6−204
@@ -1,210 +1,12 @@
11 "use client";
22
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";
74
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+});
169
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 />;
20911
21012 export default Page;
v2/apps/web/lib/control-plane/atoms.ts+145−21
@@ -1,5 +1,6 @@
11 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";
34 import * as Cause from "effect/Cause";
45 import * as Option from "effect/Option";
56
@@ -9,9 +10,19 @@ type SourcesResult = Result.Result<ReadonlyArray<Source>, unknown>; · 3 unmodified lines
910
1011 const emptySources: ReadonlyArray<Source> = [];
1112
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+ });
1526
1627 export const sourcesResultByWorkspace = Atom.family(
1728 (workspaceId: WorkspaceId): Atom.Atom<SourcesResult> =>
@@ -22,6 +33,25 @@ export const sourcesResultByWorkspace = Atom.family( · 4 unmodified lines
2233 }) as Atom.Atom<SourcesResult>,
2334 );
2435
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+
2555 export const upsertSource = controlPlaneClient.mutation("sources", "upsert");
2656 export const removeSource = controlPlaneClient.mutation("sources", "remove");
2757
@@ -37,37 +67,131 @@ export type SourcesState = · 9 unmodified lines
3767 message: string;
3868 }
3969 | {
40− state: "ready" | "refreshing";
70+ state: "ready";
4171 items: ReadonlyArray<Source>;
4272 message: null;
4373 };
4474
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),
49145 });
50146
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+
51166 export const sourcesByWorkspace = Atom.family((workspaceId: WorkspaceId) =>
52167 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));
54171
55− return Result.match(result, {
172+ if (optimistic === null) {
173+ return serverState;
174+ }
175+
176+ return Result.match(serverResult, {
56177 onInitial: () => ({
57− state: "loading",
58− items: emptySources,
178+ state: "ready",
179+ items: optimistic.items,
59180 message: null,
60181 }),
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,
69185 message: null,
70186 }),
187+ onSuccess: (success) =>
188+ isAcknowledged(success.value, optimistic.pendingAck)
189+ ? serverState
190+ : {
191+ state: "ready",
192+ items: optimistic.items,
193+ message: null,
194+ },
71195 });
72196 }),
73197 );
v2/apps/web/lib/control-plane/client.ts+8−1
@@ -1,5 +1,12 @@
11 import { createControlPlaneAtomClient } from "@executor-v2/control-plane";
22
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+
310 export const controlPlaneClient = createControlPlaneAtomClient({
4− baseUrl: process.env.NEXT_PUBLIC_CONTROL_PLANE_BASE_URL ?? "/api/control-plane",
11+ baseUrl: controlPlaneBaseUrl,
512 });
v2/apps/web/package.json+1−1
@@ -6,7 +6,7 @@ · 5 unmodified lines
66 "dev": "next dev --port 3000",
77 "build": "next build",
88 "start": "next start --port 3000",
9− "typecheck": "bunx tsc --noEmit -p tsconfig.json"
9+ "typecheck": "next typegen && bunx tsc --noEmit -p tsconfig.json"
1010 },
1111 "dependencies": {
1212 "@effect-atom/atom": "^0.4.11",
v2/apps/web/tsconfig.json+3−3
@@ -7,7 +7,6 @@ · 6 unmodified lines
77 "esnext"
88 ],
99 "jsx": "preserve",
10− "incremental": true,
1110 "plugins": [
1211 {
1312 "name": "next"
@@ -20,12 +19,13 @@ · 6 unmodified lines
2019 "node"
2120 ],
2221 "allowJs": true,
23− "esModuleInterop": true
22+ "esModuleInterop": true,
23+ "incremental": true
2424 },
2525 "include": [
26− "next-env.d.ts",
2726 "**/*.ts",
2827 "**/*.tsx",
28+ "next-env.d.ts",
2929 ".next/types/**/*.ts"
3030 ],
3131 "exclude": [