@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,303 @@
1
+ import type { Field } from '../controller/types'
2
+ import type { DevtoolsEmitter } from '../devtools'
3
+ import {
4
+ batch,
5
+ type Computed,
6
+ computed,
7
+ effect,
8
+ type ReadSignal,
9
+ type Signal,
10
+ signal,
11
+ } from '../signals'
12
+ import { isAbortError } from '../utils'
13
+ import type { Validator } from './types'
14
+
15
+ /**
16
+ * Hook attached by `ctx.form` (or `createForm`) so a Field can publish
17
+ * `field:validated` devtools events with its owning controller path + the
18
+ * field's name within the form schema. See devtools §20.9 and FieldImpl.bind.
19
+ */
20
+ export type FieldDevtoolsOwner = {
21
+ controllerPath: readonly string[]
22
+ fieldName: string
23
+ emitter: DevtoolsEmitter
24
+ }
25
+
26
+ class FieldImpl<T> implements Field<T> {
27
+ private readonly value$: Signal<T>
28
+ private readonly errors$: Signal<string[]>
29
+ private readonly touched$: Signal<boolean>
30
+ private readonly dirty$: Signal<boolean>
31
+ private readonly validating$: Signal<boolean>
32
+ private readonly isValid$: Computed<boolean>
33
+ private readonly revalidateTrigger$: Signal<number>
34
+
35
+ private readonly validators: ReadonlyArray<Validator<T>>
36
+ /** The value `reset()` returns to. Mutated by `setAsInitial()` so a form
37
+ * initialized from server data resets to *that* data, not the empty seed. */
38
+ private initial: T
39
+ private validatorDispose: (() => void) | null = null
40
+ private currentAbort: AbortController | null = null
41
+ private runId = 0
42
+ private disposed = false
43
+ private devtoolsOwner: FieldDevtoolsOwner | null = null
44
+
45
+ constructor(initial: T, validators: ReadonlyArray<Validator<T>> = []) {
46
+ this.initial = initial
47
+ this.validators = validators
48
+ this.value$ = signal(initial)
49
+ this.errors$ = signal<string[]>([])
50
+ this.touched$ = signal(false)
51
+ this.dirty$ = signal(false)
52
+ this.validating$ = signal(false)
53
+ this.revalidateTrigger$ = signal(0)
54
+ this.isValid$ = computed(() => this.errors$.value.length === 0 && !this.validating$.value)
55
+
56
+ if (validators.length > 0) {
57
+ this.validatorDispose = effect(() => {
58
+ this.runValidators()
59
+ })
60
+ }
61
+ }
62
+
63
+ // --- ReadSignal<T> ---
64
+ get value(): T {
65
+ return this.value$.value
66
+ }
67
+
68
+ peek(): T {
69
+ return this.value$.peek()
70
+ }
71
+
72
+ subscribe(handler: (value: T) => void): () => void {
73
+ return this.value$.subscribe(handler)
74
+ }
75
+
76
+ // --- Field-only signals ---
77
+ get errors(): ReadSignal<string[]> {
78
+ return this.errors$
79
+ }
80
+
81
+ get isValid(): ReadSignal<boolean> {
82
+ return this.isValid$
83
+ }
84
+
85
+ get isDirty(): ReadSignal<boolean> {
86
+ return this.dirty$
87
+ }
88
+
89
+ get touched(): ReadSignal<boolean> {
90
+ return this.touched$
91
+ }
92
+
93
+ get isValidating(): ReadSignal<boolean> {
94
+ return this.validating$
95
+ }
96
+
97
+ // --- mutating methods ---
98
+ set(value: T): void {
99
+ if (this.disposed) return
100
+ this.value$.set(value)
101
+ this.dirty$.set(true)
102
+ }
103
+
104
+ /**
105
+ * Reseat the field as if this value had been its constructor `initial`.
106
+ * Sets the value, re-anchors `reset()`'s target, and does NOT mark dirty.
107
+ * Used by `Form` when applying its own `initial` (in the constructor and
108
+ * on `reset()`), so server-loaded forms don't start dirty. Internal-ish —
109
+ * exposed for `Form`'s use, not for user code that just wants to write.
110
+ */
111
+ setAsInitial(value: T): void {
112
+ if (this.disposed) return
113
+ this.initial = value
114
+ batch(() => {
115
+ this.value$.set(value)
116
+ this.dirty$.set(false)
117
+ })
118
+ }
119
+
120
+ reset(): void {
121
+ if (this.disposed) return
122
+ this.currentAbort?.abort()
123
+ this.currentAbort = null
124
+ batch(() => {
125
+ this.value$.set(this.initial)
126
+ this.dirty$.set(false)
127
+ this.touched$.set(false)
128
+ this.errors$.set([])
129
+ this.validating$.set(false)
130
+ })
131
+ }
132
+
133
+ markTouched(): void {
134
+ if (this.disposed) return
135
+ this.touched$.set(true)
136
+ }
137
+
138
+ async revalidate(): Promise<boolean> {
139
+ if (this.disposed) return this.isValid$.peek()
140
+ // Bump the trigger to force re-run.
141
+ this.revalidateTrigger$.update((n) => n + 1)
142
+ await this.waitUntilSettled()
143
+ return this.isValid$.peek()
144
+ }
145
+
146
+ dispose(): void {
147
+ if (this.disposed) return
148
+ this.disposed = true
149
+ this.validatorDispose?.()
150
+ this.validatorDispose = null
151
+ this.currentAbort?.abort()
152
+ this.currentAbort = null
153
+ this.devtoolsOwner = null
154
+ }
155
+
156
+ /**
157
+ * Bind this field to a devtools owner. Each subsequent validation pass
158
+ * publishes a `field:validated` event with the supplied path + name.
159
+ * Idempotent — calling again replaces the owner. Internal: called by
160
+ * `createForm` / `createFieldArray` so the form's keys reach the panel.
161
+ */
162
+ bindDevtoolsOwner(owner: FieldDevtoolsOwner | null): void {
163
+ this.devtoolsOwner = owner
164
+ }
165
+
166
+ private emitValidated(valid: boolean, errors: readonly string[]): void {
167
+ if (!__DEV__) return
168
+ const owner = this.devtoolsOwner
169
+ if (owner === null) return
170
+ owner.emitter.emit({
171
+ type: 'field:validated',
172
+ path: owner.controllerPath,
173
+ field: owner.fieldName,
174
+ valid,
175
+ errors: [...errors],
176
+ })
177
+ }
178
+
179
+ // --- internal ---
180
+ private async waitUntilSettled(): Promise<void> {
181
+ // If a validation pass is in progress, wait for validating$ to become false.
182
+ if (!this.validating$.peek()) return
183
+ await new Promise<void>((resolve) => {
184
+ const unsub = this.validating$.subscribe((v) => {
185
+ if (!v) {
186
+ unsub()
187
+ resolve()
188
+ }
189
+ })
190
+ })
191
+ }
192
+
193
+ private runValidators(): void {
194
+ if (this.disposed) return
195
+
196
+ // Track value and revalidate trigger.
197
+ const value = this.value$.value
198
+ void this.revalidateTrigger$.value
199
+
200
+ // Abort previous in-flight run.
201
+ this.currentAbort?.abort()
202
+ const abort = new AbortController()
203
+ this.currentAbort = abort
204
+ const myId = ++this.runId
205
+
206
+ const syncErrors: string[] = []
207
+ const asyncPromises: Promise<string | null>[] = []
208
+
209
+ 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)
215
+ }
216
+ }
217
+
218
+ if (syncErrors.length > 0) {
219
+ batch(() => {
220
+ this.errors$.set(syncErrors)
221
+ this.validating$.set(false)
222
+ })
223
+ this.emitValidated(false, syncErrors)
224
+ return
225
+ }
226
+
227
+ if (asyncPromises.length === 0) {
228
+ batch(() => {
229
+ this.errors$.set([])
230
+ this.validating$.set(false)
231
+ })
232
+ this.emitValidated(true, [])
233
+ return
234
+ }
235
+
236
+ batch(() => {
237
+ this.errors$.set([])
238
+ this.validating$.set(true)
239
+ })
240
+
241
+ Promise.allSettled(asyncPromises).then((results) => {
242
+ if (myId !== this.runId || this.disposed) return
243
+ const asyncErrors: string[] = []
244
+ for (const r of results) {
245
+ if (r.status === 'fulfilled') {
246
+ if (r.value != null) asyncErrors.push(r.value)
247
+ } else if (!isAbortError(r.reason)) {
248
+ const msg = r.reason instanceof Error ? r.reason.message : String(r.reason)
249
+ asyncErrors.push(msg)
250
+ }
251
+ }
252
+ batch(() => {
253
+ this.errors$.set(asyncErrors)
254
+ this.validating$.set(false)
255
+ })
256
+ this.emitValidated(asyncErrors.length === 0, asyncErrors)
257
+ })
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Internal — type guard / accessor for the binding hook. Avoids exposing
263
+ * `bindDevtoolsOwner` on the public `Field<T>` type while letting `createForm`
264
+ * call it via a structural check.
265
+ */
266
+ export function bindFieldDevtoolsOwner<T>(field: Field<T>, owner: FieldDevtoolsOwner | null): void {
267
+ const impl = field as { bindDevtoolsOwner?: (o: FieldDevtoolsOwner | null) => void }
268
+ if (typeof impl.bindDevtoolsOwner === 'function') {
269
+ impl.bindDevtoolsOwner(owner)
270
+ }
271
+ }
272
+
273
+ export function createField<T>(initial: T, validators?: ReadonlyArray<Validator<T>>): Field<T> {
274
+ return new FieldImpl(initial, validators)
275
+ }
276
+
277
+ /**
278
+ * Wrap an async validator with a debounce. The debounce timer resets on every
279
+ * value change. While debouncing or the request is in flight, the field's
280
+ * `isValidating` is true and `isValid` is false (treat-as-invalid-until-proven-valid).
281
+ */
282
+ export function debouncedValidator<T>(
283
+ fn: (value: T, signal: AbortSignal) => Promise<string | null>,
284
+ ms: number,
285
+ ): Validator<T> {
286
+ return (value, signal) =>
287
+ new Promise<string | null>((resolve, reject) => {
288
+ if (signal.aborted) {
289
+ reject(new DOMException('Aborted', 'AbortError'))
290
+ return
291
+ }
292
+ const timer = setTimeout(() => {
293
+ signal.removeEventListener('abort', onAbort)
294
+ fn(value, signal).then(resolve, reject)
295
+ }, ms)
296
+ const onAbort = () => {
297
+ clearTimeout(timer)
298
+ signal.removeEventListener('abort', onAbort)
299
+ reject(new DOMException('Aborted', 'AbortError'))
300
+ }
301
+ signal.addEventListener('abort', onAbort, { once: true })
302
+ })
303
+ }
@@ -0,0 +1,130 @@
1
+ import type { Field } from '../controller/types'
2
+ import type { ReadSignal } from '../signals/types'
3
+ import type { Validator } from './types'
4
+
5
+ export type FormSchema = {
6
+ [key: string]: Field<any> | Form<any> | FieldArray<any>
7
+ }
8
+
9
+ export type FormValue<S extends FormSchema> = {
10
+ [K in keyof S]: S[K] extends Field<infer T>
11
+ ? T
12
+ : S[K] extends Form<infer SS>
13
+ ? FormValue<SS>
14
+ : S[K] extends FieldArray<infer I>
15
+ ? FieldArrayValue<I>
16
+ : never
17
+ }
18
+
19
+ export type FormErrors<S extends FormSchema> = {
20
+ [K in keyof S]?: S[K] extends Field<any>
21
+ ? string[] | undefined
22
+ : S[K] extends Form<infer SS>
23
+ ? FormErrors<SS>
24
+ : S[K] extends FieldArray<infer I>
25
+ ? Array<FieldArrayItemErrors<I> | undefined>
26
+ : never
27
+ }
28
+
29
+ export type FieldArrayValue<I> =
30
+ I extends Field<infer T> ? T[] : I extends Form<infer S> ? FormValue<S>[] : never
31
+
32
+ export type FieldArrayItemErrors<I> =
33
+ I extends Field<any> ? string[] : I extends Form<infer S> ? FormErrors<S> : never
34
+
35
+ export type ItemInitial<I> =
36
+ I extends Field<infer T> ? T : I extends Form<infer S> ? DeepPartial<FormValue<S>> : never
37
+
38
+ export type DeepPartial<T> = T extends object
39
+ ? T extends ReadonlyArray<infer U>
40
+ ? ReadonlyArray<DeepPartial<U>>
41
+ : { [K in keyof T]?: DeepPartial<T[K]> }
42
+ : T
43
+
44
+ export type FormValidator<S extends FormSchema> = Validator<FormValue<S>>
45
+ export type FieldArrayValidator<I> = Validator<FieldArrayValue<I>>
46
+
47
+ export type FormOptions<S extends FormSchema> = {
48
+ initial?: (() => DeepPartial<FormValue<S>> | undefined) | DeepPartial<FormValue<S>>
49
+ validators?: FormValidator<S>[]
50
+ }
51
+
52
+ export type FieldArrayOptions<I> = {
53
+ initial?: Array<ItemInitial<I>>
54
+ validators?: FieldArrayValidator<I>[]
55
+ }
56
+
57
+ /**
58
+ * A nested form. Created via `ctx.form(schema, options?)`. `value` aggregates
59
+ * every leaf into the structurally-typed `FormValue<S>`; `errors` mirrors that
60
+ * shape with `string[] | undefined`. `flatErrors` is a flattened view useful
61
+ * for rendering a single error summary. Spec §8, §20.7.
62
+ *
63
+ * IMPORTANT: `Form.value` is a `ReadSignal<FormValue<S>>` while `Field.value`
64
+ * is `T` directly — different shapes. See `.wiki/pitfalls/field-value-shape.md`.
65
+ */
66
+ export type Form<S extends FormSchema> = {
67
+ readonly fields: { [K in keyof S]: S[K] }
68
+ readonly value: ReadSignal<FormValue<S>>
69
+ readonly errors: ReadSignal<FormErrors<S>>
70
+ readonly topLevelErrors: ReadSignal<string[]>
71
+ readonly flatErrors: ReadSignal<Array<{ path: string; errors: string[] }>>
72
+ readonly isValid: ReadSignal<boolean>
73
+ readonly isDirty: ReadSignal<boolean>
74
+ readonly touched: ReadSignal<boolean>
75
+ readonly isValidating: ReadSignal<boolean>
76
+
77
+ /** Deep-merge a partial value into the form, batched. */
78
+ set(partial: DeepPartial<FormValue<S>>): void
79
+ /**
80
+ * Re-seat the form's leaves from `partial` as their new initials —
81
+ * each leaf calls `setAsInitial(value)`, so `isDirty` stays false and a
82
+ * subsequent `reset()` returns *here*. Internal-ish but exported for
83
+ * `Form`-traversal code (nested-form initial application).
84
+ */
85
+ resetWithInitial(partial: DeepPartial<FormValue<S>>): void
86
+ /** Reset every leaf to its initial value. */
87
+ reset(): void
88
+ /** Mark every leaf as touched (so error messages appear). */
89
+ markAllTouched(): void
90
+ /** Re-run every leaf's validators. Resolves with true if all leaves are valid. */
91
+ validate(): Promise<boolean>
92
+ /** Idempotent. Called by the owning controller's dispose. */
93
+ dispose(): void
94
+ }
95
+
96
+ /**
97
+ * A dynamically-sized list of `Field` or `Form` items. Created via
98
+ * `ctx.fieldArray(itemFactory, options?)`. The factory is invoked per
99
+ * insertion. Spec §8, §20.7.
100
+ */
101
+ export type FieldArray<I extends Field<any> | Form<any>> = {
102
+ readonly items: ReadSignal<ReadonlyArray<I>>
103
+ readonly value: ReadSignal<FieldArrayValue<I>>
104
+ readonly errors: ReadSignal<Array<FieldArrayItemErrors<I> | undefined>>
105
+ readonly topLevelErrors: ReadSignal<string[]>
106
+ readonly isValid: ReadSignal<boolean>
107
+ readonly isDirty: ReadSignal<boolean>
108
+ readonly touched: ReadSignal<boolean>
109
+ readonly isValidating: ReadSignal<boolean>
110
+ readonly size: ReadSignal<number>
111
+
112
+ add(initial?: ItemInitial<I>): void
113
+ insert(index: number, initial?: ItemInitial<I>): void
114
+ remove(index: number): void
115
+ move(from: number, to: number): void
116
+ at(index: number): I | undefined
117
+ clear(): void
118
+
119
+ reset(): void
120
+ markAllTouched(): void
121
+ validate(): Promise<boolean>
122
+ dispose(): void
123
+ }
124
+
125
+ // Brand markers used by traversal logic to distinguish primitive types.
126
+ export const FORM_BRAND = Symbol.for('olas.form')
127
+ export const FIELD_ARRAY_BRAND = Symbol.for('olas.fieldArray')
128
+
129
+ export type FormBranded = { readonly [FORM_BRAND]: true }
130
+ export type FieldArrayBranded = { readonly [FIELD_ARRAY_BRAND]: true }