@powerhousedao/connect-core 6.2.3-dev.81 → 6.2.3-dev.83
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index-ZFFeYX4A.d.ts +365 -0
- package/dist/index-ZFFeYX4A.d.ts.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/multi-reactor-Dvwv69u1.js +129 -0
- package/dist/multi-reactor-Dvwv69u1.js.map +1 -0
- package/dist/runtime-LvAkxbdf.js +609 -0
- package/dist/runtime-LvAkxbdf.js.map +1 -0
- package/dist/runtime.d.ts +2 -2
- package/dist/runtime.js +2 -2
- package/package.json +8 -8
- package/src/runtime/app-reactor-client.ts +71 -29
- package/src/runtime/drive-placement.ts +210 -0
- package/src/runtime/drive-placements.ts +361 -0
- package/src/runtime/index.ts +30 -0
- package/src/runtime/multi-reactor.ts +82 -53
- package/dist/index-BMlma368.d.ts +0 -199
- package/dist/index-BMlma368.d.ts.map +0 -1
- package/dist/multi-reactor-D_2B4jmu.js +0 -219
- package/dist/multi-reactor-D_2B4jmu.js.map +0 -1
- package/dist/runtime-D20_LlB_.js +0 -239
- package/dist/runtime-D20_LlB_.js.map +0 -1
- package/src/runtime/graphql-routable-backend.ts +0 -182
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import type { RoutingReactorClient } from "@powerhousedao/reactor-router";
|
|
2
|
+
import { driveDocumentType } from "@powerhousedao/shared/document-drive";
|
|
3
|
+
import { logger } from "document-model";
|
|
4
|
+
import {
|
|
5
|
+
applyPlacementEntries,
|
|
6
|
+
servesSlug,
|
|
7
|
+
LOCAL_BACKEND_NAME,
|
|
8
|
+
placementKeys,
|
|
9
|
+
removeLocalCopies,
|
|
10
|
+
servedUrl,
|
|
11
|
+
type PlacementEntry,
|
|
12
|
+
type ResolvedSwitchboardDrive,
|
|
13
|
+
type SwitchboardDrive,
|
|
14
|
+
type SyncRemotes,
|
|
15
|
+
} from "./drive-placement.js";
|
|
16
|
+
|
|
17
|
+
/** A collection by its declared slug among its switchboard's drives, e.g. one served hashed. */
|
|
18
|
+
export async function findServedDrive(
|
|
19
|
+
router: Pick<RoutingReactorClient, "backends">,
|
|
20
|
+
drive: SwitchboardDrive,
|
|
21
|
+
): Promise<{ id: string; slug: string } | undefined> {
|
|
22
|
+
const backend = router.backends.find((b) => b.name === drive.remote);
|
|
23
|
+
if (!backend) return undefined;
|
|
24
|
+
const { results } = await backend.api.find({ type: driveDocumentType });
|
|
25
|
+
const served = results.find(
|
|
26
|
+
(doc) => doc.header.slug && servesSlug(drive.slug, doc.header.slug),
|
|
27
|
+
);
|
|
28
|
+
return served && { id: served.header.id, slug: served.header.slug };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Where a drive opens given its stored move, if any: the move, else the host config. */
|
|
32
|
+
export function effectiveBackend(
|
|
33
|
+
drive: SwitchboardDrive,
|
|
34
|
+
entry: PlacementEntry | undefined,
|
|
35
|
+
): string {
|
|
36
|
+
return entry?.backend ?? drive.configured;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A drive opening on `backend` reads its copy here, which must arrive first. */
|
|
40
|
+
export function needsCopy(backend: string): boolean {
|
|
41
|
+
return backend === LOCAL_BACKEND_NAME;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A copy has caught up once every scope is at least at the source's revision. */
|
|
45
|
+
export function caughtUp(
|
|
46
|
+
local: Readonly<Record<string, number>>,
|
|
47
|
+
source: Readonly<Record<string, number>>,
|
|
48
|
+
): boolean {
|
|
49
|
+
return Object.entries(source).every(
|
|
50
|
+
([scope, revision]) => (local[scope] ?? -1) >= revision,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Where this browser keeps its moves; per origin, so every tab shares it. */
|
|
55
|
+
export interface PlacementStore {
|
|
56
|
+
list(): Promise<PlacementEntry[]>;
|
|
57
|
+
put(entry: PlacementEntry): Promise<void>;
|
|
58
|
+
delete(slug: string): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The router's two placement layers. */
|
|
62
|
+
export type PlacementRouter = {
|
|
63
|
+
setPlacement(key: string, backend: string | undefined): void;
|
|
64
|
+
setConfiguredPlacement(key: string, backend: string | undefined): void;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type PlacementSync = SyncRemotes & {
|
|
68
|
+
/** Adds a sync remote for the drive. */
|
|
69
|
+
add(url: string, driveId: string): Promise<void>;
|
|
70
|
+
/** Bounded wait until the local copy holds what `remote` holds; rejects when it does not. */
|
|
71
|
+
untilCopied(driveId: string, remote: string): Promise<void>;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type DrivePlacement = {
|
|
75
|
+
drive: ResolvedSwitchboardDrive;
|
|
76
|
+
/** Where the drive opens in this browser now. */
|
|
77
|
+
backend: string;
|
|
78
|
+
/** This browser moved it away from the host config. */
|
|
79
|
+
moved: boolean;
|
|
80
|
+
/** A move is running. */
|
|
81
|
+
busy: boolean;
|
|
82
|
+
/** Where a running move takes the drive. */
|
|
83
|
+
movingTo?: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Config placement plus this browser's moves: store, then sync remote, then router. */
|
|
87
|
+
export class DrivePlacements {
|
|
88
|
+
private applied = new Map<string, PlacementEntry>();
|
|
89
|
+
private resolved: ResolvedSwitchboardDrive[] = [];
|
|
90
|
+
private router: PlacementRouter | undefined;
|
|
91
|
+
private readonly busy = new Map<string, string>();
|
|
92
|
+
private attached = false;
|
|
93
|
+
private readonly listeners = new Set<() => void>();
|
|
94
|
+
private snapshot = new Map<string, DrivePlacement>();
|
|
95
|
+
|
|
96
|
+
constructor(
|
|
97
|
+
readonly drives: readonly SwitchboardDrive[],
|
|
98
|
+
applied: readonly PlacementEntry[],
|
|
99
|
+
private readonly store: PlacementStore,
|
|
100
|
+
private readonly sync: PlacementSync,
|
|
101
|
+
) {
|
|
102
|
+
for (const entry of applied) this.applied.set(entry.slug, entry);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Reads the store and drops the entries the config has overtaken (O2). */
|
|
106
|
+
static async load(
|
|
107
|
+
drives: readonly SwitchboardDrive[],
|
|
108
|
+
store: PlacementStore,
|
|
109
|
+
sync: PlacementSync,
|
|
110
|
+
): Promise<DrivePlacements> {
|
|
111
|
+
let entries: PlacementEntry[] = [];
|
|
112
|
+
try {
|
|
113
|
+
entries = await store.list();
|
|
114
|
+
} catch (error) {
|
|
115
|
+
logger.warn("Could not read this browser's drive moves: @error", error);
|
|
116
|
+
}
|
|
117
|
+
const { applied, stale } = applyPlacementEntries(drives, entries);
|
|
118
|
+
for (const entry of stale) {
|
|
119
|
+
store.delete(entry.slug).catch((error: unknown) => {
|
|
120
|
+
logger.warn(
|
|
121
|
+
"Could not drop drive move @slug: @error",
|
|
122
|
+
entry.slug,
|
|
123
|
+
error,
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return new DrivePlacements(drives, applied, store, sync);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Where `drive` opens in this browser: a move, else the config. */
|
|
131
|
+
backendOf(drive: SwitchboardDrive): string {
|
|
132
|
+
return effectiveBackend(drive, this.applied.get(drive.slug));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Whether a remote default drive at `url` keeps a copy here. */
|
|
136
|
+
keepsCopy(url: string): boolean {
|
|
137
|
+
const drive = this.drives.find((d) => d.url === url);
|
|
138
|
+
return !drive || this.backendOf(drive) === LOCAL_BACKEND_NAME;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Drives that open on their switchboard with no copy here. */
|
|
142
|
+
placed(): SwitchboardDrive[] {
|
|
143
|
+
return this.drives.filter(
|
|
144
|
+
(drive) => this.backendOf(drive) !== LOCAL_BACKEND_NAME,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Collections this browser keeps a copy of; default drives are added with the others. */
|
|
149
|
+
keptCollections(): ResolvedSwitchboardDrive[] {
|
|
150
|
+
return this.resolved.filter(
|
|
151
|
+
(drive) =>
|
|
152
|
+
drive.collection &&
|
|
153
|
+
drive.id !== undefined &&
|
|
154
|
+
this.backendOf(drive) === LOCAL_BACKEND_NAME,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Installs both layers for every drive the router can be told about. */
|
|
159
|
+
attach(
|
|
160
|
+
router: PlacementRouter | undefined,
|
|
161
|
+
resolved: readonly ResolvedSwitchboardDrive[],
|
|
162
|
+
): void {
|
|
163
|
+
this.router = router;
|
|
164
|
+
this.resolved = [...resolved];
|
|
165
|
+
this.attached = true;
|
|
166
|
+
for (const drive of this.resolved) this.install(drive);
|
|
167
|
+
this.notify();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Attaches, follows the moves other tabs made since `load`, then brings
|
|
172
|
+
* the sync remotes in line; returns the collections to keep a copy of.
|
|
173
|
+
*/
|
|
174
|
+
async start(
|
|
175
|
+
router: PlacementRouter | undefined,
|
|
176
|
+
resolved: readonly ResolvedSwitchboardDrive[],
|
|
177
|
+
syncsDrives: boolean,
|
|
178
|
+
): Promise<ResolvedSwitchboardDrive[]> {
|
|
179
|
+
this.attach(router, resolved);
|
|
180
|
+
await this.reload();
|
|
181
|
+
if (!syncsDrives) return [];
|
|
182
|
+
const placed = new Set(this.placed().map((drive) => drive.slug));
|
|
183
|
+
await removeLocalCopies(
|
|
184
|
+
this.sync,
|
|
185
|
+
this.resolved.filter(
|
|
186
|
+
(drive) => placed.has(drive.slug) && !this.busy.has(drive.slug),
|
|
187
|
+
),
|
|
188
|
+
);
|
|
189
|
+
return this.keptCollections();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The drive's placement in this browser; undefined for a drive the host does not place on a switchboard. */
|
|
193
|
+
placementOf(driveId: string): DrivePlacement | undefined {
|
|
194
|
+
return this.snapshot.get(driveId);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
subscribe(listener: () => void): () => void {
|
|
198
|
+
this.listeners.add(listener);
|
|
199
|
+
return () => {
|
|
200
|
+
this.listeners.delete(listener);
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Opens the drive on `backend` in this browser: its switchboard, or a copy here. */
|
|
205
|
+
async move(driveId: string, backend: string): Promise<void> {
|
|
206
|
+
const drive = this.resolved.find((d) => d.id === driveId);
|
|
207
|
+
if (!drive?.id) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`Drive ${driveId} is not on a switchboard this app links`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
if (backend !== drive.remote && backend !== LOCAL_BACKEND_NAME) {
|
|
213
|
+
throw new Error(`Drive ${drive.slug} can't open on ${backend}`);
|
|
214
|
+
}
|
|
215
|
+
if (this.busy.has(drive.slug) || this.backendOf(drive) === backend) return;
|
|
216
|
+
const previous = this.applied.get(drive.slug);
|
|
217
|
+
const entry: PlacementEntry | undefined =
|
|
218
|
+
backend === drive.configured
|
|
219
|
+
? undefined
|
|
220
|
+
: { slug: drive.slug, backend, configWas: drive.configured };
|
|
221
|
+
const id = drive.id;
|
|
222
|
+
this.busy.set(drive.slug, backend);
|
|
223
|
+
this.notify();
|
|
224
|
+
try {
|
|
225
|
+
await this.record(drive.slug, entry);
|
|
226
|
+
if (needsCopy(backend)) {
|
|
227
|
+
try {
|
|
228
|
+
await this.sync.add(servedUrl(drive), id);
|
|
229
|
+
await this.sync.untilCopied(id, drive.remote);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
// Store first: the removal's broadcast makes other tabs re-read it.
|
|
232
|
+
await this.record(drive.slug, previous);
|
|
233
|
+
await removeLocalCopies(this.sync, [drive]);
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
} else {
|
|
237
|
+
try {
|
|
238
|
+
await removeLocalCopies(this.sync, [drive]);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
await this.record(drive.slug, previous);
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
this.install(drive);
|
|
245
|
+
} finally {
|
|
246
|
+
this.busy.delete(drive.slug);
|
|
247
|
+
this.notify();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Back to where the host config opens the drive. */
|
|
252
|
+
reset(driveId: string): Promise<void> {
|
|
253
|
+
const drive = this.resolved.find((d) => d.id === driveId);
|
|
254
|
+
return drive ? this.move(driveId, drive.configured) : Promise.resolve();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Re-reads the store after another tab moved a drive; true when something changed. */
|
|
258
|
+
async reload(): Promise<boolean> {
|
|
259
|
+
if (!this.attached) return false;
|
|
260
|
+
const next = await this.readMoves();
|
|
261
|
+
if (!next) return false;
|
|
262
|
+
const changed = this.drives.filter(
|
|
263
|
+
(drive) =>
|
|
264
|
+
!this.busy.has(drive.slug) &&
|
|
265
|
+
effectiveBackend(drive, next.get(drive.slug)) !== this.backendOf(drive),
|
|
266
|
+
);
|
|
267
|
+
if (changed.length === 0) return false;
|
|
268
|
+
await Promise.all(
|
|
269
|
+
changed.map((drive) => this.follow(drive, next.get(drive.slug))),
|
|
270
|
+
);
|
|
271
|
+
this.notify();
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** A copy kept by another tab is routed here only once it has arrived. */
|
|
276
|
+
private async follow(
|
|
277
|
+
drive: SwitchboardDrive,
|
|
278
|
+
entry: PlacementEntry | undefined,
|
|
279
|
+
): Promise<void> {
|
|
280
|
+
const resolved = this.resolved.find((d) => d.slug === drive.slug);
|
|
281
|
+
if (needsCopy(effectiveBackend(drive, entry)) && resolved?.id) {
|
|
282
|
+
this.busy.set(drive.slug, LOCAL_BACKEND_NAME);
|
|
283
|
+
this.notify();
|
|
284
|
+
try {
|
|
285
|
+
await this.sync.untilCopied(resolved.id, resolved.remote);
|
|
286
|
+
} catch (error) {
|
|
287
|
+
logger.warn(
|
|
288
|
+
"Drive @slug was not copied into this browser; leaving it where it opens: @error",
|
|
289
|
+
drive.slug,
|
|
290
|
+
error,
|
|
291
|
+
);
|
|
292
|
+
return;
|
|
293
|
+
} finally {
|
|
294
|
+
this.busy.delete(drive.slug);
|
|
295
|
+
}
|
|
296
|
+
// The mover may have rolled back while this tab waited.
|
|
297
|
+
const latest = await this.readMoves();
|
|
298
|
+
if (!latest) return;
|
|
299
|
+
entry = latest.get(drive.slug);
|
|
300
|
+
}
|
|
301
|
+
if (entry) this.applied.set(drive.slug, entry);
|
|
302
|
+
else this.applied.delete(drive.slug);
|
|
303
|
+
if (resolved) this.install(resolved);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private async readMoves(): Promise<Map<string, PlacementEntry> | undefined> {
|
|
307
|
+
let entries: PlacementEntry[];
|
|
308
|
+
try {
|
|
309
|
+
entries = await this.store.list();
|
|
310
|
+
} catch (error) {
|
|
311
|
+
logger.warn("Could not read this browser's drive moves: @error", error);
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
const { applied } = applyPlacementEntries(this.drives, entries);
|
|
315
|
+
return new Map(applied.map((entry) => [entry.slug, entry]));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private async record(
|
|
319
|
+
slug: string,
|
|
320
|
+
entry: PlacementEntry | undefined,
|
|
321
|
+
): Promise<void> {
|
|
322
|
+
if (entry) {
|
|
323
|
+
await this.store.put(entry);
|
|
324
|
+
this.applied.set(slug, entry);
|
|
325
|
+
} else {
|
|
326
|
+
await this.store.delete(slug);
|
|
327
|
+
this.applied.delete(slug);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private install(drive: ResolvedSwitchboardDrive): void {
|
|
332
|
+
const router = this.router;
|
|
333
|
+
if (!router) return;
|
|
334
|
+
const configured =
|
|
335
|
+
drive.configured === drive.remote ? drive.remote : undefined;
|
|
336
|
+
const runtime = this.applied.get(drive.slug)?.backend;
|
|
337
|
+
for (const key of placementKeys(drive)) {
|
|
338
|
+
router.setConfiguredPlacement(key, configured);
|
|
339
|
+
router.setPlacement(key, runtime);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private notify(): void {
|
|
344
|
+
const snapshot = new Map<string, DrivePlacement>();
|
|
345
|
+
for (const drive of this.resolved) {
|
|
346
|
+
if (!drive.id) continue;
|
|
347
|
+
const backend = this.backendOf(drive);
|
|
348
|
+
snapshot.set(drive.id, {
|
|
349
|
+
drive,
|
|
350
|
+
backend,
|
|
351
|
+
moved: backend !== drive.configured,
|
|
352
|
+
busy: this.busy.has(drive.slug),
|
|
353
|
+
...(this.busy.has(drive.slug)
|
|
354
|
+
? { movingTo: this.busy.get(drive.slug) }
|
|
355
|
+
: {}),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
this.snapshot = snapshot;
|
|
359
|
+
for (const listener of [...this.listeners]) listener();
|
|
360
|
+
}
|
|
361
|
+
}
|
package/src/runtime/index.ts
CHANGED
|
@@ -28,6 +28,36 @@ export {
|
|
|
28
28
|
relistAppDrives,
|
|
29
29
|
selectAppReactorClient,
|
|
30
30
|
} from "./app-reactor-client.js";
|
|
31
|
+
export {
|
|
32
|
+
applyPlacementEntries,
|
|
33
|
+
keepsLocalCopy,
|
|
34
|
+
LOCAL_BACKEND_NAME,
|
|
35
|
+
placementKeys,
|
|
36
|
+
removeLocalCopies,
|
|
37
|
+
resolveSwitchboardDrives,
|
|
38
|
+
servedUrl,
|
|
39
|
+
servesSlug,
|
|
40
|
+
switchboardDrives,
|
|
41
|
+
} from "./drive-placement.js";
|
|
42
|
+
export type {
|
|
43
|
+
PlacementEntry,
|
|
44
|
+
ResolvedSwitchboardDrive,
|
|
45
|
+
ResolveSwitchboardDrivesOptions,
|
|
46
|
+
SwitchboardDrive,
|
|
47
|
+
} from "./drive-placement.js";
|
|
48
|
+
export {
|
|
49
|
+
caughtUp,
|
|
50
|
+
DrivePlacements,
|
|
51
|
+
effectiveBackend,
|
|
52
|
+
findServedDrive,
|
|
53
|
+
needsCopy,
|
|
54
|
+
} from "./drive-placements.js";
|
|
55
|
+
export type {
|
|
56
|
+
DrivePlacement,
|
|
57
|
+
PlacementRouter,
|
|
58
|
+
PlacementStore,
|
|
59
|
+
PlacementSync,
|
|
60
|
+
} from "./drive-placements.js";
|
|
31
61
|
export { useBackendAuthGate } from "./backend-auth-gate.js";
|
|
32
62
|
export type { BackendAuthGateMode } from "./backend-auth-gate.js";
|
|
33
63
|
export type {
|
|
@@ -2,9 +2,13 @@ import {
|
|
|
2
2
|
createReactorInspector,
|
|
3
3
|
type ReactorInfo,
|
|
4
4
|
} from "@powerhousedao/reactor";
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
import {
|
|
6
|
+
ambientRenownSigner,
|
|
7
|
+
ambientRenownTokenProvider,
|
|
8
|
+
createSwitchboardBackend,
|
|
9
|
+
type BrowserReactorClientModule,
|
|
10
|
+
type SwitchboardBackend,
|
|
11
|
+
type WorkerReactorClientModule,
|
|
8
12
|
} from "@powerhousedao/reactor-browser";
|
|
9
13
|
import {
|
|
10
14
|
FanInPartialFailureError,
|
|
@@ -20,30 +24,17 @@ import type {
|
|
|
20
24
|
DocumentModelModule,
|
|
21
25
|
ISigner,
|
|
22
26
|
} from "@powerhousedao/shared/document-model";
|
|
27
|
+
import { switchboardOfDrive } from "@powerhousedao/shared/host-tokens";
|
|
23
28
|
import { logger } from "document-model";
|
|
24
|
-
import {
|
|
25
|
-
createGraphQLRoutableBackend,
|
|
26
|
-
type GraphQLRoutableBackend,
|
|
27
|
-
} from "./graphql-routable-backend.js";
|
|
29
|
+
import { LOCAL_BACKEND_NAME } from "./drive-placement.js";
|
|
28
30
|
|
|
29
|
-
export {
|
|
30
|
-
export type {
|
|
31
|
-
GraphQLRoutableBackend,
|
|
32
|
-
GraphQLRoutableBackendOptions,
|
|
33
|
-
} from "./graphql-routable-backend.js";
|
|
34
|
-
|
|
35
|
-
export const LOCAL_BACKEND_NAME = "connect-local";
|
|
31
|
+
export { LOCAL_BACKEND_NAME } from "./drive-placement.js";
|
|
36
32
|
export const REMOTE_BACKEND_NAME = "switchboard-remote";
|
|
37
33
|
|
|
38
34
|
export type LocalReactorModule =
|
|
39
35
|
| BrowserReactorClientModule
|
|
40
36
|
| WorkerReactorClientModule;
|
|
41
37
|
|
|
42
|
-
const REMOTE_REACH: ReactorReach = Object.freeze({
|
|
43
|
-
hosting: "remote",
|
|
44
|
-
inspection: "none",
|
|
45
|
-
});
|
|
46
|
-
|
|
47
38
|
/** Only the tab's reactor is inspectable here, so new collections land on it. */
|
|
48
39
|
export const PLACE_ON_LOCAL: CollectionRequirementsInput = Object.freeze({
|
|
49
40
|
inspectable: true,
|
|
@@ -75,16 +66,12 @@ export function localFacts(
|
|
|
75
66
|
export function deriveSwitchboardGraphqlUrl(
|
|
76
67
|
remoteDriveUrl: string,
|
|
77
68
|
): string | undefined {
|
|
78
|
-
|
|
79
|
-
try {
|
|
80
|
-
parsed = new URL(remoteDriveUrl);
|
|
81
|
-
} catch {
|
|
82
|
-
return undefined;
|
|
83
|
-
}
|
|
84
|
-
const prefix = /^(.*)\/d\/[^/]+\/?$/.exec(parsed.pathname)?.[1];
|
|
85
|
-
return prefix === undefined ? undefined : `${parsed.origin}${prefix}/graphql`;
|
|
69
|
+
return switchboardOfDrive(remoteDriveUrl)?.graphql;
|
|
86
70
|
}
|
|
87
71
|
|
|
72
|
+
/** A switchboard the router reaches, by its router backend name. */
|
|
73
|
+
export type SwitchboardRemote = { name: string; graphqlUrl: string };
|
|
74
|
+
|
|
88
75
|
/** The loaded document models, or a getter that reads the current ones. */
|
|
89
76
|
export type LoadedDocumentModels =
|
|
90
77
|
| readonly DocumentModelModule[]
|
|
@@ -94,7 +81,11 @@ export type MultiReactorParams = {
|
|
|
94
81
|
/** The local module, built and ready. Undefined builds a REMOTE-ONLY router
|
|
95
82
|
* (no local backend → no local reactor, no PGlite): the `remote` client. */
|
|
96
83
|
module: LocalReactorModule | undefined;
|
|
97
|
-
remoteGraphqlUrl
|
|
84
|
+
/** One per switchboard; `remoteGraphqlUrl` alone is one named `switchboard-remote`. */
|
|
85
|
+
remotes?: readonly SwitchboardRemote[];
|
|
86
|
+
remoteGraphqlUrl?: string;
|
|
87
|
+
/** Collection key or drive id -> backend; names no backend here are dropped. */
|
|
88
|
+
collections?: Readonly<Record<string, string>>;
|
|
98
89
|
/** Signs routed writes; unsigned (anonymous) without one. */
|
|
99
90
|
signer?: ISigner;
|
|
100
91
|
/** Signing models when the local module exposes no registry; the router asks the local reactor (or, remote-only, the loaded set). */
|
|
@@ -116,8 +107,9 @@ export const PLACE_ON_REMOTE: CollectionRequirementsInput = Object.freeze({});
|
|
|
116
107
|
function localIdCheckAnswer(
|
|
117
108
|
documentId: string,
|
|
118
109
|
error: unknown,
|
|
110
|
+
remoteNames: ReadonlySet<string>,
|
|
119
111
|
): boolean | undefined {
|
|
120
|
-
if (!failedOnlyOnRemote(error)) {
|
|
112
|
+
if (!failedOnlyOnRemote(error, remoteNames)) {
|
|
121
113
|
return undefined;
|
|
122
114
|
}
|
|
123
115
|
logger.warn(
|
|
@@ -130,36 +122,61 @@ function localIdCheckAnswer(
|
|
|
130
122
|
|
|
131
123
|
/** The router Connect installs, with Connect's handling of credentials and id checks. */
|
|
132
124
|
export class ConnectRoutingClient extends RoutingReactorClient {
|
|
133
|
-
private readonly
|
|
125
|
+
private readonly remotes: readonly SwitchboardBackend[];
|
|
134
126
|
|
|
135
127
|
constructor(
|
|
136
128
|
backends: readonly RoutableBackendConfig[],
|
|
137
129
|
options: RoutingClientOptions,
|
|
138
|
-
|
|
130
|
+
remotes: readonly SwitchboardBackend[],
|
|
139
131
|
hasLocal: boolean,
|
|
140
132
|
) {
|
|
133
|
+
const remoteNames = new Set(remotes.map((remote) => remote.config.name));
|
|
141
134
|
super(backends, {
|
|
142
135
|
...options,
|
|
143
136
|
answerFailedIdCheck: hasLocal
|
|
144
|
-
? localIdCheckAnswer
|
|
137
|
+
? (id, error) => localIdCheckAnswer(id, error, remoteNames)
|
|
145
138
|
: options.answerFailedIdCheck,
|
|
146
139
|
});
|
|
147
|
-
this.
|
|
140
|
+
this.remotes = remotes;
|
|
148
141
|
}
|
|
149
142
|
|
|
150
143
|
/** Renown's sign-in and sign-out call this on whatever client is installed. */
|
|
151
144
|
notifyCredentialsChanged(): void {
|
|
152
|
-
this.remote.notifyCredentialsChanged();
|
|
145
|
+
for (const remote of this.remotes) remote.notifyCredentialsChanged();
|
|
153
146
|
}
|
|
154
147
|
}
|
|
155
148
|
|
|
156
|
-
function failedOnlyOnRemote(
|
|
149
|
+
function failedOnlyOnRemote(
|
|
150
|
+
error: unknown,
|
|
151
|
+
remoteNames: ReadonlySet<string>,
|
|
152
|
+
): boolean {
|
|
157
153
|
return (
|
|
158
154
|
error instanceof FanInPartialFailureError &&
|
|
159
|
-
error.failures.every((failure) => failure.backend
|
|
155
|
+
error.failures.every((failure) => remoteNames.has(failure.backend))
|
|
160
156
|
);
|
|
161
157
|
}
|
|
162
158
|
|
|
159
|
+
/** The remotes to build, one per switchboard: the first link to a base wins. */
|
|
160
|
+
function remotesOf(params: MultiReactorParams): SwitchboardRemote[] {
|
|
161
|
+
const listed: readonly SwitchboardRemote[] = params.remotes?.length
|
|
162
|
+
? params.remotes
|
|
163
|
+
: params.remoteGraphqlUrl
|
|
164
|
+
? [{ name: REMOTE_BACKEND_NAME, graphqlUrl: params.remoteGraphqlUrl }]
|
|
165
|
+
: [];
|
|
166
|
+
const seen = new Set<string>();
|
|
167
|
+
return listed.filter((remote) => {
|
|
168
|
+
if (seen.has(remote.graphqlUrl)) {
|
|
169
|
+
logger.warn(
|
|
170
|
+
"@name opens the same switchboard as an earlier backend; skipping it",
|
|
171
|
+
remote.name,
|
|
172
|
+
);
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
seen.add(remote.graphqlUrl);
|
|
176
|
+
return true;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
163
180
|
/**
|
|
164
181
|
* Build the Connect routing client. With a local `module` it is the router
|
|
165
182
|
* over the tab's reactor (primary) and the Switchboard; WITHOUT one it is the
|
|
@@ -174,17 +191,22 @@ export async function buildMultiReactorClient(
|
|
|
174
191
|
const registry = module?.reactorModule?.documentModelRegistry;
|
|
175
192
|
const loaded = params.documentModelModules;
|
|
176
193
|
const loadedModules = typeof loaded === "function" ? loaded : () => loaded;
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
194
|
+
const listed = remotesOf(params);
|
|
195
|
+
if (listed.length === 0) {
|
|
196
|
+
throw new Error("A routing client needs at least one switchboard");
|
|
197
|
+
}
|
|
198
|
+
// The logged-in Renown user's token and signer, read per request as before.
|
|
199
|
+
const remotes = listed.map((remote) =>
|
|
200
|
+
createSwitchboardBackend({
|
|
201
|
+
name: remote.name,
|
|
202
|
+
url: remote.graphqlUrl,
|
|
203
|
+
tokenProvider: ambientRenownTokenProvider,
|
|
204
|
+
signer: ambientRenownSigner,
|
|
205
|
+
documentModels: () => registry?.getAllModules() ?? loadedModules(),
|
|
206
|
+
inspection: "none",
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
const remoteBackends = remotes.map((remote) => remote.config);
|
|
188
210
|
const backends: RoutableBackendConfig[] = module
|
|
189
211
|
? [
|
|
190
212
|
{
|
|
@@ -194,22 +216,29 @@ export async function buildMultiReactorClient(
|
|
|
194
216
|
reach: localReach(module.kind),
|
|
195
217
|
refusesMisroutes: false,
|
|
196
218
|
},
|
|
197
|
-
|
|
219
|
+
...remoteBackends,
|
|
198
220
|
]
|
|
199
|
-
:
|
|
221
|
+
: remoteBackends;
|
|
222
|
+
const names = new Set(backends.map((backend) => backend.name));
|
|
223
|
+
const collections = Object.fromEntries(
|
|
224
|
+
Object.entries(params.collections ?? {}).filter(([, backend]) =>
|
|
225
|
+
names.has(backend),
|
|
226
|
+
),
|
|
227
|
+
);
|
|
200
228
|
const router = new ConnectRoutingClient(
|
|
201
229
|
backends,
|
|
202
230
|
{
|
|
203
|
-
// Remote-only: the Switchboard is primary
|
|
204
|
-
//
|
|
205
|
-
primaryBackend: module ? LOCAL_BACKEND_NAME :
|
|
231
|
+
// Remote-only: the first Switchboard is primary; with a local reactor,
|
|
232
|
+
// new collections land on it (inspectable).
|
|
233
|
+
primaryBackend: module ? LOCAL_BACKEND_NAME : remoteBackends[0].name,
|
|
206
234
|
defaultRequirements: module ? PLACE_ON_LOCAL : PLACE_ON_REMOTE,
|
|
235
|
+
collections,
|
|
207
236
|
signer: params.signer,
|
|
208
237
|
onDiagnostic: params.onDiagnostic,
|
|
209
238
|
// The Switchboard serves no models over GraphQL; remote-only uses the loaded set.
|
|
210
239
|
documentModelModules: module ? undefined : loadedModules,
|
|
211
240
|
},
|
|
212
|
-
|
|
241
|
+
remotes,
|
|
213
242
|
Boolean(module),
|
|
214
243
|
);
|
|
215
244
|
await router.refreshFacts();
|