@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.
- package/dist/index.cjs +4 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/{root-BBSlzvJ2.mjs → root-DqWolle_.mjs} +160 -6
- package/dist/root-DqWolle_.mjs.map +1 -0
- package/dist/{root-CoafhkTg.cjs → root-lBp7qziQ.cjs} +177 -5
- package/dist/root-lBp7qziQ.cjs.map +1 -0
- package/dist/testing.cjs +1 -1
- package/dist/testing.d.cts +1 -1
- package/dist/testing.d.mts +1 -1
- package/dist/testing.mjs +1 -1
- package/dist/{types-BCf2nB2N.d.mts → types-BH1o6nYa.d.mts} +129 -4
- package/dist/{types-BCf2nB2N.d.mts.map → types-BH1o6nYa.d.mts.map} +1 -1
- package/dist/{types-Ijeun3qo.d.cts → types-C4Vtkxbn.d.cts} +129 -4
- package/dist/{types-Ijeun3qo.d.cts.map → types-C4Vtkxbn.d.cts.map} +1 -1
- package/package.json +1 -1
- package/src/controller/instance.ts +11 -1
- package/src/index.ts +11 -2
- package/src/query/client.ts +36 -0
- package/src/query/mutation.ts +178 -1
- package/src/query/plugin.ts +91 -1
- package/dist/root-BBSlzvJ2.mjs.map +0 -1
- package/dist/root-CoafhkTg.cjs.map +0 -1
package/src/query/mutation.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/src/query/plugin.ts
CHANGED
|
@@ -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
|
+
}
|