ignotum 0.0.13 → 0.0.15

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.
Files changed (131) hide show
  1. package/README.md +6 -6
  2. package/dist/cli/bin.mjs +6111 -4448
  3. package/dist/cli/bin.mjs.map +1 -1
  4. package/dist/cli/emscripten-module-D5GzfUNv.mjs +533 -0
  5. package/dist/cli/emscripten-module-D5GzfUNv.mjs.map +1 -0
  6. package/dist/cli/ffi-7IElU4Co.mjs +148 -0
  7. package/dist/cli/ffi-7IElU4Co.mjs.map +1 -0
  8. package/dist/cli/managed-release-WHYLZwaS.mjs +10902 -0
  9. package/dist/cli/managed-release-WHYLZwaS.mjs.map +1 -0
  10. package/dist/cli/managed-release.d.mts +1 -0
  11. package/dist/cli/managed-release.mjs +14 -0
  12. package/dist/cli/managed-release.mjs.map +1 -0
  13. package/dist/cli/module-ES6BEMUI-D36I4NVn.mjs +1289 -0
  14. package/dist/cli/module-ES6BEMUI-D36I4NVn.mjs.map +1 -0
  15. package/dist/runtime/{api-Cv3hMbzo.d.ts → api-B4fwp5a2.d.ts} +4 -4
  16. package/dist/runtime/bootstrap-BISQSn_O.js +916 -0
  17. package/dist/runtime/bootstrap-BISQSn_O.js.map +1 -0
  18. package/dist/runtime/bootstrap-BTFScKAc.d.ts +545 -0
  19. package/dist/runtime/client.d.ts +50 -15
  20. package/dist/runtime/client.js +7 -1138
  21. package/dist/runtime/client.js.map +1 -1
  22. package/dist/runtime/descriptor-CTY4mtwS-cS43EbwS.js +269 -0
  23. package/dist/runtime/descriptor-CTY4mtwS-cS43EbwS.js.map +1 -0
  24. package/dist/runtime/{id-Bt9XWRGL.js → id--1m0NuUL.js} +4 -4
  25. package/dist/runtime/id--1m0NuUL.js.map +1 -0
  26. package/dist/runtime/{id-BzFHf3Wo-DGPCjgrf.d.ts → id-7an3nxTu-DZJSM5xh.d.ts} +2 -2
  27. package/dist/runtime/id-CBOt2kDo.d.ts +1 -0
  28. package/dist/runtime/identity-QjtJRxBD.d.ts +2 -0
  29. package/dist/runtime/image-Djtjy4Z8.js +19 -0
  30. package/dist/runtime/image-Djtjy4Z8.js.map +1 -0
  31. package/dist/runtime/index-D4seNHlX.d.ts +184 -0
  32. package/dist/runtime/internal/api.d.ts +1 -1
  33. package/dist/runtime/internal/api.js +30 -1
  34. package/dist/runtime/internal/api.js.map +1 -0
  35. package/dist/runtime/internal/bootstrap.d.ts +2 -0
  36. package/dist/runtime/internal/bootstrap.js +2 -0
  37. package/dist/runtime/internal/client.d.ts +2 -0
  38. package/dist/runtime/internal/client.js +75 -0
  39. package/dist/runtime/internal/client.js.map +1 -0
  40. package/dist/runtime/internal/host.d.ts +25 -8
  41. package/dist/runtime/internal/host.js +27 -6
  42. package/dist/runtime/internal/host.js.map +1 -1
  43. package/dist/runtime/internal/routes.d.ts +2 -0
  44. package/dist/runtime/internal/routes.js +2 -0
  45. package/dist/runtime/internal/server.d.ts +1 -1
  46. package/dist/runtime/internal/server.js +1 -1
  47. package/dist/runtime/internal/types.d.ts +2 -1
  48. package/dist/runtime/internal/types.js +1 -1
  49. package/dist/runtime/pagination-CBOt2kDo.d.ts +1 -0
  50. package/dist/runtime/pagination-D3qd6s9N.js +33 -0
  51. package/dist/runtime/pagination-D3qd6s9N.js.map +1 -0
  52. package/dist/runtime/result-BkziOG1L.d.ts +1 -0
  53. package/dist/runtime/router-BSBI1oN1.js +2377 -0
  54. package/dist/runtime/router-BSBI1oN1.js.map +1 -0
  55. package/dist/runtime/routes-Ce8TVP-T.js +690 -0
  56. package/dist/runtime/routes-Ce8TVP-T.js.map +1 -0
  57. package/dist/runtime/{schema-1Zs03-iS.js → schema-D1wOqiNH.js} +40 -12
  58. package/dist/runtime/schema-D1wOqiNH.js.map +1 -0
  59. package/dist/runtime/server.d.ts +5 -5
  60. package/dist/runtime/server.js +2 -2
  61. package/dist/runtime/{sync-Bs8J3fIr.d.ts → sync-D-GK2sv9.d.ts} +3 -2
  62. package/dist/runtime/{api-CAgKDij7.js → sync-DzUwA8W9.js} +22 -41
  63. package/dist/runtime/sync-DzUwA8W9.js.map +1 -0
  64. package/dist/runtime/types-DeCCyqLU-BIm7rU-0.d.ts +341 -0
  65. package/package.json +29 -4
  66. package/src/cli/agent-files.ts +14 -8
  67. package/src/cli/build/managed-client.ts +411 -0
  68. package/src/cli/build/managed-server.ts +294 -0
  69. package/src/cli/build/managed.ts +67 -0
  70. package/src/cli/build/output.ts +47 -0
  71. package/src/cli/build/server.ts +51 -220
  72. package/src/cli/client-config.ts +1 -140
  73. package/src/cli/client-styles.ts +1 -0
  74. package/src/cli/codegen.ts +9 -4
  75. package/src/cli/command.ts +28 -5
  76. package/src/cli/control-client.ts +84 -101
  77. package/src/cli/deploy.ts +133 -177
  78. package/src/cli/dev.ts +506 -107
  79. package/src/cli/image-assets.ts +499 -0
  80. package/src/cli/managed-client-boundaries.ts +103 -0
  81. package/src/cli/managed-dev-platform.ts +118 -0
  82. package/src/cli/managed-exports.ts +219 -0
  83. package/src/cli/managed-release-bin.ts +13 -0
  84. package/src/cli/managed-release.ts +260 -0
  85. package/src/cli/module-boundaries.ts +11 -1
  86. package/src/cli/new-app.ts +111 -87
  87. package/src/cli/route-codegen.ts +311 -0
  88. package/src/cli/route-static.ts +710 -0
  89. package/src/client/bootstrap.ts +110 -0
  90. package/src/client/errors.ts +13 -7
  91. package/src/client/files.ts +64 -0
  92. package/src/client/id.ts +9 -5
  93. package/src/client/image.ts +28 -0
  94. package/src/client/index.ts +25 -2
  95. package/src/client/managed-client.ts +729 -0
  96. package/src/client/managed-upgrade.ts +283 -0
  97. package/src/client/recovery-journal.ts +195 -0
  98. package/src/client/route-boundaries.ts +184 -0
  99. package/src/client/router-history.ts +152 -0
  100. package/src/client/router-store.ts +644 -0
  101. package/src/client/router.ts +324 -0
  102. package/src/client/routes.ts +28 -0
  103. package/src/client/sync.ts +66 -592
  104. package/src/dev-runtime/database.ts +77 -2
  105. package/src/dev-runtime/functions.ts +3 -3
  106. package/src/dev-runtime/id.ts +19 -4
  107. package/src/dev-runtime/managed-functions.ts +255 -0
  108. package/src/dev-runtime/managed-sync.ts +263 -0
  109. package/src/dev-runtime/managed-websocket.ts +71 -0
  110. package/src/dev-runtime/migrations.ts +20 -0
  111. package/src/dev-runtime/sync.ts +236 -236
  112. package/src/image-assets.d.ts +36 -0
  113. package/src/internal/client.ts +186 -0
  114. package/src/internal/host.ts +1 -1
  115. package/src/internal/http-paths.ts +0 -3
  116. package/src/internal/routes.ts +45 -0
  117. package/dist/runtime/api-CAgKDij7.js.map +0 -1
  118. package/dist/runtime/descriptor-Cyo9FP9n-C0SRVXNW.js +0 -154
  119. package/dist/runtime/descriptor-Cyo9FP9n-C0SRVXNW.js.map +0 -1
  120. package/dist/runtime/id-Bt9XWRGL.js.map +0 -1
  121. package/dist/runtime/id-Dz0apuB3.d.ts +0 -1
  122. package/dist/runtime/index-D54flWtH.d.ts +0 -402
  123. package/dist/runtime/pagination-DnKg3dkI-r5ZUxBBx.d.ts +0 -112
  124. package/dist/runtime/pagination-Dz0apuB3.d.ts +0 -1
  125. package/dist/runtime/result-DKAA4gpS.d.ts +0 -1
  126. package/dist/runtime/schema-1Zs03-iS.js.map +0 -1
  127. package/src/cli/build/client.ts +0 -119
  128. package/src/cli/build/public.ts +0 -186
  129. package/src/cli/client-entry.ts +0 -152
  130. package/src/cli/client-plugin.ts +0 -136
  131. package/src/client/app.ts +0 -15
