@kontsedal/olas-core 0.0.1-rc.1 → 0.0.2

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 (45) hide show
  1. package/dist/index.cjs +40 -10
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +32 -11
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +32 -11
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +40 -11
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/{root-BImHnGj1.mjs → root-De-6KWIZ.mjs} +750 -149
  10. package/dist/root-De-6KWIZ.mjs.map +1 -0
  11. package/dist/{root-Bazp5_Ik.cjs → root-XKEsSmcd.cjs} +755 -148
  12. package/dist/root-XKEsSmcd.cjs.map +1 -0
  13. package/dist/testing.cjs +1 -1
  14. package/dist/testing.d.cts +1 -1
  15. package/dist/testing.d.mts +1 -1
  16. package/dist/testing.mjs +1 -1
  17. package/dist/{types-CAMgqCMz.d.mts → types-C-zV1JZA.d.mts} +215 -13
  18. package/dist/types-C-zV1JZA.d.mts.map +1 -0
  19. package/dist/{types-emq_lZd7.d.cts → types-DKfpkm17.d.cts} +215 -13
  20. package/dist/types-DKfpkm17.d.cts.map +1 -0
  21. package/package.json +1 -1
  22. package/src/controller/index.ts +6 -0
  23. package/src/controller/instance.ts +432 -18
  24. package/src/controller/root.ts +9 -1
  25. package/src/controller/types.ts +148 -7
  26. package/src/emitter.ts +34 -3
  27. package/src/forms/field.ts +73 -8
  28. package/src/forms/form-types.ts +16 -0
  29. package/src/forms/form.ts +218 -26
  30. package/src/index.ts +12 -1
  31. package/src/query/client.ts +161 -6
  32. package/src/query/define.ts +14 -0
  33. package/src/query/entry.ts +64 -42
  34. package/src/query/infinite.ts +77 -55
  35. package/src/query/mutation.ts +11 -21
  36. package/src/query/plugin.ts +50 -0
  37. package/src/query/use.ts +80 -3
  38. package/src/signals/readonly.ts +3 -3
  39. package/src/timing/debounced.ts +24 -4
  40. package/src/timing/throttled.ts +22 -3
  41. package/src/utils.ts +32 -4
  42. package/dist/root-BImHnGj1.mjs.map +0 -1
  43. package/dist/root-Bazp5_Ik.cjs.map +0 -1
  44. package/dist/types-CAMgqCMz.d.mts.map +0 -1
  45. package/dist/types-emq_lZd7.d.cts.map +0 -1
@@ -79,6 +79,80 @@ export type CtrlProps<C> = C extends ControllerDef<infer P, unknown> ? P : never
79
79
  /** Extract a controller's Api type. */
80
80
  export type CtrlApi<C> = C extends ControllerDef<unknown, infer A> ? A : never
81
81
 
