@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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +64 -0
  3. package/dist/index.cjs +363 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +178 -0
  6. package/dist/index.d.cts.map +1 -0
  7. package/dist/index.d.mts +178 -0
  8. package/dist/index.d.mts.map +1 -0
  9. package/dist/index.mjs +339 -0
  10. package/dist/index.mjs.map +1 -0
  11. package/dist/root-BImHnGj1.mjs +3270 -0
  12. package/dist/root-BImHnGj1.mjs.map +1 -0
  13. package/dist/root-Bazp5_Ik.cjs +3347 -0
  14. package/dist/root-Bazp5_Ik.cjs.map +1 -0
  15. package/dist/testing.cjs +81 -0
  16. package/dist/testing.cjs.map +1 -0
  17. package/dist/testing.d.cts +56 -0
  18. package/dist/testing.d.cts.map +1 -0
  19. package/dist/testing.d.mts +56 -0
  20. package/dist/testing.d.mts.map +1 -0
  21. package/dist/testing.mjs +78 -0
  22. package/dist/testing.mjs.map +1 -0
  23. package/dist/types-CAMgqCMz.d.mts +816 -0
  24. package/dist/types-CAMgqCMz.d.mts.map +1 -0
  25. package/dist/types-emq_lZd7.d.cts +816 -0
  26. package/dist/types-emq_lZd7.d.cts.map +1 -0
  27. package/package.json +47 -0
  28. package/src/__dev__.d.ts +8 -0
  29. package/src/controller/define.ts +50 -0
  30. package/src/controller/index.ts +12 -0
  31. package/src/controller/instance.ts +499 -0
  32. package/src/controller/root.ts +160 -0
  33. package/src/controller/types.ts +195 -0
  34. package/src/devtools.ts +0 -0
  35. package/src/emitter.ts +79 -0
  36. package/src/errors.ts +49 -0
  37. package/src/forms/field.ts +303 -0
  38. package/src/forms/form-types.ts +130 -0
  39. package/src/forms/form.ts +640 -0
  40. package/src/forms/index.ts +2 -0
  41. package/src/forms/types.ts +1 -0
  42. package/src/forms/validators.ts +70 -0
  43. package/src/index.ts +89 -0
  44. package/src/query/client.ts +934 -0
  45. package/src/query/define.ts +154 -0
  46. package/src/query/entry.ts +322 -0
  47. package/src/query/focus-online.ts +73 -0
  48. package/src/query/index.ts +3 -0
  49. package/src/query/infinite.ts +462 -0
  50. package/src/query/keys.ts +33 -0
  51. package/src/query/local.ts +113 -0
  52. package/src/query/mutation.ts +384 -0
  53. package/src/query/plugin.ts +135 -0
  54. package/src/query/types.ts +168 -0
  55. package/src/query/use.ts +321 -0
  56. package/src/scope.ts +42 -0
  57. package/src/selection.ts +146 -0
  58. package/src/signals/index.ts +3 -0
  59. package/src/signals/readonly.ts +22 -0
  60. package/src/signals/runtime.ts +115 -0
  61. package/src/signals/types.ts +31 -0
  62. package/src/testing.ts +142 -0
  63. package/src/timing/debounced.ts +32 -0
  64. package/src/timing/index.ts +2 -0
  65. package/src/timing/throttled.ts +46 -0
  66. 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
+ }
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
+ }