Skip to content

Commit 732b8a3

Browse files
committed
feat(studio): Unit D — real backend persistence via Karrio metafields (unblocks EBE-99)
Per dan's suggestion: use metafields with a dedicated studio.* namespace as the round-trippable per-user KV the OSS API otherwise lacked. Verified live against :5002 — create_metafield(key, type:json, value) + metafields(filter:{key}) + update/delete give a clean per-user CRUD round trip, no backend change needed. - metastore.ts (new): readMeta/writeMeta/deleteMeta over metafields, + setStudioCtx so ctx-less stores reach the backend (SessionProvider registers the ctx). - preferences.ts: syncToBackend -> writeMeta('studio.customization'); loadFromBackend now ACTUALLY hydrates (was a documented no-op) by reading the metafield. - agents.ts: metafield-backed adapter (studio.agents / studio.mcp-servers) with localStorage write-through cache + offline fallback; replaces localStorage-only. - session.tsx: on login, register ctx + hydrate UI prefs from the backend metafield. Studio-native state (theme/accent/density/font, agent + MCP configs) now follows the user across devices via real Karrio persistence. localStorage remains the offline cache. tsc/build clean; tweaks/editor/build specs pass; live CRUD verified.
1 parent 76231d1 commit 732b8a3

4 files changed

Lines changed: 178 additions & 59 deletions

File tree

apps/studio/src/lib/karrio/agents.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
// a backend proxy that does not exist in OSS Karrio. The `McpServerConfig`
1313
// records stored here are *configuration only*. The `connectionStatus` field
1414
// is set to "config-only" to make this explicit.
15+
import { getStudioCtx, readMeta, writeMeta } from "~/lib/karrio/metastore";
1516

1617
// ---------------------------------------------------------------------------
1718
// Types
@@ -174,10 +175,80 @@ const localStorageAdapter: BackendAdapter = {
174175
};
175176

176177
// ---------------------------------------------------------------------------
177-
// Active adapter (swap this to a REST adapter when the backend ships)
178+
// Backend (metafield) adapter — real per-user persistence with localStorage cache
179+
// ---------------------------------------------------------------------------
180+
// Agent + MCP configs persist as per-user JSON metafields under the `studio.*`
181+
// namespace (see metastore.ts), with localStorage as an immediate write-through
182+
// cache and offline fallback. The ctx comes from SessionProvider via setStudioCtx.
183+
184+
const META_AGENTS_KEY = "studio.agents";
185+
const META_MCP_KEY = "studio.mcp-servers";
186+
187+
async function loadList<T>(metaKey: string, lsKey: string): Promise<T[]> {
188+
const ctx = getStudioCtx();
189+
if (ctx?.token) {
190+
try {
191+
const remote = await readMeta<T[]>(ctx, metaKey);
192+
if (remote) {
193+
writeJson(lsKey, remote); // refresh the local cache
194+
return remote;
195+
}
196+
} catch {
197+
/* network/permission error — fall back to cache */
198+
}
199+
}
200+
return readJson<T[]>(lsKey, []);
201+
}
202+
203+
async function persistList<T>(metaKey: string, lsKey: string, next: T[]): Promise<void> {
204+
writeJson(lsKey, next); // immediate cache
205+
const ctx = getStudioCtx();
206+
if (ctx?.token) {
207+
try {
208+
await writeMeta(ctx, metaKey, next);
209+
} catch {
210+
/* sync failure is non-fatal; cache holds and re-syncs on next write */
211+
}
212+
}
213+
}
214+
215+
const backendAdapter: BackendAdapter = {
216+
listAgents: () => loadList<AgentDef>(META_AGENTS_KEY, LS_AGENTS_KEY),
217+
218+
saveAgent: async (agent) => {
219+
const existing = await loadList<AgentDef>(META_AGENTS_KEY, LS_AGENTS_KEY);
220+
const idx = existing.findIndex((a) => a.id === agent.id);
221+
const next = idx >= 0 ? existing.map((a) => (a.id === agent.id ? agent : a)) : [...existing, agent];
222+
await persistList(META_AGENTS_KEY, LS_AGENTS_KEY, next);
223+
return agent;
224+
},
225+
226+
deleteAgent: async (id) => {
227+
const next = (await loadList<AgentDef>(META_AGENTS_KEY, LS_AGENTS_KEY)).filter((a) => a.id !== id);
228+
await persistList(META_AGENTS_KEY, LS_AGENTS_KEY, next);
229+
},
230+
231+
listMcpServers: () => loadList<McpServerConfig>(META_MCP_KEY, LS_MCP_KEY),
232+
233+
saveMcpServer: async (server) => {
234+
const existing = await loadList<McpServerConfig>(META_MCP_KEY, LS_MCP_KEY);
235+
const idx = existing.findIndex((s) => s.id === server.id);
236+
const next = idx >= 0 ? existing.map((s) => (s.id === server.id ? server : s)) : [...existing, server];
237+
await persistList(META_MCP_KEY, LS_MCP_KEY, next);
238+
return server;
239+
},
240+
241+
deleteMcpServer: async (id) => {
242+
const next = (await loadList<McpServerConfig>(META_MCP_KEY, LS_MCP_KEY)).filter((s) => s.id !== id);
243+
await persistList(META_MCP_KEY, LS_MCP_KEY, next);
244+
},
245+
};
246+
247+
// ---------------------------------------------------------------------------
248+
// Active adapter — metafield-backed with localStorage cache/fallback.
178249
// ---------------------------------------------------------------------------
179250