82
+ /**
83
+ * The reactive surface returned by `ctx.collection(...)`. `items` is the
84
+ * canonical ordered view (source-order, with any construction-failed items
85
+ * filtered out); `size` mirrors `items.length`; `get` / `has` are
86
+ * imperative key lookups. SPEC §11.1.
87
+ */
88
+ export type Collection<K, Api> = {
89
+ readonly items: ReadSignal<ReadonlyArray<{ readonly key: K; readonly api: Api }>>
90
+ readonly size: ReadSignal<number>
91
+ get(key: K): Api | undefined
92
+ has(key: K): boolean
93
+ }
94
+
95
+ /**
96
+ * Homogeneous form of `ctx.collection`: one controller def for every item,
97
+ * with `propsOf` projecting each item to the controller's `Props`. Construct
98
+ * happens once per new key — `propsOf` is **not** re-applied for unchanged
99
+ * keys.
100
+ */
101
+ export type CollectionHomogeneousOptions<Item, K, Props, Api, TDeps = AmbientDeps> = {
102
+ readonly source: ReadSignal<readonly Item[]>
103
+ readonly keyOf: (item: Item) => K
104
+ readonly controller: ControllerDef<Props, Api>
105
+ readonly propsOf: (item: Item) => Props
106
+ readonly factory?: never
107
+ readonly propsFor?: never
108
+ readonly deps?: Partial<TDeps>
109
+ }
110
+
111
+ /**
112
+ * Heterogeneous form of `ctx.collection`: a single `factory` decides per-item
113
+ * which controller + props to construct. When a key's factory result picks a
114
+ * *different* controller than last time, the existing child is disposed and
115
+ * the new one constructed (type-discriminant rebuild).
116
+ *
117
+ * `R` is the factory's *return type* (typically inferred as the union of the
118
+ * branches' `{ controller, props }` shapes). `Api` is then projected out as
119
+ * the union of every branch's controller Api via `CollectionFactoryApi<R>` —
120
+ * unlike a single `Api` generic, the union doesn't collapse to the first
121
+ * branch.
122
+ */
123
+ export type CollectionFactoryOptions<Item, K, R, TDeps = AmbientDeps> = {
124
+ readonly source: ReadSignal<readonly Item[]>
125
+ readonly keyOf: (item: Item) => K
126
+ readonly controller?: never
127
+ readonly propsOf?: never
128
+ readonly factory: (item: Item) => R
129
+ readonly deps?: Partial<TDeps>
130
+ }
131
+
132
+ /** Constraint for the factory form's return shape. */
133
+ // biome-ignore lint/suspicious/noExplicitAny: per-branch types vary
134
+ export type CollectionFactoryResult = { controller: ControllerDef<any, any>; props: any }
135
+
136
+ /** Extract the union of every branch's controller Api. Distributes over R. */
137
+ export type CollectionFactoryApi<R> = R extends {
138
+ // biome-ignore lint/suspicious/noExplicitAny: distributive infer across the union
139
+ controller: ControllerDef<any, infer A>
140
+ }
141
+ ? A
142
+ : never
143
+
144
+ /**
145
+ * Handle returned by `ctx.lazyChild(...)`. `status` walks `idle → loading →
146
+ * (ready | error)`; `api` becomes defined once `status === 'ready'`. SPEC §16.5.
147
+ */
148
+ export type LazyChild<Api> = {
149
+ readonly status: ReadSignal<'idle' | 'loading' | 'ready' | 'error'>
150
+ readonly api: ReadSignal<Api | undefined>
151
+ readonly error: ReadSignal<unknown | undefined>
152
+ load(): Promise<Api>
153
+ dispose(): void
154
+ }
155
+
82
156
  /**
83
157
  * `ctx` is the lifecycle-bound surface every controller factory receives.
84
158
  * Every primitive constructed through `ctx` is owned by the controller and
@@ -127,18 +201,85 @@ export type Ctx<TDeps = AmbientDeps> = {
127
201
  ): Api
128
202
 
129
203
  /**
130
- * Like `child(...)` but additionally returns a `dispose()` handle so the
131
- * parent can tear down this specific sub-tree earlye.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.
204
+ * Like `child(...)` but additionally returns a handle that lets the parent
205
+ * control the attached sub-tree's lifecycle independently `dispose()`
206
+ * tears it down early, and `suspend()` / `resume()` freeze and thaw it.
207
+ * The child is still disposed automatically when the parent disposes;
208
+ * `dispose()` / `suspend()` / `resume()` are idempotent.
209
+ *
210
+ * `<KeepAlive controller={…}>` in `@kontsedal/olas-react` consumes the
211
+ * returned `{ suspend, resume }` directly — no hand-rolled `isPaused`
212
+ * signal needed on the child's `Api`. Useful for "openable" sub-
213
+ * controllers driven by a user gesture (modal, side panel, wizard).
214
+ *
215
+ * `suspend()` cascades through the attached controller's lifecycle
216
+ * entries: cache subscriptions pause `refetchInterval` and release the
217
+ * entry, effects are torn down, `onSuspend(...)` handlers fire. `resume()`
218
+ * re-runs effects, re-acquires cache entries (a stale entry refetches),
219
+ * and fires `onResume(...)`. Spec §4.1, §16.5.
136
220
  */