@@ -0,0 +1,729 @@
1
+ import { Effect, Deferred, Option, Schema } from "effect";
2
+ import {
3
+ ManagedClientMessage,
4
+ ManagedServerMessage,
5
+ ManagedSelectionResponse,
6
+ ManagedUpgradeRequired,
7
+ managedSelectionPath,
8
+ managedSyncPath,
9
+ } from "@ignotum/contracts/managed-sync";
10
+ import {
11
+ sameExecutionSelection,
12
+ type ExecutionSelection,
13
+ type RecoveryIdentity,
14
+ type RecoverySessionId,
15
+ } from "@ignotum/contracts/platform";
16
+ import { InvocationId, SubscriptionId } from "@ignotum/contracts/runtime/sync";
17
+ import type { RuntimeMutationOutcome } from "@ignotum/contracts/runtime/hosted";
18
+ import { encodeTransportObject } from "@ignotum/contracts/runtime/sync";
19
+ import type { Result, ErrorValue } from "@ignotum/contracts/runtime/result";
20
+ import {
21
+ ClientInfrastructureError,
22
+ ConnectionUnavailable,
23
+ InvalidMutationArguments,
24
+ PlatformUpgrading,
25
+ ServerError,
26
+ } from "./errors.js";
27
+ import { ManagedUpgrade } from "./managed-upgrade.js";
28
+ import { RecoveryJournal, type RecoveryJournalIssue } from "./recovery-journal.js";
29
+ import { RecoveryJournalLimits } from "./recovery-journal.js";
30
+ import { prepareMutationArguments, uploadPreparedFiles } from "./files.js";
31
+ import { IdGenerator } from "@ignotum/shared/id";
32
+ import type { BrowserSession } from "./id.js";
33
+ import type { SyncClientService, syncClientInternals } from "./sync.js";
34
+
35
+ interface PendingMutation {
36
+ readonly identity: RecoveryIdentity;
37
+ readonly sessionEpoch: string;
38
+ readonly done: Deferred.Deferred<Result<unknown, ErrorValue>, ClientInfrastructureError>;
39
+ readonly prepared: Deferred.Deferred<
40
+ Extract<ManagedServerMessage, { type: "Preparation" }>["uploads"],
41
+ ClientInfrastructureError
42
+ >;
43
+ readonly preparation?: Extract<ManagedClientMessage, { type: "Prepare" }>;
44
+ }
45
+ const issueMessage = (issue: RecoveryJournalIssue): string => {
46
+ switch (issue) {
47
+ case "denied":
48
+ return "Browser storage is unavailable. Some previous mutation outcomes may be unknown.";
49
+ case "full":
50
+ return "Browser recovery storage is full. Some mutation outcomes could not be saved.";
51
+ case "corrupt":
52
+ return "Saved mutation information could not be read. Its outcomes remain unknown.";
53
+ case "expired":
54
+ return "Some saved mutation information expired. Its outcomes remain unknown.";
55
+ case "session-changed":
56
+ return "The browser session changed. Previous mutation outcomes cannot be retrieved in this session.";
57
+ }
58
+ };
59
+
60
+ /** The managed connection is mounted by bootstrap, independently of query observers. */
61
+ export class ManagedBrowserClient {
62
+ readonly upgrade: ManagedUpgrade;
63
+ private readonly queries;
64
+ private socket: WebSocket | undefined;
65
+ private acceptedSocket: WebSocket | undefined;
66
+ private session: BrowserSession["Service"] | undefined;
67
+ private ids: IdGenerator["Service"] | undefined;
68
+ private sessionEpoch: string | undefined;
69
+ private sessionViewRevision: number | undefined;
70
+ private unsubscribeSession: (() => void) | undefined;
71
+ private sessionDeadline = 0;
72
+ private clientSessionId: RecoverySessionId;
73
+ private journal: RecoveryJournal | undefined;
74
+ private readonly pending = new Map<InvocationId, PendingMutation>();
75
+ private readonly recovery = new Map<string, RecoveryIdentity>();
76
+ private readonly recoveryDeadlines = new Map<string, number>();
77
+ private readonly recoveryTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
78
+ private readonly recoveryTimers = new Set<ReturnType<typeof setTimeout>>();
79
+ private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
80
+ private handshakeTimer: ReturnType<typeof setTimeout> | undefined;
81
+ private sessionTimer: ReturnType<typeof setTimeout> | undefined;
82
+ private heartbeat: ReturnType<typeof setInterval> | undefined;
83
+ private reconnectDelay = 250;
84
+ private disposed = false;
85
+ private readonly readyWaiters = new Set<(ready: boolean) => void>();
86
+ private suspended = true;
87
+ private lastActivity = Date.now();
88
+ private validation: Promise<boolean> | undefined;
89
+ private connectionGeneration = 0;
90
+
91
+ constructor(
92
+ readonly selection: ExecutionSelection,
93
+ private readonly browser: Window,
94
+ private readonly helpers: Pick<typeof syncClientInternals, "makeQueryCache" | "resultFromWire">,
95
+ callbacks: { readonly stopAdmission: () => void; readonly reload: () => void },
96
+ ) {
97
+ this.upgrade = new ManagedUpgrade(selection, browser, {
98
+ stopAdmission: () => {
99
+ this.suspended = true;
100
+ for (const pending of this.pending.values()) {
101
+ if (pending.preparation !== undefined)
102
+ Deferred.doneUnsafe(
103
+ pending.prepared,
104
+ Effect.fail(
105
+ PlatformUpgrading.make({
106
+ message:
107
+ "The app is updating. This upload was interrupted before its mutation ran.",
108
+ }),
109
+ ),
110
+ );
111
+ }
112
+ callbacks.stopAdmission();
113
+ },
114
+ unresolved: () => this.pending.size,
115
+ persist: () => this.persistPending(),
116
+ reload: callbacks.reload,
117
+ freshSelection: this.freshSelection,
118
+ });
119
+ this.clientSessionId = this.upgrade.clientSessionId;
120
+ this.queries = helpers.makeQueryCache(
121
+ Effect.suspend(() =>
122
+ this.ids === undefined
123
+ ? Effect.die("The client identity service is not ready.")
124
+ : this.ids.generate(SubscriptionId),
125
+ ),
126
+ (message) =>
127
+ Effect.sync(() => {
128
+ if (this.canSend()) this.send(message);
129
+ }),
130
+ );
131
+ }
132
+ attachSession(session: BrowserSession["Service"], ids: IdGenerator["Service"]): void {
133
+ if (this.disposed) return;
134
+ this.unsubscribeSession?.();
135
+ this.session = session;
136
+ this.ids = ids;
137
+ this.unsubscribeSession = session.subscribe(() => {
138
+ const current = session.current();
139
+ if (
140
+ current === undefined ||
141
+ (this.sessionEpoch !== undefined &&
142
+ (current.sessionEpoch !== this.sessionEpoch ||
143
+ current.viewRevision !== this.sessionViewRevision))
144
+ ) {
145
+ this.queries.invalidatePrivate();
146
+ this.suspended = true;
147
+ this.acceptedSocket = undefined;
148
+ this.socket?.close();
149
+ }
150
+ });
151
+ }
152
+ whenReady(): Promise<boolean> {
153
+ if (this.upgrade.ready) return Promise.resolve(true);
154
+ if (this.upgrade.obsolete || this.disposed) return Promise.resolve(false);
155
+ return new Promise((resolve) => {
156
+ const finish = (ready: boolean) => {
157
+ unsubscribe();
158
+ this.readyWaiters.delete(finish);
159
+ resolve(ready);
160
+ };
161
+ const unsubscribe = this.upgrade.subscribe(() => {
162
+ if (this.upgrade.ready || this.upgrade.obsolete || this.disposed)
163
+ finish(this.upgrade.ready);
164
+ });
165
+ this.readyWaiters.add(finish);
166
+ });
167
+ }
168
+ retry = (): void => {
169
+ if (this.upgrade.obsolete) this.upgrade.retry();
170
+ else this.resume();
171
+ };
172
+ recoverLoad = async (): Promise<void> => {
173
+ await this.upgrade.check();
174
+ };
175
+ private freshSelection = async (): Promise<ExecutionSelection | undefined> => {
176
+ const response = await this.browser.fetch(managedSelectionPath, {
177
+ cache: "no-store",
178
+ credentials: "same-origin",
179
+ signal: AbortSignal.timeout(10_000),
180
+ });
181
+ if (!response.ok) throw new Error("The current app selection is unavailable.");
182
+ const current = Schema.decodeUnknownOption(ManagedSelectionResponse)(await response.json());
183
+ // A newer protocol may change selection's encoding. It can require reload without granting
184
+ // old code admission or depending on that code decoding the new protocol.
185
+ return Option.getOrUndefined(current)?.selection;
186
+ };
187
+ start(): void {
188
+ if (this.disposed) return;
189
+ this.browser.addEventListener("pageshow", this.resume);
190
+ this.browser.addEventListener("pagehide", this.hide);
191
+ this.browser.addEventListener("online", this.resume);
192
+ this.browser.addEventListener("offline", this.offline);
193
+ this.browser.addEventListener("focus", this.resume);
194
+ this.browser.document.addEventListener("visibilitychange", this.visibility);
195
+ this.heartbeat = setInterval(() => {
196
+ const now = Date.now();
197
+ if (now - this.lastActivity > 15_000) this.resume();
198
+ this.lastActivity = now;
199
+ }, 5_000);
200
+ this.connect();
201
+ }
202
+ private canSend(): boolean {
203
+ return (
204
+ !this.disposed &&
205
+ !this.suspended &&
206
+ !this.upgrade.obsolete &&
207
+ this.acceptedSocket !== undefined &&
208
+ this.acceptedSocket === this.socket &&
209
+ this.socket.readyState === 1 &&
210
+ Date.now() < this.sessionDeadline
211
+ );
212
+ }
213
+ private send(message: ManagedClientMessage): void {
214
+ if (this.socket?.readyState !== 1) return;
215
+ this.socket.send(Schema.encodeSync(Schema.fromJsonString(ManagedClientMessage))(message));
216
+ }
217
+ private connect(): void {
218
+ if (this.disposed || this.upgrade.obsolete) return;
219
+ this.suspended = true;
220
+ this.upgrade.connecting();
221
+ this.acceptedSocket = undefined;
222
+ this.recovery.clear();
223
+ for (const timer of this.recoveryTimers) clearTimeout(timer);
224
+ this.recoveryTimers.clear();
225
+ this.recoveryTimeouts.clear();
226
+ if (this.reconnectTimer !== undefined) clearTimeout(this.reconnectTimer);
227
+ if (this.handshakeTimer !== undefined) clearTimeout(this.handshakeTimer);
228
+ this.socket?.close();
229
+ this.socket = undefined;
230
+ const generation = ++this.connectionGeneration;
231
+ if (this.session === undefined) return;
232
+ void Effect.runPromise(this.session.bootstrap).then(
233
+ () => {
234
+ if (this.disposed || this.upgrade.obsolete || generation !== this.connectionGeneration)
235
+ return;
236
+ this.openSocket();
237
+ },
238
+ () => {
239
+ if (this.disposed || this.upgrade.obsolete || generation !== this.connectionGeneration)
240
+ return;
241
+ this.upgrade.unavailable("The browser session could not be verified. Reconnecting…");
242
+ this.reconnectTimer = setTimeout(() => this.connect(), 1_000);
243
+ },
244
+ );
245
+ }
246
+ private openSocket(): void {
247
+ const url = new URL(managedSyncPath, this.browser.location.href);
248
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
249
+ const socket = new WebSocket(url);
250
+ this.socket = socket;
251
+ socket.onopen = () => {
252
+ if (this.socket !== socket || this.disposed) return;
253
+ this.send({
254
+ type: "Connect",
255
+ protocolVersion: 1,
256
+ selection: this.selection,
257
+ clientSessionId: this.clientSessionId,
258
+ });
259
+ };
260
+ this.handshakeTimer = setTimeout(() => {
261
+ if (this.socket === socket && this.acceptedSocket !== socket) socket.close();
262
+ }, 10_000);
263
+ socket.onmessage = (event: MessageEvent<string>) => {
264
+ if (this.socket !== socket || this.disposed) return;
265
+ const stable = Schema.decodeUnknownOption(Schema.fromJsonString(ManagedUpgradeRequired))(
266
+ event.data,
267
+ );
268
+ if (Option.isSome(stable)) {
269
+ void this.upgrade.require(stable.value.selection);
270
+ return;
271
+ }
272
+ const decoded = Schema.decodeUnknownOption(Schema.fromJsonString(ManagedServerMessage))(
273
+ event.data,
274
+ );
275
+ if (Option.isNone(decoded)) {
276
+ void this.upgrade.require();
277
+ return;
278
+ }
279
+ this.message(socket, decoded.value);
280
+ };
281
+ socket.onerror = () => socket.close();
282
+ socket.onclose = (event) => {
283
+ if (this.socket !== socket || this.disposed) return;
284
+ if (event.code === 1012 || event.code === 1008) this.queries.invalidatePrivate();
285
+ this.acceptedSocket = undefined;
286
+ this.suspended = true;
287
+ if (this.handshakeTimer !== undefined) clearTimeout(this.handshakeTimer);
288
+ if (this.upgrade.obsolete) return;
289
+ this.upgrade.unavailable("The connection was lost. Checking for updates before resuming…");
290
+ this.reconnectTimer = setTimeout(() => this.connect(), this.reconnectDelay);
291
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, 5_000);
292
+ };
293
+ }
294
+ private message(socket: WebSocket, message: ManagedServerMessage): void {
295
+ if (message.type === "UpgradeRequired") {
296
+ void this.upgrade.require(message.selection);
297
+ return;
298
+ }
299
+ if (message.type === "Handshake") {
300
+ if (!sameExecutionSelection(this.selection, message.selection)) {
301
+ void this.upgrade.require(message.selection);
302
+ return;
303
+ }
304
+ if (this.upgrade.obsolete) return;
305
+ if (this.handshakeTimer !== undefined) clearTimeout(this.handshakeTimer);
306
+ const previousEpoch = this.sessionEpoch;
307
+ if (
308
+ previousEpoch !== undefined &&
309
+ (previousEpoch !== message.session.sessionEpoch ||
310
+ this.sessionViewRevision !== message.session.viewRevision)
311
+ )
312
+ this.queries.invalidatePrivate();
313
+ this.sessionEpoch = message.session.sessionEpoch;
314
+ this.sessionViewRevision = message.session.viewRevision;
315
+ this.sessionDeadline =
316
+ Date.now() + Math.max(0, message.session.validUntil - message.serverTime);
317
+ if (this.sessionTimer !== undefined) clearTimeout(this.sessionTimer);
318
+ this.sessionTimer = setTimeout(
319
+ () => {
320
+ this.session?.suspend();
321
+ this.queries.invalidatePrivate();
322
+ this.resume();
323
+ },
324
+ Math.min(2_147_483_647, Math.max(0, this.sessionDeadline - Date.now())),
325
+ );
326
+ this.session?.accept(message.session, message.serverTime);
327
+ this.journal = new RecoveryJournal({
328
+ appId: this.selection.appId,
329
+ sessionEpoch: message.session.sessionEpoch,
330
+ clientSessionId: this.clientSessionId,
331
+ storage: () => this.browser.sessionStorage,
332
+ });
333
+ const saved = this.journal.read();
334
+ for (const issue of saved.issues) this.upgrade.reportRecovery(issueMessage(issue));
335
+ if (saved.clientSessionId !== this.clientSessionId) {
336
+ this.clientSessionId = saved.clientSessionId;
337
+ this.connect();
338
+ return;
339
+ }
340
+ if (previousEpoch !== undefined && previousEpoch !== this.sessionEpoch) {
341
+ for (const [id, pending] of this.pending) {
342
+ Deferred.doneUnsafe(
343
+ pending.prepared,
344
+ Effect.fail(
345
+ ConnectionUnavailable.make({
346
+ message: "The session changed before file preparation completed.",
347
+ }),
348
+ ),
349
+ );
350
+ this.upgrade.reportRecovery(
351
+ `Mutation ${id} has an unknown outcome because the session changed.`,
352
+ );
353
+ Deferred.doneUnsafe(
354
+ pending.done,
355
+ Effect.fail(
356
+ ConnectionUnavailable.make({
357
+ message: "The session changed. This mutation may already have completed.",
358
+ }),
359
+ ),
360
+ );
361
+ }
362
+ this.pending.clear();
363
+ }
364
+ this.acceptedSocket = socket;
365
+ this.suspended = false;
366
+ this.reconnectDelay = 250;
367
+ this.upgrade.accepted();
368
+ for (const entry of this.queries.entries())
369
+ this.send({
370
+ type: "Subscribe",
371
+ id: entry.subscriptionId,
372
+ function: entry.function,
373
+ args: entry.args,
374
+ });
375
+ for (const entry of saved.entries) {
376
+ if (entry.state === "pending") {
377
+ if (!this.pending.has(entry.identity.invocationId)) this.recover(entry.identity);
378
+ } else {
379
+ this.upgrade.reportRecovery(`Mutation ${entry.identity.invocationId}: ${entry.state}.`);
380
+ this.journal.remove(entry.identity);
381
+ }
382
+ }
383
+ for (const pending of this.pending.values()) {
384
+ if (pending.preparation !== undefined) this.send(pending.preparation);
385
+ else this.recover(pending.identity);
386
+ }
387
+ this.journal.write([]);
388
+ return;
389
+ }
390
+ if (this.acceptedSocket !== socket) {
391
+ socket.close();
392
+ return;
393
+ }
394
+ switch (message.type) {
395
+ case "Preparation": {
396
+ if (!this.canSend()) return;
397
+ const pending = this.pending.get(message.invocationId);
398
+ if (pending?.preparation !== undefined)
399
+ Deferred.doneUnsafe(pending.prepared, Effect.succeed(message.uploads));
400
+ return;
401
+ }
402
+ case "QuerySnapshot": {
403
+ if (!this.canSend()) return;
404
+ if (!sameExecutionSelection(this.selection, message.selection)) {
405
+ void this.upgrade.require(message.selection);
406
+ return;
407
+ }
408
+ const result = Effect.runSync(
409
+ this.helpers
410
+ .resultFromWire(message.result.result, message.files)
411
+ .pipe(Effect.catchTags({ InvalidServerMessage: (error) => Effect.succeed(error) })),
412
+ );
413
+ this.queries.setResult(message.id, result, message.result.observedRevision);
414
+ return;
415
+ }
416
+ case "MutationOutcome": {
417
+ if (
418
+ !sameExecutionSelection(message.identity.originalSelection, this.selection) &&
419
+ !this.recovery.has(message.id)
420
+ )
421
+ return;
422
+ this.outcome(message.id, message.identity, message.outcome);
423
+ return;
424
+ }
425
+ case "Error": {
426
+ if (message.code === "PlatformUpgrading") {
427
+ void this.upgrade.require();
428
+ return;
429
+ }
430
+ if (message.code === "SessionExpired") {
431
+ this.suspended = true;
432
+ this.session?.suspend();
433
+ this.queries.invalidatePrivate();
434
+ socket.close();
435
+ return;
436
+ }
437
+ if (
438
+ message.code === "InvalidArguments" ||
439
+ message.code === "InvocationIdConflict" ||
440
+ message.code === "ResourceLimitExceeded"
441
+ ) {
442
+ for (const [invocationId, pending] of this.pending) {
443
+ if (invocationId !== message.id) continue;
444
+ const failure = Effect.fail(
445
+ ServerError.make({ code: message.code, message: message.message }),
446
+ );
447
+ Deferred.doneUnsafe(pending.prepared, failure);
448
+ Deferred.doneUnsafe(pending.done, failure);
449
+ this.pending.delete(invocationId);
450
+ this.journal?.remove(pending.identity);
451
+ return;
452
+ }
453
+ for (const query of this.queries.entries()) {
454
+ if (query.subscriptionId !== message.id) continue;
455
+ this.queries.setResult(
456
+ query.subscriptionId,
457
+ ServerError.make({ code: message.code, message: message.message }),
458
+ );
459
+ return;
460
+ }
461
+ return;
462
+ }
463
+ this.upgrade.unavailable(message.message);
464
+ socket.close();
465
+ return;
466
+ }
467
+ }
468
+ }
469
+ private recover(identity: RecoveryIdentity): void {
470
+ if (!this.canSend()) return;
471
+ const id = `recovery:${identity.originalSelection.dataHistoryId}:${identity.invocationId}`;
472
+ if (this.recovery.has(id)) return;
473
+ this.recovery.set(id, identity);
474
+ const deadline = this.recoveryDeadlines.get(id) ?? Date.now() + 30_000;
475
+ this.recoveryDeadlines.set(id, deadline);
476
+ if (Date.now() >= deadline) {
477
+ this.outcome(id, identity, { type: "Uncertain" });
478
+ return;
479
+ }
480
+ this.send({ type: "RecoverMutation", id, identity });
481
+ const timer = setTimeout(() => {
482
+ this.recoveryTimers.delete(timer);
483
+ this.recoveryTimeouts.delete(id);
484
+ if (!this.recovery.has(id)) return;
485
+ this.recovery.delete(id);
486
+ this.recover(identity);
487
+ }, 5_000);
488
+ this.recoveryTimers.add(timer);
489
+ this.recoveryTimeouts.set(id, timer);
490
+ }
491
+ private outcome(id: string, identity: RecoveryIdentity, outcome: RuntimeMutationOutcome): void {
492
+ const recovery = this.recovery.get(id);
493
+ const pending = this.pending.get(identity.invocationId);
494
+ if (recovery === undefined && pending === undefined) return;
495
+ const expected = recovery ?? pending!.identity;
496
+ if (
497
+ expected.invocationId !== identity.invocationId ||
498
+ !sameExecutionSelection(expected.originalSelection, identity.originalSelection)
499
+ )
500
+ return;
501
+ this.recovery.delete(id);
502
+ const timeout = this.recoveryTimeouts.get(id);
503
+ if (timeout !== undefined) {
504
+ clearTimeout(timeout);
505
+ this.recoveryTimers.delete(timeout);
506
+ this.recoveryTimeouts.delete(id);
507
+ }
508
+ if (outcome.type === "Pending") {
509
+ const timer = setTimeout(() => {
510
+ this.recoveryTimers.delete(timer);
511
+ this.recover(identity);
512
+ }, 1_000);
513
+ this.recoveryTimers.add(timer);
514
+ return;
515
+ }
516
+ const recoveryId = `recovery:${identity.originalSelection.dataHistoryId}:${identity.invocationId}`;
517
+ this.recovery.delete(recoveryId);
518
+ this.recoveryDeadlines.delete(recoveryId);
519
+ if (pending !== undefined) {
520
+ this.pending.delete(identity.invocationId);
521
+ if (outcome.type === "Settled")
522
+ Deferred.doneUnsafe(pending.done, this.helpers.resultFromWire(outcome.result.result));
523
+ else
524
+ Deferred.doneUnsafe(
525
+ pending.done,
526
+ Effect.fail(
527
+ ConnectionUnavailable.make({
528
+ message:
529
+ outcome.type === "Interrupted"
530
+ ? "The mutation was interrupted before it committed."
531
+ : "The mutation outcome is unknown. Check the app before trying it again.",
532
+ }),
533
+ ),
534
+ );
535
+ }
536
+ const state =
537
+ outcome.type === "Settled"
538
+ ? "settled"
539
+ : outcome.type === "Interrupted"
540
+ ? "interrupted"
541
+ : "uncertain";
542
+ if (recovery !== undefined || outcome.type !== "Settled") {
543
+ const detail =
544
+ outcome.type === "Settled"
545
+ ? outcome.result.result.type === "Success"
546
+ ? "completed"
547
+ : "completed with an error"
548
+ : state;
549
+ this.upgrade.reportRecovery(`Mutation ${identity.invocationId}: ${detail}.`);
550
+ }
551
+ for (const issue of this.journal?.reconcile(identity, state).issues ?? [])
552
+ this.upgrade.reportRecovery(issueMessage(issue));
553
+ for (const issue of this.journal?.remove(identity).issues ?? [])
554
+ this.upgrade.reportRecovery(issueMessage(issue));
555
+ }
556
+ private persistPending(): readonly string[] {
557
+ if (this.pending.size === 0) return [];
558
+ if (this.journal === undefined)
559
+ return ["Pending mutation outcomes could not be saved and remain unknown."];
560
+ return this.journal
561
+ .write([...this.pending.values()].map((entry) => entry.identity))
562
+ .issues.map(issueMessage);
563
+ }
564
+ readonly observe: SyncClientService["observe"] = Effect.fn("ManagedBrowserClient.observe")(
565
+ (address, args) => this.queries.observe(address, args),
566
+ );
567
+ readonly mutate: SyncClientService["mutate"] = Effect.fn("ManagedBrowserClient.mutate")(
568
+ function* (this: ManagedBrowserClient, address, args) {
569
+ if (this.upgrade.obsolete)
570
+ return yield* PlatformUpgrading.make({
571
+ message: "The platform is updating. Try again after the app reloads.",
572
+ });
573
+ const valid = yield* Effect.promise(() => this.validate());
574
+ if (this.upgrade.obsolete)
575
+ return yield* PlatformUpgrading.make({
576
+ message: "The platform is updating. Try again after the app reloads.",
577
+ });
578
+ if (!valid || !this.canSend() || this.sessionEpoch === undefined)
579
+ return yield* ConnectionUnavailable.make({
580
+ message: "The app must reconnect and verify its selection before accepting a mutation.",
581
+ });
582
+ if (this.pending.size >= RecoveryJournalLimits.maxEntries)
583
+ return yield* ConnectionUnavailable.make({
584
+ message:
585
+ "Too many mutations are waiting for outcomes. Wait for them to settle before trying again.",
586
+ });
587
+ const ids = this.ids;
588
+ if (ids === undefined)
589
+ return yield* ConnectionUnavailable.make({ message: "The client is not ready." });
590
+ const prepared = yield* prepareMutationArguments(args).pipe(
591
+ Effect.provideService(IdGenerator, ids),
592
+ Effect.mapError((cause) =>
593
+ InvalidMutationArguments.make({
594
+ cause,
595
+ function: address,
596
+ message: "The mutation arguments cannot be sent.",
597
+ }),
598
+ ),
599
+ );
600
+ const encoded = yield* Effect.try({
601
+ try: () => encodeTransportObject(prepared.value),
602
+ catch: (cause) =>
603
+ InvalidMutationArguments.make({
604
+ cause,
605
+ function: address,
606
+ message: "The mutation arguments cannot be sent.",
607
+ }),
608
+ });
609
+ if (!this.canSend())
610
+ return yield* PlatformUpgrading.make({
611
+ message: "The app is reconnecting or updating. Try again when it is ready.",
612
+ });
613
+ const invocationId = yield* ids.generate(InvocationId);
614
+ const done = yield* Deferred.make<Result<unknown, ErrorValue>, ClientInfrastructureError>();
615
+ const identity: RecoveryIdentity = { invocationId, originalSelection: this.selection };
616
+ const uploadsReady = yield* Deferred.make<
617
+ Extract<ManagedServerMessage, { type: "Preparation" }>["uploads"],
618
+ ClientInfrastructureError
619
+ >();
620
+ const preparation =
621
+ prepared.fileIds.length === 0
622
+ ? undefined
623
+ : {
624
+ type: "Prepare" as const,
625
+ invocationId,
626
+ function: address,
627
+ args: encoded,
628
+ files: prepared.fileIds,
629
+ };
630
+ this.pending.set(invocationId, {
631
+ identity,
632
+ sessionEpoch: this.sessionEpoch,
633
+ done,
634
+ prepared: uploadsReady,
635
+ preparation,
636
+ });
637
+ for (const issue of this.persistPending()) this.upgrade.reportRecovery(issue);
638
+ if (preparation !== undefined) {
639
+ this.send(preparation);
640
+ yield* Effect.gen({ self: this }, function* () {
641
+ const uploads = yield* Deferred.await(uploadsReady);
642
+ yield* uploadPreparedFiles(prepared.nativeFiles, uploads, address);
643
+ if (!this.canSend())
644
+ return yield* PlatformUpgrading.make({
645
+ message: "The app is reconnecting or updating. Try again when it is ready.",
646
+ });
647
+ }).pipe(
648
+ Effect.tapError(() =>
649
+ Effect.sync(() => {
650
+ this.pending.delete(invocationId);
651
+ this.journal?.remove(identity);
652
+ }),
653
+ ),
654
+ );
655
+ const pending = this.pending.get(invocationId);
656
+ if (pending === undefined) return yield* Deferred.await(done);
657
+ this.pending.set(invocationId, { ...pending, preparation: undefined });
658
+ }
659
+ this.send({ type: "Invoke", invocationId, function: address, args: encoded });
660
+ const timer = setTimeout(() => {
661
+ this.recoveryTimers.delete(timer);
662
+ if (this.pending.has(invocationId)) this.recover(identity);
663
+ }, 5_000);
664
+ this.recoveryTimers.add(timer);
665
+ return yield* Deferred.await(done);
666
+ },
667
+ ).bind(this);
668
+ validate = (): Promise<boolean> => {
669
+ if (this.upgrade.obsolete || this.disposed) return Promise.resolve(false);
670
+ if (this.validation !== undefined) return this.validation;
671
+ this.validation = this.upgrade
672
+ .check()
673
+ .then((current) => {
674
+ const ready = current && this.canSend();
675
+ if (ready && this.upgrade.getSnapshot().phase !== "ready") this.upgrade.accepted();
676
+ return ready;
677
+ })
678
+ .finally(() => {
679
+ this.validation = undefined;
680
+ });
681
+ return this.validation;
682
+ };
683
+ private resume = (): void => {
684
+ if (this.disposed || this.upgrade.obsolete) return;
685
+ this.suspended = true;
686
+ this.acceptedSocket = undefined;
687
+ this.recovery.clear();
688
+ this.queries.invalidate(undefined);
689
+ this.connect();
690
+ };
691
+ private offline = (): void => {
692
+ this.suspended = true;
693
+ this.acceptedSocket = undefined;
694
+ this.upgrade.unavailable(
695
+ "The app is offline. Reconnect to check for updates and mutation outcomes.",
696
+ );
697
+ this.socket?.close();
698
+ };
699
+ private hide = (): void => {
700
+ for (const issue of this.persistPending()) this.upgrade.reportRecovery(issue);
701
+ this.suspended = true;
702
+ this.acceptedSocket = undefined;
703
+ this.socket?.close();
704
+ };
705
+ private visibility = (): void => {
706
+ if (this.browser.document.visibilityState === "visible") this.resume();
707
+ else this.hide();
708
+ };
709
+ dispose(): void {
710
+ if (this.disposed) return;
711
+ this.disposed = true;
712
+ for (const finish of this.readyWaiters) finish(false);
713
+ this.unsubscribeSession?.();
714
+ this.persistPending();
715
+ this.socket?.close();
716
+ if (this.reconnectTimer !== undefined) clearTimeout(this.reconnectTimer);
717
+ if (this.handshakeTimer !== undefined) clearTimeout(this.handshakeTimer);
718
+ if (this.sessionTimer !== undefined) clearTimeout(this.sessionTimer);
719
+ if (this.heartbeat !== undefined) clearInterval(this.heartbeat);
720
+ for (const timer of this.recoveryTimers) clearTimeout(timer);
721
+ this.browser.removeEventListener("pageshow", this.resume);
722
+ this.browser.removeEventListener("pagehide", this.hide);
723
+ this.browser.removeEventListener("online", this.resume);
724
+ this.browser.removeEventListener("offline", this.offline);
725
+ this.browser.removeEventListener("focus", this.resume);
726
+ this.browser.document.removeEventListener("visibilitychange", this.visibility);
727
+ this.upgrade.dispose();
728
+ }
729
+ }