ignotum 0.0.11 → 0.0.13

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 (61) hide show
  1. package/README.md +8 -3
  2. package/dist/cli/bin.mjs +1372 -492
  3. package/dist/cli/bin.mjs.map +1 -1
  4. package/dist/runtime/{api-DzcR7spt.js → api-CAgKDij7.js} +38 -2
  5. package/dist/runtime/api-CAgKDij7.js.map +1 -0
  6. package/dist/runtime/{api-DR_8vfKg.d.ts → api-Cv3hMbzo.d.ts} +4 -3
  7. package/dist/runtime/client.d.ts +39 -8
  8. package/dist/runtime/client.js +505 -131
  9. package/dist/runtime/client.js.map +1 -1
  10. package/dist/runtime/descriptor-Cyo9FP9n-C0SRVXNW.js +154 -0
  11. package/dist/runtime/descriptor-Cyo9FP9n-C0SRVXNW.js.map +1 -0
  12. package/dist/runtime/id-Bt9XWRGL.js +423 -0
  13. package/dist/runtime/id-Bt9XWRGL.js.map +1 -0
  14. package/dist/runtime/{id-Cs82tq9Q-hmYFyqTa.d.ts → id-BzFHf3Wo-DGPCjgrf.d.ts} +13 -13
  15. package/dist/runtime/id-Dz0apuB3.d.ts +1 -0
  16. package/dist/runtime/{index-2q9FwJud.d.ts → index-D54flWtH.d.ts} +136 -76
  17. package/dist/runtime/internal/api.d.ts +1 -1
  18. package/dist/runtime/internal/api.js +1 -1
  19. package/dist/runtime/internal/host.d.ts +21 -17
  20. package/dist/runtime/internal/host.js +22 -8
  21. package/dist/runtime/internal/host.js.map +1 -1
  22. package/dist/runtime/internal/server.d.ts +1 -1
  23. package/dist/runtime/internal/server.js +1 -1
  24. package/dist/runtime/internal/types.d.ts +1 -1
  25. package/dist/runtime/internal/types.js +1 -1
  26. package/dist/runtime/{pagination-Bt3l7QaC-BYEGmLBE.d.ts → pagination-DnKg3dkI-r5ZUxBBx.d.ts} +34 -6
  27. package/dist/runtime/pagination-Dz0apuB3.d.ts +1 -0
  28. package/dist/runtime/result-DKAA4gpS.d.ts +1 -0
  29. package/dist/runtime/{schema-ERFjT8-m.js → schema-1Zs03-iS.js} +135 -17
  30. package/dist/runtime/schema-1Zs03-iS.js.map +1 -0
  31. package/dist/runtime/server.d.ts +9 -3
  32. package/dist/runtime/server.js +5 -3
  33. package/dist/runtime/server.js.map +1 -1
  34. package/dist/runtime/{sync-a2EdGSdY.d.ts → sync-Bs8J3fIr.d.ts} +2 -2
  35. package/package.json +4 -4
  36. package/src/cli/agent-files.ts +8 -0
  37. package/src/cli/auth-client.ts +1 -1
  38. package/src/cli/bin.ts +13 -3
  39. package/src/cli/build/server.ts +102 -13
  40. package/src/cli/codegen.ts +7 -4
  41. package/src/cli/control-client.ts +30 -8
  42. package/src/cli/deploy.ts +7 -2
  43. package/src/cli/environment.ts +133 -0
  44. package/src/client/files.ts +43 -27
  45. package/src/client/hooks.ts +94 -17
  46. package/src/client/id.ts +259 -0
  47. package/src/client/index.ts +5 -2
  48. package/src/client/page-observers.ts +77 -0
  49. package/src/client/sync.ts +214 -69
  50. package/src/dev-runtime/functions.ts +232 -86
  51. package/src/dev-runtime/id.ts +174 -2
  52. package/src/dev-runtime/query-cache.ts +105 -0
  53. package/src/dev-runtime/sync.ts +187 -26
  54. package/src/server/index.ts +13 -1
  55. package/dist/runtime/api-DzcR7spt.js.map +0 -1
  56. package/dist/runtime/descriptor-C5VA9qRl-DuQsowaQ.js +0 -311
  57. package/dist/runtime/descriptor-C5VA9qRl-DuQsowaQ.js.map +0 -1
  58. package/dist/runtime/file-C1abuMgd.js +0 -173
  59. package/dist/runtime/file-C1abuMgd.js.map +0 -1
  60. package/dist/runtime/pagination-CFJ3xlAt.d.ts +0 -1
  61. package/dist/runtime/schema-ERFjT8-m.js.map +0 -1
