@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 +3 -1
- package/dist/module.d.mts +7 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +47 -2
- package/dist/runtime/client.d.ts +21 -0
- package/dist/runtime/client.js +122 -13
- package/dist/runtime/composables/useMutation.d.ts +22 -5
- package/dist/runtime/composables/useMutation.js +3 -2
- package/dist/runtime/composables/useSync.d.ts +2 -0
- package/dist/runtime/composables/useSync.js +2 -0
- package/dist/runtime/db.d.ts +8 -1
- package/dist/runtime/db.js +26 -3
- package/dist/runtime/plugin.client.d.ts +6 -0
- package/dist/runtime/plugin.client.js +1 -1
- package/dist/runtime/types.d.ts +36 -3
- package/package.json +1 -1
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
|
|
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
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")}
|
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,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>;
|
package/dist/runtime/client.js
CHANGED
|
@@ -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
|
|
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
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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?.(
|
|
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
|
|
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;
|
|
@@ -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"),
|
package/dist/runtime/db.d.ts
CHANGED
|
@@ -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
|
|
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.
|
package/dist/runtime/db.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
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.
|
|
@@ -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.
|
|
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",
|