Skip to content
Source
Sign inSign up
Open History to see the last commit

storageDocument.ts

217 lines7.2 KB

1import { EnvironmentId } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4​
5import {
6 type ConnectionRegistration,
7 ConnectionCredential,
8 ConnectionProfile,
9} from "../connection/catalog.ts";
10import { type ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts";
11import * as TokenStore from "../authorization/tokenStore.ts";
12import { StoredPullRequestRoutingPermission } from "../connection/pullRequestRoutingPermissions.ts";
13​
14export const StoredConnectionCredential = Schema.Struct({
15 connectionId: Schema.String,
16 credential: ConnectionCredential,
17});
18export type StoredConnectionCredential = typeof StoredConnectionCredential.Type;
19​
20export const ConnectionCatalogDocument = Schema.Struct({
21 schemaVersion: Schema.Literal(1),
22 targets: Schema.Array(PersistedConnectionTarget),
23 profiles: Schema.Array(ConnectionProfile),
24 credentials: Schema.Array(StoredConnectionCredential),
25 remoteDpopTokens: Schema.Array(TokenStore.RemoteDpopAccessToken),
26 // Pull request routing grants. The field name predates the rename and stays, so older and
27 // newer clients sharing this document keep reading and revoking the same grants.
28 githubRoutingPermissions: Schema.optionalKey(Schema.Array(StoredPullRequestRoutingPermission)),
29 disabledEnvironmentIds: Schema.Array(EnvironmentId).pipe(
30 Schema.withDecodingDefaultKey(Effect.succeed([])),
31 ),
32});
33export type ConnectionCatalogDocument = typeof ConnectionCatalogDocument.Type;
34​
35export const EMPTY_CONNECTION_CATALOG_DOCUMENT: ConnectionCatalogDocument = Object.freeze({
36 schemaVersion: 1,
37 targets: [],
38 profiles: [],
39 credentials: [],
40 remoteDpopTokens: [],
41 disabledEnvironmentIds: [],
42});
43​
44export function replaceCatalogValue<A>(
45 values: ReadonlyArray<A>,
46 key: (value: A) => string,
47 next: A,
48): ReadonlyArray<A> {
49 const nextKey = key(next);
50 return [...values.filter((value) => key(value) !== nextKey), next];
52​
53export function removeCatalogValue<A>(
54 values: ReadonlyArray<A>,
55 key: (value: A) => string,
56 removedKey: string,
57): ReadonlyArray<A> {
58 return values.filter((value) => key(value) !== removedKey);
60​
61function connectionIdOf(target: ConnectionTarget): string | null {
62 switch (target._tag) {
63 case "PrimaryConnectionTarget":
64 case "RelayConnectionTarget":
65 return null;
66 case "BearerConnectionTarget":
67 case "SshConnectionTarget":
68 return target.connectionId;
69 }
71​
72function routeKey(target: ConnectionTarget): string {
73 return connectionIdOf(target) ?? target._tag;
75​
76function removeRouteMetadata(
77 document: ConnectionCatalogDocument,
78 removed: ReadonlyArray<ConnectionTarget>,
79): ConnectionCatalogDocument {
80 const connectionIds = new Set(removed.flatMap((target) => connectionIdOf(target) ?? []));
81 const relayRemoved = removed.some((target) => target._tag === "RelayConnectionTarget");
82 const environmentIds = new Set(removed.map((target) => target.environmentId));
83 return {
84 ...document,
85 profiles: document.profiles.filter((value) => !connectionIds.has(value.connectionId)),
86 credentials: document.credentials.filter((value) => !connectionIds.has(value.connectionId)),
87 remoteDpopTokens: relayRemoved
88 ? document.remoteDpopTokens.filter((value) => !environmentIds.has(value.environmentId))
89 : document.remoteDpopTokens,
90 };
92​
93export function catalogRoutes(
94 document: ConnectionCatalogDocument,
95 environmentId: EnvironmentId,
96): ReadonlyArray<PersistedConnectionTarget> {
97 return document.targets.filter((target) => target.environmentId === environmentId);
99​
100/**
101 * Records owned by a dropped route go with it; the environment keeps its
102 * position in the catalog. An empty list leaves the environment's other records
103 * in place; use `removeConnectionFromCatalog` to forget the environment.
104 */
105export function setRoutesInCatalog(
106 document: ConnectionCatalogDocument,
107 environmentId: EnvironmentId,
108 routes: ReadonlyArray<PersistedConnectionTarget>,
109): ConnectionCatalogDocument {
110 const kept = new Set(routes.map(routeKey));
111 const dropped = catalogRoutes(document, environmentId).filter(
112 (target) => !kept.has(routeKey(target)),
113 );
114 const firstIndex = document.targets.findIndex((target) => target.environmentId === environmentId);
115 const others = document.targets.filter((target) => target.environmentId !== environmentId);
116 const insertAt =
117 firstIndex === -1
118 ? others.length
119 : document.targets.slice(0, firstIndex).filter((t) => t.environmentId !== environmentId)
120 .length;
121 return {
122 ...removeRouteMetadata(document, dropped),
123 targets: [...others.slice(0, insertAt), ...routes, ...others.slice(insertAt)],
124 };
126​
127export function registerConnectionInCatalog(
128 document: ConnectionCatalogDocument,
129 registration: ConnectionRegistration,
130 routes: ReadonlyArray<PersistedConnectionTarget> = [registration.target],
131): ConnectionCatalogDocument {
132 const next = setRoutesInCatalog(document, registration.target.environmentId, routes);
133​
134 switch (registration._tag) {
135 case "RelayConnectionRegistration":
136 return next;
137 case "BearerConnectionRegistration":
138 case "SshConnectionRegistration":
139 return {
140 ...next,
141 profiles: replaceCatalogValue(
142 next.profiles,
143 (value) => value.connectionId,
144 registration.profile,
145 ),
146 credentials: replaceCatalogValue(next.credentials, (value) => value.connectionId, {
147 connectionId: registration.target.connectionId,
148 credential: registration.credential,
149 }),
150 };
151 }
153​
154export function removeConnectionFromCatalog(
155 document: ConnectionCatalogDocument,
156 environmentId: EnvironmentId,
157): ConnectionCatalogDocument {
158 const next = setRoutesInCatalog(document, environmentId, []);
159 return {
160 ...next,
161 remoteDpopTokens: removeCatalogValue(
162 next.remoteDpopTokens,
163 (value) => value.environmentId,
164 environmentId,
165 ),
166 disabledEnvironmentIds: removeCatalogValue(
167 next.disabledEnvironmentIds,
168 (value) => value,
169 environmentId,
170 ),
171 ...(next.githubRoutingPermissions === undefined
172 ? {}
173 : {
174 githubRoutingPermissions: next.githubRoutingPermissions.filter(
175 (permission) => permission.environmentId !== environmentId,
176 ),
177 }),
178 };
180​
181export function setConnectionEnabledInCatalog(
182 document: ConnectionCatalogDocument,
183 environmentId: EnvironmentId,
184 enabled: boolean,
185): ConnectionCatalogDocument {
186 const registered = document.targets.some((target) => target.environmentId === environmentId);
187 const without = removeCatalogValue(
188 document.disabledEnvironmentIds,
189 (value) => value,
190 environmentId,
191 );
192 return {
193 ...document,
194 disabledEnvironmentIds: registered && !enabled ? [...without, environmentId] : without,
195 };
197​
198export function putRemoteDpopTokenInCatalog(
199 document: ConnectionCatalogDocument,
200 token: TokenStore.RemoteDpopAccessToken,
201): ConnectionCatalogDocument {
202 const registered = document.targets.some(
203 (target) =>
204 target._tag === "RelayConnectionTarget" && target.environmentId === token.environmentId,
205 );
206 if (!registered) {
207 return document;
208 }
209 return {
210 ...document,
211 remoteDpopTokens: replaceCatalogValue(
212 document.remoteDpopTokens,
213 (value) => value.environmentId,
214 token,
215 ),
216 };