@@ -16,7 +16,7 @@ export type {
16
16
  FileValue,
17
17
  } from "@ignotum/contracts/schema/file";
18
18
  export { Files } from "./files.js";
19
- export { useMutation, usePaginatedQuery, useQuery } from "./hooks.js";
19
+ export { useId, useMutation, usePaginatedQuery, useQuery } from "./hooks.js";
20
20
  export type { PaginatedQueryOptions, PaginatedQueryValue, PaginationStatus } from "./hooks.js";
21
21
  export { Query } from "./query.js";
22
22
  export type { QuerySkip } from "./query.js";
@@ -89,7 +89,7 @@ export {
89
89
  useDebugValue,
90
90
  useEffect,
91
91
  useErrorBoundary,
92
- useId,
92
+ useId as useElementId,
93
93
  useImperativeHandle,
94
94
  useLayoutEffect,
95
95
  useMemo,
@@ -98,3 +98,6 @@ export {
98
98
  useState,
99
99
  } from "preact/hooks";
100
100
  export type { Dispatch, Reducer, StateUpdater } from "preact/hooks";
101
+
102
+ export type { User, UserId, ProfileField } from "@ignotum/contracts/id";
103
+ export type { IdResult, IdOptions } from "./hooks.js";
@@ -0,0 +1,77 @@
1
+ import { Effect, Schema } from "effect";
2
+
3
+ import type { FunctionAddress } from "@ignotum/contracts/runtime/sync";
4
+ import { SyncClient, syncClientInternals } from "./sync.js";
5
+ import type { ClientInfrastructureError } from "./errors.js";
6
+
7
+ type QueryObserver = Effect.Success<ReturnType<SyncClient["Service"]["observe"]>>;
8
+
9
+ interface PageEntry {
10
+ readonly acquire: Effect.Effect<QueryObserver | undefined, ClientInfrastructureError, SyncClient>;
11
+ release: (() => void) | undefined;
12
+ released: boolean;
13
+ }
14
+
15
+ // @effect-diagnostics-next-line missingPipeableSignature:off This hook-owned lifecycle takes two callbacks, not a data argument.
16
+ export const makePageObservers = (
17
+ onChange: () => void,
18
+ releaseObserver: (release: Effect.Effect<void>) => void,
19
+ ) => {
20
+ const pages = new Map<string, PageEntry>();
21
+ let disposed = false;
22
+
23
+ const releaseEntry = (entry: PageEntry) => {
24
+ entry.released = true;
25
+ entry.release?.();
26
+ };
27
+
28
+ const observe = Effect.fn("PageObservers.observe")(function* (
29
+ functionAddress: FunctionAddress,
30
+ args: Schema.Json,
31
+ ) {
32
+ if (disposed) return;
33
+ const key = syncClientInternals.queryIdentity(functionAddress, args);
34
+ const existing = pages.get(key);
35
+ if (existing !== undefined) return yield* existing.acquire;
36
+
37
+ const entry: PageEntry = {
38
+ acquire: yield* Effect.cached(
39
+ Effect.gen(function* () {
40
+ const client = yield* SyncClient;
41
+ const observer = yield* client.observe(functionAddress, args);
42
+ if (entry.released) {
43
+ releaseObserver(observer.release);
44
+ return;
45
+ }
46
+ const unsubscribe = observer.subscribe(onChange);
47
+ entry.release = () => {
48
+ unsubscribe();
49
+ releaseObserver(observer.release);
50
+ };
51
+ return observer;
52
+ }),
53
+ ),
54
+ release: undefined,
55
+ released: false,
56
+ };
57
+ pages.set(key, entry);
58
+ return yield* entry.acquire;
59
+ });
60
+
61
+ return {
62
+ observe,
63
+ retain: (keys: ReadonlySet<string>) => {
64
+ for (const [key, entry] of pages) {
65
+ if (keys.has(key)) continue;
66
+ pages.delete(key);
67
+ releaseEntry(entry);
68
+ }
69
+ },
70
+ release: () => {
71
+ if (disposed) return;
72
+ disposed = true;
73
+ for (const entry of pages.values()) releaseEntry(entry);
74
+ pages.clear();
75
+ },
76
+ };
77
+ };
@@ -1,3 +1,4 @@
1
+ import { BrowserSession } from "./id.js";
1
2
  import {
2
3
  Context,
3
4
  Deferred,
@@ -7,6 +8,7 @@ import {
7
8
  HashMap,
8
9
  Layer,
9
10
  ManagedRuntime,
11
+ Option,
10
12
  Schedule,
11
13
  Schema,
12
14
  } from "effect";
@@ -59,6 +61,7 @@ interface QueryEntry {
59
61
  readonly listeners: Set<Listener>;
60
62
  readonly subscriptionId: SubscriptionId;
61
63
  generation: number;
64
+ dependsOnId?: boolean;
62
65
  revision: AppStateRevision | undefined;
63
66
  references: number;
64
67
  result: QueryResult<unknown, ErrorValue> | ClientInfrastructureError;
@@ -73,13 +76,14 @@ interface QueryObserver {
73
76
  type QueryLifecycleMessage = Extract<ClientMessage, { readonly type: "Subscribe" | "Unsubscribe" }>;
74
77
 
75
78
  interface QueryCache {
79
+ readonly invalidate: (ids?: ReadonlyArray<SubscriptionId>) => void;
80
+ readonly invalidatePrivate: () => void;
76
81
  readonly entries: () => Iterable<QueryEntry>;
77
82
  readonly getById: (id: SubscriptionId) => QueryEntry | undefined;
78
- readonly setError: (id: SubscriptionId, error: ClientInfrastructureError) => void;
79
83
  readonly setResult: (
80
84
  id: SubscriptionId,
81
- result: QueryResult<unknown, ErrorValue>,
82
- revision: AppStateRevision,
85
+ result: QueryResult<unknown, ErrorValue> | ClientInfrastructureError,
86
+ revision?: AppStateRevision,
83
87
  ) => void;
84
88
  readonly observe: (
85
89
  functionAddress: FunctionAddress,
@@ -102,7 +106,7 @@ const makeQueryCache = (
102
106
  sendLifecycleMessage: (message: QueryLifecycleMessage) => Effect.Effect<void>,
103
107
  ) => {
104
108
  const queries = new Map<string, QueryEntry>();
105
- const queryKeysById = new Map<SubscriptionId, string>();
109
+ const queriesById = new Map<SubscriptionId, QueryEntry>();
106
110
 
107
111
  const observe = Effect.fn("SyncClient.QueryCache.observe")(function* (
108
112
  functionAddress: FunctionAddress,
@@ -123,7 +127,7 @@ const makeQueryCache = (
123
127
  subscriptionId: yield* allocateId,
124
128
  };
125
129
  queries.set(key, created);
126
- queryKeysById.set(created.subscriptionId, key);
130
+ queriesById.set(created.subscriptionId, created);
127
131
  yield* sendLifecycleMessage(subscribeMessage(created));
128
132
  entry = created;
129
133
  }
@@ -157,7 +161,7 @@ const makeQueryCache = (
157
161
  }
158
162
 
159
163
  queries.delete(key);
160
- queryKeysById.delete(observed.subscriptionId);
164
+ queriesById.delete(observed.subscriptionId);
161
165
  yield* sendLifecycleMessage({
162
166
  type: "Unsubscribe",
163
167
  id: observed.subscriptionId,
@@ -166,30 +170,29 @@ const makeQueryCache = (
166
170
  } satisfies QueryObserver;
167
171
  });
168
172
 
173
+ const clear = (entry: QueryEntry) => {
174
+ entry.result = pending();
175
+ entry.revision = undefined;
176
+ for (const listener of entry.listeners) listener();
177
+ };
169
178
  return {
170
- entries: () => queries.values(),
171
- getById: (id: SubscriptionId) => {
172
- const key = queryKeysById.get(id);
173
- return key === undefined ? undefined : queries.get(key);
179
+ invalidate: (ids) => {
180
+ for (const entry of queries.values())
181
+ if (ids === undefined || ids.includes(entry.subscriptionId)) clear(entry);
174
182
  },
175
- observe,
176
- setError: (id, error) => {
177
- const key = queryKeysById.get(id);
178
- const entry = key === undefined ? undefined : queries.get(key);
179
- if (entry === undefined) return;
180
- entry.result = error;
181
- for (const listener of entry.listeners) {
182
- listener();
183
- }
183
+ invalidatePrivate: () => {
184
+ for (const entry of queries.values()) if (entry.dependsOnId !== false) clear(entry);
184
185
  },
185
- setResult: (id, result, revision) => {
186
- const key = queryKeysById.get(id);
187
- const entry = key === undefined ? undefined : queries.get(key);
186
+ entries: () => queries.values(),
187
+ getById: (id: SubscriptionId) => queriesById.get(id),
188
+ observe,
189
+ setResult: (id, result, revision?: AppStateRevision) => {
190
+ const entry = queriesById.get(id);
188
191
  if (entry === undefined) return;
189
- if (entry.revision !== undefined && revision < entry.revision) {
190
- return;
192
+ if (revision !== undefined) {
193
+ if (entry.revision !== undefined && revision < entry.revision) return;
194
+ entry.revision = revision;
191
195
  }
192
- entry.revision = revision;
193
196
  entry.result = result;
194
197
  for (const listener of entry.listeners) {
195
198
  listener();
@@ -202,15 +205,21 @@ type SnapshotFiles = NonNullable<Extract<ServerMessage, { readonly type: "Snapsh
202
205
 
203
206
  const resultFromWire = Effect.fn("SyncClient.resultFromWire")(
204
207
  (wire: WireResult, files: SnapshotFiles = []) =>
205
- Effect.succeed(
206
- wire.type === "Success"
207
- ? Result.succeed(
208
- wire.value === undefined
209
- ? undefined
210
- : applyFileGrants(decodeTransportValue(wire.value, wire.dates ?? []), files),
211
- )
212
- : failureFromWire(wire.error, wire.dates),
213
- ),
208
+ Effect.try({
209
+ try: () =>
210
+ wire.type === "Success"
211
+ ? Result.succeed(
212
+ wire.value === undefined
213
+ ? undefined
214
+ : applyFileGrants(decodeTransportValue(wire.value, wire.dates ?? []), files),
215
+ )
216
+ : failureFromWire(wire.error, wire.dates),
217
+ catch: (cause) =>
218
+ InvalidServerMessage.make({
219
+ cause,
220
+ message: "The server returned an invalid function result.",
221
+ }),
222
+ }),
214
223
  );
215
224
 
216
225
  export const shouldReloadForCloseCode = (code: number): boolean => code === DeploymentCloseCode;
@@ -259,6 +268,7 @@ interface SyncClientService {
259
268
  }
260
269
 
261
270
  interface PendingInvocation {
271
+ readonly sessionEpoch: string;
262
272
  readonly deferred: Deferred.Deferred<
263
273
  IgnotumResult<unknown, ErrorValue>,
264
274
  ClientInfrastructureError
@@ -278,10 +288,12 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
278
288
  SyncClient,
279
289
  Effect.gen(function* () {
280
290
  const ids = yield* IdGenerator;
291
+ const session = yield* BrowserSession;
281
292
  let invocations = HashMap.empty<InvocationId, PendingInvocation>();
282
293
  let rememberedHandshake: SyncHandshake | undefined;
283
294
  let reloadRequested = false;
284
295
  let writer: SocketWriter | undefined;
296
+ let sessionSuspended: Deferred.Deferred<void> | undefined;
285
297
 
286
298
  const encode = (message: ClientMessage) =>
287
299
  Schema.encodeEffect(ClientMessageJson)(message).pipe(
@@ -324,8 +336,17 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
324
336
  ),
325
337
  );
326
338
 
339
+ const unsubscribeSession = session.subscribe(() => {
340
+ if (session.current() === undefined) {
341
+ queryCache.invalidatePrivate();
342
+ writer = undefined;
343
+ if (sessionSuspended !== undefined) Deferred.doneUnsafe(sessionSuspended, Effect.void);
344
+ }
345
+ });
346
+ yield* Effect.addFinalizer(() => Effect.sync(unsubscribeSession));
347
+
327
348
  const rejectInvocation = (id: InvocationId, error: ClientInfrastructureError) => {
328
- const pendingInvocation = HashMap.getUnsafe(invocations, id);
349
+ const pendingInvocation = Option.getOrUndefined(HashMap.get(invocations, id));
329
350
  if (pendingInvocation === undefined) {
330
351
  return Effect.void;
331
352
  }
@@ -341,33 +362,91 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
341
362
  message: Exclude<ServerMessage, SyncHandshake | { readonly type: "Deployment" }>,
342
363
  ) {
343
364
  switch (message.type) {
365
+ case "Session": {
366
+ if (message.event === "Updated") {
367
+ const previous = session.current();
368
+ if (previous !== undefined && previous.sessionEpoch !== message.session.sessionEpoch)
369
+ return;
370
+ if (previous?.viewRevision !== message.session.viewRevision)
371
+ queryCache.invalidatePrivate();
372
+ session.accept(message.session, message.serverTime);
373
+ } else {
374
+ if (rememberedHandshake?.session?.sessionEpoch !== message.sessionEpoch) return;
375
+ session.suspend(
376
+ message.event === "Unavailable"
377
+ ? "Your ID session is temporarily unavailable."
378
+ : undefined,
379
+ );
380
+ if (message.event !== "Unavailable") {
381
+ yield* Effect.forEach(HashMap.keys(invocations), (id) =>
382
+ rejectInvocation(
383
+ id,
384
+ ServerError.make({
385
+ code: "SessionChanged",
386
+ message: "The session changed. This operation may already have completed.",
387
+ }),
388
+ ),
389
+ );
390
+ }
391
+ }
392
+ return;
393
+ }
394
+ case "Invalidate": {
395
+ if (message.sessionEpoch === session.current()?.sessionEpoch)
396
+ queryCache.invalidate(message.ids);
397
+ return;
398
+ }
344
399
  case "Snapshot": {
400
+ const current = session.current();
401
+ if (current === undefined || message.identity?.sessionEpoch !== current.sessionEpoch)
402
+ return;
403
+ if (
404
+ message.identity.dependsOnId &&
405
+ message.identity.viewRevision !== current.viewRevision
406
+ )
407
+ return;
345
408
  const entry = queryCache.getById(message.id);
346
409
  if (entry === undefined) return;
410
+ entry.dependsOnId = message.identity.dependsOnId;
411
+ if (entry.revision !== undefined && message.revision < entry.revision) return;
347
412
  yield* resultFromWire(message.result, message.files ?? []).pipe(
348
413
  Effect.tap((result) =>
349
414
  Effect.sync(() => queryCache.setResult(message.id, result, message.revision)),
350
415
  ),
416
+ Effect.catchTags({
417
+ InvalidServerMessage: (error) => {
418
+ queryCache.setResult(message.id, error, message.revision);
419
+ return reportClientError(error);
420
+ },
421
+ }),
351
422
  );
352
423
  return;
353
424
  }
354
425
  case "Preparation": {
355
- const pendingInvocation = HashMap.getUnsafe(invocations, message.id);
426
+ if (message.sessionEpoch !== session.current()?.sessionEpoch) return;
427
+ const pendingInvocation = Option.getOrUndefined(HashMap.get(invocations, message.id));
356
428
  if (pendingInvocation !== undefined) {
357
429
  yield* Deferred.succeed(pendingInvocation.prepared, message.uploads);
358
430
  }
359
431
  return;
360
432
  }
361
433
  case "Result": {
362
- const pendingInvocation = HashMap.getUnsafe(invocations, message.id);
434
+ if (message.sessionEpoch !== session.current()?.sessionEpoch) return;
435
+ const pendingInvocation = Option.getOrUndefined(HashMap.get(invocations, message.id));
363
436
  if (pendingInvocation === undefined) return;
364
- invocations = HashMap.remove(invocations, message.id);
365
437
  yield* resultFromWire(message.result).pipe(
366
- Effect.flatMap((result) => Deferred.succeed(pendingInvocation.deferred, result)),
438
+ Effect.flatMap((result) => {
439
+ invocations = HashMap.remove(invocations, message.id);
440
+ return Deferred.succeed(pendingInvocation.deferred, result);
441
+ }),
442
+ Effect.catchTags({
443
+ InvalidServerMessage: (error) => rejectInvocation(message.id, error),
444
+ }),
367
445
  );
368
446
  return;
369
447
  }
370
448
  case "Error": {
449
+ if (message.sessionEpoch !== session.current()?.sessionEpoch) return;
371
450
  const error =
372
451
  message.operation === undefined
373
452
  ? ServerError.make({
@@ -382,7 +461,7 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
382
461
  if (message.operation?.type === "Invocation") {
383
462
  yield* rejectInvocation(message.operation.id, error);
384
463
  } else if (message.operation?.type === "Subscription") {
385
- queryCache.setError(message.operation.id, error);
464
+ queryCache.setResult(message.operation.id, error);
386
465
  yield* reportClientError(error);
387
466
  } else {
388
467
  yield* reportClientError(error);
@@ -406,12 +485,18 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
406
485
  const connect = Effect.scoped(
407
486
  Effect.gen(function* () {
408
487
  if (reloadRequested) return yield* Effect.never;
409
- const socket = yield* Socket.makeWebSocket(socketUrl(), {
410
- closeCodeIsError: (code) => {
411
- if (shouldReloadForCloseCode(code)) requestReload();
412
- return false;
488
+ yield* session.bootstrap.pipe(
489
+ Effect.mapError((error) => ConnectionUnavailable.make({ message: error.message })),
490
+ );
491
+ const socket = yield* Socket.makeWebSocket(
492
+ `${socketUrl()}${session.socketParameters()}`,
493
+ {
494
+ closeCodeIsError: (code) => {
495
+ if (shouldReloadForCloseCode(code)) requestReload();
496
+ return false;
497
+ },
413
498
  },
414
- });
499
+ );
415
500
  const write = yield* socket.writer;
416
501
  const handshakeReceived = yield* Deferred.make<void>();
417
502
  let handshakeAccepted = false;
@@ -424,6 +509,41 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
424
509
  return;
425
510
  }
426
511
  if (decision === "Ignore") return;
512
+ if (handshake.session === undefined || handshake.serverTime === undefined) {
513
+ requestReload();
514
+ return;
515
+ }
516
+ if (
517
+ rememberedHandshake?.session !== undefined &&
518
+ rememberedHandshake.session.sessionEpoch !== handshake.session.sessionEpoch
519
+ ) {
520
+ queryCache.invalidatePrivate();
521
+ yield* Effect.forEach(HashMap.keys(invocations), (id) =>
522
+ rejectInvocation(
523
+ id,
524
+ ServerError.make({
525
+ code: "SessionChanged",
526
+ message: "The session changed. This operation may already have completed.",
527
+ }),
528
+ ),
529
+ );
530
+ } else if (
531
+ rememberedHandshake?.session?.viewRevision !== handshake.session.viewRevision
532
+ ) {
533
+ queryCache.invalidatePrivate();
534
+ }
535
+ yield* Effect.forEach(HashMap.entries(invocations), ([id, pending]) =>
536
+ pending.sessionEpoch === handshake.session!.sessionEpoch
537
+ ? Effect.void
538
+ : rejectInvocation(
539
+ id,
540
+ ServerError.make({
541
+ code: "SessionChanged",
542
+ message: "The session changed. This operation may already have completed.",
543
+ }),
544
+ ),
545
+ );
546
+ session.accept(handshake.session, handshake.serverTime);
427
547
  rememberedHandshake = handshake;
428
548
  handshakeAccepted = true;
429
549
  writer = write;
@@ -461,12 +581,19 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
461
581
  return yield* handleMessage(message);
462
582
  });
463
583
 
584
+ const suspended = yield* Deferred.make<void>();
585
+ sessionSuspended = suspended;
586
+ yield* Effect.addFinalizer(() =>
587
+ Effect.sync(() => {
588
+ if (sessionSuspended === suspended) sessionSuspended = undefined;
589
+ }),
590
+ );
464
591
  const run = yield* socket.runString(handleText).pipe(Effect.forkChild);
465
592
  yield* Effect.raceFirst(
466
593
  Deferred.await(handshakeReceived).pipe(Effect.timeout("10 seconds")),
467
594
  Fiber.join(run),
468
595
  );
469
- yield* Fiber.join(run);
596
+ yield* Effect.raceFirst(Fiber.join(run), Deferred.await(suspended));
470
597
  }),
471
598
  ).pipe(
472
599
  Effect.mapError((cause) =>
@@ -507,6 +634,12 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
507
634
 
508
635
  const mutate: SyncClientService["mutate"] = Effect.fn("SyncClient.mutate")(
509
636
  function* (functionAddress, args) {
637
+ const admittedSession =
638
+ session.current() ??
639
+ rememberedHandshake?.session ??
640
+ (yield* session.bootstrap.pipe(
641
+ Effect.mapError((error) => ConnectionUnavailable.make({ message: error.message })),
642
+ ));
510
643
  const invocationId = yield* ids.generate(InvocationId);
511
644
  const preparedArguments = yield* prepareMutationArguments(args).pipe(
512
645
  Effect.provideService(IdGenerator, ids),
@@ -555,6 +688,7 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
555
688
  files: preparedArguments.fileIds,
556
689
  };
557
690
  invocations = HashMap.set(invocations, invocationId, {
691
+ sessionEpoch: admittedSession.sessionEpoch,
558
692
  deferred,
559
693
  function: functionAddress,
560
694
  message,
@@ -573,6 +707,7 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
573
707
  const uploads = yield* Deferred.await(prepared);
574
708
  const expected = new Set(message.files);
575
709
  const received = new Set<string>();
710
+ const transfers: Array<{ readonly file: File; readonly url: string }> = [];
576
711
  for (const upload of uploads) {
577
712
  if (!expected.has(upload.id) || received.has(upload.id)) {
578
713
  return yield* InvalidServerMessage.make({
@@ -589,28 +724,7 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
589
724
  });
590
725
  }
591
726
  if (upload.url === undefined) continue;
592
- const uploadUrl = upload.url;
593
- const response = yield* Effect.tryPromise({
594
- try: () =>
595
- fetch(uploadUrl, {
596
- method: "PUT",
597
- body: file,
598
- headers: { "content-type": file.type },
599
- }),
600
- catch: (cause) =>
601
- ConnectionUnavailable.make({
602
- cause,
603
- message: `File '${file.name}' could not be uploaded.`,
604
- }),
605
- });
606
- if (!response.ok) {
607
- const detail = yield* Effect.promise(() => response.text());
608
- return yield* InvalidMutationArguments.make({
609
- cause: new Error(detail),
610
- function: functionAddress,
611
- message: `File '${file.name}' was rejected by application storage.`,
612
- });
613
- }
727
+ transfers.push({ file, url: upload.url });
614
728
  }
615
729
  if (received.size !== expected.size) {
616
730
  return yield* InvalidServerMessage.make({
@@ -618,7 +732,37 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
618
732
  message: "The server returned incomplete application file preparation data.",
619
733
  });
620
734
  }
621
- const pendingInvocation = HashMap.getUnsafe(invocations, invocationId);
735
+ yield* Effect.forEach(
736
+ transfers,
737
+ Effect.fn("SyncClient.uploadFile")(function* ({ file, url }) {
738
+ const response = yield* Effect.tryPromise({
739
+ try: (signal) =>
740
+ fetch(url, {
741
+ method: "PUT",
742
+ body: file,
743
+ headers: { "content-type": file.type },
744
+ signal,
745
+ }),
746
+ catch: (cause) =>
747
+ ConnectionUnavailable.make({
748
+ cause,
749
+ message: `File '${file.name}' could not be uploaded.`,
750
+ }),
751
+ });
752
+ if (!response.ok) {
753
+ const detail = yield* Effect.promise(() => response.text());
754
+ return yield* InvalidMutationArguments.make({
755
+ cause: new Error(detail),
756
+ function: functionAddress,
757
+ message: `File '${file.name}' was rejected by application storage.`,
758
+ });
759
+ }
760
+ }),
761
+ { concurrency: 3, discard: true },
762
+ );
763
+ const pendingInvocation = Option.getOrUndefined(
764
+ HashMap.get(invocations, invocationId),
765
+ );
622
766
  if (pendingInvocation === undefined) return yield* Deferred.await(deferred);
623
767
  invocations = HashMap.set(invocations, invocationId, {
624
768
  ...pendingInvocation,
@@ -651,6 +795,7 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
651
795
 
652
796
  export const syncRuntime = ManagedRuntime.make(
653
797
  SyncClient.layer.pipe(
798
+ Layer.provideMerge(BrowserSession.layer),
654
799
  Layer.provideMerge(clientErrorReporterLayer),
655
800
  Layer.provideMerge(IdGenerator.layer),
656
801
  ),