terrascale 0.3.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.
- package/LICENSE +202 -0
- package/README.md +143 -0
- package/package.json +159 -0
- package/sdk-current-contract.json +27 -0
- package/sdk-route-manifest.json +67 -0
- package/src/admin.js +9 -0
- package/src/better-auth.js +14 -0
- package/src/config.js +121 -0
- package/src/database-codec.js +845 -0
- package/src/database-types.js +237 -0
- package/src/database-view.js +422 -0
- package/src/database.js +420 -0
- package/src/discovery.js +374 -0
- package/src/http.js +887 -0
- package/src/index.js +79 -0
- package/src/local/authentication.js +47 -0
- package/src/local/better-auth.js +517 -0
- package/src/local/cli.js +51 -0
- package/src/local/context.js +23 -0
- package/src/local/environment.js +109 -0
- package/src/local/index.js +204 -0
- package/src/local/router.js +999 -0
- package/src/local/server.js +664 -0
- package/src/local/store.js +530 -0
- package/src/local/test-environment.js +74 -0
- package/src/management-contracts.js +72 -0
- package/src/management.js +12 -0
- package/src/native-origin.js +75 -0
- package/src/postgres.js +494 -0
- package/src/react/core.js +743 -0
- package/src/react/index.js +99 -0
- package/src/result.js +251 -0
- package/src/schema.js +366 -0
- package/src/sql.js +996 -0
- package/src/svelte/index.js +129 -0
- package/src/tanstack/index.js +511 -0
- package/src/ts-auth-discovery.js +190 -0
- package/src/ts-auth.js +3497 -0
- package/types/admin.d.ts +6 -0
- package/types/better-auth.d.ts +8 -0
- package/types/config.d.ts +58 -0
- package/types/database-codec.d.ts +111 -0
- package/types/database-types.d.ts +213 -0
- package/types/database-view.d.ts +183 -0
- package/types/database.d.ts +98 -0
- package/types/discovery.d.ts +114 -0
- package/types/http.d.ts +46 -0
- package/types/index.d.ts +52 -0
- package/types/local/authentication.d.ts +11 -0
- package/types/local/better-auth.d.ts +33 -0
- package/types/local/cli.d.ts +2 -0
- package/types/local/context.d.ts +14 -0
- package/types/local/environment.d.ts +23 -0
- package/types/local/index.d.ts +94 -0
- package/types/local/router.d.ts +66 -0
- package/types/local/server.d.ts +54 -0
- package/types/local/store.d.ts +106 -0
- package/types/local/test-environment.d.ts +25 -0
- package/types/management-contracts.d.ts +44 -0
- package/types/management.d.ts +6 -0
- package/types/native-origin.d.ts +23 -0
- package/types/postgres.d.ts +123 -0
- package/types/react/core.d.ts +366 -0
- package/types/react/index.d.ts +54 -0
- package/types/result.d.ts +161 -0
- package/types/schema.d.ts +145 -0
- package/types/sql.d.ts +288 -0
- package/types/svelte/index.d.ts +81 -0
- package/types/tanstack/index.d.ts +165 -0
- package/types/ts-auth-discovery.d.ts +11 -0
- package/types/ts-auth.d.ts +1826 -0
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
/** @import { TerraScaleError, TerraScaleResult } from "../result.js" */
|
|
2
|
+
/** @import { DatabaseResult } from "../database-types.js" */
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
createDatabaseQuery,
|
|
6
|
+
DatabaseSubscriptionError,
|
|
7
|
+
DatabaseSubscriptionView,
|
|
8
|
+
} from "../database-view.js";
|
|
9
|
+
|
|
10
|
+
/** @typedef {import("../database-view.js").DatabaseQueryCallbacks} DatabaseQueryCallbacks */
|
|
11
|
+
/** @typedef {import("../database-view.js").DatabaseQueryOptions} DatabaseQueryOptions */
|
|
12
|
+
/** @typedef {import("../database-view.js").DatabaseQuerySource} DatabaseQuerySource */
|
|
13
|
+
/** @typedef {import("../database-view.js").DatabaseViewBinding} DatabaseViewBinding */
|
|
14
|
+
/** @typedef {import("../database-view.js").DatabaseViewPosition} DatabaseViewPosition */
|
|
15
|
+
/** @typedef {import("../database-view.js").DatabaseViewSnapshot} DatabaseViewSnapshot */
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A subscription cleanup returned by a typed reactive transport.
|
|
19
|
+
*
|
|
20
|
+
* @typedef {(() => void) | { readonly dispose: () => void } | { readonly unsubscribe: () => void }} ReactiveDisposer
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {{
|
|
25
|
+
* readonly signal: AbortSignal;
|
|
26
|
+
* }} ReactiveSubscriptionContext
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @template TValue
|
|
31
|
+
* @typedef {{
|
|
32
|
+
* readonly next: (value: TValue) => void;
|
|
33
|
+
* readonly error: (error: unknown) => void;
|
|
34
|
+
* readonly reset: (reset: ReactiveReset<TValue>) => void;
|
|
35
|
+
* }} ReactiveQueryCallbacks
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The operation owned by the public reactive contract. It may use HTTP
|
|
40
|
+
* long-polling or WebSocket delivery; the hook does not know or care which.
|
|
41
|
+
*
|
|
42
|
+
* @template TValue
|
|
43
|
+
* @typedef {{
|
|
44
|
+
* readonly key?: string | readonly unknown[];
|
|
45
|
+
* readonly subscribe: (
|
|
46
|
+
* observer: ReactiveQueryCallbacks<TValue>,
|
|
47
|
+
* context: ReactiveSubscriptionContext,
|
|
48
|
+
* ) => ReactiveDisposer | void | Promise<ReactiveDisposer | void>;
|
|
49
|
+
* }} ReactiveQuerySource
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @typedef {"checkpoint-expired"
|
|
54
|
+
* | "retention-advanced"
|
|
55
|
+
* | "generation-changed"
|
|
56
|
+
* | "checkpoint-not-current"
|
|
57
|
+
* | "view-diverged"
|
|
58
|
+
* | "oversized"
|
|
59
|
+
* | "invalid-operations"
|
|
60
|
+
* | "invalid-checkpoint"
|
|
61
|
+
* | "backpressure"
|
|
62
|
+
* | (string & {})} ReactiveResetReason
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A server-authorized reset. The checkpoint remains opaque to the hook.
|
|
67
|
+
*
|
|
68
|
+
* @template TValue
|
|
69
|
+
* @typedef {{
|
|
70
|
+
* readonly reason: ReactiveResetReason;
|
|
71
|
+
* readonly data?: TValue;
|
|
72
|
+
* readonly checkpoint?: string | Uint8Array;
|
|
73
|
+
* readonly code?: string;
|
|
74
|
+
* readonly message?: string;
|
|
75
|
+
* }} ReactiveReset
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/** @typedef {"subscription" | "transport" | "remote" | "cancelled" | "mutation"} ReactiveErrorKind */
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @typedef {{
|
|
82
|
+
* readonly kind: ReactiveErrorKind;
|
|
83
|
+
* readonly code: string;
|
|
84
|
+
* readonly message: string;
|
|
85
|
+
* readonly cause?: unknown;
|
|
86
|
+
* readonly terraScale?: TerraScaleError;
|
|
87
|
+
* readonly database?: Extract<DatabaseResult<never>, { readonly ok: false }>["error"];
|
|
88
|
+
* }} ReactiveError
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
/** @typedef {"pending" | "success" | "error" | "reset"} ReactiveQueryStatus */
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @template TValue
|
|
95
|
+
* @typedef {{
|
|
96
|
+
* readonly status: ReactiveQueryStatus;
|
|
97
|
+
* readonly data: TValue | undefined;
|
|
98
|
+
* readonly error: ReactiveError | undefined;
|
|
99
|
+
* readonly reset: ReactiveReset<TValue> | undefined;
|
|
100
|
+
* readonly isLoading: boolean;
|
|
101
|
+
* readonly isFetching: boolean;
|
|
102
|
+
* readonly isStale: boolean;
|
|
103
|
+
* }} ReactiveQueryState
|
|
104
|
+
*/
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @template TValue
|
|
108
|
+
* @typedef {{
|
|
109
|
+
* readonly enabled?: boolean;
|
|
110
|
+
* readonly initialData?: TValue;
|
|
111
|
+
* }} ReactiveQueryOptions
|
|
112
|
+
*/
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Framework-neutral query observer used by useQuery and SSR-safe tests.
|
|
116
|
+
*
|
|
117
|
+
* @template TValue
|
|
118
|
+
*/
|
|
119
|
+
export class ReactiveQueryObserver {
|
|
120
|
+
/** @readonly @type {ReactiveQuerySource<TValue>} */
|
|
121
|
+
#source;
|
|
122
|
+
/** @type {boolean} */
|
|
123
|
+
#enabled;
|
|
124
|
+
/** @readonly @type {Set<() => void>} */
|
|
125
|
+
#listeners = new Set();
|
|
126
|
+
/** @readonly @type {ReactiveQueryState<TValue>} */
|
|
127
|
+
#serverSnapshot;
|
|
128
|
+
/** @type {ReactiveQueryState<TValue>} */
|
|
129
|
+
#state;
|
|
130
|
+
/** @type {AbortController | undefined} */
|
|
131
|
+
#abort;
|
|
132
|
+
/** @type {(() => void) | undefined} */
|
|
133
|
+
#cleanup;
|
|
134
|
+
#generation = 0;
|
|
135
|
+
#started = false;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {ReactiveQuerySource<TValue>} source
|
|
139
|
+
* @param {ReactiveQueryOptions<TValue>} [options]
|
|
140
|
+
*/
|
|
141
|
+
constructor(source, options = {}) {
|
|
142
|
+
this.#source = source;
|
|
143
|
+
this.#enabled = options.enabled ?? true;
|
|
144
|
+
const hasInitial = options.initialData !== undefined;
|
|
145
|
+
this.#state = freezeState({
|
|
146
|
+
status: hasInitial ? "success" : "pending",
|
|
147
|
+
data: options.initialData,
|
|
148
|
+
error: undefined,
|
|
149
|
+
reset: undefined,
|
|
150
|
+
isLoading: !hasInitial,
|
|
151
|
+
isFetching: false,
|
|
152
|
+
isStale: false,
|
|
153
|
+
});
|
|
154
|
+
this.#serverSnapshot = this.#state;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** @returns {ReactiveQueryState<TValue>} */
|
|
158
|
+
getSnapshot = () => this.#state;
|
|
159
|
+
/** @returns {ReactiveQueryState<TValue>} */
|
|
160
|
+
getServerSnapshot = () => this.#serverSnapshot;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Updates the small, hook-facing option set without replacing the external
|
|
164
|
+
* store. React may render a hook with a new options object while preserving
|
|
165
|
+
* the observer; an `enabled` transition must therefore start/stop delivery
|
|
166
|
+
* at the subscription boundary instead of leaving a stale subscription.
|
|
167
|
+
*
|
|
168
|
+
* @param {ReactiveQueryOptions<TValue>} [options]
|
|
169
|
+
* @returns {void}
|
|
170
|
+
*/
|
|
171
|
+
updateOptions(options = {}) {
|
|
172
|
+
const enabled = options.enabled ?? true;
|
|
173
|
+
const wasEnabled = this.#enabled;
|
|
174
|
+
this.#enabled = enabled;
|
|
175
|
+
if (!enabled && wasEnabled) {
|
|
176
|
+
this.dispose();
|
|
177
|
+
} else if (enabled && !wasEnabled && this.#listeners.size > 0) {
|
|
178
|
+
this.start();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @param {() => void} listener
|
|
184
|
+
* @returns {() => void}
|
|
185
|
+
*/
|
|
186
|
+
subscribe = listener => {
|
|
187
|
+
this.#listeners.add(listener);
|
|
188
|
+
if (this.#listeners.size === 1 && this.#enabled) this.start();
|
|
189
|
+
return () => {
|
|
190
|
+
this.#listeners.delete(listener);
|
|
191
|
+
if (this.#listeners.size === 0) this.dispose();
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/** @returns {void} */
|
|
196
|
+
start() {
|
|
197
|
+
if (this.#started || !this.#enabled) return;
|
|
198
|
+
this.#started = true;
|
|
199
|
+
const abort = new AbortController();
|
|
200
|
+
this.#abort = abort;
|
|
201
|
+
const generation = ++this.#generation;
|
|
202
|
+
this.#setState({
|
|
203
|
+
...this.#state,
|
|
204
|
+
isFetching: true,
|
|
205
|
+
isLoading: this.#state.data === undefined,
|
|
206
|
+
...(this.#state.data === undefined ? { status: /** @type {const} */ ("pending") } : {}),
|
|
207
|
+
});
|
|
208
|
+
// State listeners may synchronously refetch or disable this generation.
|
|
209
|
+
if (generation !== this.#generation || abort.signal.aborted) return;
|
|
210
|
+
/** @type {ReactiveDisposer | void | Promise<ReactiveDisposer | void>} */
|
|
211
|
+
let result;
|
|
212
|
+
try {
|
|
213
|
+
result = this.#source.subscribe(this.#observer(generation), { signal: abort.signal });
|
|
214
|
+
} catch (error) {
|
|
215
|
+
this.#onError(generation, error);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* @param {ReactiveDisposer | void} cleanup
|
|
220
|
+
* @returns {void}
|
|
221
|
+
*/
|
|
222
|
+
const retainCleanup = cleanup => {
|
|
223
|
+
if (generation !== this.#generation || abort.signal.aborted) disposeSubscription(cleanup);
|
|
224
|
+
else this.#cleanup = toCleanup(cleanup);
|
|
225
|
+
};
|
|
226
|
+
if (result instanceof Promise) {
|
|
227
|
+
void result.then(retainCleanup, error => this.#onError(generation, error));
|
|
228
|
+
} else {
|
|
229
|
+
retainCleanup(result);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Aborts HTTP polling and disposes the current transport subscription.
|
|
235
|
+
*
|
|
236
|
+
* @returns {void}
|
|
237
|
+
*/
|
|
238
|
+
dispose() {
|
|
239
|
+
if (!this.#started) return;
|
|
240
|
+
++this.#generation;
|
|
241
|
+
const abort = this.#abort;
|
|
242
|
+
this.#abort = undefined;
|
|
243
|
+
const cleanup = this.#cleanup;
|
|
244
|
+
this.#cleanup = undefined;
|
|
245
|
+
this.#started = false;
|
|
246
|
+
abort?.abort();
|
|
247
|
+
cleanup?.();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Restart only while an enabled query has live observers.
|
|
252
|
+
*
|
|
253
|
+
* @returns {void}
|
|
254
|
+
*/
|
|
255
|
+
refetch() {
|
|
256
|
+
if (!this.#enabled || this.#listeners.size === 0) return;
|
|
257
|
+
this.dispose();
|
|
258
|
+
this.start();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* @param {number} generation
|
|
263
|
+
* @returns {ReactiveQueryCallbacks<TValue>}
|
|
264
|
+
*/
|
|
265
|
+
#observer(generation) {
|
|
266
|
+
return {
|
|
267
|
+
next: value => {
|
|
268
|
+
if (generation !== this.#generation) return;
|
|
269
|
+
if (this.#state.status === "success" && this.#state.data === value && this.#state.isFetching && !this.#state.isStale) return;
|
|
270
|
+
this.#setState({
|
|
271
|
+
status: "success",
|
|
272
|
+
data: value,
|
|
273
|
+
error: undefined,
|
|
274
|
+
reset: undefined,
|
|
275
|
+
isLoading: false,
|
|
276
|
+
isFetching: true,
|
|
277
|
+
isStale: false,
|
|
278
|
+
});
|
|
279
|
+
},
|
|
280
|
+
error: error => this.#onError(generation, error),
|
|
281
|
+
reset: reset => {
|
|
282
|
+
if (generation !== this.#generation) return;
|
|
283
|
+
this.#setState({
|
|
284
|
+
status: "reset",
|
|
285
|
+
data: reset.data,
|
|
286
|
+
error: undefined,
|
|
287
|
+
reset,
|
|
288
|
+
isLoading: reset.data === undefined,
|
|
289
|
+
isFetching: true,
|
|
290
|
+
isStale: true,
|
|
291
|
+
});
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* @param {number} generation
|
|
298
|
+
* @param {unknown} cause
|
|
299
|
+
* @returns {void}
|
|
300
|
+
*/
|
|
301
|
+
#onError(generation, cause) {
|
|
302
|
+
if (generation !== this.#generation || this.#abort?.signal.aborted) return;
|
|
303
|
+
const error = normalizeReactiveError(cause, "subscription");
|
|
304
|
+
if (generation !== this.#generation || this.#abort?.signal.aborted) return;
|
|
305
|
+
// A subscription error is terminal for this delivery generation. Fence
|
|
306
|
+
// late stream events before aborting and disposing the transport so an
|
|
307
|
+
// already reported error cannot be replaced by a subsequent value.
|
|
308
|
+
const terminalGeneration = ++this.#generation;
|
|
309
|
+
const abort = this.#abort;
|
|
310
|
+
this.#abort = undefined;
|
|
311
|
+
const cleanup = this.#cleanup;
|
|
312
|
+
this.#cleanup = undefined;
|
|
313
|
+
this.#started = false;
|
|
314
|
+
// Abort listeners and transport cleanup may synchronously refetch. Detach
|
|
315
|
+
// the failed generation first, then leave any replacement ownership intact.
|
|
316
|
+
abort?.abort();
|
|
317
|
+
cleanup?.();
|
|
318
|
+
if (terminalGeneration !== this.#generation) return;
|
|
319
|
+
this.#setState({
|
|
320
|
+
...this.#state,
|
|
321
|
+
status: "error",
|
|
322
|
+
error,
|
|
323
|
+
isLoading: this.#state.data === undefined,
|
|
324
|
+
isFetching: false,
|
|
325
|
+
isStale: true,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* @param {ReactiveQueryState<TValue>} state
|
|
331
|
+
* @returns {void}
|
|
332
|
+
*/
|
|
333
|
+
#setState(state) {
|
|
334
|
+
this.#state = freezeState(state);
|
|
335
|
+
for (const listener of this.#listeners) listener();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* @template TValue
|
|
341
|
+
* @typedef {{ readonly kind: "snapshot" | "data"; readonly value: TValue }
|
|
342
|
+
* | { readonly kind: "reset"; readonly reset: ReactiveReset<TValue> }
|
|
343
|
+
* | { readonly kind: "error"; readonly error: unknown }} ReactiveHttpEvent
|
|
344
|
+
*/
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Typed boundary for the owning HTTP contract. Implementations perform the
|
|
348
|
+
* documented request and polling protocol; this adapter only maps events to
|
|
349
|
+
* the hook's observer and propagates AbortSignal.
|
|
350
|
+
*
|
|
351
|
+
* @template TRequest, TValue
|
|
352
|
+
* @typedef {{
|
|
353
|
+
* readonly subscribe: (
|
|
354
|
+
* request: TRequest,
|
|
355
|
+
* observer: { readonly event: (event: ReactiveHttpEvent<TValue>) => void },
|
|
356
|
+
* context: ReactiveSubscriptionContext,
|
|
357
|
+
* ) => ReactiveDisposer | void | Promise<ReactiveDisposer | void>;
|
|
358
|
+
* }} ReactiveHttpTransport
|
|
359
|
+
*/
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* @template TRequest, TValue
|
|
363
|
+
* @param {ReactiveHttpTransport<TRequest, TValue>} transport
|
|
364
|
+
* @param {TRequest} request
|
|
365
|
+
* @param {string | readonly unknown[]} [key]
|
|
366
|
+
* @returns {ReactiveQuerySource<TValue>}
|
|
367
|
+
*/
|
|
368
|
+
export function createReactiveHttpQuery(transport, request, key) {
|
|
369
|
+
return Object.freeze({
|
|
370
|
+
...(key === undefined ? {} : { key }),
|
|
371
|
+
/**
|
|
372
|
+
* @param {ReactiveQueryCallbacks<TValue>} observer
|
|
373
|
+
* @param {ReactiveSubscriptionContext} context
|
|
374
|
+
*/
|
|
375
|
+
subscribe: (observer, context) =>
|
|
376
|
+
transport.subscribe(request, {
|
|
377
|
+
event: event => {
|
|
378
|
+
switch (event.kind) {
|
|
379
|
+
case "snapshot":
|
|
380
|
+
case "data":
|
|
381
|
+
observer.next(event.value);
|
|
382
|
+
break;
|
|
383
|
+
case "reset":
|
|
384
|
+
observer.reset(event.reset);
|
|
385
|
+
break;
|
|
386
|
+
case "error":
|
|
387
|
+
observer.error(event.error);
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
}, context),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* @typedef {{
|
|
397
|
+
* readonly signal: AbortSignal;
|
|
398
|
+
* readonly idempotencyKey: string;
|
|
399
|
+
* }} ReactiveMutationContext
|
|
400
|
+
*/
|
|
401
|
+
|
|
402
|
+
/** @typedef {void | (() => void) | { readonly rollback: () => void }} OptimisticRollback */
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* @template TValue, TVariables
|
|
406
|
+
* @typedef {{
|
|
407
|
+
* readonly mutationFn: (
|
|
408
|
+
* variables: TVariables,
|
|
409
|
+
* context: ReactiveMutationContext,
|
|
410
|
+
* ) => Promise<TValue | TerraScaleResult<TValue> | DatabaseResult<TValue>>;
|
|
411
|
+
* readonly idempotencyKey?: string | ((variables: TVariables) => string);
|
|
412
|
+
* readonly onMutate?: (variables: TVariables, context: ReactiveMutationContext) => OptimisticRollback | Promise<OptimisticRollback>;
|
|
413
|
+
* }} ReactiveMutationOptions
|
|
414
|
+
*/
|
|
415
|
+
|
|
416
|
+
/** @typedef {"idle" | "pending" | "success" | "error" | "cancelled"} ReactiveMutationStatus */
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* @template TValue
|
|
420
|
+
* @typedef {{
|
|
421
|
+
* readonly status: ReactiveMutationStatus;
|
|
422
|
+
* readonly data: TValue | undefined;
|
|
423
|
+
* readonly error: ReactiveError | undefined;
|
|
424
|
+
* readonly idempotencyKey: string | undefined;
|
|
425
|
+
* readonly isPending: boolean;
|
|
426
|
+
* }} ReactiveMutationState
|
|
427
|
+
*/
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* @template TValue, TVariables
|
|
431
|
+
*/
|
|
432
|
+
export class ReactiveMutationObserver {
|
|
433
|
+
/** @type {ReactiveMutationOptions<TValue, TVariables>} */
|
|
434
|
+
#options;
|
|
435
|
+
/** @readonly @type {Set<() => void>} */
|
|
436
|
+
#listeners = new Set();
|
|
437
|
+
/** @type {ReactiveMutationState<TValue>} */
|
|
438
|
+
#state = freezeMutationState({ status: "idle", data: /** @type {TValue | undefined} */ (undefined), error: undefined, idempotencyKey: undefined, isPending: false });
|
|
439
|
+
/** @type {AbortController | undefined} */
|
|
440
|
+
#abort;
|
|
441
|
+
/** @type {(() => void) | undefined} */
|
|
442
|
+
#rollback;
|
|
443
|
+
/** @type {string | undefined} */
|
|
444
|
+
#activeKey;
|
|
445
|
+
#run = 0;
|
|
446
|
+
#activeRun = 0;
|
|
447
|
+
/** @type {number | undefined} */
|
|
448
|
+
#cancelledRun;
|
|
449
|
+
#disposed = false;
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* @param {ReactiveMutationOptions<TValue, TVariables>} options
|
|
453
|
+
*/
|
|
454
|
+
constructor(options) {
|
|
455
|
+
this.#options = options;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Keep callbacks current when a component re-renders without replacing the
|
|
460
|
+
* observer (the normal `useMutation` lifecycle). A mutation already running
|
|
461
|
+
* captures the callbacks it started with, so a render cannot redirect an
|
|
462
|
+
* in-flight durable request.
|
|
463
|
+
*
|
|
464
|
+
* @param {ReactiveMutationOptions<TValue, TVariables>} options
|
|
465
|
+
* @returns {void}
|
|
466
|
+
*/
|
|
467
|
+
updateOptions(options) {
|
|
468
|
+
this.#options = options;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** @returns {ReactiveMutationState<TValue>} */
|
|
472
|
+
getSnapshot = () => this.#state;
|
|
473
|
+
/**
|
|
474
|
+
* @param {() => void} listener
|
|
475
|
+
* @returns {() => void}
|
|
476
|
+
*/
|
|
477
|
+
subscribe = listener => {
|
|
478
|
+
this.#listeners.add(listener);
|
|
479
|
+
return () => this.#listeners.delete(listener);
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* @param {TVariables} variables
|
|
484
|
+
* @returns {Promise<TValue>}
|
|
485
|
+
*/
|
|
486
|
+
async mutateAsync(variables) {
|
|
487
|
+
if (this.#disposed) throw new ReactiveMutationError("cancelled", "the mutation observer has been disposed");
|
|
488
|
+
if (this.#state.isPending) throw new ReactiveMutationError("mutation_in_flight", "only one mutation may be in flight per hook");
|
|
489
|
+
const options = this.#options;
|
|
490
|
+
const idempotencyKey = normalizeIdempotencyKey(
|
|
491
|
+
typeof options.idempotencyKey === "function"
|
|
492
|
+
? options.idempotencyKey(variables)
|
|
493
|
+
: options.idempotencyKey ?? createIdempotencyKey(),
|
|
494
|
+
);
|
|
495
|
+
const abort = new AbortController();
|
|
496
|
+
const run = ++this.#run;
|
|
497
|
+
const context = Object.freeze({ signal: abort.signal, idempotencyKey });
|
|
498
|
+
this.#abort = abort;
|
|
499
|
+
this.#activeRun = run;
|
|
500
|
+
this.#cancelledRun = undefined;
|
|
501
|
+
this.#activeKey = idempotencyKey;
|
|
502
|
+
/** @type {(() => void) | undefined} */
|
|
503
|
+
let rollback;
|
|
504
|
+
/** @returns {void} */
|
|
505
|
+
const rollbackNow = () => {
|
|
506
|
+
const pending = rollback;
|
|
507
|
+
rollback = undefined;
|
|
508
|
+
pending?.();
|
|
509
|
+
};
|
|
510
|
+
this.#rollback = rollbackNow;
|
|
511
|
+
try {
|
|
512
|
+
this.#setState({ status: "pending", data: this.#state.data, error: undefined, idempotencyKey, isPending: true });
|
|
513
|
+
if (abort.signal.aborted || run !== this.#activeRun) throw cancelledError();
|
|
514
|
+
rollback = toRollback(await options.onMutate?.(variables, context));
|
|
515
|
+
if (abort.signal.aborted || run !== this.#activeRun) throw cancelledError();
|
|
516
|
+
const result = await options.mutationFn(variables, context);
|
|
517
|
+
const value = unwrapMutationResult(result);
|
|
518
|
+
if (abort.signal.aborted) throw cancelledError();
|
|
519
|
+
rollback = undefined;
|
|
520
|
+
if (run === this.#activeRun) this.#setState({ status: "success", data: value, error: undefined, idempotencyKey, isPending: false });
|
|
521
|
+
return value;
|
|
522
|
+
} catch (cause) {
|
|
523
|
+
rollbackNow();
|
|
524
|
+
const error = normalizeReactiveError(cause, abort.signal.aborted ? "cancelled" : "mutation");
|
|
525
|
+
if (run === this.#activeRun) this.#setState({ status: error.kind === "cancelled" ? "cancelled" : "error", data: this.#state.data, error, idempotencyKey, isPending: false });
|
|
526
|
+
else if (run === this.#cancelledRun && this.#state.status === "cancelled" && error.database !== undefined) {
|
|
527
|
+
// Cancellation stops presentation immediately. A later native result
|
|
528
|
+
// may still establish uncertainty for this same server operation.
|
|
529
|
+
this.#setState({ ...this.#state, error });
|
|
530
|
+
}
|
|
531
|
+
throw cause instanceof Error ? cause : new ReactiveMutationError(error.code, error.message, cause);
|
|
532
|
+
} finally {
|
|
533
|
+
if (run === this.#activeRun) {
|
|
534
|
+
this.#rollback = undefined;
|
|
535
|
+
this.#abort = undefined;
|
|
536
|
+
this.#activeKey = undefined;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* @param {TVariables} variables
|
|
543
|
+
* @returns {void}
|
|
544
|
+
*/
|
|
545
|
+
mutate(variables) {
|
|
546
|
+
void this.mutateAsync(variables).catch(() => undefined);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Cancels the durable request and immediately rolls back its presentation.
|
|
551
|
+
*
|
|
552
|
+
* @returns {void}
|
|
553
|
+
*/
|
|
554
|
+
cancel() {
|
|
555
|
+
if (!this.#state.isPending) return;
|
|
556
|
+
const abort = this.#abort;
|
|
557
|
+
const rollback = this.#rollback;
|
|
558
|
+
const key = this.#activeKey;
|
|
559
|
+
this.#abort = undefined;
|
|
560
|
+
this.#rollback = undefined;
|
|
561
|
+
this.#activeKey = undefined;
|
|
562
|
+
this.#cancelledRun = this.#activeRun;
|
|
563
|
+
const cancelledGeneration = this.#activeRun = ++this.#run;
|
|
564
|
+
const error = normalizeReactiveError(cancelledError(), "cancelled");
|
|
565
|
+
// Publish the detached ownership before invoking abort/rollback callbacks,
|
|
566
|
+
// then notify only if those callbacks did not start a replacement run.
|
|
567
|
+
this.#state = freezeMutationState({ status: "cancelled", data: this.#state.data, error, idempotencyKey: key, isPending: false });
|
|
568
|
+
abort?.abort();
|
|
569
|
+
rollback?.();
|
|
570
|
+
if (cancelledGeneration === this.#activeRun) this.#notify();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** @returns {void} */
|
|
574
|
+
reset() {
|
|
575
|
+
const resetGeneration = this.#run + (this.#state.isPending ? 1 : 0);
|
|
576
|
+
this.cancel();
|
|
577
|
+
if (resetGeneration !== this.#run) return;
|
|
578
|
+
this.#setState({ status: "idle", data: undefined, error: undefined, idempotencyKey: undefined, isPending: false });
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** @returns {void} */
|
|
582
|
+
dispose() {
|
|
583
|
+
this.#disposed = true;
|
|
584
|
+
this.cancel();
|
|
585
|
+
this.#listeners.clear();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* @param {ReactiveMutationState<TValue>} state
|
|
590
|
+
* @returns {void}
|
|
591
|
+
*/
|
|
592
|
+
#setState(state) {
|
|
593
|
+
this.#state = freezeMutationState(state);
|
|
594
|
+
this.#notify();
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** @returns {void} */
|
|
598
|
+
#notify() {
|
|
599
|
+
for (const listener of this.#listeners) listener();
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export class ReactiveMutationError extends Error {
|
|
604
|
+
/** @override @readonly */
|
|
605
|
+
name = "ReactiveMutationError";
|
|
606
|
+
/** @readonly @type {string} */
|
|
607
|
+
code;
|
|
608
|
+
/** @override @readonly @type {unknown} */
|
|
609
|
+
cause;
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* @param {string} code
|
|
613
|
+
* @param {string} message
|
|
614
|
+
* @param {unknown} [cause]
|
|
615
|
+
*/
|
|
616
|
+
constructor(code, message, cause) {
|
|
617
|
+
super(`terrascale reactive mutation: ${code}: ${message}`, { cause });
|
|
618
|
+
this.code = code;
|
|
619
|
+
this.cause = cause;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* @template TValue
|
|
625
|
+
* @param {TValue | TerraScaleResult<TValue> | DatabaseResult<TValue>} result
|
|
626
|
+
* @returns {TValue}
|
|
627
|
+
*/
|
|
628
|
+
function unwrapMutationResult(result) {
|
|
629
|
+
if (isTerraScaleResult(result)) {
|
|
630
|
+
if (!result.ok) throw new ReactiveMutationError(result.error.code, "message" in result.error ? result.error.message : `TerraBase request failed: ${result.error.code}`, result.error);
|
|
631
|
+
return result.value;
|
|
632
|
+
}
|
|
633
|
+
return result;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* @template TValue
|
|
638
|
+
* @param {TValue | TerraScaleResult<TValue> | DatabaseResult<TValue>} value
|
|
639
|
+
* @returns {value is TerraScaleResult<TValue> | DatabaseResult<TValue>}
|
|
640
|
+
*/
|
|
641
|
+
function isTerraScaleResult(value) {
|
|
642
|
+
return typeof value === "object" && value !== null && "ok" in value && typeof (/** @type {{ readonly ok?: unknown }} */ (value)).ok === "boolean";
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* @param {unknown} cause
|
|
647
|
+
* @param {ReactiveErrorKind} fallback
|
|
648
|
+
* @returns {ReactiveError}
|
|
649
|
+
*/
|
|
650
|
+
function normalizeReactiveError(cause, fallback) {
|
|
651
|
+
if (cause instanceof ReactiveMutationError) return Object.freeze({ kind: fallback, code: cause.code, message: cause.message, cause: cause.cause, ...(isDatabaseError(cause.cause) ? { database: cause.cause } : {}) });
|
|
652
|
+
if (isTerraScaleError(cause)) {
|
|
653
|
+
return Object.freeze({ kind: fallback, code: cause.code, message: cause.message, cause, terraScale: cause, ...(cause instanceof Error && isDatabaseError(cause.cause) ? { database: cause.cause } : {}) });
|
|
654
|
+
}
|
|
655
|
+
if (isDatabaseError(cause)) return Object.freeze({ kind: fallback, code: cause.code, message: `TerraBase request failed: ${cause.code}`, cause, database: cause });
|
|
656
|
+
if (cause instanceof Error) return Object.freeze({ kind: fallback, code: fallback, message: cause.message, cause });
|
|
657
|
+
return Object.freeze({ kind: fallback, code: fallback, message: String(cause), cause });
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* @param {unknown} value
|
|
662
|
+
* @returns {value is Extract<DatabaseResult<never>, { readonly ok: false }>["error"]}
|
|
663
|
+
*/
|
|
664
|
+
function isDatabaseError(value) {
|
|
665
|
+
return typeof value === "object" && value !== null && "code" in value && typeof value.code === "string" && !("message" in value);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* @param {unknown} value
|
|
670
|
+
* @returns {value is TerraScaleError}
|
|
671
|
+
*/
|
|
672
|
+
function isTerraScaleError(value) {
|
|
673
|
+
return typeof value === "object" && value !== null && "code" in value && "message" in value && typeof (/** @type {{ readonly code?: unknown }} */ (value)).code === "string";
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* @template TValue
|
|
678
|
+
* @param {ReactiveQueryState<TValue>} state
|
|
679
|
+
* @returns {ReactiveQueryState<TValue>}
|
|
680
|
+
*/
|
|
681
|
+
function freezeState(state) {
|
|
682
|
+
return Object.freeze(state);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* @template TValue
|
|
687
|
+
* @param {ReactiveMutationState<TValue>} state
|
|
688
|
+
* @returns {ReactiveMutationState<TValue>}
|
|
689
|
+
*/
|
|
690
|
+
function freezeMutationState(state) {
|
|
691
|
+
return Object.freeze(state);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* @param {ReactiveDisposer | void} disposer
|
|
696
|
+
* @returns {(() => void) | undefined}
|
|
697
|
+
*/
|
|
698
|
+
function toCleanup(disposer) {
|
|
699
|
+
if (disposer === undefined) return undefined;
|
|
700
|
+
if (typeof disposer === "function") return disposer;
|
|
701
|
+
if ("dispose" in disposer) return () => disposer.dispose();
|
|
702
|
+
return () => disposer.unsubscribe();
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* @param {ReactiveDisposer | void} disposer
|
|
707
|
+
* @returns {void}
|
|
708
|
+
*/
|
|
709
|
+
function disposeSubscription(disposer) {
|
|
710
|
+
toCleanup(disposer)?.();
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* @param {OptimisticRollback} rollback
|
|
715
|
+
* @returns {(() => void) | undefined}
|
|
716
|
+
*/
|
|
717
|
+
function toRollback(rollback) {
|
|
718
|
+
if (rollback === undefined) return undefined;
|
|
719
|
+
if (typeof rollback === "function") return rollback;
|
|
720
|
+
return rollback.rollback;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* @param {string} value
|
|
725
|
+
* @returns {string}
|
|
726
|
+
*/
|
|
727
|
+
function normalizeIdempotencyKey(value) {
|
|
728
|
+
if (typeof value !== "string" || value.trim().length === 0 || value.trim().length > 128) {
|
|
729
|
+
throw new TypeError("Reactive mutation idempotency keys must be nonblank and contain at most 128 characters.");
|
|
730
|
+
}
|
|
731
|
+
return value.trim();
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** @returns {string} */
|
|
735
|
+
function createIdempotencyKey() {
|
|
736
|
+
const randomUuid = globalThis.crypto?.randomUUID;
|
|
737
|
+
return randomUuid === undefined ? `tsm-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` : randomUuid.call(globalThis.crypto);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** @returns {ReactiveMutationError} */
|
|
741
|
+
function cancelledError() {
|
|
742
|
+
return new ReactiveMutationError("cancelled", "the mutation was cancelled");
|
|
743
|
+
}
|