@playfast/reform-resource 1.2.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/resource.d.ts.map +1 -1
- package/dist/resource.js +7 -3
- package/dist/resource.js.map +1 -1
- package/package.json +1 -1
- package/src/resource-teardown-order.test.ts +93 -0
- package/src/resource.ts +16 -3
package/dist/resource.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../src/resource.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,QAAQ,EAIb,KAAK,KAAK,EAEX,MAAM,2BAA2B,CAAA;AAClC,OAAO,
|
|
1
|
+
{"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../src/resource.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,QAAQ,EAIb,KAAK,KAAK,EAEX,MAAM,2BAA2B,CAAA;AAClC,OAAO,EAEL,OAAO,EACP,MAAM,EAEN,KAAK,EACL,MAAM,EACN,KAAK,MAAM,EACX,KAAK,EACN,MAAM,QAAQ,CAAA;AACf,OAAO,EAAyB,aAAa,EAAe,MAAM,iBAAiB,CAAA;AAEnF,KAAK,SAAS,CAAC,CAAC,IAAI,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC,CAAA;AAEvF,MAAM,WAAW,gBAAgB,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,EAAE,CAAC,CAAE,SAAQ,QAAQ;IACxE,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;IACzB,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAA;IAChB,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,CAAA;IAC7B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAA;CAC5C;AAED,MAAM,WAAW,aAAa,CAAC,GAAG,CAAC,CAAC,SAAS,MAAM,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,CAAE,SAAQ,MAAM,CAAC,MAAM,CAC5F,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,EACnB,KAAK,EACL,KAAK,CAAC,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAC3B;IACC,QAAQ,EAAE,CAAA;IACV,QAAQ,CAAC,QAAQ,EAAE,gBAAgB,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAA;IAC5C,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAA;IACnF,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAA;CACjB;AAED,MAAM,WAAW,cAAc,CAAC,CAAC,EAAE,CAAC;IAClC,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,CAAA;IAC7B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAA;CAC5C;AAED,eAAO,MAAM,IAAI,GAAI,KAAK,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,EAAE,CAAC,GAAG,KAAK,EACvD,MAAM,CAAC,EACP,QAAQ,cAAc,CAAC,CAAC,EAAE,CAAC,CAAC,KAC3B,aAAa,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAUvB,CAAA;AAED,eAAO,MAAM,IAAI,GAAI,CAAC,SAAS,MAAM,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAC5C,UAAU,aAAa,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EAChC,SAAS,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,KAC5C,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,CAAC,CAAC,EAAE,KAAK,CAAC,KAAK,CAAC,CAgCtE,CAAA"}
|
package/dist/resource.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { makeStore, readTracked, resolveScheduler, yieldableClass, } from '@playfast/reform/internal';
|
|
2
|
-
import { Cause, Context, Effect, Layer, Option, Scope } from 'effect';
|
|
2
|
+
import { Cause, Context, Effect, ExecutionStrategy, Layer, Option, Scope, } from 'effect';
|
|
3
3
|
import { AsyncResource, narrowStore } from './asyncResource.js';
|
|
4
4
|
export const make = (name, config) => {
|
|
5
5
|
const store = Context.GenericTag(`reform/resource/${name}`);
|
|
@@ -17,9 +17,13 @@ export const live = (resource, acquire) => Layer.scoped(resource.store, Effect.g
|
|
|
17
17
|
const scope = yield* Effect.scope;
|
|
18
18
|
// Sync build seeds pending so layer mount never trips AsyncSceneLayer; store holds full union, tag sees narrowed arms.
|
|
19
19
|
const store = makeStore(AsyncResource.pending, scheduler);
|
|
20
|
+
// Forked before the load fiber so it finalizes AFTER it: a scope releases in
|
|
21
|
+
// reverse order, so unmount interrupts the fiber that is still using the
|
|
22
|
+
// resource and only then releases the resource itself.
|
|
23
|
+
const loadScope = yield* Scope.fork(scope, ExecutionStrategy.sequential);
|
|
20
24
|
yield* Effect.forkScoped(acquire.pipe(
|
|
21
|
-
// Finalizers
|
|
22
|
-
Scope.extend(
|
|
25
|
+
// Finalizers outlive the load fiber — the resource lives until unmount.
|
|
26
|
+
Scope.extend(loadScope), Effect.matchCauseEffect({
|
|
23
27
|
onSuccess: (acquired) => Effect.sync(() => store.set(AsyncResource.ready(acquired))),
|
|
24
28
|
onFailure: (cause) => Option.match(Cause.failureOption(cause), {
|
|
25
29
|
onSome: (error) => Effect.sync(() => store.set(AsyncResource.failed(error))),
|
package/dist/resource.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resource.js","sourceRoot":"","sources":["../src/resource.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,SAAS,EACT,WAAW,EACX,gBAAgB,EAEhB,cAAc,GACf,MAAM,2BAA2B,CAAA;AAClC,OAAO,
|
|
1
|
+
{"version":3,"file":"resource.js","sourceRoot":"","sources":["../src/resource.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,SAAS,EACT,WAAW,EACX,gBAAgB,EAEhB,cAAc,GACf,MAAM,2BAA2B,CAAA;AAClC,OAAO,EACL,KAAK,EACL,OAAO,EACP,MAAM,EACN,iBAAiB,EACjB,KAAK,EACL,MAAM,EAEN,KAAK,GACN,MAAM,QAAQ,CAAA;AACf,OAAO,EAAyB,aAAa,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AA2BnF,MAAM,CAAC,MAAM,IAAI,GAAG,CAClB,IAAO,EACP,MAA4B,EACJ,EAAE;IAC1B,MAAM,KAAK,GAAG,OAAO,CAAC,UAAU,CAA6B,mBAAmB,IAAI,EAAE,CAAC,CAAA;IACvF,MAAM,QAAQ,GAA8B;QAC1C,IAAI,EAAE,UAAU;QAChB,IAAI;QACJ,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,KAAK,EAAE,MAAM,CAAC,KAAK;KACpB,CAAA;IACD,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,KAAK,EAAE,WAAW,CAAC,CAAA;IAC/C,OAAO,cAAc,CAAC,IAAI,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAA;AACxD,CAAC,CAAA;AAED,MAAM,CAAC,MAAM,IAAI,GAAG,CAClB,QAAgC,EAChC,OAA6C,EAC4B,EAAE,CAC3E,KAAK,CAAC,MAAM,CACV,QAAQ,CAAC,KAAK,EACd,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;IAClB,MAAM,SAAS,GAAG,KAAK,CAAC,CAAC,gBAAgB,CAAA;IACzC,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,MAAM,CAAC,KAAK,CAAA;IACjC,uHAAuH;IACvH,MAAM,KAAK,GAAG,SAAS,CAAyB,aAAa,CAAC,OAAO,EAAE,SAAS,CAAC,CAAA;IACjF,6EAA6E;IAC7E,yEAAyE;IACzE,uDAAuD;IACvD,MAAM,SAAS,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,EAAE,iBAAiB,CAAC,UAAU,CAAC,CAAA;IACxE,KAAK,CAAC,CAAC,MAAM,CAAC,UAAU,CACtB,OAAO,CAAC,IAAI;IACV,wEAAwE;IACxE,KAAK,CAAC,MAAM,CAAC,SAAS,CAAC,EACvB,MAAM,CAAC,gBAAgB,CAAC;QACtB,SAAS,EAAE,CAAC,QAAQ,EAAE,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,aAAa,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC;QACpF,SAAS,EAAE,CAAC,KAAK,EAAE,EAAE,CACnB,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,aAAa,CAAC,KAAK,CAAC,EAAE;YACvC,MAAM,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,aAAa,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;YAC5E,MAAM,EAAE,GAAG,EAAE,CACX,KAAK,CAAC,iBAAiB,CAAC,KAAK,CAAC;gBAC5B,CAAC,CAAC,MAAM,CAAC,IAAI;gBACb,CAAC,CAAC,oKAAoK;oBACpK,MAAM,CAAC,QAAQ,CAAC,8BAA8B,QAAQ,CAAC,IAAI,UAAU,EAAE,KAAK,CAAC;SACpF,CAAC;KACL,CAAC,CACH,CACF,CAAA;IACD,OAAO,WAAW,CAAC,KAAK,CAAC,CAAA;AAC3B,CAAC,CAAC,CACH,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform-resource",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "Async resources for Reform — a layer that builds synchronously, loads in the background, and flips a reactive store from pending to ready/failed. The sanctioned home for async work that would otherwise break reform's synchronous scene build.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"async",
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { expect, it } from '@effect/vitest'
|
|
2
|
+
import { makeFlightGate, until } from '@playfast/reform/testkit/flight.testkit'
|
|
3
|
+
import { Deferred, Effect, Exit, Fiber, Layer, Option, Schema as S, Scope } from 'effect'
|
|
4
|
+
import { Resource } from './index'
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// `Resource.live` runs `acquire` on a forked fiber but `Scope.extend`s its
|
|
8
|
+
// finalizers onto the *layer* scope (resource.ts:63-66) so the resource outlives
|
|
9
|
+
// the load. Both the fork's interrupt and the acquire's release therefore live on
|
|
10
|
+
// the same scope — and a scope releases finalizers in reverse registration order.
|
|
11
|
+
// The fork is registered first (so it is interrupted LAST) and the release
|
|
12
|
+
// second (so it runs FIRST): on unmount the resource is closed while the fiber
|
|
13
|
+
// that opened it, and is still using it, is left running.
|
|
14
|
+
//
|
|
15
|
+
// It only shows once the close is genuinely asynchronous — which every real one
|
|
16
|
+
// is (a socket, a worker, a wasm db, an http client).
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
it.live('a resource is released while its own load fiber is still using it', () => {
|
|
20
|
+
class Conn extends Resource.make('HuntConn', { output: S.Number, error: Option.none() }) {}
|
|
21
|
+
|
|
22
|
+
const handle = { open: true }
|
|
23
|
+
const marks = { queries: 0, queriesAfterClose: 0 }
|
|
24
|
+
const query = () => {
|
|
25
|
+
marks.queries += 1
|
|
26
|
+
if (!handle.open) {
|
|
27
|
+
marks.queriesAfterClose += 1
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const order: Array<string> = []
|
|
31
|
+
|
|
32
|
+
// the load fiber's next unit of work, and the parked half of an async close
|
|
33
|
+
const nextStep = Effect.runSync(Deferred.make<void>())
|
|
34
|
+
const closeFinishes = Effect.runSync(Deferred.make<void>())
|
|
35
|
+
const loaded = makeFlightGate()
|
|
36
|
+
|
|
37
|
+
const layer = Resource.live(
|
|
38
|
+
Conn,
|
|
39
|
+
Effect.gen(function* () {
|
|
40
|
+
yield* Effect.acquireRelease(Effect.succeed(handle), () =>
|
|
41
|
+
Effect.sync(() => {
|
|
42
|
+
order.push('release-start')
|
|
43
|
+
handle.open = false
|
|
44
|
+
}).pipe(
|
|
45
|
+
// a real close is asynchronous; the fiber that opened the connection
|
|
46
|
+
// is still schedulable for the whole of it
|
|
47
|
+
Effect.zipRight(Deferred.await(closeFinishes)),
|
|
48
|
+
Effect.zipRight(Effect.sync(() => order.push('release-end'))),
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
query()
|
|
52
|
+
yield* loaded.through(Effect.void)
|
|
53
|
+
yield* Deferred.await(nextStep).pipe(
|
|
54
|
+
Effect.onInterrupt(() => Effect.sync(() => order.push('load-interrupted'))),
|
|
55
|
+
)
|
|
56
|
+
query()
|
|
57
|
+
return 1
|
|
58
|
+
}),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return Effect.gen(function* () {
|
|
62
|
+
const scope = yield* Scope.make()
|
|
63
|
+
yield* Scope.extend(Layer.build(layer), scope)
|
|
64
|
+
yield* loaded.awaitEntry(1)
|
|
65
|
+
|
|
66
|
+
// CONTROL: while the scene is mounted the load fiber uses a live connection
|
|
67
|
+
expect(marks.queries).toBe(1)
|
|
68
|
+
expect(marks.queriesAfterClose).toBe(0)
|
|
69
|
+
expect(handle.open).toBe(true)
|
|
70
|
+
|
|
71
|
+
// unmount
|
|
72
|
+
const closing = yield* Effect.fork(Scope.close(scope, Exit.void))
|
|
73
|
+
yield* until(
|
|
74
|
+
() => handle.open,
|
|
75
|
+
(open) => open === false,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
// the connection is closed; hand the load fiber its next unit of work
|
|
79
|
+
yield* Deferred.succeed(nextStep, undefined)
|
|
80
|
+
yield* until(
|
|
81
|
+
() => marks.queries,
|
|
82
|
+
(queries) => queries > 1,
|
|
83
|
+
20,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
yield* Deferred.succeed(closeFinishes, undefined)
|
|
87
|
+
yield* Fiber.join(closing)
|
|
88
|
+
|
|
89
|
+
// the load fiber must be stopped before the resource it opened is released
|
|
90
|
+
expect(marks.queriesAfterClose).toBe(0)
|
|
91
|
+
expect(order[0]).toBe('load-interrupted')
|
|
92
|
+
})
|
|
93
|
+
})
|
package/src/resource.ts
CHANGED
|
@@ -6,7 +6,16 @@ import {
|
|
|
6
6
|
type Store,
|
|
7
7
|
yieldableClass,
|
|
8
8
|
} from '@playfast/reform/internal'
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
Cause,
|
|
11
|
+
Context,
|
|
12
|
+
Effect,
|
|
13
|
+
ExecutionStrategy,
|
|
14
|
+
Layer,
|
|
15
|
+
Option,
|
|
16
|
+
type Schema,
|
|
17
|
+
Scope,
|
|
18
|
+
} from 'effect'
|
|
10
19
|
import { type AnyAsyncResource, AsyncResource, narrowStore } from './asyncResource'
|
|
11
20
|
|
|
12
21
|
type AnySchema<A> = Schema.Schema<A, Schema.Schema.Encoded<Schema.Schema.AnyNoContext>>
|
|
@@ -60,10 +69,14 @@ export const live = <N extends string, A, E, R>(
|
|
|
60
69
|
const scope = yield* Effect.scope
|
|
61
70
|
// Sync build seeds pending so layer mount never trips AsyncSceneLayer; store holds full union, tag sees narrowed arms.
|
|
62
71
|
const store = makeStore<AnyAsyncResource<A, E>>(AsyncResource.pending, scheduler)
|
|
72
|
+
// Forked before the load fiber so it finalizes AFTER it: a scope releases in
|
|
73
|
+
// reverse order, so unmount interrupts the fiber that is still using the
|
|
74
|
+
// resource and only then releases the resource itself.
|
|
75
|
+
const loadScope = yield* Scope.fork(scope, ExecutionStrategy.sequential)
|
|
63
76
|
yield* Effect.forkScoped(
|
|
64
77
|
acquire.pipe(
|
|
65
|
-
// Finalizers
|
|
66
|
-
Scope.extend(
|
|
78
|
+
// Finalizers outlive the load fiber — the resource lives until unmount.
|
|
79
|
+
Scope.extend(loadScope),
|
|
67
80
|
Effect.matchCauseEffect({
|
|
68
81
|
onSuccess: (acquired) => Effect.sync(() => store.set(AsyncResource.ready(acquired))),
|
|
69
82
|
onFailure: (cause) =>
|