180-
const defaultAdapter: BackendAdapter = localStorageAdapter;
251+
const defaultAdapter: BackendAdapter = backendAdapter;
181252

182253
// ---------------------------------------------------------------------------
183254
// Helpers
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// metastore.ts — round-trippable per-user backend KV via Karrio metafields.
2+
// Studio-native app state (UI preferences, agent + MCP configs) is stored as
3+
// JSON metafields under a `studio.*` key namespace, scoped to the authenticated
4+
// user by Karrio's ownership model. This is the real backend behind the
5+
// localStorage caches in preferences.ts / agents.ts (unblocks EBE-99).
6+
//
7+
// Verified live: a free-standing `create_metafield(key, type:json, value)` plus
8+
// `metafields(filter:{ key })` gives a clean per-user create/read/update/delete
9+
// round trip — no backend change required.
10+
import { graphql, type KarrioCtx } from "~/lib/karrio/client";
11+
12+
// The SessionProvider registers the current authenticated ctx here (client-side)
13+
// so ctx-less stores (agents.ts) can reach the backend without threading ctx
14+
// through every call. Null on the server / when unauthenticated.
15+
let studioCtx: KarrioCtx | null = null;
16+
export function setStudioCtx(ctx: KarrioCtx | null): void {
17+
studioCtx = ctx;
18+
}
19+
export function getStudioCtx(): KarrioCtx | null {
20+
return studioCtx;
21+
}
22+
23+
const READ = `query($key: String!) {
24+
metafields(filter: { key: $key }) { edges { node { id value } } }
25+
}`;
26+
const CREATE = `mutation($input: CreateMetafieldInput!) {
27+
create_metafield(input: $input) { metafield { id } errors { field messages } }
28+
}`;
29+
const UPDATE = `mutation($input: UpdateMetafieldInput!) {
30+
update_metafield(input: $input) { metafield { id } errors { field messages } }
31+
}`;
32+
const DELETE = `mutation($input: DeleteMutationInput!) {
33+
delete_metafield(input: $input) { id }
34+
}`;
35+
36+
type ReadResult<T> = { metafields: { edges: Array<{ node: { id: string; value: T } }> } };
37+
38+
async function find<T>(ctx: KarrioCtx, key: string): Promise<{ id: string; value: T } | null> {
39+
const data = await graphql<Partial<ReadResult<T>>>(ctx, READ, { key });
40+
return data?.metafields?.edges?.[0]?.node ?? null;
41+
}
42+
43+
/** Read a JSON metafield value by key, or null when absent/unauthenticated. */
44+
export async function readMeta<T>(ctx: KarrioCtx, key: string): Promise<T | null> {
45+
if (!ctx.token) return null;
46+
return (await find<T>(ctx, key))?.value ?? null;
47+
}
48+
49+
/** Upsert a JSON metafield by key (create or update). No-op when unauthenticated. */
50+
export async function writeMeta(ctx: KarrioCtx, key: string, value: unknown): Promise<void> {
51+
if (!ctx.token) return;
52+
const existing = await find(ctx, key);
53+
if (existing) {
54+
await graphql(ctx, UPDATE, { input: { id: existing.id, value } });
55+
} else {
56+
await graphql(ctx, CREATE, { input: { key, type: "json", value } });
57+
}
58+
}
59+
60+
/** Delete the metafield for a key, if present. */
61+
export async function deleteMeta(ctx: KarrioCtx, key: string): Promise<void> {
62+
if (!ctx.token) return;
63+
const existing = await find(ctx, key);
64+
if (existing) await graphql(ctx, DELETE, { input: { id: existing.id } });
65+
}

