@kontsedal/olas-core 0.0.6 → 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-B6pNq75w.mjs → root-Byq-QYTp.mjs} +1465 -347
- package/dist/root-Byq-QYTp.mjs.map +1 -0
- package/dist/{root-CFgYhBd-.cjs → root-CPSL5kpR.cjs} +1470 -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-Bmi04hDI.d.mts → types-Brp-4WaZ.d.mts} +233 -23
- package/dist/types-Brp-4WaZ.d.mts.map +1 -0
- package/dist/{types-BsZMPPOE.d.cts → types-DzDIypPo.d.cts} +233 -23
- package/dist/types-DzDIypPo.d.cts.map +1 -0
- package/package.json +4 -1
- package/src/controller/instance.ts +345 -94
- package/src/controller/root.ts +52 -30
- package/src/controller/types.ts +55 -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-B6pNq75w.mjs.map +0 -1
- package/dist/root-CFgYhBd-.cjs.map +0 -1
- package/dist/types-Bmi04hDI.d.mts.map +0 -1
- package/dist/types-BsZMPPOE.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(
|
|
@@ -64,14 +66,39 @@ export function createRootWithProps<Props, Api, TDeps extends Record<string, unk
|
|
|
64
66
|
throw err
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
// Non-object apis get wrapped so root controls have somewhere to live.
|
|
70
|
+
let target = api
|
|
67
71
|
if (typeof api !== 'object' || api === null) {
|
|
68
72
|
// Allow primitive APIs in principle but root controls must live somewhere.
|
|
69
|
-
// Wrap in a holder.
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
72
87
|
}
|
|
73
88
|
|
|
74
|
-
|
|
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
|
+
}
|
|
75
102
|
}
|
|
76
103
|
|
|
77
104
|
function attachRootControls<Api>(
|
|
@@ -122,41 +149,36 @@ function attachRootControls<Api>(
|
|
|
122
149
|
|
|
123
150
|
const target = api as Record<string, unknown>
|
|
124
151
|
for (const method of ROOT_METHODS) {
|
|
125
|
-
|
|
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) {
|
|
126
156
|
throw new Error(
|
|
127
157
|
`[olas] Root controller api defines '${method}' which conflicts with the root controls.`,
|
|
128
158
|
)
|
|
129
159
|
}
|
|
130
160
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
Object.defineProperty(target, '
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
})
|
|
141
|
-
Object.defineProperty(target, 'resume', {
|
|
142
|
-
value: resume,
|
|
143
|
-
enumerable: false,
|
|
144
|
-
configurable: true,
|
|
145
|
-
})
|
|
146
|
-
Object.defineProperty(target, '__debug', {
|
|
147
|
-
value: debug,
|
|
148
|
-
enumerable: false,
|
|
149
|
-
configurable: true,
|
|
150
|
-
})
|
|
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 })
|
|
151
170
|
Object.defineProperty(target, 'dehydrate', {
|
|
152
171
|
value: () => queryClient.dehydrate(),
|
|
153
|
-
|
|
154
|
-
configurable: true,
|
|
172
|
+
...lock,
|
|
155
173
|
})
|
|
156
174
|
Object.defineProperty(target, 'waitForIdle', {
|
|
157
175
|
value: () => queryClient.waitForIdle(),
|
|
158
|
-
|
|
159
|
-
|
|
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,
|
|
160
182
|
})
|
|
161
183
|
|
|
162
184
|
return api as Root<Api>
|
|
@@ -166,7 +188,7 @@ function attachRootControls<Api>(
|
|
|
166
188
|
* Construct a root controller. Root factories take no props — startup config
|
|
167
189
|
* goes in `deps`.
|
|
168
190
|
*/
|
|
169
|
-
export function createRoot<Api, TDeps extends Record<string, unknown> = AmbientDeps>(
|
|
191
|
+
export function createRoot<Api extends object, TDeps extends Record<string, unknown> = AmbientDeps>(
|
|
170
192
|
def: ControllerDef<void, Api>,
|
|
171
193
|
options: RootOptions<TDeps>,
|
|
172
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
|
|
|
@@ -367,5 +401,22 @@ export type Root<Api> = Api & {
|
|
|
367
401
|
resume(): void
|
|
368
402
|
dehydrate(): DehydratedState
|
|
369
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
|
|
370
421
|
readonly __debug: DebugBus
|
|
371
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
|
}
|