@@ -6,12 +6,27 @@ import * as Exit from "effect/Exit";
66import * as Fiber from "effect/Fiber" ;
77import * as Latch from "effect/Latch" ;
88import * as Layer from "effect/Layer" ;
9+ import * as Option from "effect/Option" ;
10+ import * as Schema from "effect/Schema" ;
911import * as Stream from "effect/Stream" ;
12+ import * as SubscriptionRef from "effect/SubscriptionRef" ;
1013import { AsyncResult , Atom , AtomRegistry } from "effect/unstable/reactivity" ;
1114
15+ import {
16+ AVAILABLE_CONNECTION_STATE ,
17+ ConnectionBlockedError ,
18+ ConnectionTransientError ,
19+ PrimaryConnectionTarget ,
20+ type PreparedConnection ,
21+ type SupervisorConnectionState ,
22+ } from "../connection/model.ts" ;
23+ import * as EnvironmentRegistry from "../connection/registry.ts" ;
24+ import * as EnvironmentSupervisor from "../connection/supervisor.ts" ;
25+ import type * as RpcSession from "../rpc/session.ts" ;
1226import {
1327 environmentRpcKey ,
1428 createAtomCommandScheduler ,
29+ createEnvironmentQueryAtomFamily ,
1530 createRuntimeCommand ,
1631 scheduleAtomCommandEffect ,
1732 executeAtomCommand ,
@@ -24,6 +39,79 @@ import {
2439 squashAtomCommandFailure ,
2540} from "./runtime.ts" ;
2641
42+ const QUERY_ENVIRONMENT = new PrimaryConnectionTarget ( {
43+ environmentId : EnvironmentId . make ( "query-environment" ) ,
44+ label : "Query environment" ,
45+ httpBaseUrl : "https://query.example.test" ,
46+ wsBaseUrl : "wss://query.example.test" ,
47+ } ) ;
48+
49+ class TestQueryError extends Schema . TaggedErrorClass < TestQueryError > ( ) ( "TestQueryError" , {
50+ message : Schema . String ,
51+ } ) { }
52+
53+ function queryConnectionState (
54+ overrides : Partial < SupervisorConnectionState > = { } ,
55+ ) : SupervisorConnectionState {
56+ return {
57+ ...AVAILABLE_CONNECTION_STATE ,
58+ desired : true ,
59+ network : "online" ,
60+ phase : "connected" ,
61+ attempt : 1 ,
62+ generation : 1 ,
63+ ...overrides ,
64+ } ;
65+ }
66+
67+ const makeEnvironmentQueryHarness = Effect . fn ( "TestEnvironmentQuery.makeHarness" ) ( function * < A , E > (
68+ execute : Effect . Effect < A , E > ,
69+ ) {
70+ const supervisorState = yield * SubscriptionRef . make ( queryConnectionState ( ) ) ;
71+ const supervisor = EnvironmentSupervisor . EnvironmentSupervisor . of ( {
72+ target : QUERY_ENVIRONMENT ,
73+ state : supervisorState ,
74+ session : yield * SubscriptionRef . make < Option . Option < RpcSession . RpcSession > > ( Option . none ( ) ) ,
75+ prepared : yield * SubscriptionRef . make < Option . Option < PreparedConnection > > ( Option . none ( ) ) ,
76+ connect : Effect . void ,
77+ disconnect : Effect . void ,
78+ retryNow : Effect . void ,
79+ } satisfies EnvironmentSupervisor . EnvironmentSupervisor [ "Service" ] ) ;
80+ const run : EnvironmentRegistry . EnvironmentRegistry [ "Service" ] [ "run" ] = ( _environmentId , effect ) =>
81+ Effect . provideService ( effect , EnvironmentSupervisor . EnvironmentSupervisor , supervisor ) ;
82+ const environmentRegistry = EnvironmentRegistry . EnvironmentRegistry . of ( {
83+ run,
84+ stateChanges : ( ) => SubscriptionRef . changes ( supervisorState ) ,
85+ } as unknown as EnvironmentRegistry . EnvironmentRegistry [ "Service" ] ) ;
86+ const runtime = Atom . runtime (
87+ Layer . succeed ( EnvironmentRegistry . EnvironmentRegistry , environmentRegistry ) ,
88+ ) ;
89+ const family = createEnvironmentQueryAtomFamily ( runtime , {
90+ label : "test.environment-query" ,
91+ staleTimeMs : 60_000 ,
92+ execute : ( ) => execute ,
93+ } ) ;
94+
95+ return {
96+ atom : family ( { environmentId : QUERY_ENVIRONMENT . environmentId , input : undefined } ) ,
97+ supervisorState,
98+ } ;
99+ } ) ;
100+
101+ const mountEnvironmentQuery = Effect . fn ( "TestEnvironmentQuery.mount" ) ( function * < A , E > (
102+ atom : Atom . Atom < AsyncResult . AsyncResult < A , E > > ,
103+ ) {
104+ const registry = AtomRegistry . make ( ) ;
105+ const unmount = registry . mount ( atom ) ;
106+ yield * Effect . addFinalizer ( ( ) =>
107+ Effect . sync ( ( ) => {
108+ unmount ( ) ;
109+ registry . dispose ( ) ;
110+ } ) ,
111+ ) ;
112+ return registry ;
113+ } ) ;
114+
27115describe ( "settleAsyncResult" , ( ) => {
28116 it ( "preserves successful values and typed failures" , async ( ) => {
29117 const success = await settleAsyncResult ( ( ) => Promise . resolve ( Exit . succeed ( "done" ) ) ) ;
@@ -163,6 +251,267 @@ describe("environmentRpcKey", () => {
163251 } ) ;
164252} ) ;
165253
254+ describe ( "environment query lifecycle" , ( ) => {
255+ it . effect (
256+ "retries an interrupted query without exposing a failure during session replacement" ,
257+ ( ) =>
258+ Effect . scoped (
259+ Effect . gen ( function * ( ) {
260+ const firstStarted = Latch . makeUnsafe ( ) ;
261+ const interruptFirst = Latch . makeUnsafe ( ) ;
262+ const firstSettled = Latch . makeUnsafe ( ) ;
263+ let executions = 0 ;
264+ const execute = Effect . suspend ( ( ) => {
265+ executions += 1 ;
266+ if ( executions > 1 ) {
267+ return Effect . succeed ( "recovered" ) ;
268+ }
269+ firstStarted . openUnsafe ( ) ;
270+ return interruptFirst . await . pipe (
271+ Effect . andThen ( Effect . interrupt ) ,
272+ Effect . ensuring (
273+ Effect . sync ( ( ) => {
274+ firstSettled . openUnsafe ( ) ;
275+ } ) ,
276+ ) ,
277+ ) ;
278+ } ) ;
279+ const harness = yield * makeEnvironmentQueryHarness ( execute ) ;
280+ const registry = AtomRegistry . make ( ) ;
281+ const observed : Array < AsyncResult . AsyncResult < string , unknown > > = [ ] ;
282+ const unsubscribe = registry . subscribe (
283+ harness . atom ,
284+ ( result ) => {
285+ observed . push ( result ) ;
286+ } ,
287+ { immediate : true } ,
288+ ) ;
289+ yield * Effect . addFinalizer ( ( ) =>
290+ Effect . sync ( ( ) => {
291+ unsubscribe ( ) ;
292+ registry . dispose ( ) ;
293+ } ) ,
294+ ) ;
295+
296+ yield * firstStarted . await ;
297+ yield * SubscriptionRef . set (
298+ harness . supervisorState ,
299+ queryConnectionState ( { phase : "connecting" , stage : "preparing" } ) ,
300+ ) ;
301+ yield * Effect . yieldNow ;
302+ yield * SubscriptionRef . set (
303+ harness . supervisorState ,
304+ queryConnectionState ( {
305+ phase : "backoff" ,
306+ stage : null ,
307+ lastFailure : new ConnectionTransientError ( {
308+ reason : "transport" ,
309+ detail : "Relay session is reconnecting." ,
310+ } ) ,
311+ retryAt : 1 ,
312+ } ) ,
313+ ) ;
314+ yield * Effect . yieldNow ;
315+ interruptFirst . openUnsafe ( ) ;
316+ yield * firstSettled . await ;
317+ yield * Effect . yieldNow ;
318+
319+ expect ( observed . some ( AsyncResult . isFailure ) ) . toBe ( false ) ;
320+
321+ yield * SubscriptionRef . set (
322+ harness . supervisorState ,
323+ queryConnectionState ( { generation : 2 } ) ,
324+ ) ;
325+ expect (
326+ yield * AtomRegistry . getResult ( registry , harness . atom , {
327+ suspendOnWaiting : true ,
328+ } ) ,
329+ ) . toBe ( "recovered" ) ;
330+ } ) ,
331+ ) ,
332+ ) ;
333+
334+ it . effect . each ( [
335+ {
336+ condition : "after a manual disconnect" ,
337+ state : queryConnectionState ( {
338+ desired : false ,
339+ phase : "available" ,
340+ stage : null ,
341+ attempt : 0 ,
342+ } ) ,
343+ } ,
344+ {
345+ condition : "while the environment is offline" ,
346+ state : queryConnectionState ( { network : "offline" , phase : "offline" , stage : null } ) ,
347+ } ,
348+ {
349+ condition : "when connection recovery is blocked" ,
350+ state : queryConnectionState ( {
351+ phase : "blocked" ,
352+ stage : null ,
353+ lastFailure : new ConnectionBlockedError ( {
354+ reason : "permission" ,
355+ detail : "Access denied." ,
356+ } ) ,
357+ } ) ,
358+ } ,
359+ ] as const ) ( "settles as unavailable $condition" , ( { state } ) =>
360+ Effect . scoped (
361+ Effect . gen ( function * ( ) {
362+ const harness = yield * makeEnvironmentQueryHarness ( Effect . succeed ( "connected" ) ) ;
363+ const registry = yield * mountEnvironmentQuery ( harness . atom ) ;
364+
365+ expect (
366+ yield * AtomRegistry . getResult ( registry , harness . atom , {
367+ suspendOnWaiting : true ,
368+ } ) ,
369+ ) . toBe ( "connected" ) ;
370+
371+ yield * SubscriptionRef . set ( harness . supervisorState , state ) ;
372+ yield * Effect . yieldNow ;
373+
374+ const result = registry . get ( harness . atom ) ;
375+ expect ( AsyncResult . isFailure ( result ) ) . toBe ( true ) ;
376+ expect ( result . waiting ) . toBe ( false ) ;
377+ } ) ,
378+ ) ,
379+ ) ;
380+
381+ it . effect ( "keeps a genuine query failure settled while reconnecting" , ( ) =>
382+ Effect . scoped (
383+ Effect . gen ( function * ( ) {
384+ const expectedFailure = new TestQueryError ( { message : "Query failed." } ) ;
385+ const firstStarted = Latch . makeUnsafe ( ) ;
386+ const failFirst = Latch . makeUnsafe ( ) ;
387+ const refreshStarted = Latch . makeUnsafe ( ) ;
388+ const finishRefresh = Latch . makeUnsafe ( ) ;
389+ let executions = 0 ;
390+ const execute = Effect . suspend ( ( ) => {
391+ executions += 1 ;
392+ if ( executions === 1 ) {
393+ firstStarted . openUnsafe ( ) ;
394+ return failFirst . await . pipe ( Effect . andThen ( Effect . fail ( expectedFailure ) ) ) ;
395+ }
396+ refreshStarted . openUnsafe ( ) ;
397+ return finishRefresh . await . pipe ( Effect . as ( "recovered" ) ) ;
398+ } ) ;
399+ const harness = yield * makeEnvironmentQueryHarness ( execute ) ;
400+ const registry = yield * mountEnvironmentQuery ( harness . atom ) ;
401+
402+ yield * firstStarted . await ;
403+ failFirst . openUnsafe ( ) ;
404+ const initial = yield * AtomRegistry . getResult ( registry , harness . atom , {
405+ suspendOnWaiting : true ,
406+ } ) . pipe ( Effect . exit ) ;
407+ expect ( Exit . isFailure ( initial ) ) . toBe ( true ) ;
408+ if ( Exit . isFailure ( initial ) ) {
409+ expect ( Cause . squash ( initial . cause ) ) . toBe ( expectedFailure ) ;
410+ }
411+
412+ yield * SubscriptionRef . set (
413+ harness . supervisorState ,
414+ queryConnectionState ( { phase : "connecting" , stage : "opening" } ) ,
415+ ) ;
416+ yield * Effect . yieldNow ;
417+
418+ const refreshing = registry . get ( harness . atom ) ;
419+ expect ( AsyncResult . isFailure ( refreshing ) ) . toBe ( true ) ;
420+ expect ( refreshing . waiting ) . toBe ( true ) ;
421+ if ( AsyncResult . isFailure ( refreshing ) ) {
422+ expect ( Cause . squash ( refreshing . cause ) ) . toBe ( expectedFailure ) ;
423+ }
424+
425+ yield * SubscriptionRef . set (
426+ harness . supervisorState ,
427+ queryConnectionState ( { generation : 2 } ) ,
428+ ) ;
429+ yield * refreshStarted . await ;
430+ finishRefresh . openUnsafe ( ) ;
431+ expect (
432+ yield * AtomRegistry . getResult ( registry , harness . atom , {
433+ suspendOnWaiting : true ,
434+ } ) ,
435+ ) . toBe ( "recovered" ) ;
436+ } ) ,
437+ ) ,
438+ ) ;
439+
440+ it . effect ( "retains the last successful value while reconnecting" , ( ) =>
441+ Effect . scoped (
442+ Effect . gen ( function * ( ) {
443+ const refreshStarted = Latch . makeUnsafe ( ) ;
444+ const finishRefresh = Latch . makeUnsafe ( ) ;
445+ let executions = 0 ;
446+ const execute = Effect . suspend ( ( ) => {
447+ executions += 1 ;
448+ if ( executions === 1 ) {
449+ return Effect . succeed ( "cached" ) ;
450+ }
451+ refreshStarted . openUnsafe ( ) ;
452+ return finishRefresh . await . pipe ( Effect . as ( "updated" ) ) ;
453+ } ) ;
454+ const harness = yield * makeEnvironmentQueryHarness ( execute ) ;
455+ const registry = yield * mountEnvironmentQuery ( harness . atom ) ;
456+
457+ expect (
458+ yield * AtomRegistry . getResult ( registry , harness . atom , {
459+ suspendOnWaiting : true ,
460+ } ) ,
461+ ) . toBe ( "cached" ) ;
462+
463+ yield * SubscriptionRef . set (
464+ harness . supervisorState ,
465+ queryConnectionState ( { phase : "connecting" , stage : "opening" } ) ,
466+ ) ;
467+ yield * Effect . yieldNow ;
468+ expect ( registry . get ( harness . atom ) ) . toMatchObject ( {
469+ _tag : "Success" ,
470+ value : "cached" ,
471+ waiting : true ,
472+ } ) ;
473+
474+ yield * SubscriptionRef . set (
475+ harness . supervisorState ,
476+ queryConnectionState ( {
477+ phase : "backoff" ,
478+ stage : null ,
479+ lastFailure : new ConnectionTransientError ( {
480+ reason : "transport" ,
481+ detail : "Retrying." ,
482+ } ) ,
483+ retryAt : 1 ,
484+ } ) ,
485+ ) ;
486+ yield * Effect . yieldNow ;
487+ expect ( registry . get ( harness . atom ) ) . toMatchObject ( {
488+ _tag : "Success" ,
489+ value : "cached" ,
490+ waiting : true ,
491+ } ) ;
492+
493+ yield * SubscriptionRef . set (
494+ harness . supervisorState ,
495+ queryConnectionState ( { generation : 2 } ) ,
496+ ) ;
497+ yield * refreshStarted . await ;
498+ expect ( registry . get ( harness . atom ) ) . toMatchObject ( {
499+ _tag : "Success" ,
500+ value : "cached" ,
501+ waiting : true ,
502+ } ) ;
503+
504+ finishRefresh . openUnsafe ( ) ;
505+ expect (
506+ yield * AtomRegistry . getResult ( registry , harness . atom , {
507+ suspendOnWaiting : true ,
508+ } ) ,
509+ ) . toBe ( "updated" ) ;
510+ } ) ,
511+ ) ,
512+ ) ;
513+ } ) ;
514+
166515describe ( "Atom.fn mutation semantics" , ( ) => {
167516 it . effect ( "interrupts the previous invocation when the same mutation atom is written again" , ( ) =>
168517 Effect . gen ( function * ( ) {
0 commit comments