137
221
  attach<Props, Api>(
138
222
  def: ControllerDef<Props, Api>,
139
223
  props: Props,
140
224
  options?: { deps?: Partial<TDeps> },
141
- ): { api: Api; dispose: () => void }
225
+ ): { api: Api; dispose: () => void; suspend: () => void; resume: () => void }
226
+
227
+ /**
228
+ * Ephemeral child controller bound to either (a) the explicit `dispose()`
229
+ * call returned in the tuple, or (b) the parent's disposal — whichever
230
+ * comes first. Same lifecycle semantics as `ctx.attach` minus suspend /
231
+ * resume (sessions are short-lived, not pause-able). Returns a `[api,
232
+ * dispose]` tuple so the api shape is exactly the controller's return
233
+ * type, with no wrapper to unpack.
234
+ *
235
+ * Use cases: modal forms, inline edit sessions, wizards, command palette.
236
+ * SPEC §11.1.
237
+ */
238
+ session<Props, Api>(
239
+ def: ControllerDef<Props, Api>,
240
+ props: Props,
241
+ options?: { deps?: Partial<TDeps> },
242
+ ): readonly [api: Api, dispose: () => void]
243
+
244
+ /**
245
+ * Diff-by-key set of child controllers driven by a reactive `source`.
246
+ * On every change to `source`, the collection:
247
+ * - **new keys** → construct a child via `controller` + `propsOf(item)`
248
+ * (or `factory(item)` for the heterogeneous form);
249
+ * - **removed keys** → dispose that child;
250
+ * - **unchanged keys** → leave it alone (`propsOf` is NOT re-applied).
251
+ *
252
+ * For per-item type-discriminated children, use the `factory` form —
253
+ * type changes for an existing key dispose and reconstruct.
254
+ *
255
+ * Construction errors (factory or controller throw) are routed to
256
+ * `onError` with `kind: 'construction'` and the item is **skipped** —
257
+ * the collection's surface shows one fewer entry. The diff loop does
258
+ * not re-throw. SPEC §11.1, §12.1.6.
259
+ */
260
+ collection<Item, K, Props, Api>(
261
+ options: CollectionHomogeneousOptions<Item, K, Props, Api, TDeps>,
262
+ ): Collection<K, Api>
263
+ collection<Item, K, R extends CollectionFactoryResult>(
264
+ options: CollectionFactoryOptions<Item, K, R, TDeps>,
265
+ ): Collection<K, CollectionFactoryApi<R>>
266
+
267
+ /**
268
+ * Code-split child controller. The loader is invoked on `load()`
269
+ * (idempotent), then the controller is constructed with the supplied
270
+ * `props`. `status` / `api` / `error` are reactive signals; subscribe
271
+ * via `use(child.api)` in your view layer.
272
+ *
273
+ * Parent disposal disposes the loaded child (if any) and flags any
274
+ * in-flight load so its eventual settle is dropped on the floor.
275
+ * Construction or import failures route through `onError` with
276
+ * `kind: 'construction'`. SPEC §16.5.
277
+ */
278
+ lazyChild<Props, Api>(
279
+ loader: () => Promise<ControllerDef<Props, Api>>,
280
+ props: Props,
281
+ options?: { deps?: Partial<TDeps> },
282
+ ): LazyChild<Api>
142
283
 
143
284
  effect(fn: () => void | (() => void)): void
144
285
 
