@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 CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=4.0.0"
6
6
  },
7
- "version": "0.1.2",
7
+ "version": "0.2.0",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "3.6.1"
@@ -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>;
@@ -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 { undo } = await withUndoLog(db, () => h.apply({ db, cache: createCacheApi(invalidated), variables: row.variables }));
223
- await db.mutations.update(row.id, { undo });
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 result = await request(row.document, row.variables);
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?.({ db, cache: createCacheApi(), variables: row.variables }, result);
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 UseMutationReturn<V> {
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<MutationRow>;
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): UseMutationReturn<MutationVariables<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>;
@@ -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: HandlerContext<MutationVariables<N>>) => unknown;
118
- /** Runs with the server's response once the mutation has been sent. */
119
- reconcile?: (ctx: HandlerContext<MutationVariables<N>>, result: MutationResult<N>) => unknown;
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.1.2",
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",