@kontsedal/olas-core 0.0.4 → 0.0.5

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.
@@ -3,6 +3,7 @@ import { dispatchError, type ErrorHandler } from '../errors'
3
3
  import { batch, type Signal, signal } from '../signals'
4
4
  import type { ReadSignal } from '../signals/types'
5
5
  import { abortableSleep, isAbortError } from '../utils'
6
+ import { registerMutationById } from './plugin'
6
7
  import type { RetryDelay, RetryPolicy, Snapshot } from './types'
7
8
 
8
9
  /**
@@ -40,6 +41,83 @@ export type MutationSpec<V, R> = {
40
41
  concurrency?: MutationConcurrency
41
42
  retry?: RetryPolicy
42
43
  retryDelay?: RetryDelay
44
+ /**
45
+ * Stable identifier used by the mutation-queue plugin
46
+ * (`@kontsedal/olas-mutation-queue`) to route persistable runs across a
47
+ * page reload. REQUIRED when `persist: true`. Recommended even without
48
+ * `persist` if you want devtools to group runs across mutation instances
49
+ * — same shape as `defineQuery({ queryId })`.
50
+ *
51
+ * Don't auto-derive from `name` or function identity; both are fragile
52
+ * under minification.
53
+ */
54
+ mutationId?: string
55
+ /**
56
+ * Opt this mutation into durable persistence. When `true`, the runner
57
+ * emits `onMutationEnqueue` to plugins before the user's `mutate` runs
58
+ * and `onMutationSettle` after retries exhaust. Requires `mutationId`.
59
+ * SPEC §13.3.
60
+ */
61
+ persist?: boolean
62
+ }
63
+
64
+ /**
65
+ * Module-scope handle for a persistable mutation. Returned by
66
+ * `defineMutation(...)`. Pass it to `ctx.mutation(...)` (spread or as-is)
67
+ * so per-controller lifecycle hooks (`onSuccess` / `onError` / ...) can be
68
+ * layered on top.
69
+ *
70
+ * Registering at module import time means the mutation-queue plugin can
71
+ * replay pending runs from durable storage during `init` — before any
72
+ * controller reconstructs.
73
+ */
74
+ export type MutationDef<V, R> = MutationSpec<V, R> & {
75
+ readonly __olas: 'mutation'
76
+ readonly mutationId: string
77
+ }
78
+
79
+ /**
80
+ * Register a persistable mutation at module scope. Returns the spec
81
+ * unchanged (with a `__olas: 'mutation'` brand) so consumers can pass it
82
+ * to `ctx.mutation(...)`, optionally spreading per-controller hooks on
83
+ * top:
84
+ *
85
+ * ```ts
86
+ * // module-scope
87
+ * export const createOrder = defineMutation({
88
+ * mutationId: 'order/create',
89
+ * mutate: async (vars: OrderInput, { signal }) => api.createOrder(vars, { signal }),
90
+ * })
91
+ *
92
+ * // controller
93
+ * const m = ctx.mutation({
94
+ * ...createOrder,
95
+ * onSuccess: () => toast('Order placed'),
96
+ * })
97
+ * ```
98
+ *
99
+ * The `mutate` function MUST NOT close over controller-instance state — on
100
+ * replay there is no controller. Module-level dependencies (a shared `api`
101
+ * client, etc.) are fine.
102
+ */
103
+ export function defineMutation<V, R>(
104
+ spec: MutationSpec<V, R> & { mutationId: string; persist?: boolean },
105
+ ): MutationDef<V, R> {
106
+ if (typeof spec.mutationId !== 'string' || spec.mutationId.length === 0) {
107
+ throw new Error('[olas] defineMutation requires a non-empty `mutationId`.')
108
+ }
109
+ // Default `persist: true` for defined mutations — that's the whole point
110
+ // of using the module-scope helper. Consumers who want a non-persistable
111
+ // module-scope handle can override with `persist: false`.
112
+ const persistSpec: MutationSpec<V, R> = { ...spec, persist: spec.persist ?? true }
113
+ registerMutationById(spec.mutationId, {
114
+ mutationId: spec.mutationId,
115
+ mutate: spec.mutate as (vars: unknown, signal: AbortSignal) => Promise<unknown>,
116
+ })
117
+ return Object.assign(persistSpec, {
118
+ __olas: 'mutation' as const,
119
+ mutationId: spec.mutationId,
120
+ })
43
121
  }
44
122
 
