@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.
Files changed (61) hide show
  1. package/dist/index.cjs +0 -0
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +109 -27
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +109 -27
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +0 -0
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/{root-DqWolle_.mjs → root-Byq-QYTp.mjs} +1478 -347
  10. package/dist/root-Byq-QYTp.mjs.map +1 -0
  11. package/dist/{root-lBp7qziQ.cjs → root-CPSL5kpR.cjs} +1483 -346
  12. package/dist/root-CPSL5kpR.cjs.map +1 -0
  13. package/dist/testing.cjs +5 -1
  14. package/dist/testing.cjs.map +1 -1
  15. package/dist/testing.d.cts +3 -2
  16. package/dist/testing.d.cts.map +1 -1
  17. package/dist/testing.d.mts +3 -2
  18. package/dist/testing.d.mts.map +1 -1
  19. package/dist/testing.mjs +5 -2
  20. package/dist/testing.mjs.map +1 -1
  21. package/dist/{types-BH1o6nYa.d.mts → types-Brp-4WaZ.d.mts} +244 -23
  22. package/dist/types-Brp-4WaZ.d.mts.map +1 -0
  23. package/dist/{types-C4Vtkxbn.d.cts → types-DzDIypPo.d.cts} +244 -23
  24. package/dist/types-DzDIypPo.d.cts.map +1 -0
  25. package/package.json +4 -1
  26. package/src/controller/instance.ts +360 -94
  27. package/src/controller/root.ts +58 -30
  28. package/src/controller/types.ts +66 -4
  29. package/src/emitter.ts +8 -2
  30. package/src/errors.ts +39 -3
  31. package/src/forms/field.ts +219 -25
  32. package/src/forms/form-types.ts +16 -3
  33. package/src/forms/form.ts +360 -38
  34. package/src/forms/index.ts +3 -1
  35. package/src/forms/types.ts +27 -1
  36. package/src/forms/validators.ts +45 -11
  37. package/src/index.ts +13 -7
  38. package/src/query/client.ts +237 -58
  39. package/src/query/define.ts +0 -0
  40. package/src/query/entry.ts +259 -15
  41. package/src/query/focus-online.ts +53 -11
  42. package/src/query/infinite.ts +351 -47
  43. package/src/query/keys.ts +33 -2
  44. package/src/query/local.ts +3 -0
  45. package/src/query/mutation.ts +27 -6
  46. package/src/query/plugin.ts +43 -4
  47. package/src/query/types.ts +76 -6
  48. package/src/query/use.ts +53 -10
  49. package/src/selection.ts +49 -12
  50. package/src/signals/readonly.ts +3 -0
  51. package/src/signals/runtime.ts +27 -0
  52. package/src/signals/types.ts +10 -0
  53. package/src/testing.ts +9 -0
  54. package/src/timing/debounced.ts +106 -23
  55. package/src/timing/index.ts +1 -1
  56. package/src/timing/throttled.ts +85 -28
  57. package/src/utils.ts +15 -2
  58. package/dist/root-DqWolle_.mjs.map +0 -1
  59. package/dist/root-lBp7qziQ.cjs.map +0 -1
  60. package/dist/types-BH1o6nYa.d.mts.map +0 -1
  61. package/dist/types-C4Vtkxbn.d.cts.map +0 -1
@@ -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
- const holder = { value: api } as unknown as Api
65
- return attachRootControls(holder, instance, devtools, queryClient)
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
- return attachRootControls(api, instance, devtools, queryClient)
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
- if (Object.hasOwn(target, method)) {
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
- Object.defineProperty(target, 'dispose', {
126
- value: dispose,
127
- enumerable: false,
128
- configurable: true,
129
- })
130
- Object.defineProperty(target, 'suspend', {
131
- value: suspend,
132
- enumerable: false,
133
- configurable: true,
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
- enumerable: false,
148
- configurable: true,
172
+ ...lock,
149
173
  })
150
174
  Object.defineProperty(target, 'waitForIdle', {
151
175
  value: () => queryClient.waitForIdle(),
152
- enumerable: false,
153
- configurable: true,
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> {
@@ -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
- field<T>(initial: T, validators?: ReadonlyArray<Validator<T>>): Field<T>
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
- * Handlers added during emit don't fire for the current emission; handlers
4
- * removed during emit are skipped from that point in the iteration.
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: ErrorContext,
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, context)
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, context)
80
+ console.error('[olas] original error:', err, full)
45
81
  } catch {
46
82
  // Console itself failed — give up silently.
47
83
  }