@playfast/reform-resource 0.1.0 → 1.0.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/package.json +1 -1
- package/src/asyncResource.ts +0 -33
- package/src/index.ts +0 -7
- package/src/reform-resource.test.ts +0 -7
- package/src/resource.ts +7 -34
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform-resource",
|
|
3
3
|
"playbook": "./playbook",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "1.0.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"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.",
|
|
7
7
|
"keywords": [
|
package/src/asyncResource.ts
CHANGED
|
@@ -1,32 +1,17 @@
|
|
|
1
1
|
import { type Store } from '@playfast/reform'
|
|
2
2
|
|
|
3
|
-
// The value an `AsyncResource` holds: a tagged union discriminated by `isReady`.
|
|
4
|
-
// A resource builds synchronously as `pending`, its load runs in the background,
|
|
5
|
-
// and the store flips to `ready` (or `failed`, only when an `error` schema is
|
|
6
|
-
// declared) when it settles. Consumed with a plain `if`:
|
|
7
|
-
//
|
|
8
|
-
// const r = yield* MyResource
|
|
9
|
-
// if (r.isReady) use(r.value)
|
|
10
|
-
// else if (r.failed) handle(r.error) // only present when E is not `never`
|
|
11
|
-
//
|
|
12
|
-
// `_tag` is the constant `'AsyncResource'` (so the value reads well in logs and a
|
|
13
|
-
// `Match.tag` still groups it); `isReady` is the real discriminant.
|
|
14
|
-
|
|
15
|
-
/** The initial state: the load has not settled yet. */
|
|
16
3
|
export interface AsyncResourcePending {
|
|
17
4
|
readonly _tag: 'AsyncResource'
|
|
18
5
|
readonly isReady: false
|
|
19
6
|
readonly failed: false
|
|
20
7
|
}
|
|
21
8
|
|
|
22
|
-
/** The load resolved; `value` is the acquired resource. */
|
|
23
9
|
export interface AsyncResourceReady<A> {
|
|
24
10
|
readonly _tag: 'AsyncResource'
|
|
25
11
|
readonly isReady: true
|
|
26
12
|
readonly value: A
|
|
27
13
|
}
|
|
28
14
|
|
|
29
|
-
/** The load failed with the declared error. Only occurs when `E` is not `never`. */
|
|
30
15
|
export interface AsyncResourceFailed<E> {
|
|
31
16
|
readonly _tag: 'AsyncResource'
|
|
32
17
|
readonly isReady: false
|
|
@@ -34,17 +19,11 @@ export interface AsyncResourceFailed<E> {
|
|
|
34
19
|
readonly error: E
|
|
35
20
|
}
|
|
36
21
|
|
|
37
|
-
/**
|
|
38
|
-
* The resource lifecycle, narrowed by the definition: the `Failed` arm exists
|
|
39
|
-
* only when the resource declares an `error` schema (a non-`never` `E`), so a
|
|
40
|
-
* resource with no error schema is exactly the two-arm `pending | ready` shape.
|
|
41
|
-
*/
|
|
42
22
|
export type AsyncResource<A, E = never> =
|
|
43
23
|
| AsyncResourcePending
|
|
44
24
|
| AsyncResourceReady<A>
|
|
45
25
|
| ([E] extends [never] ? never : AsyncResourceFailed<E>)
|
|
46
26
|
|
|
47
|
-
/** The full (un-narrowed) union — held by the live store, narrowed at the read boundary. */
|
|
48
27
|
export type AnyAsyncResource<A, E> = AsyncResourcePending | AsyncResourceReady<A> | AsyncResourceFailed<E>
|
|
49
28
|
|
|
50
29
|
const pending: AsyncResourcePending = { _tag: 'AsyncResource', isReady: false, failed: false }
|
|
@@ -56,26 +35,14 @@ const failed = <E>(error: E): AsyncResourceFailed<E> => ({
|
|
|
56
35
|
error,
|
|
57
36
|
})
|
|
58
37
|
|
|
59
|
-
/** The arm-constructor namespace exposed as `AsyncResource`. */
|
|
60
38
|
export interface AsyncResourceConstructors {
|
|
61
39
|
readonly pending: AsyncResourcePending
|
|
62
40
|
readonly ready: <A>(value: A) => AsyncResourceReady<A>
|
|
63
41
|
readonly failed: <E>(error: E) => AsyncResourceFailed<E>
|
|
64
42
|
}
|
|
65
43
|
|
|
66
|
-
/**
|
|
67
|
-
* Constructors for the arms, namespaced under the same name as the type so call
|
|
68
|
-
* sites read `AsyncResource.ready(v)` / `AsyncResource.failed(e)` — one obvious
|
|
69
|
-
* home for every arm (mirrors `AsyncData` in reform core).
|
|
70
|
-
*/
|
|
71
44
|
export const AsyncResource: AsyncResourceConstructors = { pending, ready, failed }
|
|
72
45
|
|
|
73
|
-
/**
|
|
74
|
-
* Narrow the live store (which holds the full `AnyAsyncResource` union) to the
|
|
75
|
-
* arms the definition permits (drops `Failed` when `E` is `never`). The single
|
|
76
|
-
* documented home for that narrowing, so `Resource.live` returns it without an
|
|
77
|
-
* inline cast — the established `asyncData.narrowStore` pattern.
|
|
78
|
-
*/
|
|
79
46
|
export const narrowStore = <A, E>(store: Store<AnyAsyncResource<A, E>>): Store<AsyncResource<A, E>> =>
|
|
80
47
|
// oxlint-disable-next-line reform-rules/no-type-assertion -- sanctioned variance seam: the live store holds the full union; this is the one documented home for narrowing it to the definition-permitted arms (mirrors asyncData.narrowStore).
|
|
81
48
|
store as unknown as Store<AsyncResource<A, E>>
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
// @playfast/reform-resource — async resources for reform.
|
|
2
|
-
//
|
|
3
|
-
// A `Resource` is a layer that builds synchronously (so it's safe in reform's
|
|
4
|
-
// synchronous scene build), forks an async load under the scene scope, and flips
|
|
5
|
-
// a reactive store from `pending` to `ready`/`failed` when it settles — the
|
|
6
|
-
// sanctioned home for the async work that otherwise trips `AsyncSceneLayer`.
|
|
7
|
-
|
|
8
1
|
export * as Resource from './resource'
|
|
9
2
|
export type { ResourceClass, ResourceConfig, ResourceManifest } from './resource'
|
|
10
3
|
export {
|
|
@@ -2,10 +2,6 @@ import { expect, it } from '@effect/vitest'
|
|
|
2
2
|
import { Context, Data, Duration, Effect, Layer, Option, Ref, Schema as S } from 'effect'
|
|
3
3
|
import { Resource } from './index'
|
|
4
4
|
|
|
5
|
-
// A Resource builds synchronously as `pending`, forks its load under the scene
|
|
6
|
-
// scope, and flips a reactive store to `ready`/`failed` when it settles. Scoped
|
|
7
|
-
// acquire/release ties to the scene scope.
|
|
8
|
-
|
|
9
5
|
const tick = (ms = 30) => Effect.sleep(Duration.millis(ms))
|
|
10
6
|
|
|
11
7
|
class Boom extends Data.TaggedError('Boom')<{ readonly message: string }> {}
|
|
@@ -16,7 +12,6 @@ it.live('builds pending then resolves to ready', () => {
|
|
|
16
12
|
|
|
17
13
|
return Effect.gen(function* () {
|
|
18
14
|
const store = yield* Num.store
|
|
19
|
-
// Synchronous build → the value is available immediately as pending.
|
|
20
15
|
expect(store.get().isReady).toBe(false)
|
|
21
16
|
yield* tick(60)
|
|
22
17
|
const value = store.get()
|
|
@@ -70,10 +65,8 @@ it.live('ties acquire/release to the scene scope', () =>
|
|
|
70
65
|
const store = Context.get(context, Conn.store)
|
|
71
66
|
yield* tick()
|
|
72
67
|
expect(store.get().isReady).toBe(true)
|
|
73
|
-
// Still open while the scene scope is alive.
|
|
74
68
|
expect(yield* Ref.get(released)).toBe(false)
|
|
75
69
|
}),
|
|
76
70
|
)
|
|
77
|
-
// Scene scope closed → the acquire's finalizer ran.
|
|
78
71
|
expect(yield* Ref.get(released)).toBe(true)
|
|
79
72
|
}))
|
package/src/resource.ts
CHANGED
|
@@ -13,20 +13,13 @@ import {
|
|
|
13
13
|
narrowStore,
|
|
14
14
|
} from './asyncResource'
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
// reform's synchronous layer build (and trip `AsyncSceneLayer`). Its `live` layer
|
|
18
|
-
// builds synchronously — seeding a `pending` store — then forks the load under the
|
|
19
|
-
// scene scope and flips the store to `ready`/`failed` when it settles. The flip is
|
|
20
|
-
// an ordinary `Store.set`, so a render that read the resource (via `readTracked`)
|
|
21
|
-
// re-runs just like for any state/calc. Unlike `AsyncCalc` it has no reactive
|
|
22
|
-
// inputs and no query driver: a one-shot, scoped load.
|
|
16
|
+
type AnySchema<A> = Schema.Schema<A, Schema.Schema.Encoded<Schema.Schema.AnyNoContext>>
|
|
23
17
|
|
|
24
18
|
export interface ResourceManifest<N extends string, A, E> extends Manifest {
|
|
25
19
|
readonly kind: 'Resource'
|
|
26
20
|
readonly name: N
|
|
27
|
-
readonly output:
|
|
28
|
-
|
|
29
|
-
readonly error: Option.Option<Schema.Schema<E, any>>
|
|
21
|
+
readonly output: AnySchema<A>
|
|
22
|
+
readonly error: Option.Option<AnySchema<E>>
|
|
30
23
|
}
|
|
31
24
|
|
|
32
25
|
export interface ResourceClass<out N extends string, in out A, in out E>
|
|
@@ -34,22 +27,14 @@ export interface ResourceClass<out N extends string, in out A, in out E>
|
|
|
34
27
|
new (): {}
|
|
35
28
|
readonly manifest: ResourceManifest<N, A, E>
|
|
36
29
|
readonly store: Context.Tag<Store<AsyncResource<A, E>>, Store<AsyncResource<A, E>>>
|
|
37
|
-
/** The resource's name. */
|
|
38
30
|
readonly name: N
|
|
39
31
|
}
|
|
40
32
|
|
|
41
33
|
export interface ResourceConfig<A, E> {
|
|
42
|
-
|
|
43
|
-
readonly
|
|
44
|
-
/** Schema of the failure. `None` ⇒ infallible: no `Failed` arm in the value type. */
|
|
45
|
-
readonly error: Option.Option<Schema.Schema<E, any>>
|
|
34
|
+
readonly output: AnySchema<A>
|
|
35
|
+
readonly error: Option.Option<AnySchema<E>>
|
|
46
36
|
}
|
|
47
37
|
|
|
48
|
-
/**
|
|
49
|
-
* Define an async resource. `output`/`error` schemas shape the value type, so
|
|
50
|
-
* `yield* MyResource` is typed to exactly the arms that can occur (no `Failed` arm
|
|
51
|
-
* without an `error` schema). The load effect itself is supplied by `Resource.live`.
|
|
52
|
-
*/
|
|
53
38
|
export const make = <const N extends string, A, E = never>(
|
|
54
39
|
name: N,
|
|
55
40
|
config: ResourceConfig<A, E>,
|
|
@@ -65,15 +50,6 @@ export const make = <const N extends string, A, E = never>(
|
|
|
65
50
|
return yieldableClass(read, { manifest, store, name })
|
|
66
51
|
}
|
|
67
52
|
|
|
68
|
-
/**
|
|
69
|
-
* Wire the load for a resource. The layer builds synchronously (the store starts
|
|
70
|
-
* `pending`), then forks `acquire` and flips the store to `ready` on success or
|
|
71
|
-
* `failed` on a declared failure. `acquire` may be scoped: its `acquireRelease`
|
|
72
|
-
* finalizers attach to the scene/layer scope (via `Scope.extend`), so an opened
|
|
73
|
-
* connection stays alive while the resource is mounted and releases on dispose.
|
|
74
|
-
* A defect (an unexpected throw) is logged and leaves the resource `pending`,
|
|
75
|
-
* rather than vanishing silently.
|
|
76
|
-
*/
|
|
77
53
|
export const live = <N extends string, A, E, R>(
|
|
78
54
|
resource: ResourceClass<N, A, E>,
|
|
79
55
|
acquire: Effect.Effect<A, E, R | Scope.Scope>,
|
|
@@ -83,14 +59,11 @@ export const live = <N extends string, A, E, R>(
|
|
|
83
59
|
Effect.gen(function* () {
|
|
84
60
|
const scheduler = yield* resolveScheduler
|
|
85
61
|
const scope = yield* Effect.scope
|
|
86
|
-
//
|
|
87
|
-
// definition-narrowed arms. Built `pending` so the sync layer build returns
|
|
88
|
-
// immediately and never trips `AsyncSceneLayer`.
|
|
62
|
+
// Sync build seeds pending so layer mount never trips AsyncSceneLayer; store holds full union, tag sees narrowed arms.
|
|
89
63
|
const store = makeStore<AnyAsyncResource<A, E>>(AsyncResource.pending, scheduler)
|
|
90
64
|
yield* Effect.forkScoped(
|
|
91
65
|
acquire.pipe(
|
|
92
|
-
// Finalizers
|
|
93
|
-
// a resource opened here lives until the scene unmounts.
|
|
66
|
+
// Finalizers attach to scene scope, not the load fiber — resource lives until unmount.
|
|
94
67
|
Scope.extend(scope),
|
|
95
68
|
Effect.matchCauseEffect({
|
|
96
69
|
onSuccess: (acquired) => Effect.sync(() => store.set(AsyncResource.ready(acquired))),
|