45
123
  /**
@@ -88,6 +166,26 @@ type SerialEntry<V, R> = {
88
166
  reject: (err: unknown) => void
89
167
  }
90
168
 
169
+ /**
170
+ * Hooks for emitting persistable-mutation lifecycle events back to the
171
+ * `QueryClient`. Wired from `createMutation` when `spec.persist === true`.
172
+ * Internal — not part of any public surface.
173
+ */
174
+ export type MutationLifecycleHooks = {
175
+ emitEnqueue(event: {
176
+ mutationId: string
177
+ runId: string
178
+ variables: unknown
179
+ attempt: number
180
+ }): void
181
+ emitSettle(event: {
182
+ mutationId: string
183
+ runId: string
184
+ outcome: 'success' | 'error' | 'cancelled'
185
+ error?: unknown
186
+ }): void
187
+ }
188
+
91
189
  class MutationImpl<V, R> implements Mutation<V, R> {
92
190
  readonly data: Signal<R | undefined> = signal(undefined)
93
191
  readonly error: Signal<unknown | undefined> = signal(undefined)
@@ -107,8 +205,18 @@ class MutationImpl<V, R> implements Mutation<V, R> {
107
205
  update(fn: (n: number) => number): void
108
206
  },
109
207
  private readonly devtools?: DevtoolsEmitter,
208
+ private readonly lifecycle?: MutationLifecycleHooks,
110
209
  ) {}
111
210
 
211
+ /**
212
+ * True iff this mutation should emit persistable-lifecycle events.
213
+ * Validated at construction time (in `createMutation`) so any malformed
214
+ * `persist: true`-without-`mutationId` config surfaces early.
215
+ */
216
+ private get isPersistable(): boolean {
217
+ return this.spec.persist === true && this.lifecycle !== undefined
218
+ }
219
+
112
220
  private emit(event: { type: 'mutation:run'; vars: unknown }): void
113
221
  private emit(event: { type: 'mutation:success'; result: unknown }): void
114
222
  private emit(event: { type: 'mutation:error'; error: unknown }): void
