@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.
@@ -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
+ }
@@ -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 type {
6
- BrowserReactorClientModule,
7
- WorkerReactorClientModule,
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 { createGraphQLRoutableBackend } from "./graphql-routable-backend.js";
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
- let parsed: URL;
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: string;
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 remote: GraphQLRoutableBackend;
125
+ private readonly remotes: readonly SwitchboardBackend[];
134
126
 
135
127
  constructor(
136
128
  backends: readonly RoutableBackendConfig[],
137
129
  options: RoutingClientOptions,
138
- remote: GraphQLRoutableBackend,
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.remote = remote;
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(error: unknown): boolean {
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 === REMOTE_BACKEND_NAME)
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 remote = createGraphQLRoutableBackend({
178
- url: params.remoteGraphqlUrl,
179
- documentModels: () => registry?.getAllModules() ?? loadedModules(),
180
- });
181
- const remoteBackend: RoutableBackendConfig = {
182
- name: REMOTE_BACKEND_NAME,
183
- backend: remote.backend,
184
- facts: () => remote.info(),
185
- reach: REMOTE_REACH,
186
- refusesMisroutes: false,
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
- remoteBackend,
219
+ ...remoteBackends,
198
220
  ]
199
- : [remoteBackend];
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 and serves every collection;
204
- // with a local reactor, new collections land on it (inspectable).
205
- primaryBackend: module ? LOCAL_BACKEND_NAME : REMOTE_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
- remote,
241
+ remotes,
213
242
  Boolean(module),
214
243
  );
215
244
  await router.refreshFacts();