@treygrr/nuxt-airplane 0.1.1 → 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/README.md CHANGED
@@ -36,6 +36,7 @@ export default defineNuxtConfig({
36
36
  | `documents` | `['**/*.gql', '**/*.graphql']` | Globs (relative to `srcDir`) of the files holding your operations. |
37
37
  | `handlersDir` | `airplane` | Directory (relative to `srcDir`) scanned for mutation handler files. |
38
38
  | `dbName` | `nuxt-airplane` | IndexedDB database name. |
39
+ | `stopOnFailure` | `true` | A failed mutation holds back the ones queued after it until retried or rolled back (`useSync().blocked` counts them). |
39
40
  | `cacheKey` | – | Deploy key (git SHA, build id). A change wipes cached data on next load, outbox kept; a hash of your documents is always mixed in. `NUXT_PUBLIC_AIRPLANE_CACHE_KEY` overrides it. |
40
41
  | `codegen` | `{}` | Extra config for the `typescript` / `typescript-operations` codegen plugins. |
41
42
 
@@ -152,8 +153,9 @@ const { online, syncing, synced, pending, syncTotal, syncCount, lastError, sync,
152
153
 
153
154
  - `pending` lists the queued mutations oldest first, each with a human-readable `description` (from the handler's `describe` hook, or the humanized document name), its variables and status.
154
155
  - `rollback(id)` undoes a queued mutation locally and drops it from the queue. Mutations queued after it are re-applied on top of the restored data.
155
- - A mutation the server rejects is marked `failed` and left in the queue; `retry(id)` or `rollback(id)` it.
156
+ - A mutation the server rejects is marked `failed` and left in the queue, and the mutations queued after it wait (`blocked`) until you `retry(id)` or `rollback(id)` it; set `stopOnFailure: false` to keep sending past failures.
156
157
  - A network failure stops the batch and keeps everything queued; the next `online` event (or `sync()`) resumes it.
158
+ - Tabs share the database; a Web Lock makes sure only one of them sends the queue at a time.
157
159
  - `goOffline()` holds the client offline whatever the network says: queries serve the cache only and mutations stay queued. `goOnline()` releases it and syncs. `online` reflects the effective state; `networkOnline` and `forcedOffline` expose the two inputs. Handy for a "work offline" switch or for testing.
158
160
 
159
161
  ## Security
package/dist/module.d.mts CHANGED
@@ -25,6 +25,13 @@ interface ModuleOptions {
25
25
  * Overridable at runtime with `NUXT_PUBLIC_AIRPLANE_CACHE_KEY`.
26
26
  */
27
27
  cacheKey?: string;
28
+ /**
29
+ * When a mutation is rejected by the server, hold back the mutations queued
30
+ * after it until it is retried or rolled back, so the outbox keeps its
31
+ * order (an item added to a site that was never created would fail too).
32
+ * Set to `false` to skip failed mutations and keep sending the rest.
33
+ */
34
+ stopOnFailure: boolean;
28
35
  /** Extra options passed to the `typescript` / `typescript-operations` codegen plugins. */
29
36
  codegen: Record<string, unknown>;
30
37
  }
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=4.0.0"
6
6
  },
7
- "version": "0.1.1",
7
+ "version": "0.2.0",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -1,9 +1,9 @@
1
+ import { readFile, mkdir, writeFile } from 'node:fs/promises';
1
2
  import { join, dirname, resolve, sep, basename } from 'node:path';
2
3
  import { defineNuxtModule, createResolver, useLogger, addTemplate, addTypeTemplate, addImportsDir, addPlugin, updateTemplates } from '@nuxt/kit';
3
4
  import { hash } from 'ohash';
4
5
  import { glob } from 'tinyglobby';
5
6
  import { existsSync } from 'node:fs';
6
- import { readFile, mkdir, writeFile } from 'node:fs/promises';
7
7
  import { codegen } from '@graphql-codegen/core';
8
8
  import * as typescriptPlugin from '@graphql-codegen/typescript';
9
9
  import * as typescriptOperationsPlugin from '@graphql-codegen/typescript-operations';
@@ -154,6 +154,39 @@ async function generateTypes(schema, docs, config = {}) {
154
154
  ].join("\n");
155
155
  }
156
156
 
157
+ const DEFINE_HELPERS = [
158
+ "defineMutationHandler",
159
+ "defineMutationHandlers",
160
+ "defineQueryHandler",
161
+ "defineQueryHandlers",
162
+ "defineAirplaneStores",
163
+ "defineAirplaneRequest"
164
+ ];
165
+ const WRAP_HINT = "Wrap it in defineMutationHandlers(...) / defineQueryHandler(name, ...) / defineAirplaneStores(...) / defineAirplaneRequest(...), or export it from outside the handlers directory.";
166
+ function stripComments(source) {
167
+ return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1");
168
+ }
169
+ function findUntaggedDefaultExport(source) {
170
+ const code = stripComments(source);
171
+ const match = /\bexport\s+default\s+(?:await\s+)?/.exec(code);
172
+ if (!match) return null;
173
+ const rest = code.slice(match.index + match[0].length).trimStart();
174
+ if (rest.startsWith("{")) return { reason: "an object literal" };
175
+ if (rest.startsWith("[")) return { reason: "an array literal" };
176
+ if (/^(?:async\s+)?function\b/.test(rest)) return { reason: "a function" };
177
+ if (/^class\b/.test(rest)) return { reason: "a class" };
178
+ if (/^new\b/.test(rest)) return { reason: "a `new` expression" };
179
+ if (/^(?:async\s+)?(?:\([^)]*\)|[\w$]+)\s*=>/.test(rest)) return { reason: "an arrow function" };
180
+ if (/^(?:['"`\d]|true\b|false\b|null\b|undefined\b)/.test(rest)) return { reason: "a literal value" };
181
+ const call = /^([\w$]+(?:\s*\.\s*[\w$]+)*)\s*(?:<[^()]*>\s*)?\(/.exec(rest);
182
+ if (call) {
183
+ const callee = call[1].replace(/\s+/g, "");
184
+ if (DEFINE_HELPERS.includes(callee)) return null;
185
+ return { reason: `a call to \`${callee}()\`` };
186
+ }
187
+ return null;
188
+ }
189
+
157
190
  const module$1 = defineNuxtModule({
158
191
  meta: {
159
192
  name: "nuxt-airplane",
@@ -165,6 +198,7 @@ const module$1 = defineNuxtModule({
165
198
  documents: ["**/*.gql", "**/*.graphql"],
166
199
  handlersDir: "airplane",
167
200
  dbName: "nuxt-airplane",
201
+ stopOnFailure: true,
168
202
  codegen: {}
169
203
  },
170
204
  async setup(options, nuxt) {
@@ -176,7 +210,8 @@ const module$1 = defineNuxtModule({
176
210
  endpoint: options.endpoint,
177
211
  dbName: options.dbName,
178
212
  cacheKey: options.cacheKey ?? "",
179
- headers: options.headers ?? {}
213
+ headers: options.headers ?? {},
214
+ stopOnFailure: options.stopOnFailure
180
215
  };
181
216
  nuxt.options.build.transpile.push(resolver.resolve("./runtime"));
182
217
  nuxt.options.vite.optimizeDeps ||= {};
@@ -215,6 +250,16 @@ const module$1 = defineNuxtModule({
215
250
  write: true,
216
251
  getContents: async () => {
217
252
  const files = await handlerFiles();
253
+ const offenders = [];
254
+ for (const file of files) {
255
+ const untagged = findUntaggedDefaultExport(await readFile(file, "utf8"));
256
+ if (untagged) offenders.push(` ${file}: default export is ${untagged.reason}`);
257
+ }
258
+ if (offenders.length) {
259
+ throw new Error(`[nuxt-airplane] Files in the handlers directory must default-export the result of a define* helper:
260
+ ${offenders.join("\n")}
261
+ ${WRAP_HINT}`);
262
+ }
218
263
  const imports = files.map((file, i) => `import h${i} from ${JSON.stringify(file)}`);
219
264
  const list = files.map((_, i) => `h${i}`).join(", ");
220
265
  return `${imports.join("\n")}
@@ -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,7 +210,9 @@ export declare function createAirplaneClient(options: AirplaneClientOptions): {
192
210
  })[];
193
211
  before?: unknown;
194
212
  }[] | undefined;
213
+ tempIds?: string[] | undefined;
195
214
  }[];
215
+ readonly blocked: number;
196
216
  syncTotal: number;
197
217
  syncCount: number;
198
218
  lastError: string | null;
@@ -217,6 +237,7 @@ export declare function createAirplaneClient(options: AirplaneClientOptions): {
217
237
  };
218
238
  refetchActive: (only?: Iterable<QueryName>) => Promise<void>;
219
239
  enqueue: (name: string, variables: unknown) => Promise<MutationRow>;
240
+ execute: <T = unknown>(name: string, variables: unknown) => Promise<T>;
220
241
  rollback: (id: number) => Promise<void>;
221
242
  retry: (id: number) => Promise<void>;
222
243
  sync: () => Promise<void>;
@@ -13,9 +13,48 @@ 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;
57
+ const stopOnFailure = options.stopOnFailure ?? true;
19
58
  const opened = db.open().catch(async (error) => {
20
59
  if (!["VersionError", "UpgradeError", "SchemaError"].includes(error.name)) throw error;
21
60
  console.warn(`[nuxt-airplane] Could not open "${options.dbName}" (${error.name}), recreating it. Bump the store version to migrate instead.`);
@@ -36,8 +75,11 @@ export function createAirplaneClient(options) {
36
75
  await clearCache();
37
76
  }
38
77
  if (stored !== cacheKey) await db.meta.put({ key: "cacheKey", value: cacheKey });
39
- await db.mutations.where("status").equals("syncing").modify({ status: "pending" });
78
+ await requeueStuck();
40
79
  });
80
+ async function requeueStuck() {
81
+ await db.mutations.where("status").equals("syncing").modify({ status: "pending" });
82
+ }
41
83
  const state = reactive({
42
84
  /** What the browser reports; assumed online where `navigator.onLine` does not exist (Node, some webviews). */
43
85
  networkOnline: typeof navigator === "object" && typeof navigator.onLine === "boolean" ? navigator.onLine : true,
@@ -50,6 +92,12 @@ export function createAirplaneClient(options) {
50
92
  syncing: false,
51
93
  /** Mutations still waiting to be sent, oldest first. */
52
94
  pending: [],
95
+ /** Pending mutations held back because an older one failed; `0` with `stopOnFailure: false`. */
96
+ get blocked() {
97
+ if (!stopOnFailure) return 0;
98
+ const failed = this.pending.findIndex((m) => m.status === "failed");
99
+ return failed < 0 ? 0 : this.pending.slice(failed + 1).filter((m) => m.status === "pending").length;
100
+ },
53
101
  /** Size of the batch the current (or last) sync started with. */
54
102
  syncTotal: 0,
55
103
  /** Mutations of that batch that have reached the server so far. */
@@ -209,10 +257,34 @@ export function createAirplaneClient(options) {
209
257
  const h = mutationHandler(row.document);
210
258
  if (!h?.apply) return;
211
259
  const invalidated = /* @__PURE__ */ new Set();
212
- const { undo } = await withUndoLog(db, () => h.apply({ db, cache: createCacheApi(invalidated), variables: row.variables }));
213
- 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 });
214
265
  if (!rebase && invalidated.size) await refetchActive(invalidated);
215
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
+ }
216
288
  async function rebasePending() {
217
289
  const queue = await db.mutations.orderBy("createdAt").toArray();
218
290
  for (const row of queue) await applyMutation(row, { rebase: true });
@@ -245,12 +317,13 @@ export function createAirplaneClient(options) {
245
317
  if (h?.revert) {
246
318
  await h.revert({ db, cache: createCacheApi(), variables: row.variables });
247
319
  await db.mutations.delete(id);
248
- return;
320
+ } else {
321
+ const later = (await db.mutations.where("id").above(row.id).toArray()).filter((m) => m.status !== "syncing");
322
+ if (row.undo) await applyUndo(db, row.undo);
323
+ await db.mutations.delete(id);
324
+ for (const m of later) await applyMutation(m, { rebase: true });
249
325
  }
250
- const later = (await db.mutations.where("createdAt").above(row.createdAt).toArray()).filter((m) => m.status !== "syncing");
251
- if (row.undo) await applyUndo(db, row.undo);
252
- await db.mutations.delete(id);
253
- for (const m of later) await applyMutation(m, { rebase: true });
326
+ void sync();
254
327
  }
255
328
  async function retry(id) {
256
329
  await db.mutations.update(id, { status: "pending", error: void 0 });
@@ -259,13 +332,32 @@ export function createAirplaneClient(options) {
259
332
  let syncPromise = null;
260
333
  function sync() {
261
334
  if (syncPromise) return syncPromise;
262
- syncPromise = runSync().finally(() => syncPromise = null);
335
+ syncPromise = runSyncLocked().finally(() => syncPromise = null);
263
336
  return syncPromise;
264
337
  }
338
+ async function sendable() {
339
+ const rows = await db.mutations.orderBy("createdAt").toArray();
340
+ const queue = [];
341
+ for (const row of rows) {
342
+ if (row.status === "failed" && stopOnFailure) break;
343
+ if (row.status === "pending") queue.push(row);
344
+ }
345
+ return queue;
346
+ }
347
+ const syncLock = `nuxt-airplane:sync:${options.dbName}`;
348
+ async function runSyncLocked() {
349
+ const locks = typeof navigator === "object" ? navigator.locks : void 0;
350
+ if (!locks) return runSync();
351
+ await locks.request(syncLock, async () => {
352
+ await ready;
353
+ await requeueStuck();
354
+ await runSync();
355
+ });
356
+ }
265
357
  async function runSync() {
266
358
  await ready;
267
359
  if (!state.online) return;
268
- const queue = await db.mutations.where("status").equals("pending").sortBy("createdAt");
360
+ const queue = await sendable();
269
361
  if (!queue.length) return;
270
362
  state.syncing = true;
271
363
  state.syncTotal = queue.length;
@@ -277,16 +369,19 @@ export function createAirplaneClient(options) {
277
369
  for (const row of queue) {
278
370
  await db.mutations.update(row.id, { status: "syncing" });
279
371
  try {
280
- 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);
281
375
  const h = mutationHandler(row.document);
282
- await h?.reconcile?.({ db, cache: createCacheApi(), variables: row.variables }, result);
376
+ await h?.reconcile?.(reconcileContext(variables, row.tempIds), result);
283
377
  if (h?.refetch) h.refetch.forEach((n) => refetch.add(n));
284
378
  else refetchAll = true;
285
379
  await db.mutations.delete(row.id);
286
380
  state.syncCount++;
287
381
  } catch (error) {
288
- if (error instanceof GraphQLRequestError) {
382
+ if (error instanceof GraphQLRequestError || error instanceof UnresolvedTempIdError) {
289
383
  await db.mutations.update(row.id, { status: "failed", error: error.message });
384
+ if (stopOnFailure) break;
290
385
  continue;
291
386
  }
292
387
  await db.mutations.update(row.id, { status: "pending" });
@@ -299,6 +394,19 @@ export function createAirplaneClient(options) {
299
394
  }
300
395
  if (state.syncCount) await refetchActive(refetchAll ? void 0 : refetch);
301
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
+ }
302
410
  async function clear() {
303
411
  await db.transaction("rw", db.tables, async () => {
304
412
  for (const table of db.tables) await table.clear();
@@ -317,6 +425,7 @@ export function createAirplaneClient(options) {
317
425
  watchQuery,
318
426
  refetchActive,
319
427
  enqueue,
428
+ execute,
320
429
  rollback,
321
430
  retry,
322
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;
@@ -13,6 +13,8 @@ export interface UseSyncReturn {
13
13
  synced: ComputedRef<boolean>;
14
14
  /** Mutations still waiting to reach the server, oldest first. */
15
15
  pending: Ref<MutationRow[]>;
16
+ /** Pending mutations held back because an older one failed (see the `stopOnFailure` option). */
17
+ blocked: Ref<number>;
16
18
  /** Size of the batch the current (or last) sync started with. */
17
19
  syncTotal: Ref<number>;
18
20
  /** How many of that batch have been sent so far. */
@@ -12,6 +12,7 @@ export function useSync() {
12
12
  syncing: computed(() => false),
13
13
  synced: computed(() => true),
14
14
  pending: computed(() => []),
15
+ blocked: computed(() => 0),
15
16
  syncTotal: computed(() => 0),
16
17
  syncCount: computed(() => 0),
17
18
  lastError: computed(() => null),
@@ -33,6 +34,7 @@ export function useSync() {
33
34
  syncing: toRef(state, "syncing"),
34
35
  synced: client.synced,
35
36
  pending: toRef(state, "pending"),
37
+ blocked: toRef(state, "blocked"),
36
38
  syncTotal: toRef(state, "syncTotal"),
37
39
  syncCount: toRef(state, "syncCount"),
38
40
  lastError: toRef(state, "lastError"),
@@ -6,7 +6,14 @@ export declare const MODULE_STORES: {
6
6
  };
7
7
  /** Tables that hold module bookkeeping rather than cached data. */
8
8
  export declare const BOOKKEEPING_TABLES: Set<string>;
9
- export declare function createDb(name: string, stores?: Record<string, string>, version?: number): AirplaneDexie;
9
+ export interface CreateDbOptions {
10
+ /**
11
+ * Warn (once per table and index) when a boolean is written to an indexed
12
+ * key path, since IndexedDB cannot index booleans. Defaults to dev mode.
13
+ */
14
+ warnOnBooleanIndex?: boolean;
15
+ }
16
+ export declare function createDb(name: string, stores?: Record<string, string>, version?: number, { warnOnBooleanIndex }?: CreateDbOptions): AirplaneDexie;
10
17
  /**
11
18
  * Runs `fn` in a read-write transaction over every table and returns the
12
19
  * writes it made. If `fn` throws, the transaction aborts and nothing is kept.
@@ -5,12 +5,33 @@ export const MODULE_STORES = {
5
5
  meta: "&key"
6
6
  };
7
7
  export const BOOKKEEPING_TABLES = /* @__PURE__ */ new Set(["mutations", "meta"]);
8
- export function createDb(name, stores = {}, version = 1) {
8
+ export function createDb(name, stores = {}, version = 1, { warnOnBooleanIndex = import.meta.dev } = {}) {
9
9
  const db = new Dexie(name);
10
10
  db.version(version).stores({ ...MODULE_STORES, ...stores });
11
- installUndoLog(db);
11
+ installUndoLog(db, warnOnBooleanIndex);
12
12
  return db;
13
13
  }
14
+ function valueAt(row, keyPath) {
15
+ return keyPath.split(".").reduce((o, k) => o && typeof o === "object" ? o[k] : void 0, row);
16
+ }
17
+ function warnBooleanIndexes(table, values, warned) {
18
+ const { primaryKey, indexes } = table.schema;
19
+ for (const index of [primaryKey, ...indexes]) {
20
+ if (!index.keyPath || index.name?.includes("(virtual-from:")) continue;
21
+ const label = index.name ?? "primary key";
22
+ const id = `${table.name}/${label}`;
23
+ if (warned.has(id)) continue;
24
+ const paths = Array.isArray(index.keyPath) ? index.keyPath : [index.keyPath];
25
+ for (const value of values) {
26
+ const at = paths.findIndex((path) => typeof valueAt(value, path) === "boolean");
27
+ if (at === -1) continue;
28
+ warned.add(id);
29
+ const where = paths.length > 1 ? `index "${label}" at "${paths[at]}"` : `index "${label}"`;
30
+ console.warn(`[nuxt-airplane] Table "${table.name}": ${where} received a boolean, and IndexedDB cannot index booleans; store it as 0 | 1 (see the docs on stores).`);
31
+ break;
32
+ }
33
+ }
34
+ }
14
35
  const logs = /* @__PURE__ */ new WeakMap();
15
36
  function keysOf(index, values) {
16
37
  return index.extractKey ? values.map((v) => index.extractKey(v)) : void 0;
@@ -36,7 +57,8 @@ async function recordBefore(table, req) {
36
57
  const before = await table.getMany({ trans, keys });
37
58
  return { entries: keys.map((key, i) => ({ table: name, key, before: before[i] })) };
38
59
  }
39
- function installUndoLog(db) {
60
+ function installUndoLog(db, warnOnBooleanIndex = false) {
61
+ const warned = /* @__PURE__ */ new Set();
40
62
  db.use({
41
63
  stack: "dbcore",
42
64
  name: "nuxt-airplane-undo",
@@ -48,6 +70,7 @@ function installUndoLog(db) {
48
70
  return {
49
71
  ...table,
50
72
  async mutate(req) {
73
+ if (warnOnBooleanIndex && (req.type === "add" || req.type === "put")) warnBooleanIndexes(table, req.values, warned);
51
74
  const log = logs.get(req.trans);
52
75
  if (!log) return table.mutate(req);
53
76
  const { entries, afterMutate } = await recordBefore(table, req);
@@ -170,7 +170,9 @@ declare const _default: import("#app").Plugin<{
170
170
  })[];
171
171
  before?: unknown;
172
172
  }[] | undefined;
173
+ tempIds?: string[] | undefined;
173
174
  }[];
175
+ readonly blocked: number;
174
176
  syncTotal: number;
175
177
  syncCount: number;
176
178
  lastError: string | null;
@@ -195,6 +197,7 @@ declare const _default: import("#app").Plugin<{
195
197
  };
196
198
  refetchActive: (only?: Iterable<import("./types.js").QueryName>) => Promise<void>;
197
199
  enqueue: (name: string, variables: unknown) => Promise<import("./types.js").MutationRow>;
200
+ execute: <T = unknown>(name: string, variables: unknown) => Promise<T>;
198
201
  rollback: (id: number) => Promise<void>;
199
202
  retry: (id: number) => Promise<void>;
200
203
  sync: () => Promise<void>;
@@ -376,7 +379,9 @@ declare const _default: import("#app").Plugin<{
376
379
  })[];
377
380
  before?: unknown;
378
381
  }[] | undefined;
382
+ tempIds?: string[] | undefined;
379
383
  }[];
384
+ readonly blocked: number;
380
385
  syncTotal: number;
381
386
  syncCount: number;
382
387
  lastError: string | null;
@@ -401,6 +406,7 @@ declare const _default: import("#app").Plugin<{
401
406
  };
402
407
  refetchActive: (only?: Iterable<import("./types.js").QueryName>) => Promise<void>;
403
408
  enqueue: (name: string, variables: unknown) => Promise<import("./types.js").MutationRow>;
409
+ execute: <T = unknown>(name: string, variables: unknown) => Promise<T>;
404
410
  rollback: (id: number) => Promise<void>;
405
411
  retry: (id: number) => Promise<void>;
406
412
  sync: () => Promise<void>;
@@ -14,7 +14,7 @@ function collect(exports) {
14
14
  if (item === void 0) continue;
15
15
  const kind = item && typeof item === "object" ? item[HANDLER_KIND] : void 0;
16
16
  if (!kind) {
17
- if (import.meta.dev) console.warn("[nuxt-airplane] Ignoring a default export in the handlers directory that was not created with defineMutationHandler(s), defineQueryHandler(s) or defineAirplaneStores.");
17
+ console.warn("[nuxt-airplane] Ignoring a default export in the handlers directory that was not created with a define* helper (defineMutationHandler(s), defineQueryHandler(s), defineAirplaneStores, defineAirplaneRequest).");
18
18
  continue;
19
19
  }
20
20
  if (kind === "queries") Object.assign(queryHandlers, item);
@@ -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.
@@ -213,4 +244,6 @@ export interface AirplaneRuntimeConfig {
213
244
  headers?: Record<string, string>;
214
245
  /** Deploy-specific part of the cache key; see the `cacheKey` module option. */
215
246
  cacheKey?: string;
247
+ /** A failed mutation holds back the ones queued after it; see the `stopOnFailure` module option. Default `true`. */
248
+ stopOnFailure?: boolean;
216
249
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@treygrr/nuxt-airplane",
3
- "version": "0.1.1",
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",