@kontsedal/olas-core 0.0.1-rc.0
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/LICENSE +21 -0
- package/README.md +64 -0
- package/dist/index.cjs +363 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +178 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +178 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +339 -0
- package/dist/index.mjs.map +1 -0
- package/dist/root-BImHnGj1.mjs +3270 -0
- package/dist/root-BImHnGj1.mjs.map +1 -0
- package/dist/root-Bazp5_Ik.cjs +3347 -0
- package/dist/root-Bazp5_Ik.cjs.map +1 -0
- package/dist/testing.cjs +81 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +56 -0
- package/dist/testing.d.cts.map +1 -0
- package/dist/testing.d.mts +56 -0
- package/dist/testing.d.mts.map +1 -0
- package/dist/testing.mjs +78 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/types-CAMgqCMz.d.mts +816 -0
- package/dist/types-CAMgqCMz.d.mts.map +1 -0
- package/dist/types-emq_lZd7.d.cts +816 -0
- package/dist/types-emq_lZd7.d.cts.map +1 -0
- package/package.json +47 -0
- package/src/__dev__.d.ts +8 -0
- package/src/controller/define.ts +50 -0
- package/src/controller/index.ts +12 -0
- package/src/controller/instance.ts +499 -0
- package/src/controller/root.ts +160 -0
- package/src/controller/types.ts +195 -0
- package/src/devtools.ts +0 -0
- package/src/emitter.ts +79 -0
- package/src/errors.ts +49 -0
- package/src/forms/field.ts +303 -0
- package/src/forms/form-types.ts +130 -0
- package/src/forms/form.ts +640 -0
- package/src/forms/index.ts +2 -0
- package/src/forms/types.ts +1 -0
- package/src/forms/validators.ts +70 -0
- package/src/index.ts +89 -0
- package/src/query/client.ts +934 -0
- package/src/query/define.ts +154 -0
- package/src/query/entry.ts +322 -0
- package/src/query/focus-online.ts +73 -0
- package/src/query/index.ts +3 -0
- package/src/query/infinite.ts +462 -0
- package/src/query/keys.ts +33 -0
- package/src/query/local.ts +113 -0
- package/src/query/mutation.ts +384 -0
- package/src/query/plugin.ts +135 -0
- package/src/query/types.ts +168 -0
- package/src/query/use.ts +321 -0
- package/src/scope.ts +42 -0
- package/src/selection.ts +146 -0
- package/src/signals/index.ts +3 -0
- package/src/signals/readonly.ts +22 -0
- package/src/signals/runtime.ts +115 -0
- package/src/signals/types.ts +31 -0
- package/src/testing.ts +142 -0
- package/src/timing/debounced.ts +32 -0
- package/src/timing/index.ts +2 -0
- package/src/timing/throttled.ts +46 -0
- package/src/utils.ts +13 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { DevtoolsEmitter } from '../devtools'
|
|
2
|
+
import { QueryClient } from '../query/client'
|
|
3
|
+
import { getFactory } from './define'
|
|
4
|
+
import { ControllerInstance, type RootShared } from './instance'
|
|
5
|
+
import type { AmbientDeps, ControllerDef, Root, RootOptions } from './types'
|
|
6
|
+
|
|
7
|
+
const ROOT_METHODS = [
|
|
8
|
+
'dispose',
|
|
9
|
+
'suspend',
|
|
10
|
+
'resume',
|
|
11
|
+
'dehydrate',
|
|
12
|
+
'waitForIdle',
|
|
13
|
+
'__debug',
|
|
14
|
+
] as const
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Construct a root controller.
|
|
18
|
+
*
|
|
19
|
+
* Internal: this is the shared engine. The public `createRoot` (props-less)
|
|
20
|
+
* and `createTestController` (props-allowing) both call through here.
|
|
21
|
+
*/
|
|
22
|
+
export function createRootWithProps<Props, Api, TDeps extends Record<string, unknown>>(
|
|
23
|
+
def: ControllerDef<Props, Api>,
|
|
24
|
+
props: Props,
|
|
25
|
+
options: RootOptions<TDeps>,
|
|
26
|
+
): Root<Api> {
|
|
27
|
+
const devtools = new DevtoolsEmitter()
|
|
28
|
+
const queryClient = new QueryClient({
|
|
29
|
+
onError: options.onError,
|
|
30
|
+
hydrate: options.hydrate,
|
|
31
|
+
devtools,
|
|
32
|
+
deps: options.deps as Record<string, unknown>,
|
|
33
|
+
refetchOnWindowFocus: options.refetchOnWindowFocus,
|
|
34
|
+
refetchOnReconnect: options.refetchOnReconnect,
|
|
35
|
+
plugins: options.plugins,
|
|
36
|
+
})
|
|
37
|
+
const rootShared: RootShared = {
|
|
38
|
+
devtools,
|
|
39
|
+
onError: options.onError,
|
|
40
|
+
queryClient,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const instance = new ControllerInstance(
|
|
44
|
+
null,
|
|
45
|
+
rootShared,
|
|
46
|
+
'root',
|
|
47
|
+
options.deps as Record<string, unknown>,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
// Bootstrap failure throws straight out of createRoot. Spec §12.1.5.
|
|
51
|
+
const api = instance.construct(getFactory(def), props)
|
|
52
|
+
|
|
53
|
+
if (typeof api !== 'object' || api === null) {
|
|
54
|
+
// Allow primitive APIs in principle but root controls must live somewhere.
|
|
55
|
+
// Wrap in a holder.
|
|
56
|
+
const holder = { value: api } as unknown as Api
|
|
57
|
+
return attachRootControls(holder, instance, devtools, queryClient)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return attachRootControls(api, instance, devtools, queryClient)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function attachRootControls<Api>(
|
|
64
|
+
api: Api,
|
|
65
|
+
instance: ControllerInstance,
|
|
66
|
+
devtools: DevtoolsEmitter,
|
|
67
|
+
queryClient: QueryClient,
|
|
68
|
+
): Root<Api> {
|
|
69
|
+
let suspendTimer: ReturnType<typeof setTimeout> | null = null
|
|
70
|
+
|
|
71
|
+
const dispose = () => {
|
|
72
|
+
if (suspendTimer != null) {
|
|
73
|
+
clearTimeout(suspendTimer)
|
|
74
|
+
suspendTimer = null
|
|
75
|
+
}
|
|
76
|
+
instance.dispose()
|
|
77
|
+
queryClient.dispose()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const suspend = (opts?: { maxIdle?: number }) => {
|
|
81
|
+
instance.suspend()
|
|
82
|
+
if (suspendTimer != null) {
|
|
83
|
+
clearTimeout(suspendTimer)
|
|
84
|
+
suspendTimer = null
|
|
85
|
+
}
|
|
86
|
+
const maxIdle = opts?.maxIdle
|
|
87
|
+
if (maxIdle != null && maxIdle !== Number.POSITIVE_INFINITY) {
|
|
88
|
+
suspendTimer = setTimeout(() => {
|
|
89
|
+
suspendTimer = null
|
|
90
|
+
dispose()
|
|
91
|
+
}, maxIdle)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const resume = () => {
|
|
96
|
+
if (suspendTimer != null) {
|
|
97
|
+
clearTimeout(suspendTimer)
|
|
98
|
+
suspendTimer = null
|
|
99
|
+
}
|
|
100
|
+
instance.resume()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const debug = {
|
|
104
|
+
subscribe: (handler: Parameters<DevtoolsEmitter['subscribe']>[0]) =>
|
|
105
|
+
devtools.subscribe(handler),
|
|
106
|
+
queryEntries: () => queryClient.queryEntriesSnapshot(),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const target = api as Record<string, unknown>
|
|
110
|
+
for (const method of ROOT_METHODS) {
|
|
111
|
+
if (Object.hasOwn(target, method)) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`[olas] Root controller api defines '${method}' which conflicts with the root controls.`,
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
Object.defineProperty(target, 'dispose', {
|
|
118
|
+
value: dispose,
|
|
119
|
+
enumerable: false,
|
|
120
|
+
configurable: true,
|
|
121
|
+
})
|
|
122
|
+
Object.defineProperty(target, 'suspend', {
|
|
123
|
+
value: suspend,
|
|
124
|
+
enumerable: false,
|
|
125
|
+
configurable: true,
|
|
126
|
+
})
|
|
127
|
+
Object.defineProperty(target, 'resume', {
|
|
128
|
+
value: resume,
|
|
129
|
+
enumerable: false,
|
|
130
|
+
configurable: true,
|
|
131
|
+
})
|
|
132
|
+
Object.defineProperty(target, '__debug', {
|
|
133
|
+
value: debug,
|
|
134
|
+
enumerable: false,
|
|
135
|
+
configurable: true,
|
|
136
|
+
})
|
|
137
|
+
Object.defineProperty(target, 'dehydrate', {
|
|
138
|
+
value: () => queryClient.dehydrate(),
|
|
139
|
+
enumerable: false,
|
|
140
|
+
configurable: true,
|
|
141
|
+
})
|
|
142
|
+
Object.defineProperty(target, 'waitForIdle', {
|
|
143
|
+
value: () => queryClient.waitForIdle(),
|
|
144
|
+
enumerable: false,
|
|
145
|
+
configurable: true,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
return api as Root<Api>
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Construct a root controller. Root factories take no props — startup config
|
|
153
|
+
* goes in `deps`.
|
|
154
|
+
*/
|
|
155
|
+
export function createRoot<Api, TDeps extends Record<string, unknown> = AmbientDeps>(
|
|
156
|
+
def: ControllerDef<void, Api>,
|
|
157
|
+
options: RootOptions<TDeps>,
|
|
158
|
+
): Root<Api> {
|
|
159
|
+
return createRootWithProps<void, Api, TDeps>(def, undefined as void, options)
|
|
160
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { Emitter } from '../emitter'
|
|
2
|
+
import type { ErrorContext } from '../errors'
|
|
3
|
+
import type {
|
|
4
|
+
FieldArray,
|
|
5
|
+
FieldArrayOptions,
|
|
6
|
+
Form,
|
|
7
|
+
FormOptions,
|
|
8
|
+
FormSchema,
|
|
9
|
+
ItemInitial,
|
|
10
|
+
} from '../forms/form-types'
|
|
11
|
+
import type { Validator } from '../forms/types'
|
|
12
|
+
import type { InfiniteQuery, InfiniteQuerySubscription } from '../query/infinite'
|
|
13
|
+
import type { Mutation, MutationSpec } from '../query/mutation'
|
|
14
|
+
import type { QueryClientPlugin } from '../query/plugin'
|
|
15
|
+
import type { LocalCache, Query, QuerySubscription, UseOptions } from '../query/types'
|
|
16
|
+
import type { Scope } from '../scope'
|
|
17
|
+
import type { ReadSignal } from '../signals/types'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* App-wide deps available on every controller's `ctx.deps`.
|
|
21
|
+
*
|
|
22
|
+
* Default shape carries an index signature so untyped reads compile (as
|
|
23
|
+
* `unknown`). Users augment this interface in their app to add typed services:
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* declare module '@kontsedal/olas-core' {
|
|
27
|
+
* interface AmbientDeps {
|
|
28
|
+
* api: ApiClient
|
|
29
|
+
* session: SessionStore
|
|
30
|
+
* }
|
|
31
|
+
* }
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export interface AmbientDeps {
|
|
35
|
+
[key: string]: unknown
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A reactive form field. Extends `ReadSignal<T>` for the current value, plus
|
|
40
|
+
* five signals for state (errors / isValid / isDirty / touched / isValidating)
|
|
41
|
+
* and four methods (`set`, `reset`, `markTouched`, `revalidate`). Created via
|
|
42
|
+
* `ctx.field(initial, validators?)`. Spec §8, §20.7.
|
|
43
|
+
*/
|
|
44
|
+
export type Field<T> = ReadSignal<T> & {
|
|
45
|
+
errors: ReadSignal<string[]>
|
|
46
|
+
isValid: ReadSignal<boolean>
|
|
47
|
+
isDirty: ReadSignal<boolean>
|
|
48
|
+
touched: ReadSignal<boolean>
|
|
49
|
+
isValidating: ReadSignal<boolean>
|
|
50
|
+
set(value: T): void
|
|
51
|
+
/**
|
|
52
|
+
* Reseat the field as if this value had been its constructor `initial`:
|
|
53
|
+
* writes the value, re-anchors `reset()`'s target, leaves `isDirty` false.
|
|
54
|
+
* `Form` uses this when applying its own `initial` (constructor + reset),
|
|
55
|
+
* so a form populated from server data isn't born dirty. Useful for any
|
|
56
|
+
* "load this value as the new baseline" pattern.
|
|
57
|
+
*/
|
|
58
|
+
setAsInitial(value: T): void
|
|
59
|
+
reset(): void
|
|
60
|
+
markTouched(): void
|
|
61
|
+
revalidate(): Promise<boolean>
|
|
62
|
+
/** Idempotent. Called by the owning controller's dispose. */
|
|
63
|
+
dispose(): void
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The handle returned by `defineController(...)`. Pass it to `createRoot(...)`
|
|
68
|
+
* or `ctx.child(...)` to instantiate. Phantom types preserve `Props` / `Api`
|
|
69
|
+
* for inference via `CtrlProps<C>` / `CtrlApi<C>`.
|
|
70
|
+
*/
|
|
71
|
+
export type ControllerDef<Props, Api> = {
|
|
72
|
+
readonly __olas: 'controller'
|
|
73
|
+
readonly __types?: { props: Props; api: Api }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Extract a controller's Props type. */
|
|
77
|
+
export type CtrlProps<C> = C extends ControllerDef<infer P, unknown> ? P : never
|
|
78
|
+
|
|
79
|
+
/** Extract a controller's Api type. */
|
|
80
|
+
export type CtrlApi<C> = C extends ControllerDef<unknown, infer A> ? A : never
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `ctx` is the lifecycle-bound surface every controller factory receives.
|
|
84
|
+
* Every primitive constructed through `ctx` is owned by the controller and
|
|
85
|
+
* disposed when the controller disposes.
|
|
86
|
+
*
|
|
87
|
+
* Phase 3 surface — caches, mutations, forms, collections, scopes, etc.
|
|
88
|
+
* land in later phases.
|
|
89
|
+
*/
|
|
90
|
+
export type Ctx<TDeps = AmbientDeps> = {
|
|
91
|
+
cache<T>(
|
|
92
|
+
fetcher: (signal: AbortSignal) => Promise<T>,
|
|
93
|
+
options?: {
|
|
94
|
+
key?: () => readonly unknown[]
|
|
95
|
+
staleTime?: number
|
|
96
|
+
keepPreviousData?: boolean
|
|
97
|
+
initialData?: T | undefined
|
|
98
|
+
},
|
|
99
|
+
): LocalCache<T>
|
|
100
|
+
|
|
101
|
+
use<Args extends unknown[], T>(
|
|
102
|
+
source: Query<Args, T>,
|
|
103
|
+
keyOrOptions?: (() => Args) | UseOptions<Args>,
|
|
104
|
+
): QuerySubscription<T>
|
|
105
|
+
use<Args extends unknown[], TPage, TItem>(
|
|
106
|
+
source: InfiniteQuery<Args, TPage, TItem>,
|
|
107
|
+
keyOrOptions?: (() => Args) | UseOptions<Args>,
|
|
108
|
+
): InfiniteQuerySubscription<TPage, TItem>
|
|
109
|
+
|
|
110
|
+
mutation<V, R>(spec: MutationSpec<V, R>): Mutation<V, R>
|
|
111
|
+
|
|
112
|
+
emitter<T = void>(): Emitter<T>
|
|
113
|
+
|
|
114
|
+
field<T>(initial: T, validators?: ReadonlyArray<Validator<T>>): Field<T>
|
|
115
|
+
|
|
116
|
+
form<S extends FormSchema>(schema: S, options?: FormOptions<S>): Form<S>
|
|
117
|
+
|
|
118
|
+
fieldArray<I extends Field<any> | Form<any>>(
|
|
119
|
+
itemFactory: (initial?: ItemInitial<I>) => I,
|
|
120
|
+
options?: FieldArrayOptions<I>,
|
|
121
|
+
): FieldArray<I>
|
|
122
|
+
|
|
123
|
+
child<Props, Api>(
|
|
124
|
+
def: ControllerDef<Props, Api>,
|
|
125
|
+
props: Props,
|
|
126
|
+
options?: { deps?: Partial<TDeps> },
|
|
127
|
+
): Api
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Like `child(...)` but additionally returns a `dispose()` handle so the
|
|
131
|
+
* parent can tear down this specific sub-tree early — e.g. when the user
|
|
132
|
+
* closes a details panel. The child is still disposed automatically when
|
|
133
|
+
* the parent disposes; `dispose()` is idempotent and only earlies the
|
|
134
|
+
* teardown. Useful for "openable" sub-controllers whose lifetime is driven
|
|
135
|
+
* by a user gesture rather than the parent's lifetime alone.
|
|
136
|
+
*/
|
|
137
|
+
attach<Props, Api>(
|
|
138
|
+
def: ControllerDef<Props, Api>,
|
|
139
|
+
props: Props,
|
|
140
|
+
options?: { deps?: Partial<TDeps> },
|
|
141
|
+
): { api: Api; dispose: () => void }
|
|
142
|
+
|
|
143
|
+
effect(fn: () => void | (() => void)): void
|
|
144
|
+
|
|
145
|
+
on<T>(emitter: Emitter<T>, handler: (value: T) => void): void
|
|
146
|
+
|
|
147
|
+
// scopes — typed cross-tree data (§10.3)
|
|
148
|
+
provide<T>(scope: Scope<T>, value: T): void
|
|
149
|
+
inject<T>(scope: Scope<T>): T
|
|
150
|
+
|
|
151
|
+
onDispose(fn: () => void): void
|
|
152
|
+
onSuspend(fn: () => void): void
|
|
153
|
+
onResume(fn: () => void): void
|
|
154
|
+
|
|
155
|
+
readonly deps: TDeps
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
import type { DebugBus } from '../devtools'
|
|
159
|
+
import type { DehydratedState } from '../query/types'
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Configuration passed to `createRoot(def, options)`. `deps` is required and
|
|
163
|
+
* available everywhere as `ctx.deps`. `onError` receives errors from effects,
|
|
164
|
+
* mutations, caches, emitter handlers, and construction. `hydrate` replays a
|
|
165
|
+
* `DehydratedState` produced on the server. Spec §20.8.
|
|
166
|
+
*/
|
|
167
|
+
export type RootOptions<TDeps> = {
|
|
168
|
+
deps: TDeps
|
|
169
|
+
onError?: (err: unknown, context: ErrorContext) => void
|
|
170
|
+
hydrate?: DehydratedState
|
|
171
|
+
/** Default for queries that don't set `refetchOnWindowFocus` on their spec (§5.9). */
|
|
172
|
+
refetchOnWindowFocus?: boolean
|
|
173
|
+
/** Default for queries that don't set `refetchOnReconnect` on their spec (§5.9). */
|
|
174
|
+
refetchOnReconnect?: boolean
|
|
175
|
+
/**
|
|
176
|
+
* `QueryClientPlugin`s — cross-tab sync, server-push patches, etc.
|
|
177
|
+
* Installed when the root's `QueryClient` is constructed; disposed when
|
|
178
|
+
* the root disposes. SPEC §13.2.
|
|
179
|
+
*/
|
|
180
|
+
plugins?: QueryClientPlugin[]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The root's public surface: the controller's `Api` plus lifecycle controls
|
|
185
|
+
* (`dispose`, `suspend`, `resume`), SSR (`dehydrate`, `waitForIdle`), and
|
|
186
|
+
* devtools (`__debug`). Spec §20.8.
|
|
187
|
+
*/
|
|
188
|
+
export type Root<Api> = Api & {
|
|
189
|
+
dispose(): void
|
|
190
|
+
suspend(options?: { maxIdle?: number }): void
|
|
191
|
+
resume(): void
|
|
192
|
+
dehydrate(): DehydratedState
|
|
193
|
+
waitForIdle(): Promise<void>
|
|
194
|
+
readonly __debug: DebugBus
|
|
195
|
+
}
|
package/src/devtools.ts
ADDED
|
Binary file
|
package/src/emitter.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synchronous fan-out event bus. Handlers run in the order they subscribed.
|
|
3
|
+
* Handlers added during emit don't fire for the current emission; handlers
|
|
4
|
+
* removed during emit are skipped from that point in the iteration.
|
|
5
|
+
*
|
|
6
|
+
* `Emitter<void>` exposes `emit()` (no argument); other shapes expose
|
|
7
|
+
* `emit(value: T)`.
|
|
8
|
+
*
|
|
9
|
+
* Spec §7, §20.6.
|
|
10
|
+
*/
|
|
11
|
+
export type Emitter<T> = {
|
|
12
|
+
emit: [T] extends [void] ? () => void : (value: T) => void
|
|
13
|
+
/** Subscribe to every emission. Returns the unsubscribe function. */
|
|
14
|
+
on(handler: (value: T) => void): () => void
|
|
15
|
+
/** Subscribe to the next emission only. Auto-unsubscribes after firing. */
|
|
16
|
+
once(handler: (value: T) => void): () => void
|
|
17
|
+
/** Tear down the emitter. Subsequent `emit` / `on` / `once` are no-ops. */
|
|
18
|
+
dispose(): void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type AnyHandler = (value: unknown) => void
|
|
22
|
+
|
|
23
|
+
class EmitterImpl<T> {
|
|
24
|
+
private handlers = new Set<AnyHandler>()
|
|
25
|
+
private disposed = false
|
|
26
|
+
|
|
27
|
+
emit(value: T): void {
|
|
28
|
+
if (this.disposed) return
|
|
29
|
+
// Snapshot so a handler that unsubscribes itself (or another) doesn't
|
|
30
|
+
// mutate the set mid-iteration.
|
|
31
|
+
const snapshot = Array.from(this.handlers)
|
|
32
|
+
for (const handler of snapshot) {
|
|
33
|
+
handler(value as unknown)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
on(handler: (value: T) => void): () => void {
|
|
38
|
+
if (this.disposed) return () => {}
|
|
39
|
+
const wrapped = handler as AnyHandler
|
|
40
|
+
this.handlers.add(wrapped)
|
|
41
|
+
return () => {
|
|
42
|
+
this.handlers.delete(wrapped)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
once(handler: (value: T) => void): () => void {
|
|
47
|
+
if (this.disposed) return () => {}
|
|
48
|
+
const wrapped: AnyHandler = (value) => {
|
|
49
|
+
this.handlers.delete(wrapped)
|
|
50
|
+
handler(value as T)
|
|
51
|
+
}
|
|
52
|
+
this.handlers.add(wrapped)
|
|
53
|
+
return () => {
|
|
54
|
+
this.handlers.delete(wrapped)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
dispose(): void {
|
|
59
|
+
if (this.disposed) return
|
|
60
|
+
this.disposed = true
|
|
61
|
+
this.handlers.clear()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Create a standalone emitter. Handlers persist until explicitly unsubscribed
|
|
67
|
+
* (or the emitter is disposed). Use this for emitters that live outside any
|
|
68
|
+
* single controller — typically in deps. Use `ctx.emitter()` for emitters that
|
|
69
|
+
* should auto-clean with a controller.
|
|
70
|
+
*/
|
|
71
|
+
export function createEmitter<T = void>(): Emitter<T> {
|
|
72
|
+
const impl = new EmitterImpl<T>()
|
|
73
|
+
return {
|
|
74
|
+
emit: ((value?: T) => impl.emit(value as T)) as Emitter<T>['emit'],
|
|
75
|
+
on: (handler) => impl.on(handler),
|
|
76
|
+
once: (handler) => impl.once(handler),
|
|
77
|
+
dispose: () => impl.dispose(),
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context passed to a root's `onError` handler. `kind` identifies where in
|
|
3
|
+
* the controller's surface the throw originated; `controllerPath` is the
|
|
4
|
+
* path from root to the controller that owned the failing code; `queryKey`
|
|
5
|
+
* is set for `cache` kinds. Spec §12, §20.9.
|
|
6
|
+
*
|
|
7
|
+
* `'plugin'` is used for exceptions raised by `QueryClientPlugin` callbacks
|
|
8
|
+
* (`@kontsedal/olas-cross-tab` and friends); SPEC §13.2.
|
|
9
|
+
*/
|
|
10
|
+
export type ErrorContext = {
|
|
11
|
+
kind: 'effect' | 'cache' | 'mutation' | 'emitter' | 'construction' | 'plugin'
|
|
12
|
+
controllerPath: readonly string[]
|
|
13
|
+
queryKey?: readonly unknown[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Signature of `RootOptions.onError`. */
|
|
17
|
+
export type ErrorHandler = (err: unknown, context: ErrorContext) => void
|
|
18
|
+
|
|
19
|
+
const defaultHandler: ErrorHandler = (err, context) => {
|
|
20
|
+
// eslint-disable-next-line no-console
|
|
21
|
+
console.error('[olas]', context, err)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Dispatch an error to a user-provided handler, falling back to console.error.
|
|
26
|
+
* The handler itself is wrapped — if it throws, the throw is swallowed and
|
|
27
|
+
* logged so an `onError` bug never tears down the tree.
|
|
28
|
+
*
|
|
29
|
+
* Internal — used by the controller container and query client.
|
|
30
|
+
*/
|
|
31
|
+
export function dispatchError(
|
|
32
|
+
handler: ErrorHandler | undefined,
|
|
33
|
+
err: unknown,
|
|
34
|
+
context: ErrorContext,
|
|
35
|
+
): void {
|
|
36
|
+
const fn = handler ?? defaultHandler
|
|
37
|
+
try {
|
|
38
|
+
fn(err, context)
|
|
39
|
+
} catch (handlerErr) {
|
|
40
|
+
try {
|
|
41
|
+
// eslint-disable-next-line no-console
|
|
42
|
+
console.error('[olas] onError handler threw:', handlerErr)
|
|
43
|
+
// eslint-disable-next-line no-console
|
|
44
|
+
console.error('[olas] original error:', err, context)
|
|
45
|
+
} catch {
|
|
46
|
+
// Console itself failed — give up silently.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|