@kontsedal/olas-core 0.0.5 → 0.1.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/dist/index.cjs +0 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +109 -27
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +109 -27
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +0 -0
- package/dist/index.mjs.map +1 -1
- package/dist/{root-DqWolle_.mjs → root-Byq-QYTp.mjs} +1478 -347
- package/dist/root-Byq-QYTp.mjs.map +1 -0
- package/dist/{root-lBp7qziQ.cjs → root-CPSL5kpR.cjs} +1483 -346
- package/dist/root-CPSL5kpR.cjs.map +1 -0
- package/dist/testing.cjs +5 -1
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +3 -2
- package/dist/testing.d.cts.map +1 -1
- package/dist/testing.d.mts +3 -2
- package/dist/testing.d.mts.map +1 -1
- package/dist/testing.mjs +5 -2
- package/dist/testing.mjs.map +1 -1
- package/dist/{types-BH1o6nYa.d.mts → types-Brp-4WaZ.d.mts} +244 -23
- package/dist/types-Brp-4WaZ.d.mts.map +1 -0
- package/dist/{types-C4Vtkxbn.d.cts → types-DzDIypPo.d.cts} +244 -23
- package/dist/types-DzDIypPo.d.cts.map +1 -0
- package/package.json +4 -1
- package/src/controller/instance.ts +360 -94
- package/src/controller/root.ts +58 -30
- package/src/controller/types.ts +66 -4
- package/src/emitter.ts +8 -2
- package/src/errors.ts +39 -3
- package/src/forms/field.ts +219 -25
- package/src/forms/form-types.ts +16 -3
- package/src/forms/form.ts +360 -38
- package/src/forms/index.ts +3 -1
- package/src/forms/types.ts +27 -1
- package/src/forms/validators.ts +45 -11
- package/src/index.ts +13 -7
- package/src/query/client.ts +237 -58
- package/src/query/define.ts +0 -0
- package/src/query/entry.ts +259 -15
- package/src/query/focus-online.ts +53 -11
- package/src/query/infinite.ts +351 -47
- package/src/query/keys.ts +33 -2
- package/src/query/local.ts +3 -0
- package/src/query/mutation.ts +27 -6
- package/src/query/plugin.ts +43 -4
- package/src/query/types.ts +76 -6
- package/src/query/use.ts +53 -10
- package/src/selection.ts +49 -12
- package/src/signals/readonly.ts +3 -0
- package/src/signals/runtime.ts +27 -0
- package/src/signals/types.ts +10 -0
- package/src/testing.ts +9 -0
- package/src/timing/debounced.ts +106 -23
- package/src/timing/index.ts +1 -1
- package/src/timing/throttled.ts +85 -28
- package/src/utils.ts +15 -2
- package/dist/root-DqWolle_.mjs.map +0 -1
- package/dist/root-lBp7qziQ.cjs.map +0 -1
- package/dist/types-BH1o6nYa.d.mts.map +0 -1
- package/dist/types-C4Vtkxbn.d.cts.map +0 -1
package/src/controller/root.ts
CHANGED
|
@@ -10,6 +10,7 @@ const ROOT_METHODS = [
|
|
|
10
10
|
'resume',
|
|
11
11
|
'dehydrate',
|
|
12
12
|
'waitForIdle',
|
|
13
|
+
'applyDehydratedEntry',
|
|
13
14
|
'__debug',
|
|
14
15
|
] as const
|
|
15
16
|
|
|
@@ -38,6 +39,7 @@ export function createRootWithProps<Props, Api, TDeps extends Record<string, unk
|
|
|
38
39
|
devtools,
|
|
39
40
|
onError: options.onError,
|
|
40
41
|
queryClient,
|
|
42
|
+
scopesVersion: { value: 0 },
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
const instance = new ControllerInstance(
|
|
@@ -47,6 +49,12 @@ export function createRootWithProps<Props, Api, TDeps extends Record<string, unk
|
|
|
47
49
|
options.deps as Record<string, unknown>,
|
|
48
50
|
)
|
|
49
51
|
|
|
52
|
+
// Pre-seed scopes from RootOptions before the factory runs so
|
|
53
|
+
// ctx.inject() resolves them from any descendant. SPEC §10.3.
|
|
54
|
+
if (options.scopes !== undefined && options.scopes.length > 0) {
|
|
55
|
+
instance.seedScopes(options.scopes)
|
|
56
|
+
}
|
|
57
|
+
|
|
50
58
|
// Bootstrap failure throws straight out of createRoot. Spec §12.1.5.
|
|
51
59
|
// Tear down the QueryClient and any plugins it spawned (window/storage
|
|
52
60
|
// listeners, transports) before re-throwing so the failure doesn't leak.
|
|
@@ -58,14 +66,39 @@ export function createRootWithProps<Props, Api, TDeps extends Record<string, unk
|
|
|
58
66
|
throw err
|
|
59
67
|
}
|
|
60
68
|
|
|
69
|
+
// Non-object apis get wrapped so root controls have somewhere to live.
|
|
70
|
+
let target = api
|
|
61
71
|
if (typeof api !== 'object' || api === null) {
|
|
62
72
|
// Allow primitive APIs in principle but root controls must live somewhere.
|
|
63
|
-
// Wrap in a holder.
|
|
64
|
-
|
|
65
|
-
|
|
73
|
+
// Wrap in a holder. The declared `Root<Api>` type intersection lies in
|
|
74
|
+
// this branch — `(holder as Api).dispose` won't be present on the
|
|
75
|
+
// primitive itself. Dev-warn so the footgun is visible at first run
|
|
76
|
+
// instead of as a confusing "undefined.value" later.
|
|
77
|
+
if (__DEV__) {
|
|
78
|
+
// eslint-disable-next-line no-console
|
|
79
|
+
console.warn(
|
|
80
|
+
'[olas] createRoot: controller returned a non-object api ' +
|
|
81
|
+
`(${api === null ? 'null' : typeof api}). ` +
|
|
82
|
+
'Wrapping as { value: api } so root controls (dispose / suspend / ...) ' +
|
|
83
|
+
"can be attached. Prefer returning an object from a root controller's factory.",
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
target = { value: api } as unknown as Api
|
|
66
87
|
}
|
|
67
88
|
|
|
68
|
-
|
|
89
|
+
// attachRootControls throws on a root-controls NAME CONFLICT (the api defines
|
|
90
|
+
// `dispose`/`suspend`/...) — AFTER the tree is fully constructed. Tear down
|
|
91
|
+
// the live instance + queryClient (effects, focus/online listeners, plugin
|
|
92
|
+
// transports) before rethrowing so the conflict doesn't leak the whole tree.
|
|
93
|
+
// The construct-throw path above rolls back via queryClient.dispose(); this
|
|
94
|
+
// is the symmetric guard for the post-construction failure. (T2.5)
|
|
95
|
+
try {
|
|
96
|
+
return attachRootControls(target, instance, devtools, queryClient)
|
|
97
|
+
} catch (err) {
|
|
98
|
+
instance.dispose()
|
|
99
|
+
queryClient.dispose()
|
|
100
|
+
throw err
|
|
101
|
+
}
|
|
69
102
|
}
|
|
70
103
|
|
|
71
104
|
function attachRootControls<Api>(
|
|
@@ -116,41 +149,36 @@ function attachRootControls<Api>(
|
|
|
116
149
|
|
|
117
150
|
const target = api as Record<string, unknown>
|
|
118
151
|
for (const method of ROOT_METHODS) {
|
|
119
|
-
|
|
152
|
+
// Use `in` rather than `Object.hasOwn` so class-based apis with
|
|
153
|
+
// prototype methods like `dispose()` still trigger the conflict
|
|
154
|
+
// detection instead of being silently overwritten by defineProperty.
|
|
155
|
+
if (method in target) {
|
|
120
156
|
throw new Error(
|
|
121
157
|
`[olas] Root controller api defines '${method}' which conflicts with the root controls.`,
|
|
122
158
|
)
|
|
123
159
|
}
|
|
124
160
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
Object.defineProperty(target, '
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
})
|
|
135
|
-
Object.defineProperty(target, 'resume', {
|
|
136
|
-
value: resume,
|
|
137
|
-
enumerable: false,
|
|
138
|
-
configurable: true,
|
|
139
|
-
})
|
|
140
|
-
Object.defineProperty(target, '__debug', {
|
|
141
|
-
value: debug,
|
|
142
|
-
enumerable: false,
|
|
143
|
-
configurable: true,
|
|
144
|
-
})
|
|
161
|
+
// Lock root controls: writable:false + configurable:false so user code
|
|
162
|
+
// can't `delete api.dispose` (orphaning the root) or shadow them with a
|
|
163
|
+
// typo. The `in`-check above is the friendly preflight; this is the
|
|
164
|
+
// hard fence in case a consumer mutates the api after construction.
|
|
165
|
+
const lock = { enumerable: false, writable: false, configurable: false }
|
|
166
|
+
Object.defineProperty(target, 'dispose', { value: dispose, ...lock })
|
|
167
|
+
Object.defineProperty(target, 'suspend', { value: suspend, ...lock })
|
|
168
|
+
Object.defineProperty(target, 'resume', { value: resume, ...lock })
|
|
169
|
+
Object.defineProperty(target, '__debug', { value: debug, ...lock })
|
|
145
170
|
Object.defineProperty(target, 'dehydrate', {
|
|
146
171
|
value: () => queryClient.dehydrate(),
|
|
147
|
-
|
|
148
|
-
configurable: true,
|
|
172
|
+
...lock,
|
|
149
173
|
})
|
|
150
174
|
Object.defineProperty(target, 'waitForIdle', {
|
|
151
175
|
value: () => queryClient.waitForIdle(),
|
|
152
|
-
|
|
153
|
-
|
|
176
|
+
...lock,
|
|
177
|
+
})
|
|
178
|
+
Object.defineProperty(target, 'applyDehydratedEntry', {
|
|
179
|
+
value: (queryId: string, keyArgs: readonly unknown[], data: unknown, lastUpdatedAt: number) =>
|
|
180
|
+
queryClient.applyDehydratedEntry(queryId, keyArgs, data, lastUpdatedAt),
|
|
181
|
+
...lock,
|
|
154
182
|
})
|
|
155
183
|
|
|
156
184
|
return api as Root<Api>
|
|
@@ -160,7 +188,7 @@ function attachRootControls<Api>(
|
|
|
160
188
|
* Construct a root controller. Root factories take no props — startup config
|
|
161
189
|
* goes in `deps`.
|
|
162
190
|
*/
|
|
163
|
-
export function createRoot<Api, TDeps extends Record<string, unknown> = AmbientDeps>(
|
|
191
|
+
export function createRoot<Api extends object, TDeps extends Record<string, unknown> = AmbientDeps>(
|
|
164
192
|
def: ControllerDef<void, Api>,
|
|
165
193
|
options: RootOptions<TDeps>,
|
|
166
194
|
): Root<Api> {
|
package/src/controller/types.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type { Mutation, MutationSpec } from '../query/mutation'
|
|
|
14
14
|
import type { QueryClientPlugin } from '../query/plugin'
|
|
15
15
|
import type { LocalCache, Query, QuerySubscription, UseOptions } from '../query/types'
|
|
16
16
|
import type { Scope } from '../scope'
|
|
17
|
-
import type { ReadSignal } from '../signals/types'
|
|
17
|
+
import type { Computed, ReadSignal, Signal } from '../signals/types'
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* App-wide deps available on every controller's `ctx.deps`.
|
|
@@ -103,6 +103,22 @@ export type Collection<K, Api> = {
|
|
|
103
103
|
readonly size: ReadSignal<number>
|
|
104
104
|
get(key: K): Api | undefined
|
|
105
105
|
has(key: K): boolean
|
|
106
|
+
/**
|
|
107
|
+
* Suspend a specific collection item by key — pauses its effects without
|
|
108
|
+
* disposing it (mirrors `attach.suspend()`). Useful for virtualized
|
|
109
|
+
* lists where rows scrolled out of view should stop running their
|
|
110
|
+
* effects but stay reconstructible without re-fetching their state.
|
|
111
|
+
*
|
|
112
|
+
* No-op if the key isn't in the collection. Neither the collection
|
|
113
|
+
* reconcile nor a whole-tree `suspend()`/`resume()` cascade (e.g.
|
|
114
|
+
* KeepAlive) will auto-resume a suspended item — call `resumeItem(key)`
|
|
115
|
+
* to bring it back (spec §4.1).
|
|
116
|
+
*/
|
|
117
|
+
suspendItem(key: K): void
|
|
118
|
+
/** Resume a previously-suspended item. No-op if not suspended / not present. */
|
|
119
|
+
resumeItem(key: K): void
|
|
120
|
+
/** Whether the item is currently suspended. False when not present. */
|
|
121
|
+
isItemSuspended(key: K): boolean
|
|
106
122
|
}
|
|
107
123
|
|
|
108
124
|
/**
|
|
@@ -143,12 +159,10 @@ export type CollectionFactoryOptions<Item, K, R, TDeps = AmbientDeps> = {
|
|
|
143
159
|
}
|
|
144
160
|
|
|
145
161
|
/** Constraint for the factory form's return shape. */
|
|
146
|
-
// biome-ignore lint/suspicious/noExplicitAny: per-branch types vary
|
|
147
162
|
export type CollectionFactoryResult = { controller: ControllerDef<any, any>; props: any }
|
|
148
163
|
|
|
149
164
|
/** Extract the union of every branch's controller Api. Distributes over R. */
|
|
150
165
|
export type CollectionFactoryApi<R> = R extends {
|
|
151
|
-
// biome-ignore lint/suspicious/noExplicitAny: distributive infer across the union
|
|
152
166
|
controller: ControllerDef<any, infer A>
|
|
153
167
|
}
|
|
154
168
|
? A
|
|
@@ -209,7 +223,27 @@ export type Ctx<TDeps = AmbientDeps> = {
|
|
|
209
223
|
|
|
210
224
|
emitter<T = void>(): Emitter<T>
|
|
211
225
|
|
|
212
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Convenience re-export of the standalone `signal(initial)` function bound
|
|
228
|
+
* to the controller's surface. Identical semantics — there's no lifecycle
|
|
229
|
+
* to manage for a plain signal — but having it on `ctx` makes "everything
|
|
230
|
+
* I need is on ctx" feel honest and lets consumers avoid importing from
|
|
231
|
+
* `@kontsedal/olas-core` separately.
|
|
232
|
+
*/
|
|
233
|
+
signal<T>(initial: T): Signal<T>
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Convenience re-export of the standalone `computed(fn)` function bound
|
|
237
|
+
* to the controller's surface. Re-evaluates on tracked-dep change; same
|
|
238
|
+
* caveat as `signal` — no lifecycle binding, just discoverability.
|
|
239
|
+
*/
|
|
240
|
+
computed<T>(fn: () => T): Computed<T>
|
|
241
|
+
|
|
242
|
+
field<T>(
|
|
243
|
+
initial: T,
|
|
244
|
+
validators?: ReadonlyArray<Validator<T>>,
|
|
245
|
+
options?: { validateOn?: 'change' | 'blur' | 'submit' },
|
|
246
|
+
): Field<T>
|
|
213
247
|
|
|
214
248
|
form<S extends FormSchema>(schema: S, options?: FormOptions<S>): Form<S>
|
|
215
249
|
|
|
@@ -343,6 +377,17 @@ export type RootOptions<TDeps> = {
|
|
|
343
377
|
* the root disposes. SPEC §13.2.
|
|
344
378
|
*/
|
|
345
379
|
plugins?: QueryClientPlugin[]
|
|
380
|
+
/**
|
|
381
|
+
* Pre-seed scopes on the root controller before its factory runs. Useful
|
|
382
|
+
* for cross-cutting values an adapter wants to provide once (route
|
|
383
|
+
* params from a router bridge, theme tokens, etc.) without forcing the
|
|
384
|
+
* user's root controller to call `ctx.provide(...)`.
|
|
385
|
+
*
|
|
386
|
+
* Bindings are flat `[scope, value]` tuples; later bindings for the
|
|
387
|
+
* same scope override earlier ones. `ctx.inject` from any descendant
|
|
388
|
+
* resolves these via the normal scope chain walk. SPEC §10.3.
|
|
389
|
+
*/
|
|
390
|
+
scopes?: ReadonlyArray<readonly [Scope<unknown>, unknown]>
|
|
346
391
|
}
|
|
347
392
|
|
|
348
393
|
/**
|
|
@@ -356,5 +401,22 @@ export type Root<Api> = Api & {
|
|
|
356
401
|
resume(): void
|
|
357
402
|
dehydrate(): DehydratedState
|
|
358
403
|
waitForIdle(): Promise<void>
|
|
404
|
+
/**
|
|
405
|
+
* Apply a single dehydrated query entry to this root's cache. Idempotent
|
|
406
|
+
* across pre-bind / post-bind: if a `ClientEntry` for `queryId + keyArgs`
|
|
407
|
+
* already exists, the data is written through and supersedes any inflight
|
|
408
|
+
* fetch; otherwise it's buffered for the next `bindEntry`.
|
|
409
|
+
*
|
|
410
|
+
* Designed for streaming SSR — each `<Suspense>` boundary that resolves
|
|
411
|
+
* on the server can push its entry into the live client root as the
|
|
412
|
+
* bootstrap script executes. Also useful for `localStorage` warm-starts
|
|
413
|
+
* and similar "I have fresh data from elsewhere, inject it" patterns.
|
|
414
|
+
*/
|
|
415
|
+
applyDehydratedEntry(
|
|
416
|
+
queryId: string,
|
|
417
|
+
keyArgs: readonly unknown[],
|
|
418
|
+
data: unknown,
|
|
419
|
+
lastUpdatedAt: number,
|
|
420
|
+
): void
|
|
359
421
|
readonly __debug: DebugBus
|
|
360
422
|
}
|
package/src/emitter.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Synchronous fan-out event bus. Handlers run in the order they subscribed.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* Emission iterates a SNAPSHOT of the handler set taken at the start of
|
|
5
|
+
* `emit()`. So within one emission: a handler added during emit does NOT fire
|
|
6
|
+
* for the current emission, and a handler removed during emit STILL fires for
|
|
7
|
+
* the current emission (it was captured in the snapshot).
|
|
8
|
+
*
|
|
9
|
+
* Handlers are stored in a `Set` keyed by reference — calling `on(h)` twice
|
|
10
|
+
* with the same `h` registers it ONCE; a single unsubscribe then removes it.
|
|
5
11
|
*
|
|
6
12
|
* `Emitter<void>` exposes `emit()` (no argument); other shapes expose
|
|
7
13
|
* `emit(value: T)`.
|
package/src/errors.ts
CHANGED
|
@@ -6,21 +6,52 @@
|
|
|
6
6
|
*
|
|
7
7
|
* `'plugin'` is used for exceptions raised by `QueryClientPlugin` callbacks
|
|
8
8
|
* (`@kontsedal/olas-cross-tab` and friends); SPEC §13.2.
|
|
9
|
+
*
|
|
10
|
+
* The remaining fields are correlation hooks for telemetry adapters
|
|
11
|
+
* (Sentry / OpenTelemetry breadcrumbs / Datadog RUM): `eventId` is a stable
|
|
12
|
+
* per-dispatch UUID, `timestamp` is wall-clock ms, `attempt` is the
|
|
13
|
+
* 0-based retry attempt for cache/mutation paths, `cause` is the
|
|
14
|
+
* `Error.cause`-style underlying error if the surfaced error wrapped it,
|
|
15
|
+
* `pluginName` identifies the throwing plugin (set only when `kind ==
|
|
16
|
+
* 'plugin'`).
|
|
9
17
|
*/
|
|
10
18
|
export type ErrorContext = {
|
|
11
19
|
kind: 'effect' | 'cache' | 'mutation' | 'emitter' | 'construction' | 'plugin'
|
|
12
20
|
controllerPath: readonly string[]
|
|
13
21
|
queryKey?: readonly unknown[]
|
|
22
|
+
eventId: string
|
|
23
|
+
timestamp: number
|
|
24
|
+
attempt?: number
|
|
25
|
+
cause?: unknown
|
|
26
|
+
pluginName?: string
|
|
14
27
|
}
|
|
15
28
|
|
|
16
29
|
/** Signature of `RootOptions.onError`. */
|
|
17
30
|
export type ErrorHandler = (err: unknown, context: ErrorContext) => void
|
|
18
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Partial context — `dispatchError` fills in `eventId` + `timestamp`. Call
|
|
34
|
+
* sites pass the diagnostic fields they know about; the dispatcher stamps
|
|
35
|
+
* the per-dispatch correlation data.
|
|
36
|
+
*/
|
|
37
|
+
export type ErrorContextInput = Omit<ErrorContext, 'eventId' | 'timestamp'>
|
|
38
|
+
|
|
19
39
|
const defaultHandler: ErrorHandler = (err, context) => {
|
|
20
40
|
// eslint-disable-next-line no-console
|
|
21
41
|
console.error('[olas]', context, err)
|
|
22
42
|
}
|
|
23
43
|
|
|
44
|
+
let eventCounter = 0
|
|
45
|
+
function nextEventId(): string {
|
|
46
|
+
// 24 bits of randomness + a monotonic counter. Cheap (no crypto) and
|
|
47
|
+
// unique-per-process. Adapters that need stronger guarantees can
|
|
48
|
+
// re-stamp inside their own handler.
|
|
49
|
+
eventCounter = (eventCounter + 1) >>> 0
|
|
50
|
+
const r = ((Math.random() * 0x1000000) | 0).toString(16).padStart(6, '0')
|
|
51
|
+
const c = eventCounter.toString(16).padStart(6, '0')
|
|
52
|
+
return `e_${r}${c}`
|
|
53
|
+
}
|
|
54
|
+
|
|
24
55
|
/**
|
|
25
56
|
* Dispatch an error to a user-provided handler, falling back to console.error.
|
|
26
57
|
* The handler itself is wrapped — if it throws, the throw is swallowed and
|
|
@@ -31,17 +62,22 @@ const defaultHandler: ErrorHandler = (err, context) => {
|
|
|
31
62
|
export function dispatchError(
|
|
32
63
|
handler: ErrorHandler | undefined,
|
|
33
64
|
err: unknown,
|
|
34
|
-
context:
|
|
65
|
+
context: ErrorContextInput,
|
|
35
66
|
): void {
|
|
36
67
|
const fn = handler ?? defaultHandler
|
|
68
|
+
const full: ErrorContext = {
|
|
69
|
+
...context,
|
|
70
|
+
eventId: nextEventId(),
|
|
71
|
+
timestamp: Date.now(),
|
|
72
|
+
}
|
|
37
73
|
try {
|
|
38
|
-
fn(err,
|
|
74
|
+
fn(err, full)
|
|
39
75
|
} catch (handlerErr) {
|
|
40
76
|
try {
|
|
41
77
|
// eslint-disable-next-line no-console
|
|
42
78
|
console.error('[olas] onError handler threw:', handlerErr)
|
|
43
79
|
// eslint-disable-next-line no-console
|
|
44
|
-
console.error('[olas] original error:', err,
|
|
80
|
+
console.error('[olas] original error:', err, full)
|
|
45
81
|
} catch {
|
|
46
82
|
// Console itself failed — give up silently.
|
|
47
83
|
}
|