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
3import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect-atom/atom-react";
4import type { UpsertSourcePayload } from "@executor-v2/control-plane";
5import type { SourceId, SourceKind, WorkspaceId } from "@executor-v2/schema";
6import { useState, type FormEvent } from "react";
7
8import {
9 optimisticRemoveSources,
10 optimisticSourcesByWorkspace,
11 optimisticUpsertSources,
12 removeSource,
13 sourcesByWorkspace,
14 sourcesPendingByWorkspace,
15 sourcesResultByWorkspace,
16 upsertSource,
17} from "../lib/control-plane/atoms";
18
19const kindOptions: ReadonlyArray<SourceKind> = [
20 "openapi",
21 "mcp",
22 "graphql",
23 "internal",
24];
25
26const 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
238export default Page;
v2/apps/web/app/page.tsx+6−204
@@ -1,210 +1,12 @@
1"use client";1"use client";
2 2
3import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect-atom/atom-react";3import dynamic from "next/dynamic";
4import type { UpsertSourcePayload } from "@executor-v2/control-plane";
5import type { SourceId, SourceKind, WorkspaceId } from "@executor-v2/schema";
6import { useEffect, useState, useTransition, type FormEvent } from "react";
7 4
8import {5const ControlPlanePageContent = dynamic(() => import("./page-content"), {
9 removeSource,6 ssr: false,
10 sourcesByWorkspace,7 loading: () => null,
11 sourcesLoading,8});
12 sourcesResultByWorkspace,
13 sourcesResultDisabled,
14 upsertSource,
15} from "../lib/control-plane/atoms";
16 9
17const kindOptions: ReadonlyArray<SourceKind> = [10const Page = () => <ControlPlanePageContent />;
18 "openapi",
19 "mcp",
20 "graphql",
21 "internal",
22];
23
24const 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};
209 11
210export default Page;12export default Page;
v2/apps/web/lib/control-plane/atoms.ts+145−21
@@ -1,5 +1,6 @@
1import { Atom, Result } from "@effect-atom/atom";1import { Atom, Result } from "@effect-atom/atom";
2import type { Source, WorkspaceId } from "@executor-v2/schema";2import type { UpsertSourcePayload } from "@executor-v2/control-plane";
3import type { Source, SourceId, WorkspaceId } from "@executor-v2/schema";
3import * as Cause from "effect/Cause";4import * as Cause from "effect/Cause";
4import * as Option from "effect/Option";5import * as Option from "effect/Option";
5 6
@@ -9,9 +10,19 @@ type SourcesResult = Result.Result<ReadonlyArray<Source>, unknown>; · 3 unmodified lines
9 10
10const emptySources: ReadonlyArray<Source> = [];11const emptySources: ReadonlyArray<Source> = [];
11 12
12export const sourcesResultDisabled = Atom.make(13const sourceStoreKey = (source: Source): string => `${source.workspaceId}:${source.id}`;
13 Result.initial<ReadonlyArray<Source>, unknown>(),14
14);15const 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
16export const sourcesResultByWorkspace = Atom.family(27export const sourcesResultByWorkspace = Atom.family(
17 (workspaceId: WorkspaceId): Atom.Atom<SourcesResult> =>28 (workspaceId: WorkspaceId): Atom.Atom<SourcesResult> =>
@@ -22,6 +33,25 @@ export const sourcesResultByWorkspace = Atom.family( · 4 unmodified lines
22 }) as Atom.Atom<SourcesResult>,33 }) as Atom.Atom<SourcesResult>,
23);34);
24 35
36type OptimisticPendingAck =
37 | {
38 kind: "upsert";
39 sourceId: SourceId;
40 }
41 | {
42 kind: "remove";
43 sourceId: SourceId;
44 };
45
46type OptimisticSources = {
47 items: ReadonlyArray<Source>;
48 pendingAck: OptimisticPendingAck;
49};
50
51export const optimisticSourcesByWorkspace = Atom.family((workspaceId: WorkspaceId) =>
52 Atom.make<OptimisticSources | null>(null),
53);
54
25export const upsertSource = controlPlaneClient.mutation("sources", "upsert");55export const upsertSource = controlPlaneClient.mutation("sources", "upsert");
26export const removeSource = controlPlaneClient.mutation("sources", "remove");56export const removeSource = controlPlaneClient.mutation("sources", "remove");
27 57
@@ -37,37 +67,131 @@ export type SourcesState = · 9 unmodified lines
37 message: string;67 message: string;
38 }68 }
39 | {69 | {
40 state: "ready" | "refreshing";70 state: "ready";
41 items: ReadonlyArray<Source>;71 items: ReadonlyArray<Source>;
42 message: null;72 message: null;
43 };73 };
44 74
45export const sourcesLoading = Atom.make<SourcesState>({75const sourceStateFromResult = (result: SourcesResult): SourcesState =>
46 state: "loading",76 Result.match(result, {
47 items: emptySources,77 onInitial: () => ({
48 message: null,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
94const 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
102export 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
136export 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
147export 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
51export const sourcesByWorkspace = Atom.family((workspaceId: WorkspaceId) =>166export const sourcesByWorkspace = Atom.family((workspaceId: WorkspaceId) =>
52 Atom.make((get): SourcesState => {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 onInitial: () => ({177 onInitial: () => ({
57 state: "loading",178 state: "ready",
58 items: emptySources,179 items: optimistic.items,
59 message: null,180 message: null,
60 }),181 }),
61 onFailure: (failure) => ({182 onFailure: () => ({
62 state: "error",183 state: "ready",
63 items: Option.getOrElse(Result.value(result), () => emptySources),184 items: optimistic.items,
64 message: Cause.pretty(failure.cause),
65 }),
66 onSuccess: (success) => ({
67 state: success.waiting ? "refreshing" : "ready",
68 items: success.value,
69 message: null,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);
v2/apps/web/lib/control-plane/client.ts+8−1
@@ -1,5 +1,12 @@
1import { createControlPlaneAtomClient } from "@executor-v2/control-plane";1import { createControlPlaneAtomClient } from "@executor-v2/control-plane";
2 2
3const 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
3export const controlPlaneClient = createControlPlaneAtomClient({10export const controlPlaneClient = createControlPlaneAtomClient({
4 baseUrl: process.env.NEXT_PUBLIC_CONTROL_PLANE_BASE_URL ?? "/api/control-plane",11 baseUrl: controlPlaneBaseUrl,
5});12});
v2/apps/web/package.json+1−1
@@ -6,7 +6,7 @@ · 5 unmodified lines
6 "dev": "next dev --port 3000",6 "dev": "next dev --port 3000",
7 "build": "next build",7 "build": "next build",
8 "start": "next start --port 3000",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 "dependencies": {11 "dependencies": {
12 "@effect-atom/atom": "^0.4.11",12 "@effect-atom/atom": "^0.4.11",
v2/apps/web/tsconfig.json+3−3
@@ -7,7 +7,6 @@ · 6 unmodified lines
7 "esnext"7 "esnext"
8 ],8 ],
9 "jsx": "preserve",9 "jsx": "preserve",
10 "incremental": true,
11 "plugins": [10 "plugins": [
12 {11 {
13 "name": "next"12 "name": "next"
@@ -20,12 +19,13 @@ · 6 unmodified lines
20 "node"19 "node"
21 ],20 ],
22 "allowJs": true,21 "allowJs": true,
23 "esModuleInterop": true22 "esModuleInterop": true,
23 "incremental": true
24 },24 },
25 "include": [25 "include": [
26 "next-env.d.ts",
27 "**/*.ts",26 "**/*.ts",
28 "**/*.tsx",27 "**/*.tsx",
28 "next-env.d.ts",
29 ".next/types/**/*.ts"29 ".next/types/**/*.ts"
30 ],30 ],
31 "exclude": [31 "exclude": [