@@ -204,10 +312,30 @@ class MutationImpl<V, R> implements Mutation<V, R> {
204
312
 
205
313
  if (__DEV__) this.emit({ type: 'mutation:run', vars })
206
314
 
315
+ // Persistable mutations emit an enqueue event BEFORE the user's `mutate`
316
+ // runs. If the page reloads mid-mutation, the queue plugin replays from
317
+ // this entry. `runId` is unique per `executeRun` invocation; retries
318
+ // within `runWithRetry` reuse it via `attempt` bumps inside that loop.
319
+ const runId = this.isPersistable ? makeRunId() : ''
320
+ const mutationId = this.spec.mutationId
321
+ if (this.isPersistable && mutationId !== undefined) {
322
+ try {
323
+ this.lifecycle?.emitEnqueue({ mutationId, runId, variables: vars, attempt: 0 })
324
+ } catch (err) {
325
+ dispatchError(this.onError, err, {
326
+ kind: 'plugin',
327
+ controllerPath: this.controllerPath,
328
+ })
329
+ }
330
+ }
331
+
207
332
  try {
208
333
  const result = await raceAbort(this.runWithRetry(vars, abort.signal), abort.signal)
209
334
  if (abort.signal.aborted || this.disposed) {
210
335
  snapshot?.rollback()
336
+ if (this.isPersistable && mutationId !== undefined) {
337
+ this.safeEmitSettle({ mutationId, runId, outcome: 'cancelled' })
338
+ }
211
339
  throw new DOMException('Superseded', 'AbortError')
212
340
  }
213
341
  batch(() => {
@@ -221,10 +349,16 @@ class MutationImpl<V, R> implements Mutation<V, R> {
221
349
  // Spec §6.4.
222
350
  snapshot?.finalize()
223
351
  this.safeCall(() => this.spec.onSettled?.(result, undefined, vars), 'mutation')
352
+ if (this.isPersistable && mutationId !== undefined) {
353
+ this.safeEmitSettle({ mutationId, runId, outcome: 'success' })
354
+ }
224
355
  return result
225
356
  } catch (err) {
226
357
  if (isAbortError(err) || abort.signal.aborted) {
227
358
  snapshot?.rollback()
359
+ if (this.isPersistable && mutationId !== undefined) {
360
+ this.safeEmitSettle({ mutationId, runId, outcome: 'cancelled' })
361
+ }
228
362
  // Reserve `error` signal for genuine failures.
229
363
  throw err
230
364
  }
@@ -236,6 +370,9 @@ class MutationImpl<V, R> implements Mutation<V, R> {
236
370
  // turns the auto-call into a no-op. Spec §6.4.
237
371
  snapshot?.rollback()
238
372
  this.safeCall(() => this.spec.onSettled?.(undefined, err, vars), 'mutation')
373
+ if (this.isPersistable && mutationId !== undefined) {
374
+ this.safeEmitSettle({ mutationId, runId, outcome: 'error', error: err })
375
+ }
239
376
  throw err
240
377
  } finally {
241
378
  this.inflight.delete(handle)
@@ -246,6 +383,22 @@ class MutationImpl<V, R> implements Mutation<V, R> {
246
383
  }
247
384
  }
248
385
 
386
+ private safeEmitSettle(event: {
387
+ mutationId: string
388
+ runId: string
389
+ outcome: 'success' | 'error' | 'cancelled'
390
+ error?: unknown
391
+ }): void {
392
+ try {
393
+ this.lifecycle?.emitSettle(event)
394
+ } catch (err) {
395
+ dispatchError(this.onError, err, {
396
+ kind: 'plugin',
397
+ controllerPath: this.controllerPath,
398
+ })
399
+ }
400
+ }
401
+
249
402
  // Wrap so any rollback / finalize path runs the raw operation at most
250
403
  // once. The mutation auto-finalizes on success and auto-rolls-back on
251
404
  // error; user code may also call rollback() from onError. Whichever
@@ -334,8 +487,32 @@ export function createMutation<V, R>(
334
487
  controllerPath: readonly string[],
335
488
  inflightCounter?: { update(fn: (n: number) => number): void },
336
489
  devtools?: DevtoolsEmitter,
490
+ lifecycle?: MutationLifecycleHooks,
337
491
  ): Mutation<V, R> {
338
- return new MutationImpl<V, R>(spec, onError, controllerPath, inflightCounter, devtools)
492
+ // Validate persistable-mutation config at construction time so misconfig
493
+ // surfaces synchronously rather than on first `run()`.
494
+ if (spec.persist === true) {
495
+ if (typeof spec.mutationId !== 'string' || spec.mutationId.length === 0) {
496
+ throw new Error(
497
+ '[olas] ctx.mutation({ persist: true, ... }) requires a non-empty `mutationId`.',
498
+ )
499
+ }
500
+ }
501
+ return new MutationImpl<V, R>(spec, onError, controllerPath, inflightCounter, devtools, lifecycle)
502
+ }
503
+
504
+ /**
505
+ * Generate a unique-enough run id for the persistable-mutation lifecycle.
506
+ * Uses `crypto.randomUUID` where available (Node 19+, modern browsers),
507
+ * with a timestamp+random fallback for older runtimes. Collisions only
508
+ * affect dedup at the plugin layer, not correctness, so the fallback's
509
+ * weakness is acceptable.
510
+ */
511
+ function makeRunId(): string {
512
+ const g = globalThis as { crypto?: { randomUUID?: () => string } }
513
+ if (typeof g.crypto?.randomUUID === 'function') return g.crypto.randomUUID()
514
+ const rand = Math.random().toString(36).slice(2, 12)
515
+ return `${Date.now().toString(36)}-${rand}`
339
516
  }
340
517
 
341
518
  /**
@@ -118,6 +118,36 @@ export type GcEvent = {
118
118
  kind: 'data' | 'infinite'
119
119
  }
120
120
 
121
+ /**
122
+ * Emitted when a persistable mutation (`spec.persist === true`) starts
123
+ * executing — before the user's `mutate` is invoked. Plugins use this to
124
+ * persist the variables to durable storage; if the page reloads mid-run,
125
+ * the queue replays from these entries.
126
+ *
127
+ * `runId` is unique per execution (a single `mutation.run(...)` call OR a
128
+ * replay attempt). `attempt` counts retry passes within a single runId.
129
+ */
130
+ export type MutationEnqueueEvent = {
131
+ mutationId: string
132
+ runId: string
133
+ variables: unknown
134
+ attempt: number
135
+ }
136
+
137
+ /**
138
+ * Emitted after a persistable mutation settles. Plugins use this to drop
139
+ * the run from the durable queue (on `'success'` or `'error'` after retries
140
+ * exhaust), or to leave it pending (on `'cancelled'` — e.g. parent dispose
141
+ * mid-flight).
142
+ */
143
+ export type MutationSettleEvent = {
144
+ mutationId: string
145
+ runId: string
146
+ outcome: 'success' | 'error' | 'cancelled'
147
+ /** Only present on `'error'` — the final thrown value after retries. */
148
+ error?: unknown
149
+ }
150
+
121
151
  /**
122
152
  * Plugin contract. Every hook is optional. Hooks are wrapped in try/catch
123
153
  * by `QueryClient`; thrown exceptions are routed through the root's
@@ -126,12 +156,24 @@ export type GcEvent = {
126
156
  export type QueryClientPlugin = {
127
157
  /**
128
158
  * Called once after the `QueryClient` is constructed. Use it to wire up
129
- * transport listeners and capture the `QueryClientPluginApi`.
159
+ * transport listeners and capture the `QueryClientPluginApi`. SPEC §13.2.
160
+ *
161
+ * Persistable-mutation replay typically happens HERE — module-scope
162
+ * `defineMutation(...)` calls have already registered their handlers by
163
+ * the time `createRoot(...)` runs, so `init` can walk durable storage
164
+ * and re-invoke registered mutates for any pending entries.
130
165
  */
131
166
  init?(api: QueryClientPluginApi): void
132
167
  onSetData?(event: SetDataEvent): void
133
168
  onInvalidate?(event: InvalidateEvent): void
134
169
  onGc?(event: GcEvent): void
170
+ /**
171
+ * Fired when a persistable mutation (`spec.persist === true`) starts
172
+ * executing. SPEC §13.3.
173
+ */
174
+ onMutationEnqueue?(event: MutationEnqueueEvent): void
175
+ /** Fired after a persistable mutation settles. SPEC §13.3. */
176
+ onMutationSettle?(event: MutationSettleEvent): void
135
177
  /** Called from `QueryClient.dispose`. Tear down transports / listeners here. */
136
178
  dispose?(): void
137
179
  }
@@ -183,3 +225,51 @@ export function lookupRegisteredQuery(queryId: string): RegisteredQuery | undefi
183
225
  export function _unregisterQueryById(queryId: string): void {
184
226
  queryRegistry.delete(queryId)
185
227
  }
228
+
229
+ // ────────────────────────────────────────────────────────────────────────────
230
+ // Mutation registry — parallel to query registry. Persistable mutations
231
+ // (`spec.persist === true`) register themselves at module-import time via
232
+ // `defineMutation(...)` so the mutation-queue plugin can replay pending
233
+ // runs at `init` time, BEFORE controllers reconstruct.
234
+ // ────────────────────────────────────────────────────────────────────────────
235
+
236
+ /**
237
+ * Shape stored in the `mutationId → handler` registry. Only the `mutate`
238
+ * function and the id are needed for replay — lifecycle hooks like
239
+ * `onSuccess` / `onError` are per-controller and can't be safely replayed
240
+ * across page reloads (the controller doesn't exist yet).
241
+ */
242
+ export type RegisteredMutation = {
243
+ readonly mutationId: string
244
+ /**
245
+ * Replay-safe `mutate`. Matches `MutationSpec.mutate` 1:1 — receives the
246
+ * variables and an `AbortSignal`. The mutation-queue plugin invokes this
247
+ * directly on replay, so the implementation MUST NOT close over
248
+ * controller-instance state. Module-level deps (a shared `api` client,
249
+ * etc.) are fine.
250
+ */
251
+ readonly mutate: (vars: unknown, signal: AbortSignal) => Promise<unknown>
252
+ }
253
+
254
+ const mutationRegistry = new Map<string, RegisteredMutation>()
255
+
256
+ /** Register a mutation by its `mutationId`. Internal — called from `defineMutation`. */
257
+ export function registerMutationById(mutationId: string, entry: RegisteredMutation): void {
258
+ mutationRegistry.set(mutationId, entry)
259
+ }
260
+
261
+ /**
262
+ * Look up a registered mutation by id. Returns `undefined` when no
263
+ * mutation with that id has been defined — typical when a queue entry
264
+ * references a mutation whose module hasn't been imported (e.g. a
265
+ * code-split route boundary). The plugin should leave such entries in
266
+ * place and retry once the module loads.
267
+ */
268
+ export function lookupRegisteredMutation(mutationId: string): RegisteredMutation | undefined {
269
+ return mutationRegistry.get(mutationId)
270
+ }
271
+
272
+ /** Test-only — drop a registered mutation. Not exported from the package. */
273
+ export function _unregisterMutationById(mutationId: string): void {
274
+ mutationRegistry.delete(mutationId)
275
+ }