1import {
2 ConnectionCatalogDocument,
3 type ConnectionCatalogDocument as ConnectionCatalogDocumentType,
4 EMPTY_CONNECTION_CATALOG_DOCUMENT,
5 ORCHESTRATION_CACHE_SCHEMA_VERSION,
6 ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION,
7 StoredOrchestrationShellSnapshot,
8 StoredOrchestrationThreadSnapshot,
9 decodeOrDiscardOrchestrationCache,
10 putRemoteDpopTokenInCatalog,
11 registerConnectionInCatalog,
12 removeCatalogValue,
13 removeConnectionFromCatalog,
14 setConnectionEnabledInCatalog,
15 setRoutesInCatalog,
16 replaceCatalogValue,
17 Persistence,
18} from "@t3tools/client-runtime/platform";
19import { TokenStore } from "@t3tools/client-runtime/authorization";
20import {
21 ConnectionTransientError,
22 ConnectionBlockedError,
23 CredentialStore,
24 ProfileStore,
25 GitHubRoutingPermissions,
26 StoredGitHubRoutingPermission,
27 gitHubRoutingConnectionKey,
28 gitHubRoutingPermissionFor,
29} from "@t3tools/client-runtime/connection";
30import { EnvironmentId, ServerConfig, ThreadId, VcsListRefsResult } from "@t3tools/contracts";
31import * as Context from "effect/Context";
32import * as Effect from "effect/Effect";
33import * as Layer from "effect/Layer";
34import * as Option from "effect/Option";
35import * as Ref from "effect/Ref";
36import * as Queue from "effect/Queue";
37import * as Schema from "effect/Schema";
38import * as Semaphore from "effect/Semaphore";
39import * as Stream from "effect/Stream";
40import { projectFaviconCache } from "../assets/projectFaviconCache";
41
42const DATABASE_NAME = "t3code:connection-runtime";
43const DATABASE_VERSION = 4;
44const CATALOG_STORE_NAME = "catalog";
45const SHELL_STORE_NAME = "shell";
46const THREAD_STORE_NAME = "thread";
47const SERVER_CONFIG_STORE_NAME = "server-config";
48const VCS_REFS_STORE_NAME = "vcs-refs";
49const CATALOG_KEY = "document";
50const StoredShellSnapshot = StoredOrchestrationShellSnapshot;
51const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot);
52const StoredThreadSnapshot = StoredOrchestrationThreadSnapshot;
53const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot);
54const StoredServerConfig = Schema.Struct({
55 schemaVersion: Schema.Literal(1),
56 environmentId: EnvironmentId,
57 config: ServerConfig,
58});
59const StoredServerConfigJson = Schema.fromJsonString(StoredServerConfig);
60const StoredVcsRefs = Schema.Struct({
61 schemaVersion: Schema.Literal(1),
62 environmentId: EnvironmentId,
63 cwd: Schema.String,
64 refs: VcsListRefsResult,
65});
66const StoredVcsRefsJson = Schema.fromJsonString(StoredVcsRefs);
67const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument);
68const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson);
69const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson);
70const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshotJson);
71const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson);
72const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson);
73const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson);
74const decodeStoredServerConfig = Schema.decodeUnknownEffect(StoredServerConfigJson);
75const encodeStoredServerConfig = Schema.encodeEffect(StoredServerConfigJson);
76const decodeStoredVcsRefs = Schema.decodeUnknownEffect(StoredVcsRefsJson);
77const encodeStoredVcsRefs = Schema.encodeEffect(StoredVcsRefsJson);
78
79function catalogError(operation: string, cause: unknown) {
80 return new ConnectionTransientError({
81 reason: "remote-unavailable",
82 detail: `Could not ${operation} the local connection catalog: ${String(cause)}`,
83 });
84}
85
86function persistenceError(
87 operation:
88 | "list-targets"
89 | "list-disabled-targets"
90 | "register-connection"
91 | "set-connection-routes"
92 | "remove-connection"
93 | "set-connection-enabled"
94 | "load-shell"
95 | "save-shell"
96 | "load-thread"
97 | "save-thread"
98 | "remove-thread"
99 | "load-server-config"
100 | "save-server-config"
101 | "load-vcs-refs"
102 | "save-vcs-refs"
103 | "remove-vcs-refs"
104 | "clear-vcs-refs"
105 | "clear-environment",
106 cause: unknown,
107) {
108 return new Persistence.ConnectionPersistenceError({
109 operation,
110 message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`,
111 });
112}
113
114const openDatabase = Effect.fn("web.connectionStorage.openDatabase")(function* () {
115 return yield* Effect.callback<IDBDatabase, ConnectionTransientError>((resume) => {
116 if (typeof indexedDB === "undefined") {
117 resume(
118 Effect.fail(catalogError("open", "IndexedDB is unavailable in this browser context.")),
119 );
120 return;
121 }
122 try {
123 const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
124 request.addEventListener("upgradeneeded", () => {
125 if (!request.result.objectStoreNames.contains(CATALOG_STORE_NAME)) {
126 request.result.createObjectStore(CATALOG_STORE_NAME);
127 }
128 if (!request.result.objectStoreNames.contains(SHELL_STORE_NAME)) {
129 request.result.createObjectStore(SHELL_STORE_NAME);
130 }
131 if (!request.result.objectStoreNames.contains(THREAD_STORE_NAME)) {
132 request.result.createObjectStore(THREAD_STORE_NAME);
133 }
134 if (!request.result.objectStoreNames.contains(SERVER_CONFIG_STORE_NAME)) {
135 request.result.createObjectStore(SERVER_CONFIG_STORE_NAME);
136 }
137 if (!request.result.objectStoreNames.contains(VCS_REFS_STORE_NAME)) {
138 request.result.createObjectStore(VCS_REFS_STORE_NAME);
139 }
140 });
141 request.addEventListener("error", () => {
142 resume(Effect.fail(catalogError("open", request.error ?? "Unknown IndexedDB error")));
143 });
144 request.addEventListener("success", () => {
145 resume(Effect.succeed(request.result));
146 });
147 } catch (cause) {
148 resume(Effect.fail(catalogError("open", cause)));
149 }
150 });
151});
152
153interface DatabaseHandle {
154 readonly get: Effect.Effect<IDBDatabase, ConnectionTransientError>;
155 /** Forget `database` if it is still the shared connection, so the next access reopens. */
156 readonly invalidate: (database: IDBDatabase) => Effect.Effect<void>;
157}
158
159/** Share a connection until the browser closes it; the next access reopens it. */
160const makeDatabaseHandle = Effect.fn("web.connectionStorage.makeDatabaseHandle")(function* () {
161 const lock = yield* Semaphore.make(1);
162 let current: IDBDatabase | null = null;
163 const forget = (database: IDBDatabase) => {
164 if (current === database) current = null;
165 };
166 const get = Effect.suspend(() =>
167 current !== null
168 ? Effect.succeed(current)
169 : lock.withPermits(1)(
170 Effect.gen(function* () {
171 if (current !== null) return current;
172 const opened = yield* openDatabase();
173 current = opened;
174 opened.addEventListener("close", () => forget(opened));
175 // Another tab upgrading the schema waits on this connection.
176 opened.addEventListener("versionchange", () => {
177 forget(opened);
178 opened.close();
179 });
180 return opened;
181 }),
182 ),
183 );
184 const close = lock.withPermits(1)(
185 Effect.sync(() => {
186 current?.close();
187 current = null;
188 }),
189 );
190 const handle: DatabaseHandle = {
191 get,
192 invalidate: (database) => Effect.sync(() => forget(database)),
193 };
194 return { handle, close };
195});
196
197/**
198 * Runs `use` on the shared connection. A connection the browser already closed
199 * throws InvalidStateError even when no close event reached this tab, so drop
200 * it and retry once on a fresh one instead of failing every later operation.
201 */
202function withDatabase<A>(
203 database: DatabaseHandle,
204 use: (opened: IDBDatabase) => Effect.Effect<A, ConnectionTransientError>,
205) {
206 // Only a connection that opened and then failed is stale; a failing open is
207 // storage being unavailable, which a retry would not fix.
208 const closedConnection = Symbol("closedConnection");
209 const attempt = Effect.flatMap(database.get, (opened) =>
210 use(opened).pipe(
211 Effect.catchIf(
212 (error) => error.detail.includes("InvalidStateError"),
213 (error) =>
214 database
215 .invalidate(opened)
216 .pipe(Effect.andThen(Effect.fail({ [closedConnection]: error } as const))),
217 ),
218 ),
219 );
220 return attempt.pipe(
221 Effect.catchIf(
222 (error): error is { readonly [closedConnection]: ConnectionTransientError } =>
223 closedConnection in error,
224 () => Effect.flatMap(database.get, use),
225 ),
226 );
227}
228
229function readDatabaseValueOnConnection(database: IDBDatabase, storeName: string, key: IDBValidKey) {
230 return Effect.callback<unknown, ConnectionTransientError>((resume) => {
231 try {
232 const request = database.transaction(storeName, "readonly").objectStore(storeName).get(key);
233 request.addEventListener("error", () => {
234 resume(Effect.fail(catalogError("read", request.error ?? "Unknown IndexedDB read error")));
235 });
236 request.addEventListener("success", () => {
237 resume(Effect.succeed(request.result));
238 });
239 } catch (cause) {
240 resume(Effect.fail(catalogError("read", cause)));
241 }
242 }).pipe(Effect.withSpan("web.connectionStorage.readDatabaseValue"));
243}
244
245function writeDatabaseValueOnConnection(
246 database: IDBDatabase,
247 storeName: string,
248 key: IDBValidKey,
249 value: unknown,
250) {
251 return Effect.callback<void, ConnectionTransientError>((resume) => {
252 try {
253 const transaction = database.transaction(storeName, "readwrite");
254 // Every failed write fires "abort". A failed commit, such as
255 // QuotaExceededError, fires only "abort" and no "error".
256 transaction.addEventListener("abort", () => {
257 resume(
258 Effect.fail(catalogError("write", transaction.error ?? "Unknown IndexedDB write error")),
259 );
260 });
261 transaction.addEventListener("complete", () => {
262 resume(Effect.void);
263 });
264 transaction.objectStore(storeName).put(value, key);
265 } catch (cause) {
266 resume(Effect.fail(catalogError("write", cause)));
267 }
268 }).pipe(Effect.withSpan("web.connectionStorage.writeDatabaseValue"));
269}
270
271function removeDatabaseValueOnConnection(
272 database: IDBDatabase,
273 storeName: string,
274 key: IDBValidKey,
275) {
276 return Effect.callback<void, ConnectionTransientError>((resume) => {
277 try {
278 const transaction = database.transaction(storeName, "readwrite");
279 transaction.addEventListener("abort", () => {
280 resume(
281 Effect.fail(
282 catalogError("remove", transaction.error ?? "Unknown IndexedDB remove error"),
283 ),
284 );
285 });
286 transaction.addEventListener("complete", () => {
287 resume(Effect.void);
288 });
289 transaction.objectStore(storeName).delete(key);
290 } catch (cause) {
291 resume(Effect.fail(catalogError("remove", cause)));
292 }
293 }).pipe(Effect.withSpan("web.connectionStorage.removeDatabaseValue"));
294}
295
296function removeDatabaseValuesInRangeOnConnection(
297 database: IDBDatabase,
298 storeName: string,
299 range: IDBKeyRange,
300) {
301 return Effect.callback<void, ConnectionTransientError>((resume) => {
302 try {
303 const transaction = database.transaction(storeName, "readwrite");
304 transaction.addEventListener("abort", () => {
305 resume(
306 Effect.fail(
307 catalogError("remove", transaction.error ?? "Unknown IndexedDB cursor error"),
308 ),
309 );
310 });
311 transaction.addEventListener("complete", () => {
312 resume(Effect.void);
313 });
314 const request = transaction.objectStore(storeName).openCursor(range);
315 request.addEventListener("error", () => {
316 resume(
317 Effect.fail(catalogError("remove", request.error ?? "Unknown IndexedDB cursor error")),
318 );
319 });
320 request.addEventListener("success", () => {
321 const cursor = request.result;
322 if (cursor === null) {
323 return;
324 }
325 try {
326 cursor.delete();
327 cursor.continue();
328 } catch (cause) {
329 resume(Effect.fail(catalogError("remove", cause)));
330 }
331 });
332 } catch (cause) {
333 resume(Effect.fail(catalogError("remove", cause)));
334 }
335 }).pipe(Effect.withSpan("web.connectionStorage.removeDatabaseValuesInRange"));
336}
337
338function readDatabaseValue(database: DatabaseHandle, storeName: string, key: IDBValidKey) {
339 return withDatabase(database, (opened) => readDatabaseValueOnConnection(opened, storeName, key));
340}
341
342function writeDatabaseValue(
343 database: DatabaseHandle,
344 storeName: string,
345 key: IDBValidKey,
346 value: unknown,
347) {
348 return withDatabase(database, (opened) =>
349 writeDatabaseValueOnConnection(opened, storeName, key, value),
350 );
351}
352
353function removeDatabaseValue(database: DatabaseHandle, storeName: string, key: IDBValidKey) {
354 return withDatabase(database, (opened) =>
355 removeDatabaseValueOnConnection(opened, storeName, key),
356 );
357}
358
359function removeDatabaseValuesInRange(
360 database: DatabaseHandle,
361 storeName: string,
362 range: IDBKeyRange,
363) {
364 return withDatabase(database, (opened) =>
365 removeDatabaseValuesInRangeOnConnection(opened, storeName, range),
366 );
367}
368
369function threadCacheKey(environmentId: EnvironmentId, threadId: ThreadId) {
370 return `${environmentId}:${threadId}`;
371}
372
373function vcsRefsCacheKey(environmentId: EnvironmentId, cwd: string) {
374 return `${environmentId}:${cwd}`;
375}
376
377const decodeCatalog = Effect.fn("web.connectionStorage.decodeCatalog")(function* (raw: string) {
378 return yield* decodeConnectionCatalogDocument(raw).pipe(
379 Effect.mapError((cause) => catalogError("decode", cause)),
380 );
381});
382
383const encodeCatalog = Effect.fn("web.connectionStorage.encodeCatalog")(function* (
384 catalog: ConnectionCatalogDocumentType,
385) {
386 return yield* encodeConnectionCatalogDocument(catalog).pipe(
387 Effect.mapError((cause) => catalogError("encode", cause)),
388 );
389});
390
391export interface CatalogBackend {
392 readonly read: Effect.Effect<string | null, ConnectionTransientError>;
393 readonly write: (raw: string) => Effect.Effect<void, ConnectionTransientError>;
394 readonly quarantine?: (raw: string) => Effect.Effect<void, ConnectionTransientError>;
395}
396
397export function makeCatalogBackend(database: DatabaseHandle): CatalogBackend {
398 const bridge = window.desktopBridge;
399 if (bridge?.getConnectionCatalog !== undefined && bridge.setConnectionCatalog !== undefined) {
400 return {
401 read: Effect.tryPromise({
402 try: () => bridge.getConnectionCatalog!(),
403 catch: (cause) => catalogError("load", cause),
404 }),
405 write: (raw) =>
406 Effect.tryPromise({
407 try: () => bridge.setConnectionCatalog!(raw),
408 catch: (cause) => catalogError("save", cause),
409 }).pipe(
410 Effect.flatMap((stored) =>
411 stored
412 ? Effect.void
413 : Effect.fail(
414 catalogError(
415 "save",
416 "Desktop secure storage is unavailable in this system context.",
417 ),
418 ),
419 ),
420 ),
421 };
422 }
423
424 return {
425 read: readDatabaseValue(database, CATALOG_STORE_NAME, CATALOG_KEY).pipe(
426 Effect.map((value) => (typeof value === "string" ? value : null)),
427 ),
428 write: (raw) => writeDatabaseValue(database, CATALOG_STORE_NAME, CATALOG_KEY, raw),
429 quarantine: (raw) =>
430 writeDatabaseValue(database, CATALOG_STORE_NAME, `${CATALOG_KEY}:corrupt:${Date.now()}`, raw),
431 };
432}
433
434interface CatalogStore {
435 readonly read: Effect.Effect<ConnectionCatalogDocumentType, ConnectionTransientError>;
436 readonly update: (
437 transform: (catalog: ConnectionCatalogDocumentType) => ConnectionCatalogDocumentType,
438 ) => Effect.Effect<void, ConnectionTransientError>;
439}
440
441export const makeCatalogStore = Effect.fn("web.connectionStorage.makeCatalogStore")(function* (
442 backend: CatalogBackend,
443) {
444 const state = yield* Ref.make<Option.Option<ConnectionCatalogDocumentType>>(Option.none());
445 const lock = yield* Semaphore.make(1);
446
447 const loadUnlocked = Effect.fn("web.connectionStorage.loadCatalog")(function* () {
448 const cached = yield* Ref.get(state);
449 if (Option.isSome(cached)) {
450 return cached.value;
451 }
452 const raw = yield* backend.read;
453 let catalog = EMPTY_CONNECTION_CATALOG_DOCUMENT;
454 if (raw !== null && raw.trim() !== "") {
455 catalog = yield* decodeCatalog(raw).pipe(
456 Effect.catch((error) =>
457 Effect.gen(function* () {
458 yield* Effect.logWarning("Discarding a corrupt web connection catalog.", {
459 error: error.message,
460 });
461 if (backend.quarantine !== undefined) {
462 yield* backend.quarantine(raw).pipe(
463 Effect.catch((cause) =>
464 Effect.logWarning("Could not quarantine the corrupt web connection catalog.", {
465 error: cause.message,
466 }),
467 ),
468 );
469 }
470 const encoded = yield* encodeCatalog(EMPTY_CONNECTION_CATALOG_DOCUMENT);
471 yield* backend.write(encoded).pipe(
472 Effect.catch((cause) =>
473 Effect.logWarning("Could not persist the recovered web connection catalog.", {
474 error: cause.message,
475 }),
476 ),
477 );
478 return EMPTY_CONNECTION_CATALOG_DOCUMENT;
479 }),
480 ),
481 );
482 }
483 yield* Ref.set(state, Option.some(catalog));
484 return catalog;
485 });
486
487 const read = lock.withPermits(1)(loadUnlocked());
488 const update: CatalogStore["update"] = Effect.fn("web.connectionStorage.updateCatalog")(
489 function* (transform) {
490 yield* lock.withPermits(1)(
491 Effect.gen(function* () {
492 const next = transform(yield* loadUnlocked());
493 yield* backend.write(yield* encodeCatalog(next));
494 yield* Ref.set(state, Option.some(next));
495 }),
496 );
497 },
498 );
499
500 return { read, update } satisfies CatalogStore;
501});
502
503const GITHUB_ROUTING_KEY_PREFIX = "t3code:github-routing:";
504const GITHUB_ROUTING_CHANGED = "t3code:github-routing-changed";
505const isStoredGitHubRoutingPermission = Schema.is(StoredGitHubRoutingPermission);
506const encodeStoredGitHubRoutingPermission = Schema.encodeSync(
507 Schema.fromJsonString(StoredGitHubRoutingPermission),
508);
509
510/** Each grant has its own key so stale tabs and unrelated catalog saves cannot restore trust. */
511export function makeBrowserGitHubRoutingPermissions(
512 browser: Pick<Window, "localStorage"> & EventTarget = window,
513) {
514 const read = (key: string): StoredGitHubRoutingPermission | null => {
515 try {
516 const raw = browser.localStorage.getItem(key);
517 const value: unknown = raw === null ? null : JSON.parse(raw);
518 return isStoredGitHubRoutingPermission(value) &&
519 key === `${GITHUB_ROUTING_KEY_PREFIX}${value.environmentId}`
520 ? value
521 : null;
522 } catch {
523 return null;
524 }
525 };
526 const readAll = (): ReadonlyArray<StoredGitHubRoutingPermission> => {
527 try {
528 const values: StoredGitHubRoutingPermission[] = [];
529 const storage = browser.localStorage;
530 for (let index = 0; index < storage.length; index++) {
531 const key = storage.key(index);
532 if (key?.startsWith(GITHUB_ROUTING_KEY_PREFIX)) {
533 const value = read(key);
534 if (value !== null) values.push(value);
535 }
536 }
537 return values;
538 } catch {
539 return [];
540 }
541 };
542 const write = (environmentId: EnvironmentId, value: StoredGitHubRoutingPermission | null) =>
543 Effect.try({
544 try: () => {
545 const key = `${GITHUB_ROUTING_KEY_PREFIX}${environmentId}`;
546 if (value === null) browser.localStorage.removeItem(key);
547 else browser.localStorage.setItem(key, encodeStoredGitHubRoutingPermission(value));
548 browser.dispatchEvent(new Event(GITHUB_ROUTING_CHANGED));
549 },
550 catch: (cause) => catalogError("save GitHub routing permissions in", cause),
551 });
552 return GitHubRoutingPermissions.of({
553 get: (entry) =>
554 Effect.sync(() => {
555 const value = read(`${GITHUB_ROUTING_KEY_PREFIX}${entry.target.environmentId}`);
556 return gitHubRoutingPermissionFor(entry, value === null ? [] : [value]);
557 }),
558 changes: Stream.callback<ReadonlyArray<StoredGitHubRoutingPermission>>((queue) =>
559 Effect.acquireRelease(
560 Effect.sync(() => {
561 const listener = (event: Event) => {
562 if (event.type === "storage") {
563 const key = (event as StorageEvent).key;
564 if (key !== null && !key?.startsWith(GITHUB_ROUTING_KEY_PREFIX)) return;
565 }
566 Queue.offerUnsafe(queue, readAll());
567 };
568 browser.addEventListener("storage", listener);
569 browser.addEventListener(GITHUB_ROUTING_CHANGED, listener);
570 Queue.offerUnsafe(queue, readAll());
571 return listener;
572 }),
573 (listener) =>
574 Effect.sync(() => {
575 browser.removeEventListener("storage", listener);
576 browser.removeEventListener(GITHUB_ROUTING_CHANGED, listener);
577 }),
578 ).pipe(Effect.asVoid),
579 ),
580 set: (entry, permission) => {
581 const connectionKey = gitHubRoutingConnectionKey(entry);
582 if (connectionKey === null)
583 return Effect.fail(
584 new ConnectionBlockedError({
585 reason: "configuration",
586 detail: "This environment does not have a saved connection endpoint.",
587 }),
588 );
589 return write(
590 entry.target.environmentId,
591 permission === "off"
592 ? null
593 : {
594 environmentId: entry.target.environmentId,
595 connectionKey,
596 permission,
597 },
598 );
599 },
600 forget: (environmentId) => write(environmentId, null),
601 });
602}
603
604export const layer = Layer.effectContext(
605 Effect.gen(function* () {
606 const { handle: database } = yield* Effect.acquireRelease(
607 makeDatabaseHandle(),
608 (owned) => owned.close,
609 );
610 const catalog = yield* makeCatalogStore(makeCatalogBackend(database));
611 const githubRoutingPermissions = makeBrowserGitHubRoutingPermissions();
612
613 const targetStore = Persistence.ConnectionTargetStore.of({
614 list: catalog.read.pipe(
615 Effect.map((document) => document.targets),
616 Effect.mapError((cause) => persistenceError("list-targets", cause)),
617 ),
618 listDisabled: catalog.read.pipe(
619 Effect.map((document) => document.disabledEnvironmentIds),
620 Effect.mapError((cause) => persistenceError("list-disabled-targets", cause)),
621 ),
622 });
623 const registrationStore = Persistence.ConnectionRegistrationStore.of({
624 register: (registration, routes) =>
625 catalog
626 .update((document) => registerConnectionInCatalog(document, registration, routes))
627 .pipe(Effect.mapError((cause) => persistenceError("register-connection", cause))),
628 setRoutes: (environmentId, routes) =>
629 catalog
630 .update((document) => setRoutesInCatalog(document, environmentId, routes))
631 .pipe(Effect.mapError((cause) => persistenceError("set-connection-routes", cause))),
632 remove: (environmentId) =>
633 catalog
634 .update((document) => removeConnectionFromCatalog(document, environmentId))
635 .pipe(Effect.mapError((cause) => persistenceError("remove-connection", cause))),
636 setEnabled: (environmentId, enabled) =>
637 catalog
638 .update((document) => setConnectionEnabledInCatalog(document, environmentId, enabled))
639 .pipe(Effect.mapError((cause) => persistenceError("set-connection-enabled", cause))),
640 });
641 const profileStore = ProfileStore.make({
642 get: (connectionId) =>
643 catalog.read.pipe(
644 Effect.map((document) =>
645 Option.fromUndefinedOr(
646 document.profiles.find((profile) => profile.connectionId === connectionId),
647 ),
648 ),
649 ),
650 put: (profile) =>
651 catalog.update((document) => ({
652 ...document,
653 profiles: replaceCatalogValue(document.profiles, (value) => value.connectionId, profile),
654 })),
655 remove: (connectionId) =>
656 catalog.update((document) => ({
657 ...document,
658 profiles: removeCatalogValue(
659 document.profiles,
660 (value) => value.connectionId,
661 connectionId,
662 ),
663 })),
664 });
665 const credentialStore = CredentialStore.make({
666 get: (connectionId) =>
667 catalog.read.pipe(
668 Effect.map((document) =>
669 Option.fromUndefinedOr(
670 document.credentials.find((entry) => entry.connectionId === connectionId)?.credential,
671 ),
672 ),
673 ),
674 put: (connectionId, credential) =>
675 catalog.update((document) => ({
676 ...document,
677 credentials: replaceCatalogValue(document.credentials, (value) => value.connectionId, {
678 connectionId,
679 credential,
680 }),
681 })),
682 remove: (connectionId) =>
683 catalog.update((document) => ({
684 ...document,
685 credentials: removeCatalogValue(
686 document.credentials,
687 (value) => value.connectionId,
688 connectionId,
689 ),
690 })),
691 });
692 const remoteTokenStore = TokenStore.make({
693 get: (environmentId) =>
694 catalog.read.pipe(
695 Effect.map((document) =>
696 Option.fromUndefinedOr(
697 document.remoteDpopTokens.find((token) => token.environmentId === environmentId),
698 ),
699 ),
700 ),
701 put: (token) => catalog.update((document) => putRemoteDpopTokenInCatalog(document, token)),
702 remove: (environmentId) =>
703 catalog.update((document) => ({
704 ...document,
705 remoteDpopTokens: removeCatalogValue(
706 document.remoteDpopTokens,
707 (value) => value.environmentId,
708 environmentId,
709 ),
710 })),
711 });
712 const cacheStore = Persistence.EnvironmentCacheStore.of({
713 loadShell: (environmentId) =>
714 readDatabaseValue(database, SHELL_STORE_NAME, environmentId).pipe(
715 Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate())),
716 Effect.flatMap((raw) => {
717 if (typeof raw !== "string") {
718 return Effect.succeedNone;
719 }
720 return decodeOrDiscardOrchestrationCache(
721 decodeStoredShellSnapshot(raw).pipe(
722 Effect.mapError((cause) => persistenceError("load-shell", cause)),
723 Effect.map((stored) =>
724 stored.environmentId === environmentId
725 ? Option.some(stored.snapshot)
726 : Option.none(),
727 ),
728 ),
729 removeDatabaseValue(database, SHELL_STORE_NAME, environmentId),
730 );
731 }),
732 Effect.mapError((cause) => persistenceError("load-shell", cause)),
733 ),
734 saveShell: (environmentId, snapshot) =>
735 Effect.gen(function* () {
736 const encoded = yield* encodeStoredShellSnapshot({
737 schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
738 environmentId,
739 snapshot,
740 }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause)));
741 yield* writeDatabaseValue(database, SHELL_STORE_NAME, environmentId, encoded);
742 }).pipe(
743 Effect.mapError((cause) =>
744 cause._tag === "ConnectionPersistenceError"
745 ? cause
746 : persistenceError("save-shell", cause),
747 ),
748 ),
749 loadServerConfig: (environmentId) =>
750 readDatabaseValue(database, SERVER_CONFIG_STORE_NAME, environmentId).pipe(
751 Effect.flatMap((raw) => {
752 if (typeof raw !== "string") {
753 return Effect.succeedNone;
754 }
755 return decodeStoredServerConfig(raw).pipe(
756 Effect.mapError((cause) => persistenceError("load-server-config", cause)),
757 Effect.map((stored) =>
758 stored.environmentId === environmentId ? Option.some(stored.config) : Option.none(),
759 ),
760 );
761 }),
762 Effect.mapError((cause) =>
763 cause._tag === "ConnectionPersistenceError"
764 ? cause
765 : persistenceError("load-server-config", cause),
766 ),
767 ),
768 saveServerConfig: (environmentId, config) =>
769 Effect.gen(function* () {
770 const encoded = yield* encodeStoredServerConfig({
771 schemaVersion: 1,
772 environmentId,
773 config,
774 }).pipe(Effect.mapError((cause) => persistenceError("save-server-config", cause)));
775 yield* writeDatabaseValue(database, SERVER_CONFIG_STORE_NAME, environmentId, encoded);
776 }).pipe(
777 Effect.mapError((cause) =>
778 cause._tag === "ConnectionPersistenceError"
779 ? cause
780 : persistenceError("save-server-config", cause),
781 ),
782 ),
783 loadThread: (environmentId, threadId) =>
784 readDatabaseValue(
785 database,
786 THREAD_STORE_NAME,
787 threadCacheKey(environmentId, threadId),
788 ).pipe(
789 Effect.flatMap((raw) => {
790 if (typeof raw !== "string") {
791 return Effect.succeedNone;
792 }
793 return decodeOrDiscardOrchestrationCache(
794 decodeStoredThreadSnapshot(raw).pipe(
795 Effect.mapError((cause) => persistenceError("load-thread", cause)),
796 Effect.map((stored) =>
797 stored.environmentId === environmentId && stored.threadId === threadId
798 ? Option.some(stored.snapshot)
799 : Option.none(),
800 ),
801 ),
802 removeDatabaseValue(
803 database,
804 THREAD_STORE_NAME,
805 threadCacheKey(environmentId, threadId),
806 ),
807 );
808 }),
809 Effect.mapError((cause) => persistenceError("load-thread", cause)),
810 ),
811 saveThread: (environmentId, snapshot) =>
812 Effect.gen(function* () {
813 const encoded = yield* encodeStoredThreadSnapshot({
814 schemaVersion: ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION,
815 environmentId,
816 threadId: snapshot.projection.thread.id,
817 snapshot,
818 }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause)));
819 yield* writeDatabaseValue(
820 database,
821 THREAD_STORE_NAME,
822 threadCacheKey(environmentId, snapshot.projection.thread.id),
823 encoded,
824 );
825 }).pipe(
826 Effect.mapError((cause) =>
827 cause._tag === "ConnectionPersistenceError"
828 ? cause
829 : persistenceError("save-thread", cause),
830 ),
831 ),
832 loadVcsRefs: (environmentId, cwd) =>
833 readDatabaseValue(database, VCS_REFS_STORE_NAME, vcsRefsCacheKey(environmentId, cwd)).pipe(
834 Effect.flatMap((raw) => {
835 if (typeof raw !== "string") {
836 return Effect.succeedNone;
837 }
838 return decodeStoredVcsRefs(raw).pipe(
839 Effect.mapError((cause) => persistenceError("load-vcs-refs", cause)),
840 Effect.map((stored) =>
841 stored.environmentId === environmentId && stored.cwd === cwd
842 ? Option.some(stored.refs)
843 : Option.none(),
844 ),
845 );
846 }),
847 Effect.mapError((cause) =>
848 cause._tag === "ConnectionPersistenceError"
849 ? cause
850 : persistenceError("load-vcs-refs", cause),
851 ),
852 ),
853 saveVcsRefs: (environmentId, cwd, refs) =>
854 Effect.gen(function* () {
855 const encoded = yield* encodeStoredVcsRefs({
856 schemaVersion: 1,
857 environmentId,
858 cwd,
859 refs,
860 }).pipe(Effect.mapError((cause) => persistenceError("save-vcs-refs", cause)));
861 yield* writeDatabaseValue(
862 database,
863 VCS_REFS_STORE_NAME,
864 vcsRefsCacheKey(environmentId, cwd),
865 encoded,
866 );
867 }).pipe(
868 Effect.mapError((cause) =>
869 cause._tag === "ConnectionPersistenceError"
870 ? cause
871 : persistenceError("save-vcs-refs", cause),
872 ),
873 ),
874 removeVcsRefs: (environmentId, cwd) =>
875 removeDatabaseValue(
876 database,
877 VCS_REFS_STORE_NAME,
878 vcsRefsCacheKey(environmentId, cwd),
879 ).pipe(Effect.mapError((cause) => persistenceError("remove-vcs-refs", cause))),
880 clearVcsRefs: (environmentId) =>
881 removeDatabaseValuesInRange(
882 database,
883 VCS_REFS_STORE_NAME,
884 IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`),
885 ).pipe(Effect.mapError((cause) => persistenceError("clear-vcs-refs", cause))),
886 removeThread: (environmentId, threadId) =>
887 removeDatabaseValue(
888 database,
889 THREAD_STORE_NAME,
890 threadCacheKey(environmentId, threadId),
891 ).pipe(Effect.mapError((cause) => persistenceError("remove-thread", cause))),
892 clear: (environmentId) =>
893 Effect.all(
894 [
895 Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)),
896 removeDatabaseValue(database, SHELL_STORE_NAME, environmentId),
897 removeDatabaseValuesInRange(
898 database,
899 THREAD_STORE_NAME,
900 IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`),
901 ),
902 removeDatabaseValue(database, SERVER_CONFIG_STORE_NAME, environmentId),
903 removeDatabaseValuesInRange(
904 database,
905 VCS_REFS_STORE_NAME,
906 IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`),
907 ),
908 ],
909 { concurrency: "unbounded", discard: true },
910 ).pipe(Effect.mapError((cause) => persistenceError("clear-environment", cause))),
911 });
912
913 return Context.make(Persistence.ConnectionTargetStore, targetStore).pipe(
914 Context.add(GitHubRoutingPermissions, githubRoutingPermissions),
915 Context.add(Persistence.ConnectionRegistrationStore, registrationStore),
916 Context.add(ProfileStore.ConnectionProfileStore, profileStore),
917 Context.add(CredentialStore.ConnectionCredentialStore, credentialStore),
918 Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore),
919 Context.add(Persistence.EnvironmentCacheStore, cacheStore),
920 );
921 }),
922);