Line data Source code
1 : import { connectionParamsShareEqual, type ConnectionParams } from '../../connections/connection_params.js';
2 : import type { ConnectionEntry, SessionConnection, SessionData } from '../../storage/storage_backend.js';
3 : import { dedupeConnections } from '../../storage/storage_writer.js';
4 : import { isLocalHyperWasmParams } from '../../storage/session_validation.js';
5 :
6 : /// A connection that a session brings along
7 : export interface ImportedConnectionRef {
8 : /// The id of the connection in the session
9 : connectionId: string;
10 : /// The params of the connection in the session, shared sessions carry them without secrets
11 : params: ConnectionParams;
12 : /// The name the connection had in the exporting app
13 : name?: string;
14 : }
15 :
16 : /// How an imported connection relates to the connections of the app
17 : export type ConnectionImportDecision =
18 : /// The connection is unknown, it is registered as a new connection
19 : | { kind: 'register' }
20 : /// The connection is known and describes the same database, the session uses it
21 : | { kind: 'link'; connectionId: string }
22 : /// Connections of the app may describe the same database, the user decides
23 : | { kind: 'ask'; candidates: ConnectionEntry[] };
24 :
25 : /// The decision for a connection of an imported session
26 : export interface ConnectionImportPlanItem {
27 : /// The connection of the session
28 : ref: ImportedConnectionRef;
29 : /// The decision
30 : decision: ConnectionImportDecision;
31 : }
32 :
33 : /// What the user chose for a connection that may consolidate with an existing one
34 : export type ConnectionImportChoice =
35 : /// Use an existing connection
36 : | { kind: 'link'; connectionId: string }
37 : /// Keep the connection of the session as a connection of its own
38 : | { kind: 'separate' };
39 :
40 : /// Find the entry of a connection id
41 1 : function findEntry(entries: readonly ConnectionEntry[], connectionId: string): ConnectionEntry | null {
42 1 : return entries.find(entry => entry.id === connectionId) ?? null;
43 1 : }
44 :
45 : /// Decide how the connections of an imported session map to the connections of the app.
46 : ///
47 : /// A session that comes back with the id of a known connection and the same params (ignoring
48 : /// secrets, sharing strips them) simply uses the known connection. Every other connection that
49 : /// describes the same database, or shares the id but not the params, is a candidate for
50 : /// consolidation that the user decides on. Exports usually regenerate the ids, so the candidates are
51 : /// found by params.
52 : ///
53 : /// The embedded database is not a choice: the app holds a single connection to it, every embedded
54 : /// database of a session uses that one. It is registered only when the app doesn't have one.
55 1 : export function planConnectionImport(session: SessionData, entries: readonly ConnectionEntry[]): ConnectionImportPlanItem[] {
56 1 : const refs: ImportedConnectionRef[] = session.connections.map(connection => ({
57 1 : connectionId: connection.connectionId,
58 1 : params: connection.params,
59 1 : ...(connection.name == null ? {} : { name: connection.name }),
60 1 : }));
61 1 : const embedded = entries.find(entry => isLocalHyperWasmParams(entry.params)) ?? null;
62 1 : return refs.map(ref => {
63 1 : if (isLocalHyperWasmParams(ref.params)) {
64 1 : return { ref, decision: embedded == null ? { kind: 'register' } : { kind: 'link', connectionId: embedded.id } };
65 : }
66 1 : const sameId = findEntry(entries, ref.connectionId);
67 1 : if (sameId != null && connectionParamsShareEqual(sameId.params, ref.params)) {
68 1 : return { ref, decision: { kind: 'link', connectionId: sameId.id } };
69 : }
70 1 : const candidates = [
71 1 : ...(sameId == null ? [] : [sameId]),
72 1 : ...entries.filter(entry => entry !== sameId && connectionParamsShareEqual(entry.params, ref.params)),
73 1 : ];
74 1 : if (candidates.length === 0) {
75 1 : return { ref, decision: { kind: 'register' } };
76 : }
77 1 : return { ref, decision: { kind: 'ask', candidates } };
78 1 : });
79 1 : }
80 :
81 : /// The plan items that need a decision of the user
82 1 : export function connectionImportQuestions(plan: readonly ConnectionImportPlanItem[]): ConnectionImportPlanItem[] {
83 1 : return plan.filter(item => item.decision.kind === 'ask');
84 1 : }
85 :
86 : /// An imported session with resolved connection references
87 : export interface ConnectionImport {
88 : /// The session with the rewritten references
89 : session: SessionData;
90 : /// The ids that the connections of the imported session got (imported id -> id)
91 : connectionIds: Map<string, string>;
92 : }
93 :
94 : /// Rewrite the connection references of an imported session.
95 : ///
96 : /// Linked references take the id and the full params of the app's connection, the session then
97 : /// carries the secrets the same way every other copy does, and the app's connection keeps its name.
98 : /// Separate connections keep their id unless the app already uses it for another connection, and
99 : /// keep their name to be registered with it. Unanswered questions keep the connection separate.
100 1 : export function applyConnectionImportPlan(
101 1 : session: SessionData,
102 1 : plan: readonly ConnectionImportPlanItem[],
103 1 : choices: ReadonlyMap<string, ConnectionImportChoice>,
104 1 : entries: readonly ConnectionEntry[],
105 1 : randomUUID: () => string = () => crypto.randomUUID(),
106 1 : ): ConnectionImport {
107 1 : const byRef = new Map(plan.map(item => [item.ref.connectionId, item]));
108 1 : const freshIds = new Map<string, string>();
109 1 : const resolve = (connectionId: string): { connectionId: string; entry: ConnectionEntry | null } => {
110 1 : const item = byRef.get(connectionId);
111 1 : let link: string | null = null;
112 1 : if (item?.decision.kind === 'link') {
113 1 : link = item.decision.connectionId;
114 1 : } else if (item?.decision.kind === 'ask') {
115 1 : const choice = choices.get(connectionId);
116 1 : if (choice?.kind === 'link') link = choice.connectionId;
117 1 : }
118 1 : if (link != null) {
119 1 : const entry = entries.find(e => e.id === link);
120 1 : if (entry != null) return { connectionId: entry.id, entry };
121 1 : }
122 : // A separate connection must not take over the id of an existing one
123 1 : if (findEntry(entries, connectionId) == null) {
124 1 : return { connectionId, entry: null };
125 : }
126 1 : let freshId = freshIds.get(connectionId);
127 1 : if (freshId == null) {
128 1 : freshId = randomUUID();
129 1 : freshIds.set(connectionId, freshId);
130 1 : }
131 1 : return { connectionId: freshId, entry: null };
132 1 : };
133 1 : const rewrite = (connection: SessionConnection): SessionConnection => {
134 1 : const resolved = resolve(connection.connectionId);
135 1 : if (resolved.entry != null) return { connectionId: resolved.connectionId, params: resolved.entry.params };
136 1 : return {
137 1 : connectionId: resolved.connectionId,
138 1 : params: connection.params,
139 1 : ...(connection.name == null ? {} : { name: connection.name }),
140 1 : };
141 1 : };
142 : // The scripts follow their connections
143 1 : return {
144 1 : session: {
145 1 : ...session,
146 1 : connections: dedupeConnections(session.connections.map(rewrite)),
147 1 : scripts: session.scripts.map(script => ({ ...script, connectionId: resolve(script.connectionId).connectionId })),
148 1 : },
149 1 : connectionIds: new Map(session.connections.map(connection => [connection.connectionId, resolve(connection.connectionId).connectionId])),
150 1 : };
151 1 : }
152 :
153 : /// Did resolving the connections change the references of the session?
154 1 : export function connectionRefsChanged(before: SessionData, after: SessionData): boolean {
155 1 : const refs = (session: SessionData) => JSON.stringify([
156 1 : ...session.connections.flatMap(connection => [connection.connectionId, connection.params]),
157 1 : ...session.scripts.map(script => script.connectionId),
158 1 : ]);
159 1 : return refs(before) !== refs(after);
160 1 : }
|