apps/studio/src/lib/karrio/preferences.ts

Lines changed: 26 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
// savePrefs({ accent: "#10B981" }); // synchronous + kicks off async sync
2222
// await syncToBackend(ctx, prefs); // explicit flush (e.g. on unload)
2323

24-
import { graphql, type KarrioCtx } from "~/lib/karrio/client";
24+
import type { KarrioCtx } from "~/lib/karrio/client";
25+
import { readMeta, writeMeta } from "~/lib/karrio/metastore";
2526

2627
// ---------------------------------------------------------------------------
2728
// Types
@@ -154,78 +155,47 @@ export function applyFont(font: FontStack): void {
154155
}
155156

156157
// ---------------------------------------------------------------------------
157-
// Backend adapter — async sync layer (Karrio User.metadata)
158+
// Backend adapter — async sync layer (Karrio metafields, key-namespaced)
158159
// ---------------------------------------------------------------------------
159-
160-
// GraphQL mutation — writes the full preferences blob into User.metadata under
161-
// the BACKEND_META_KEY namespace. Karrio's process_dictionaries_mutations helper
162-
// merges dict keys, so other metadata keys are preserved.
163-
const UPDATE_USER_PREFS_MUTATION = `
164-
mutation UpdateUserPreferences($data: UpdateUserInput!) {
165-
update_user(input: $data) {
166-
user {
167-
email
168-
}
169-
errors {
170-
field
171-
messages
172-
}
173-
}
174-
}
175-
`;
176-
177-
type UpdateUserResponse = {
178-
update_user: {
179-
user: { email: string } | null;
180-
errors: Array<{ field: string; messages: string[] }> | null;
181-
};
182-
};
160+
// Studio prefs persist as a per-user JSON metafield under BACKEND_META_KEY
161+
// ("studio.customization"). Unlike User.metadata (write-only on the OSS
162+
// GraphQL), metafields round-trip cleanly — so loadFromBackend() actually
163+
// hydrates. See metastore.ts.
183164

184165
/**
185-
* Flush the given preferences to the Karrio backend (User.metadata).
186-
*
187-
* Writes to `User.metadata["studio.customization"]`. Fails silently if the
188-
* user is not authenticated (ctx.token absent) — localStorage remains the
189-
* source of truth in that case.
166+
* Flush the given preferences to the Karrio backend (metafield
167+
* `studio.customization`). No-op + silent when unauthenticated or on error —
168+
* localStorage remains the applied source of truth in that case.
190169
*/
191170
export async function syncToBackend(
192171
ctx: KarrioCtx,
193172
prefs: Preferences,
194173
): Promise<void> {
195-
if (!ctx.token) return; // unauthenticated — localStorage only
196-
197174
try {
198-
await graphql<UpdateUserResponse>(ctx, UPDATE_USER_PREFS_MUTATION, {
199-
data: {
200-
metadata: { [BACKEND_META_KEY]: prefs },
201-
},
202-
});
175+
await writeMeta(ctx, BACKEND_META_KEY, prefs);
203176
} catch {
204-
// Sync failure is non-fatal. The user's local settings remain applied;
205-
// they will re-sync on next successful write.
177+
// Sync failure is non-fatal; local settings stay applied and re-sync later.
206178
}
207179
}
208180