package/src/emitter.ts CHANGED
@@ -20,17 +20,43 @@ export type Emitter<T> = {
20
20
 
21
21
  type AnyHandler = (value: unknown) => void
22
22
 
23
+ /**
24
+ * Optional escape hatch for emit-time handler throws. If supplied, a thrown
25
+ * handler is reported here and emission continues with the remaining handlers
26
+ * (spec §20.6 — one throwing handler must not block the rest). If absent,
27
+ * the throw is logged via `console.error`.
28
+ */
29
+ export type EmitterErrorReporter = (err: unknown) => void
30
+
23
31
  class EmitterImpl<T> {
24
32
  private handlers = new Set<AnyHandler>()
25
33
  private disposed = false
26
34
 
35
+ constructor(private onError?: EmitterErrorReporter) {}
36
+
27
37
  emit(value: T): void {
28
38
  if (this.disposed) return
29
39
  // Snapshot so a handler that unsubscribes itself (or another) doesn't
30
40
  // mutate the set mid-iteration.
31
41
  const snapshot = Array.from(this.handlers)
32
42
  for (const handler of snapshot) {
33
- handler(value as unknown)
43
+ try {
44
+ handler(value as unknown)
45
+ } catch (err) {
46
+ // Spec §20.6: isolate handler throws so siblings still fire.
47
+ if (this.onError) {
48
+ try {
49
+ this.onError(err)
50
+ } catch {
51
+ // Reporter itself threw — last resort.
52
+ // eslint-disable-next-line no-console
53
+ console.error('[olas] emitter handler threw and reporter threw:', err)
54
+ }
55
+ } else {
56
+ // eslint-disable-next-line no-console
57
+ console.error('[olas] emitter handler threw:', err)
58
+ }
59
+ }
34
60
  }
35
61
  }
36
62
 
@@ -67,9 +93,14 @@ class EmitterImpl<T> {
67
93
  * (or the emitter is disposed). Use this for emitters that live outside any
68
94
  * single controller — typically in deps. Use `ctx.emitter()` for emitters that
69
95
  * should auto-clean with a controller.
96
+ *
97
+ * Pass `onError` to receive emit-time handler throws (spec §20.6 — one
98
+ * throwing handler must not block the rest of the fan-out). `ctx.emitter()`
99
+ * wires this to the root's `onError` so deps-level emitters get isolation
100
+ * by default when constructed via `ctx`.
70
101
  */
71
- export function createEmitter<T = void>(): Emitter<T> {
72
- const impl = new EmitterImpl<T>()
102
+ export function createEmitter<T = void>(options?: { onError?: EmitterErrorReporter }): Emitter<T> {
103
+ const impl = new EmitterImpl<T>(options?.onError)
73
104
  return {
74
105
  emit: ((value?: T) => impl.emit(value as T)) as Emitter<T>['emit'],
75
106
  on: (handler) => impl.on(handler),
@@ -23,6 +23,17 @@ export type FieldDevtoolsOwner = {
23
23
  emitter: DevtoolsEmitter
24
24
  }
25
25
 
26
+ /**
27
+ * Optional reporter for synchronous validator throws — wired in by `ctx.field`
28
+ * (and `createForm` for leaf fields inside a form) so a thrown validator
29
+ * doesn't escape the signal effect silently. Without this, a buggy validator
30
+ * just stops contributing to `errors` and the field reads as "valid" while
31
+ * silently broken. With it, the throw is routed through `root.onError` as
32
+ * `kind: 'effect'` AND the throw's message lands in the field's `errors`
33
+ * array so the UI surfaces the problem.
34
+ */
35
+ export type ValidatorErrorReporter = (err: unknown) => void
36
+
26
37
  class FieldImpl<T> implements Field<T> {
27
38
  private readonly value$: Signal<T>
28
39
  private readonly errors$: Signal<string[]>
@@ -41,10 +52,20 @@ class FieldImpl<T> implements Field<T> {
41
52
  private runId = 0
42
53
  private disposed = false
43
54
  private devtoolsOwner: FieldDevtoolsOwner | null = null
55
+ private onValidatorError: ValidatorErrorReporter | null = null
44
56
 
45
- constructor(initial: T, validators: ReadonlyArray<Validator<T>> = []) {
57
+ constructor(
58
+ initial: T,
59
+ validators: ReadonlyArray<Validator<T>> = [],
60
+ options?: { onValidatorError?: ValidatorErrorReporter },
61
+ ) {
46
62
  this.initial = initial
47
63
  this.validators = validators
64
+ // Capture the reporter BEFORE the validator effect kicks off so a sync
65
+ // throw on the very first pass routes through `onError` instead of
66
+ // disappearing into the effect (`bindValidatorErrorReporter` is a
67
+ // post-construct hook so it can't catch the first run).
68
+ this.onValidatorError = options?.onValidatorError ?? null
48
69
  this.value$ = signal(initial)
49
70
  this.errors$ = signal<string[]>([])
50
71
  this.touched$ = signal(false)
@@ -60,6 +81,14 @@ class FieldImpl<T> implements Field<T> {
60
81
  }
61
82
  }
62
83
 
84
+ /**
85
+ * Internal hook for `ctx.field` / `createForm` to route synchronous
86
+ * validator throws through `root.onError`. See `ValidatorErrorReporter`.
87
+ */
88
+ bindValidatorErrorReporter(reporter: ValidatorErrorReporter | null): void {
89
+ this.onValidatorError = reporter
90
+ }
91
+
63
92
  // --- ReadSignal<T> ---
64
93
  get value(): T {
65
94
  return this.value$.value
@@ -207,11 +236,27 @@ class FieldImpl<T> implements Field<T> {
207
236
  const asyncPromises: Promise<string | null>[] = []
208
237
 
209
238
  for (const validator of this.validators) {
210
- const result = validator(value, abort.signal)
211
- if (result instanceof Promise) {
212
- asyncPromises.push(result)
213
- } else if (result != null) {
214
- syncErrors.push(result)
239
+ try {
240
+ const result = validator(value, abort.signal)
241
+ if (result instanceof Promise) {
242
+ // Defend against the validator promise rejecting *synchronously*
243
+ // with a thrown error (rare but legal) — the catch-handler in
244
+ // `Promise.allSettled` covers true async rejection.
245
+ asyncPromises.push(result)
246
+ } else if (result != null) {
247
+ syncErrors.push(result)
248
+ }
249
+ } catch (err) {
250
+ // A buggy validator that throws synchronously: surface it twice.
251
+ // (1) Route through `onError` so the user knows something is wrong.
252
+ // (2) Convert to a validation error string so the field reads invalid
253
+ // until the bug is fixed (don't pretend everything's OK).
254
+ try {
255
+ this.onValidatorError?.(err)
256
+ } catch {
257
+ // The reporter must not propagate.
258
+ }
259
+ syncErrors.push(err instanceof Error ? err.message : String(err))
215
260
  }
216
261
  }
217
262
 
@@ -270,8 +315,28 @@ export function bindFieldDevtoolsOwner<T>(field: Field<T>, owner: FieldDevtoolsO
270
315
  }
271
316
  }
272
317
 
273
- export function createField<T>(initial: T, validators?: ReadonlyArray<Validator<T>>): Field<T> {
274
- return new FieldImpl(initial, validators)
318
+ /**
319
+ * Internal — install a synchronous-validator-throw reporter on a `Field`
320
+ * (matched structurally to keep the public `Field<T>` surface stable).
321
+ * Called by `ctx.field` and `bindTreeToDevtools` so leaves inside a form/
322
+ * field-array tree get the same reporting as a standalone field.
323
+ */
324
+ export function bindFieldValidatorErrorReporter<T>(
325
+ field: Field<T>,
326
+ reporter: ValidatorErrorReporter | null,
327
+ ): void {
328
+ const impl = field as { bindValidatorErrorReporter?: (r: ValidatorErrorReporter | null) => void }
329
+ if (typeof impl.bindValidatorErrorReporter === 'function') {
330
+ impl.bindValidatorErrorReporter(reporter)
331
+ }
332
+ }
333
+
334
+ export function createField<T>(
335
+ initial: T,
336
+ validators?: ReadonlyArray<Validator<T>>,
337
+ options?: { onValidatorError?: ValidatorErrorReporter },
338
+ ): Field<T> {
339
+ return new FieldImpl(initial, validators, options)
275
340
  }
276
341
 
277
342
  /**
@@ -45,8 +45,24 @@ export type FormValidator<S extends FormSchema> = Validator<FormValue<S>>
45
45
  export type FieldArrayValidator<I> = Validator<FieldArrayValue<I>>
46
46
 
47
47
  export type FormOptions<S extends FormSchema> = {
48
+ /**
49
+ * Initial values for the form. A function form is **tracked** — if the
50
+ * function reads reactive signals (e.g. a query's `data`), the form re-seats
51
+ * itself when those signals change, but only while the form is not dirty
52
+ * (so a user mid-edit isn't clobbered by a background refetch). See
53
+ * `resetOnInitialChange` for opt-out. Spec §8.4.
54
+ */
48
55
  initial?: (() => DeepPartial<FormValue<S>> | undefined) | DeepPartial<FormValue<S>>
49
56
  validators?: FormValidator<S>[]
57
+ /**
58
+ * When `initial` is a function and one of its tracked deps changes:
59
+ * - `'when-clean'` (default) — re-seat only if the form is not dirty.
60
+ * - `'never'` — never re-seat; `initial()` runs once at construction.
61
+ * - `'always'` — re-seat unconditionally (dirty state is discarded).
62
+ *
63
+ * Spec §20.7.
64
+ */
65
+ resetOnInitialChange?: 'when-clean' | 'never' | 'always'
50
66
  }
51
67
 
52
68
  export type FieldArrayOptions<I> = {