@treygrr/nuxt-airplane 0.1.2 → 0.2.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/dist/module.json +1 -1
- package/dist/runtime/client.d.ts +20 -0
- package/dist/runtime/client.js +83 -5
- package/dist/runtime/composables/useMutation.d.ts +22 -5
- package/dist/runtime/composables/useMutation.js +3 -2
- package/dist/runtime/plugin.client.d.ts +4 -0
- package/dist/runtime/types.d.ts +34 -3
- package/package.json +1 -1
package/dist/module.json
CHANGED
package/dist/runtime/client.d.ts
CHANGED
|
@@ -21,6 +21,24 @@ export interface QuerySnapshot {
|
|
|
21
21
|
export declare function humanize(name: string): string;
|
|
22
22
|
/** `undefined`, `null` and `{}` all mean "no variables" and share a key. */
|
|
23
23
|
export declare function queryKey(name: string, variables: unknown): string;
|
|
24
|
+
/** A fresh placeholder id: `tmp-` plus a UUID. */
|
|
25
|
+
export declare function newTempId(): string;
|
|
26
|
+
/** Whether a value is an id minted by `tempId()`. */
|
|
27
|
+
export declare function isTempId(value: unknown): value is string;
|
|
28
|
+
/**
|
|
29
|
+
* Replaces, anywhere in `value`, every string that is a key of `ids` with its
|
|
30
|
+
* mapped id. Reports whether anything changed and which minted temp ids are
|
|
31
|
+
* still in there with no mapping.
|
|
32
|
+
*/
|
|
33
|
+
export declare function substituteTempIds<T>(value: T, ids: Record<string, string>): {
|
|
34
|
+
value: T;
|
|
35
|
+
replaced: boolean;
|
|
36
|
+
unresolved: string[];
|
|
37
|
+
};
|
|
38
|
+
/** A mutation about to be sent still carries a temp id the server never assigned. */
|
|
39
|
+
export declare class UnresolvedTempIdError extends Error {
|
|
40
|
+
name: string;
|
|
41
|
+
}
|
|
24
42
|
export type AirplaneClient = ReturnType<typeof createAirplaneClient>;
|
|
25
43
|
export declare function createAirplaneClient(options: AirplaneClientOptions): {
|
|
26
44
|
db: import("./types.js").AirplaneDexie;
|
|
@@ -192,6 +210,7 @@ export declare function createAirplaneClient(options: AirplaneClientOptions): {
|
|
|
192
210
|
})[];
|
|
193
211
|
before?: unknown;
|
|
194
212
|
}[] | undefined;
|
|
213
|
+
tempIds?: string[] | undefined;
|
|
195
214
|
}[];
|
|
196
215
|
readonly blocked: number;
|
|
197
216
|
syncTotal: number;
|
|
@@ -218,6 +237,7 @@ export declare function createAirplaneClient(options: AirplaneClientOptions): {
|
|
|
218
237
|
};
|
|
219
238
|
refetchActive: (only?: Iterable<QueryName>) => Promise<void>;
|
|
220
239
|
enqueue: (name: string, variables: unknown) => Promise<MutationRow>;
|
|
240
|
+
execute: <T = unknown>(name: string, variables: unknown) => Promise<T>;
|
|
221
241
|
rollback: (id: number) => Promise<void>;
|
|
222
242
|
retry: (id: number) => Promise<void>;
|
|
223
243
|
sync: () => Promise<void>;
|
package/dist/runtime/client.js
CHANGED
|
@@ -13,6 +13,44 @@ export function queryKey(name, variables) {
|
|
|
13
13
|
const normalized = variables && typeof variables === "object" && Object.keys(variables).length ? variables : null;
|
|
14
14
|
return `${name}:${hash(normalized)}`;
|
|
15
15
|
}
|
|
16
|
+
const TEMP_ID_PREFIX = "tmp-";
|
|
17
|
+
const TEMP_ID_RE = /^tmp-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
18
|
+
export function newTempId() {
|
|
19
|
+
const uuid = typeof crypto === "object" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
20
|
+
const r = Math.random() * 16 | 0;
|
|
21
|
+
return (c === "x" ? r : r & 3 | 8).toString(16);
|
|
22
|
+
});
|
|
23
|
+
return `${TEMP_ID_PREFIX}${uuid}`;
|
|
24
|
+
}
|
|
25
|
+
export function isTempId(value) {
|
|
26
|
+
return typeof value === "string" && TEMP_ID_RE.test(value);
|
|
27
|
+
}
|
|
28
|
+
export function substituteTempIds(value, ids) {
|
|
29
|
+
let replaced = false;
|
|
30
|
+
const unresolved = /* @__PURE__ */ new Set();
|
|
31
|
+
const walk = (v) => {
|
|
32
|
+
if (typeof v === "string") {
|
|
33
|
+
if (v in ids) {
|
|
34
|
+
replaced = true;
|
|
35
|
+
return ids[v];
|
|
36
|
+
}
|
|
37
|
+
if (isTempId(v)) unresolved.add(v);
|
|
38
|
+
return v;
|
|
39
|
+
}
|
|
40
|
+
if (Array.isArray(v)) return v.map(walk);
|
|
41
|
+
if (v && typeof v === "object" && Object.getPrototypeOf(v) === Object.prototype) {
|
|
42
|
+
const out = {};
|
|
43
|
+
for (const [k, item] of Object.entries(v)) out[k] = walk(item);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
return v;
|
|
47
|
+
};
|
|
48
|
+
const next = walk(value);
|
|
49
|
+
return { value: replaced ? next : value, replaced, unresolved: [...unresolved] };
|
|
50
|
+
}
|
|
51
|
+
export class UnresolvedTempIdError extends Error {
|
|
52
|
+
name = "UnresolvedTempIdError";
|
|
53
|
+
}
|
|
16
54
|
export function createAirplaneClient(options) {
|
|
17
55
|
const db = createDb(options.dbName, options.stores.stores, options.stores.version);
|
|
18
56
|
const { documents, mutationHandlers, queryHandlers } = options;
|
|
@@ -219,10 +257,34 @@ export function createAirplaneClient(options) {
|
|
|
219
257
|
const h = mutationHandler(row.document);
|
|
220
258
|
if (!h?.apply) return;
|
|
221
259
|
const invalidated = /* @__PURE__ */ new Set();
|
|
222
|
-
const
|
|
223
|
-
|
|
260
|
+
const tempIds = [...row.tempIds ?? []];
|
|
261
|
+
let minted = 0;
|
|
262
|
+
const tempId = () => (tempIds[minted] ??= newTempId(), tempIds[minted++]);
|
|
263
|
+
const { undo } = await withUndoLog(db, () => h.apply({ db, cache: createCacheApi(invalidated), variables: row.variables, tempId }));
|
|
264
|
+
await db.mutations.update(row.id, { undo, tempIds });
|
|
224
265
|
if (!rebase && invalidated.size) await refetchActive(invalidated);
|
|
225
266
|
}
|
|
267
|
+
const TEMP_IDS_KEY = "tempIds";
|
|
268
|
+
const TEMP_IDS_MAX = 1e3;
|
|
269
|
+
async function readTempIds() {
|
|
270
|
+
const raw = (await db.meta.get(TEMP_IDS_KEY))?.value;
|
|
271
|
+
return raw ? JSON.parse(raw) : {};
|
|
272
|
+
}
|
|
273
|
+
async function resolveTempId(tempId, id) {
|
|
274
|
+
const entries = Object.entries(await readTempIds()).filter(([key]) => key !== tempId);
|
|
275
|
+
entries.push([tempId, id]);
|
|
276
|
+
await db.meta.put({ key: TEMP_IDS_KEY, value: JSON.stringify(Object.fromEntries(entries.slice(-TEMP_IDS_MAX))) });
|
|
277
|
+
}
|
|
278
|
+
function reconcileContext(variables, tempIds = []) {
|
|
279
|
+
return { db, cache: createCacheApi(), variables, tempIds, resolve: resolveTempId };
|
|
280
|
+
}
|
|
281
|
+
async function outboundVariables(name, variables) {
|
|
282
|
+
const { value, unresolved } = substituteTempIds(variables, await readTempIds());
|
|
283
|
+
if (unresolved.length) {
|
|
284
|
+
throw new UnresolvedTempIdError(`[nuxt-airplane] "${name}" references the temp id "${unresolved[0]}" which has no server id yet: the mutation that created it has not been accepted, or its reconcile handler did not call resolve().`);
|
|
285
|
+
}
|
|
286
|
+
return value;
|
|
287
|
+
}
|
|
226
288
|
async function rebasePending() {
|
|
227
289
|
const queue = await db.mutations.orderBy("createdAt").toArray();
|
|
228
290
|
for (const row of queue) await applyMutation(row, { rebase: true });
|
|
@@ -307,15 +369,17 @@ export function createAirplaneClient(options) {
|
|
|
307
369
|
for (const row of queue) {
|
|
308
370
|
await db.mutations.update(row.id, { status: "syncing" });
|
|
309
371
|
try {
|
|
310
|
-
const
|
|
372
|
+
const variables = await outboundVariables(row.document, row.variables);
|
|
373
|
+
if (variables !== row.variables) await db.mutations.update(row.id, { variables });
|
|
374
|
+
const result = await request(row.document, variables);
|
|
311
375
|
const h = mutationHandler(row.document);
|
|
312
|
-
await h?.reconcile?.(
|
|
376
|
+
await h?.reconcile?.(reconcileContext(variables, row.tempIds), result);
|
|
313
377
|
if (h?.refetch) h.refetch.forEach((n) => refetch.add(n));
|
|
314
378
|
else refetchAll = true;
|
|
315
379
|
await db.mutations.delete(row.id);
|
|
316
380
|
state.syncCount++;
|
|
317
381
|
} catch (error) {
|
|
318
|
-
if (error instanceof GraphQLRequestError) {
|
|
382
|
+
if (error instanceof GraphQLRequestError || error instanceof UnresolvedTempIdError) {
|
|
319
383
|
await db.mutations.update(row.id, { status: "failed", error: error.message });
|
|
320
384
|
if (stopOnFailure) break;
|
|
321
385
|
continue;
|
|
@@ -330,6 +394,19 @@ export function createAirplaneClient(options) {
|
|
|
330
394
|
}
|
|
331
395
|
if (state.syncCount) await refetchActive(refetchAll ? void 0 : refetch);
|
|
332
396
|
}
|
|
397
|
+
async function execute(name, variables) {
|
|
398
|
+
if (document(name).kind !== "mutation") throw new Error(`[nuxt-airplane] "${name}" is not a mutation`);
|
|
399
|
+
await ready;
|
|
400
|
+
if (!state.online) throw new Error(`[nuxt-airplane] Cannot send "${name}" while offline`);
|
|
401
|
+
await sync();
|
|
402
|
+
if (state.online && (await sendable()).length) await sync();
|
|
403
|
+
const outbound = await outboundVariables(name, variables);
|
|
404
|
+
const result = await request(name, outbound);
|
|
405
|
+
const h = mutationHandler(name);
|
|
406
|
+
await h?.reconcile?.(reconcileContext(outbound), result);
|
|
407
|
+
await refetchActive(h?.refetch);
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
333
410
|
async function clear() {
|
|
334
411
|
await db.transaction("rw", db.tables, async () => {
|
|
335
412
|
for (const table of db.tables) await table.clear();
|
|
@@ -348,6 +425,7 @@ export function createAirplaneClient(options) {
|
|
|
348
425
|
watchQuery,
|
|
349
426
|
refetchActive,
|
|
350
427
|
enqueue,
|
|
428
|
+
execute,
|
|
351
429
|
rollback,
|
|
352
430
|
retry,
|
|
353
431
|
sync,
|
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
import { type Ref } from 'vue';
|
|
2
|
-
import type { MutationName, MutationRow, MutationVariables } from '../types.js';
|
|
3
|
-
export interface
|
|
2
|
+
import type { MutationName, MutationResult, MutationRow, MutationVariables } from '../types.js';
|
|
3
|
+
export interface UseMutationOptions {
|
|
4
|
+
/**
|
|
5
|
+
* `false` sends the mutation directly instead of queuing it: `mutate`
|
|
6
|
+
* resolves with the server's data, runs no `apply`, and rejects while
|
|
7
|
+
* offline. For mutations that cannot wait in the outbox, such as a checkout
|
|
8
|
+
* or anything whose response the UI needs. Default `true`.
|
|
9
|
+
*/
|
|
10
|
+
queue?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface UseMutationReturn<V, R = MutationRow> {
|
|
4
13
|
/**
|
|
5
14
|
* Applies the mutation to the local cache and queues it for the server.
|
|
6
15
|
* Resolves with the outbox entry as soon as the change is visible locally;
|
|
7
16
|
* the request itself is sent in the background by the sync loop.
|
|
17
|
+
*
|
|
18
|
+
* With `queue: false` it sends the request now and resolves with the data
|
|
19
|
+
* the server returned.
|
|
8
20
|
*/
|
|
9
|
-
mutate: (variables: V) => Promise<
|
|
10
|
-
/** `true` while the local `apply` handler runs
|
|
21
|
+
mutate: (variables: V) => Promise<R>;
|
|
22
|
+
/** `true` while the local `apply` handler runs, or while the request is in flight with `queue: false`. */
|
|
11
23
|
loading: Ref<boolean>;
|
|
12
24
|
error: Ref<Error | null>;
|
|
13
25
|
}
|
|
14
|
-
export declare function useMutation<N extends MutationName>(name: N
|
|
26
|
+
export declare function useMutation<N extends MutationName>(name: N, options?: UseMutationOptions & {
|
|
27
|
+
queue?: true;
|
|
28
|
+
}): UseMutationReturn<MutationVariables<N>>;
|
|
29
|
+
export declare function useMutation<N extends MutationName>(name: N, options: UseMutationOptions & {
|
|
30
|
+
queue: false;
|
|
31
|
+
}): UseMutationReturn<MutationVariables<N>, MutationResult<N>>;
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { ref, shallowRef } from "vue";
|
|
2
2
|
import { useAirplane } from "./useAirplane.js";
|
|
3
|
-
export function useMutation(name) {
|
|
3
|
+
export function useMutation(name, options = {}) {
|
|
4
4
|
const loading = ref(false);
|
|
5
5
|
const error = shallowRef(null);
|
|
6
6
|
const client = useAirplane();
|
|
7
|
+
const queue = options.queue ?? true;
|
|
7
8
|
async function mutate(variables) {
|
|
8
9
|
if (!client) throw new Error("[nuxt-airplane] Mutations can only run on the client");
|
|
9
10
|
loading.value = true;
|
|
10
11
|
error.value = null;
|
|
11
12
|
try {
|
|
12
|
-
return await client.enqueue(name, variables);
|
|
13
|
+
return queue ? await client.enqueue(name, variables) : await client.execute(name, variables);
|
|
13
14
|
} catch (e) {
|
|
14
15
|
error.value = e;
|
|
15
16
|
throw e;
|
|
@@ -170,6 +170,7 @@ declare const _default: import("#app").Plugin<{
|
|
|
170
170
|
})[];
|
|
171
171
|
before?: unknown;
|
|
172
172
|
}[] | undefined;
|
|
173
|
+
tempIds?: string[] | undefined;
|
|
173
174
|
}[];
|
|
174
175
|
readonly blocked: number;
|
|
175
176
|
syncTotal: number;
|
|
@@ -196,6 +197,7 @@ declare const _default: import("#app").Plugin<{
|
|
|
196
197
|
};
|
|
197
198
|
refetchActive: (only?: Iterable<import("./types.js").QueryName>) => Promise<void>;
|
|
198
199
|
enqueue: (name: string, variables: unknown) => Promise<import("./types.js").MutationRow>;
|
|
200
|
+
execute: <T = unknown>(name: string, variables: unknown) => Promise<T>;
|
|
199
201
|
rollback: (id: number) => Promise<void>;
|
|
200
202
|
retry: (id: number) => Promise<void>;
|
|
201
203
|
sync: () => Promise<void>;
|
|
@@ -377,6 +379,7 @@ declare const _default: import("#app").Plugin<{
|
|
|
377
379
|
})[];
|
|
378
380
|
before?: unknown;
|
|
379
381
|
}[] | undefined;
|
|
382
|
+
tempIds?: string[] | undefined;
|
|
380
383
|
}[];
|
|
381
384
|
readonly blocked: number;
|
|
382
385
|
syncTotal: number;
|
|
@@ -403,6 +406,7 @@ declare const _default: import("#app").Plugin<{
|
|
|
403
406
|
};
|
|
404
407
|
refetchActive: (only?: Iterable<import("./types.js").QueryName>) => Promise<void>;
|
|
405
408
|
enqueue: (name: string, variables: unknown) => Promise<import("./types.js").MutationRow>;
|
|
409
|
+
execute: <T = unknown>(name: string, variables: unknown) => Promise<T>;
|
|
406
410
|
rollback: (id: number) => Promise<void>;
|
|
407
411
|
retry: (id: number) => Promise<void>;
|
|
408
412
|
sync: () => Promise<void>;
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -72,6 +72,11 @@ export interface MutationRow {
|
|
|
72
72
|
error?: string;
|
|
73
73
|
/** Writes made by `apply`, oldest first, used to roll the mutation back. */
|
|
74
74
|
undo?: UndoEntry[];
|
|
75
|
+
/**
|
|
76
|
+
* Ids handed out by `tempId()` while `apply` ran, in call order. A re-apply
|
|
77
|
+
* (rebase) hands out the same ones so the local rows keep their ids.
|
|
78
|
+
*/
|
|
79
|
+
tempIds?: string[];
|
|
75
80
|
}
|
|
76
81
|
/** Module bookkeeping, e.g. the cache key the data was written under. */
|
|
77
82
|
export interface MetaRow {
|
|
@@ -103,6 +108,28 @@ export interface HandlerContext<V = unknown> {
|
|
|
103
108
|
cache: CacheApi;
|
|
104
109
|
variables: V;
|
|
105
110
|
}
|
|
111
|
+
/** What `apply` receives: the handler context plus a way to mint placeholder ids. */
|
|
112
|
+
export interface ApplyContext<V = unknown> extends HandlerContext<V> {
|
|
113
|
+
/**
|
|
114
|
+
* A placeholder id for a row the server will assign an id to. Mutations
|
|
115
|
+
* queued later that reference it are rewritten with the real id before they
|
|
116
|
+
* are sent, once `reconcile` has called `resolve`. The same ids come back,
|
|
117
|
+
* in the same order, when `apply` is re-run on fresh server data.
|
|
118
|
+
*/
|
|
119
|
+
tempId: () => string;
|
|
120
|
+
}
|
|
121
|
+
/** What `reconcile` receives: the handler context plus `resolve`. */
|
|
122
|
+
export interface ReconcileContext<V = unknown> extends HandlerContext<V> {
|
|
123
|
+
/** The ids `apply` minted with `tempId()`, in call order; empty for a mutation sent with `execute`. */
|
|
124
|
+
tempIds: string[];
|
|
125
|
+
/**
|
|
126
|
+
* Records that the placeholder `tempId` stands for the server-assigned `id`.
|
|
127
|
+
* Every queued mutation whose variables mention `tempId` is sent with `id`
|
|
128
|
+
* instead, also one queued after the outbox drained with an id a component
|
|
129
|
+
* still held. The last 1000 mappings are kept.
|
|
130
|
+
*/
|
|
131
|
+
resolve: (tempId: string, id: string) => Promise<void>;
|
|
132
|
+
}
|
|
106
133
|
export interface MutationHandler<N extends MutationName = MutationName> {
|
|
107
134
|
/**
|
|
108
135
|
* A human-readable summary for the pending list, e.g. "Archive North Warehouse".
|
|
@@ -114,9 +141,13 @@ export interface MutationHandler<N extends MutationName = MutationName> {
|
|
|
114
141
|
* Runs as soon as the mutation is queued, so the UI reflects it immediately.
|
|
115
142
|
* It runs inside a Dexie transaction and every write is recorded for rollback.
|
|
116
143
|
*/
|
|
117
|
-
apply?: (ctx:
|
|
118
|
-
/**
|
|
119
|
-
|
|
144
|
+
apply?: (ctx: ApplyContext<MutationVariables<N>>) => unknown;
|
|
145
|
+
/**
|
|
146
|
+
* Runs with the server's response once the mutation has been sent. Merge
|
|
147
|
+
* server-assigned values into the local data here and call `resolve` for
|
|
148
|
+
* every id minted with `tempId()`.
|
|
149
|
+
*/
|
|
150
|
+
reconcile?: (ctx: ReconcileContext<MutationVariables<N>>, result: MutationResult<N>) => unknown;
|
|
120
151
|
/**
|
|
121
152
|
* Runs when a queued mutation is rolled back. When omitted the writes made
|
|
122
153
|
* by `apply` are undone from the recorded log instead.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@treygrr/nuxt-airplane",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Airplane mode for your GraphQL app: typed composables from your .gql files, an IndexedDB cache and a mutation queue that lands when you are back online.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nuxt",
|