209181
/**
210-
* Load preferences from the Karrio backend into localStorage.
211-
*
212-
* LIMITATION: The OSS `UserType` GraphQL selection does not expose the
213-
* `metadata` field. Until `metadata: JSON` is added to the `user` query
214-
* response in `karrio/server/graph/schemas/base/types.py` (UserType class),
215-
* this function is a no-op and localStorage remains the primary read source.
216-
*
217-
* TODO(backend): Expose `metadata` on UserType, then implement:
218-
* const data = await graphql<{ user: { metadata: Record<string, unknown> } }>(
219-
* ctx, `query { user { metadata } }`,
220-
* );
221-
* const remote = data.user?.metadata?.[BACKEND_META_KEY] as Partial<Preferences> | undefined;
222-
* if (remote) savePrefs(remote);
182+
* Load preferences from the Karrio backend (metafield `studio.customization`)
183+
* into localStorage, and return the merged preferences. Falls back to the
184+
* localStorage cache when unauthenticated or on any error.
223185
*/
224186
export async function loadFromBackend(
225-
_ctx: KarrioCtx,
187+
ctx: KarrioCtx,
226188
): Promise<Preferences> {
227-
// No-op: returns the current localStorage-cached prefs.
228-
// When the TODO above is resolved, replace this body with the remote fetch.
189+
try {
190+
const remote = await readMeta<Partial<Preferences>>(ctx, BACKEND_META_KEY);
191+
if (remote) {
192+
const merged = { ...loadPrefs(), ...remote };
193+
savePrefs(merged);
194+
return merged;
195+
}
196+
} catch {
197+
// Ignore — fall through to the localStorage cache.
198+
}
229199
return loadPrefs();
230200
}
231201

apps/studio/src/lib/karrio/session.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
1414
import { getSession, refreshSession } from "~/server/auth";
1515
import { karrioBaseUrl } from "~/lib/karrio/env";
1616
import { setRefreshHandler, type KarrioCtx } from "~/lib/karrio/client";
17+
import { applyPrefsToDOM, loadFromBackend } from "~/lib/karrio/preferences";
18+
import { setStudioCtx } from "~/lib/karrio/metastore";
1719

1820
type SessionContextValue = {
1921
ctx: KarrioCtx;
@@ -51,8 +53,19 @@ export function SessionProvider({ children }: { children: ReactNode }) {
5153
return () => setRefreshHandler(null);
5254
}, [queryClient]);
5355

56+
// Hydrate Studio UI preferences from the backend (metafield `studio.customization`)
57+
// once authenticated, so settings follow the user across devices.
58+
const token = sessionQuery.data?.access;
59+
useEffect(() => {
60+
// Register the current ctx so ctx-less stores (agents/MCP configs) can reach
61+
// the backend, then hydrate UI preferences from the backend metafield.
62+
setStudioCtx(token ? { baseUrl, token, orgId, testMode } : null);
63+
if (!token) return;
64+
void loadFromBackend({ baseUrl, token, orgId, testMode }).then(applyPrefsToDOM);
65+
// eslint-disable-next-line react-hooks/exhaustive-deps
66+
}, [token, baseUrl, orgId, testMode]);
67+
5468
const value = useMemo<SessionContextValue>(() => {
55-
const token = sessionQuery.data?.access;
5669
return {
5770
ctx: { baseUrl, token, orgId, testMode },
5871
isAuthenticated: Boolean(token),

0 commit comments

Comments
 (0)