@powerhousedao/connect-core 6.2.3-dev.82 → 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-CVwr_LdS.js → multi-reactor-Dvwv69u1.js} +45 -28
- 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 +74 -33
- package/dist/index-CCs6HTQV.d.ts +0 -187
- package/dist/index-CCs6HTQV.d.ts.map +0 -1
- package/dist/multi-reactor-CVwr_LdS.js.map +0 -1
- package/dist/runtime-Cb3HQhKo.js +0 -239
- package/dist/runtime-Cb3HQhKo.js.map +0 -1
|
@@ -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 {
|
|
@@ -24,9 +24,11 @@ import type {
|
|
|
24
24
|
DocumentModelModule,
|
|
25
25
|
ISigner,
|
|
26
26
|
} from "@powerhousedao/shared/document-model";
|
|
27
|
+
import { switchboardOfDrive } from "@powerhousedao/shared/host-tokens";
|
|
27
28
|
import { logger } from "document-model";
|
|
29
|
+
import { LOCAL_BACKEND_NAME } from "./drive-placement.js";
|
|
28
30
|
|
|
29
|
-
export
|
|
31
|
+
export { LOCAL_BACKEND_NAME } from "./drive-placement.js";
|
|
30
32
|
export const REMOTE_BACKEND_NAME = "switchboard-remote";
|
|
31
33
|
|
|
32
34
|
export type LocalReactorModule =
|
|
@@ -64,16 +66,12 @@ export function localFacts(
|
|
|
64
66
|
export function deriveSwitchboardGraphqlUrl(
|
|
65
67
|
remoteDriveUrl: string,
|
|
66
68
|
): string | undefined {
|
|
67
|
-
|
|
68
|
-
try {
|
|
69
|
-
parsed = new URL(remoteDriveUrl);
|
|
70
|
-
} catch {
|
|
71
|
-
return undefined;
|
|
72
|
-
}
|
|
73
|
-
const prefix = /^(.*)\/d\/[^/]+\/?$/.exec(parsed.pathname)?.[1];
|
|
74
|
-
return prefix === undefined ? undefined : `${parsed.origin}${prefix}/graphql`;
|
|
69
|
+
return switchboardOfDrive(remoteDriveUrl)?.graphql;
|
|
75
70
|
}
|
|
76
71
|
|
|
72
|
+
/** A switchboard the router reaches, by its router backend name. */
|
|
73
|
+
export type SwitchboardRemote = { name: string; graphqlUrl: string };
|
|
74
|
+
|
|
77
75
|
/** The loaded document models, or a getter that reads the current ones. */
|
|
78
76
|
export type LoadedDocumentModels =
|
|
79
77
|
| readonly DocumentModelModule[]
|
|
@@ -83,7 +81,11 @@ export type MultiReactorParams = {
|
|
|
83
81
|
/** The local module, built and ready. Undefined builds a REMOTE-ONLY router
|
|
84
82
|
* (no local backend → no local reactor, no PGlite): the `remote` client. */
|
|
85
83
|
module: LocalReactorModule | undefined;
|
|
86
|
-
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>>;
|
|
87
89
|
/** Signs routed writes; unsigned (anonymous) without one. */
|
|
88
90
|
signer?: ISigner;
|
|
89
91
|
/** Signing models when the local module exposes no registry; the router asks the local reactor (or, remote-only, the loaded set). */
|
|
@@ -105,8 +107,9 @@ export const PLACE_ON_REMOTE: CollectionRequirementsInput = Object.freeze({});
|
|
|
105
107
|
function localIdCheckAnswer(
|
|
106
108
|
documentId: string,
|
|
107
109
|
error: unknown,
|
|
110
|
+
remoteNames: ReadonlySet<string>,
|
|
108
111
|
): boolean | undefined {
|
|
109
|
-
if (!failedOnlyOnRemote(error)) {
|
|
112
|
+
if (!failedOnlyOnRemote(error, remoteNames)) {
|
|
110
113
|
return undefined;
|
|
111
114
|
}
|
|
112
115
|
logger.warn(
|
|
@@ -119,36 +122,61 @@ function localIdCheckAnswer(
|
|
|
119
122
|
|
|
120
123
|
/** The router Connect installs, with Connect's handling of credentials and id checks. */
|
|
121
124
|
export class ConnectRoutingClient extends RoutingReactorClient {
|
|
122
|
-
private readonly
|
|
125
|
+
private readonly remotes: readonly SwitchboardBackend[];
|
|
123
126
|
|
|
124
127
|
constructor(
|
|
125
128
|
backends: readonly RoutableBackendConfig[],
|
|
126
129
|
options: RoutingClientOptions,
|
|
127
|
-
|
|
130
|
+
remotes: readonly SwitchboardBackend[],
|
|
128
131
|
hasLocal: boolean,
|
|
129
132
|
) {
|
|
133
|
+
const remoteNames = new Set(remotes.map((remote) => remote.config.name));
|
|
130
134
|
super(backends, {
|
|
131
135
|
...options,
|
|
132
136
|
answerFailedIdCheck: hasLocal
|
|
133
|
-
? localIdCheckAnswer
|
|
137
|
+
? (id, error) => localIdCheckAnswer(id, error, remoteNames)
|
|
134
138
|
: options.answerFailedIdCheck,
|
|
135
139
|
});
|
|
136
|
-
this.
|
|
140
|
+
this.remotes = remotes;
|
|
137
141
|
}
|
|
138
142
|
|
|
139
143
|
/** Renown's sign-in and sign-out call this on whatever client is installed. */
|
|
140
144
|
notifyCredentialsChanged(): void {
|
|
141
|
-
this.remote.notifyCredentialsChanged();
|
|
145
|
+
for (const remote of this.remotes) remote.notifyCredentialsChanged();
|
|
142
146
|
}
|
|
143
147
|
}
|
|
144
148
|
|
|
145
|
-
function failedOnlyOnRemote(
|
|
149
|
+
function failedOnlyOnRemote(
|
|
150
|
+
error: unknown,
|
|
151
|
+
remoteNames: ReadonlySet<string>,
|
|
152
|
+
): boolean {
|
|
146
153
|
return (
|
|
147
154
|
error instanceof FanInPartialFailureError &&
|
|
148
|
-
error.failures.every((failure) => failure.backend
|
|
155
|
+
error.failures.every((failure) => remoteNames.has(failure.backend))
|
|
149
156
|
);
|
|
150
157
|
}
|
|
151
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
|
+
|
|
152
180
|
/**
|
|
153
181
|
* Build the Connect routing client. With a local `module` it is the router
|
|
154
182
|
* over the tab's reactor (primary) and the Switchboard; WITHOUT one it is the
|
|
@@ -163,16 +191,22 @@ export async function buildMultiReactorClient(
|
|
|
163
191
|
const registry = module?.reactorModule?.documentModelRegistry;
|
|
164
192
|
const loaded = params.documentModelModules;
|
|
165
193
|
const loadedModules = typeof loaded === "function" ? loaded : () => loaded;
|
|
194
|
+
const listed = remotesOf(params);
|
|
195
|
+
if (listed.length === 0) {
|
|
196
|
+
throw new Error("A routing client needs at least one switchboard");
|
|
197
|
+
}
|
|
166
198
|
// The logged-in Renown user's token and signer, read per request as before.
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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);
|
|
176
210
|
const backends: RoutableBackendConfig[] = module
|
|
177
211
|
? [
|
|
178
212
|
{
|
|
@@ -182,22 +216,29 @@ export async function buildMultiReactorClient(
|
|
|
182
216
|
reach: localReach(module.kind),
|
|
183
217
|
refusesMisroutes: false,
|
|
184
218
|
},
|
|
185
|
-
|
|
219
|
+
...remoteBackends,
|
|
186
220
|
]
|
|
187
|
-
:
|
|
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
|
+
);
|
|
188
228
|
const router = new ConnectRoutingClient(
|
|
189
229
|
backends,
|
|
190
230
|
{
|
|
191
|
-
// Remote-only: the Switchboard is primary
|
|
192
|
-
//
|
|
193
|
-
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,
|
|
194
234
|
defaultRequirements: module ? PLACE_ON_LOCAL : PLACE_ON_REMOTE,
|
|
235
|
+
collections,
|
|
195
236
|
signer: params.signer,
|
|
196
237
|
onDiagnostic: params.onDiagnostic,
|
|
197
238
|
// The Switchboard serves no models over GraphQL; remote-only uses the loaded set.
|
|
198
239
|
documentModelModules: module ? undefined : loadedModules,
|
|
199
240
|
},
|
|
200
|
-
|
|
241
|
+
remotes,
|
|
201
242
|
Boolean(module),
|
|
202
243
|
);
|
|
203
244
|
await router.refreshFacts();
|