@carlonicora/nextjs-jsonapi 6.13.1 → 6.14.1
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/{BlockNoteEditor-VXMDSQAA.mjs → BlockNoteEditor-I3J6YYXK.mjs} +10 -10
- package/dist/{BlockNoteEditor-UBVEUFNR.js → BlockNoteEditor-UIAX72XW.js} +25 -25
- package/dist/{BlockNoteEditor-UBVEUFNR.js.map → BlockNoteEditor-UIAX72XW.js.map} +1 -1
- package/dist/{BlockNoteViewer-2XYASA5Y.js → BlockNoteViewer-APBOPT4B.js} +8 -8
- package/dist/{BlockNoteViewer-2XYASA5Y.js.map → BlockNoteViewer-APBOPT4B.js.map} +1 -1
- package/dist/{BlockNoteViewer-NBW2H6LC.mjs → BlockNoteViewer-HIUAJQRI.mjs} +7 -7
- package/dist/JsonApiRequest-G4J2WTSU.js +26 -0
- package/dist/{JsonApiRequest-WISJZSDE.js.map → JsonApiRequest-G4J2WTSU.js.map} +1 -1
- package/dist/{JsonApiRequest-F6TMJ42G.mjs → JsonApiRequest-IKKHWCPB.mjs} +2 -2
- package/dist/billing/index.js +497 -497
- package/dist/billing/index.mjs +9 -9
- package/dist/{chunk-7DBJTNSU.mjs → chunk-4RPGFQBA.mjs} +2 -2
- package/dist/{chunk-BZ5J2FIV.js → chunk-4XLDBOSE.js} +11 -11
- package/dist/{chunk-BZ5J2FIV.js.map → chunk-4XLDBOSE.js.map} +1 -1
- package/dist/{chunk-27LQDGNP.mjs → chunk-4ZJIUTIV.mjs} +6 -1
- package/dist/chunk-4ZJIUTIV.mjs.map +1 -0
- package/dist/{chunk-PXDUJ47N.js → chunk-6RCZ64BQ.js} +18 -18
- package/dist/{chunk-PXDUJ47N.js.map → chunk-6RCZ64BQ.js.map} +1 -1
- package/dist/{chunk-7JZHPMGW.mjs → chunk-A3RB47X4.mjs} +44 -20
- package/dist/chunk-A3RB47X4.mjs.map +1 -0
- package/dist/{chunk-DOCIFE6A.mjs → chunk-AUD6ZLBB.mjs} +3 -3
- package/dist/{chunk-GB6DBFYY.js → chunk-DGN3PR2F.js} +3 -3
- package/dist/{chunk-GB6DBFYY.js.map → chunk-DGN3PR2F.js.map} +1 -1
- package/dist/{chunk-7ZHTPFFT.js → chunk-DJXSCARG.js} +15 -15
- package/dist/{chunk-7ZHTPFFT.js.map → chunk-DJXSCARG.js.map} +1 -1
- package/dist/{chunk-QKNGZDSQ.js → chunk-GM4VZTWL.js} +2 -2
- package/dist/{chunk-QKNGZDSQ.js.map → chunk-GM4VZTWL.js.map} +1 -1
- package/dist/{chunk-47BBY5KA.mjs → chunk-HAW5PNJG.mjs} +4 -4
- package/dist/{chunk-YRKIFYOE.mjs → chunk-KTGKW72G.mjs} +27 -8
- package/dist/chunk-KTGKW72G.mjs.map +1 -0
- package/dist/{chunk-PV4Z5W6V.js → chunk-KV4N7DEG.js} +19 -19
- package/dist/{chunk-PV4Z5W6V.js.map → chunk-KV4N7DEG.js.map} +1 -1
- package/dist/{chunk-ZLTOUZN2.mjs → chunk-M3N7LPHX.mjs} +4 -4
- package/dist/{chunk-DGWOVYEC.mjs → chunk-MQ7TXALH.mjs} +2 -2
- package/dist/{chunk-W62MHDWM.js → chunk-N5ZE2Z6I.js} +58 -34
- package/dist/chunk-N5ZE2Z6I.js.map +1 -0
- package/dist/{chunk-2SV4SS6N.js → chunk-OQQ7CZWT.js} +12 -12
- package/dist/{chunk-2SV4SS6N.js.map → chunk-OQQ7CZWT.js.map} +1 -1
- package/dist/{chunk-JJMMT24D.mjs → chunk-OUEYZOLB.mjs} +2 -2
- package/dist/{chunk-XXP3TQHF.mjs → chunk-OY65RYD3.mjs} +2 -2
- package/dist/{chunk-MOGEGWXW.js → chunk-PMESKNKL.js} +868 -868
- package/dist/{chunk-MOGEGWXW.js.map → chunk-PMESKNKL.js.map} +1 -1
- package/dist/{chunk-B6JGKDP2.js → chunk-QGVVF77L.js} +48 -29
- package/dist/chunk-QGVVF77L.js.map +1 -0
- package/dist/{chunk-BH6JWIKI.mjs → chunk-QIEF7HIL.mjs} +2 -2
- package/dist/{chunk-P445QGOC.mjs → chunk-RFKSJIQB.mjs} +7 -7
- package/dist/{chunk-PYRZCKC2.js → chunk-RT7XFGIN.js} +1141 -1137
- package/dist/chunk-RT7XFGIN.js.map +1 -0
- package/dist/{chunk-N4RZZKL5.mjs → chunk-RYY7USSW.mjs} +19 -15
- package/dist/chunk-RYY7USSW.mjs.map +1 -0
- package/dist/{chunk-6JUCIWDC.mjs → chunk-UOTHRJD2.mjs} +2 -2
- package/dist/{chunk-KMW7UNL2.js → chunk-WCEYT3GG.js} +8 -8
- package/dist/{chunk-KMW7UNL2.js.map → chunk-WCEYT3GG.js.map} +1 -1
- package/dist/{chunk-MHB36MYE.js → chunk-WUNSG3IN.js} +7 -2
- package/dist/chunk-WUNSG3IN.js.map +1 -0
- package/dist/{chunk-Z4POQWFG.js → chunk-ZYSNJKOP.js} +4 -4
- package/dist/{chunk-Z4POQWFG.js.map → chunk-ZYSNJKOP.js.map} +1 -1
- package/dist/client/index.js +10 -10
- package/dist/client/index.mjs +9 -9
- package/dist/components/index.js +10 -10
- package/dist/components/index.mjs +9 -9
- package/dist/contexts/index.js +10 -10
- package/dist/contexts/index.mjs +9 -9
- package/dist/core/index.js +7 -7
- package/dist/core/index.mjs +6 -6
- package/dist/email-automations/index.js +99 -99
- package/dist/email-automations/index.mjs +9 -9
- package/dist/features/analytics/index.js +126 -126
- package/dist/features/analytics/index.mjs +9 -9
- package/dist/features/analytics/server.js +10 -10
- package/dist/features/analytics/server.mjs +8 -8
- package/dist/features/blog/index.js +156 -156
- package/dist/features/blog/index.mjs +9 -9
- package/dist/features/blog/server.js +28 -28
- package/dist/features/blog/server.mjs +7 -7
- package/dist/features/help/index.js +43 -43
- package/dist/features/help/index.mjs +9 -9
- package/dist/features/tokenusage/index.js +132 -132
- package/dist/features/tokenusage/index.mjs +9 -9
- package/dist/index.js +8 -8
- package/dist/index.mjs +7 -7
- package/dist/{lifecycle-LKGPRBS7.mjs → lifecycle-KI3PCJSE.mjs} +3 -3
- package/dist/lifecycle-TO65I7TO.js +23 -0
- package/dist/{lifecycle-4GUOTB46.js.map → lifecycle-TO65I7TO.js.map} +1 -1
- package/dist/local-transport-4YDJ6FQG.js +15 -0
- package/dist/{local-transport-7H3SPZSA.js.map → local-transport-4YDJ6FQG.js.map} +1 -1
- package/dist/local-transport-C7YTA4I7.mjs +15 -0
- package/dist/offline/index.d.mts +160 -4
- package/dist/offline/index.d.ts +160 -4
- package/dist/offline/index.js +210 -163
- package/dist/offline/index.js.map +1 -1
- package/dist/offline/index.mjs +76 -29
- package/dist/offline/index.mjs.map +1 -1
- package/dist/request-66FGJIHB.mjs +10 -0
- package/dist/request-6W6UGKJV.js +10 -0
- package/dist/{request-KTBV6HHH.js.map → request-6W6UGKJV.js.map} +1 -1
- package/dist/server/index.js +9 -9
- package/dist/server/index.mjs +8 -8
- package/dist/write-through-BAPS24GL.js +15 -0
- package/dist/{write-through-FDQ7N4YI.js.map → write-through-BAPS24GL.js.map} +1 -1
- package/dist/write-through-IWQKXYHE.mjs +15 -0
- package/package.json +1 -1
- package/src/hooks/__tests__/usePageTracker.spec.tsx +61 -0
- package/src/hooks/usePageTracker.ts +17 -7
- package/src/offline/__tests__/generic-answers.spec.ts +146 -3
- package/src/offline/__tests__/local-transport.spec.ts +15 -1
- package/src/offline/__tests__/offline-fixtures.ts +29 -2
- package/src/offline/__tests__/write-through.spec.ts +27 -1
- package/src/offline/config.ts +9 -0
- package/src/offline/generic-answers.ts +44 -17
- package/src/offline/index.ts +2 -0
- package/src/offline/local-transport.ts +5 -3
- package/src/offline/sync/__tests__/runtime.spec.ts +30 -1
- package/src/offline/sync/__tests__/sync-fixtures.ts +22 -0
- package/src/offline/sync/__tests__/sync-root.spec.ts +184 -1
- package/src/offline/sync/known-roots.ts +29 -7
- package/src/offline/sync/runtime.ts +3 -2
- package/src/offline/sync/sync-root.ts +70 -19
- package/src/offline/types.ts +39 -1
- package/src/offline/write-through.ts +6 -3
- package/dist/JsonApiRequest-WISJZSDE.js +0 -26
- package/dist/chunk-27LQDGNP.mjs.map +0 -1
- package/dist/chunk-7JZHPMGW.mjs.map +0 -1
- package/dist/chunk-B6JGKDP2.js.map +0 -1
- package/dist/chunk-MHB36MYE.js.map +0 -1
- package/dist/chunk-N4RZZKL5.mjs.map +0 -1
- package/dist/chunk-PYRZCKC2.js.map +0 -1
- package/dist/chunk-W62MHDWM.js.map +0 -1
- package/dist/chunk-YRKIFYOE.mjs.map +0 -1
- package/dist/lifecycle-4GUOTB46.js +0 -23
- package/dist/local-transport-7H3SPZSA.js +0 -15
- package/dist/local-transport-QYFKA6AN.mjs +0 -15
- package/dist/request-KTBV6HHH.js +0 -10
- package/dist/request-SQOUNWZ4.mjs +0 -10
- package/dist/write-through-FDQ7N4YI.js +0 -15
- package/dist/write-through-R25VQMH6.mjs +0 -15
- /package/dist/{BlockNoteEditor-VXMDSQAA.mjs.map → BlockNoteEditor-I3J6YYXK.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-NBW2H6LC.mjs.map → BlockNoteViewer-HIUAJQRI.mjs.map} +0 -0
- /package/dist/{JsonApiRequest-F6TMJ42G.mjs.map → JsonApiRequest-IKKHWCPB.mjs.map} +0 -0
- /package/dist/{chunk-7DBJTNSU.mjs.map → chunk-4RPGFQBA.mjs.map} +0 -0
- /package/dist/{chunk-DOCIFE6A.mjs.map → chunk-AUD6ZLBB.mjs.map} +0 -0
- /package/dist/{chunk-47BBY5KA.mjs.map → chunk-HAW5PNJG.mjs.map} +0 -0
- /package/dist/{chunk-ZLTOUZN2.mjs.map → chunk-M3N7LPHX.mjs.map} +0 -0
- /package/dist/{chunk-DGWOVYEC.mjs.map → chunk-MQ7TXALH.mjs.map} +0 -0
- /package/dist/{chunk-JJMMT24D.mjs.map → chunk-OUEYZOLB.mjs.map} +0 -0
- /package/dist/{chunk-XXP3TQHF.mjs.map → chunk-OY65RYD3.mjs.map} +0 -0
- /package/dist/{chunk-BH6JWIKI.mjs.map → chunk-QIEF7HIL.mjs.map} +0 -0
- /package/dist/{chunk-P445QGOC.mjs.map → chunk-RFKSJIQB.mjs.map} +0 -0
- /package/dist/{chunk-6JUCIWDC.mjs.map → chunk-UOTHRJD2.mjs.map} +0 -0
- /package/dist/{lifecycle-LKGPRBS7.mjs.map → lifecycle-KI3PCJSE.mjs.map} +0 -0
- /package/dist/{local-transport-QYFKA6AN.mjs.map → local-transport-C7YTA4I7.mjs.map} +0 -0
- /package/dist/{request-SQOUNWZ4.mjs.map → request-66FGJIHB.mjs.map} +0 -0
- /package/dist/{write-through-R25VQMH6.mjs.map → write-through-IWQKXYHE.mjs.map} +0 -0
|
@@ -10,18 +10,28 @@ import { getTrackablePages } from "../client/config";
|
|
|
10
10
|
const EXCLUDED_ROUTES = ["/", "/login", "/register", "/forgot-password", "/reset-password", "/activate"];
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* Detail pages title themselves
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* Detail pages title themselves in one of two shapes:
|
|
14
|
+
*
|
|
15
|
+
* `[Entity type] Entity name | App name` (bracketed)
|
|
16
|
+
* `Entity name · Entity type · Context | App name` (most-specific-first)
|
|
17
|
+
*
|
|
18
|
+
* Returns the entity name, or null when the document title carries no page
|
|
19
|
+
* name — just the bare app name. Whether the title belongs to THIS route (and
|
|
20
|
+
* not the page we left) is decided by the caller, not here.
|
|
17
21
|
*/
|
|
18
22
|
function readEntityTitle(): string | null {
|
|
19
23
|
if (typeof document === "undefined") return null;
|
|
20
24
|
|
|
21
|
-
const
|
|
22
|
-
if (!afterEntityType) return null;
|
|
25
|
+
const title = document.title;
|
|
23
26
|
|
|
24
|
-
|
|
27
|
+
if (title.includes("]")) {
|
|
28
|
+
const afterEntityType = title.split("]")[1];
|
|
29
|
+
return afterEntityType?.split("|")[0]?.trim() || null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!title.includes("|")) return null;
|
|
33
|
+
|
|
34
|
+
return title.split("|")[0].split("·")[0]?.trim() || null;
|
|
25
35
|
}
|
|
26
36
|
|
|
27
37
|
export function usePageTracker() {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { buildGenericRoutes, linksOfKind, listAnswer } from "../generic-answers";
|
|
3
3
|
import { matchRoute } from "../router";
|
|
4
4
|
import { setOfflineState } from "../state";
|
|
5
5
|
import type { DeviceStore } from "../store/DeviceStore";
|
|
6
|
-
import type { OfflineAnswer, OfflineConfig, OfflineMethod } from "../types";
|
|
7
|
-
import { npc, offlineConfig, seededStore } from "./offline-fixtures";
|
|
6
|
+
import type { OfflineAnswer, OfflineConfig, OfflineLocalWriteContext, OfflineMethod } from "../types";
|
|
7
|
+
import { bookConfig, npc, offlineConfig, page, seededStore } from "./offline-fixtures";
|
|
8
8
|
|
|
9
9
|
const API = "http://api.test/";
|
|
10
10
|
|
|
@@ -377,4 +377,147 @@ describe("generic answers", () => {
|
|
|
377
377
|
expect(post.status).toBe(503);
|
|
378
378
|
expect(await store.queue()).toEqual([]);
|
|
379
379
|
});
|
|
380
|
+
|
|
381
|
+
describe("extra root kinds", () => {
|
|
382
|
+
it("POST of an extra-kind root type roots the record at its own id", async () => {
|
|
383
|
+
const body = { data: { type: "books", id: "b9", attributes: { name: "Book" } } };
|
|
384
|
+
|
|
385
|
+
const result = await answer(store, "POST", "books", body, bookConfig());
|
|
386
|
+
|
|
387
|
+
expect(result.status).toBe(201);
|
|
388
|
+
expect(await store.getRecord("books", "b9")).toMatchObject({ rootId: "b9", localOnly: true });
|
|
389
|
+
const [entry] = await store.queue();
|
|
390
|
+
expect(entry).toMatchObject({ rootId: "b9", method: "POST", path: "books" });
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("POST of a child with a relationship to an extra-kind root roots it there", async () => {
|
|
394
|
+
const body = { data: page("p9", "Page", "b9") };
|
|
395
|
+
|
|
396
|
+
const result = await answer(store, "POST", "pages", body, bookConfig());
|
|
397
|
+
|
|
398
|
+
expect(result.status).toBe(201);
|
|
399
|
+
expect(await store.getRecord("pages", "p9")).toMatchObject({ rootId: "b9" });
|
|
400
|
+
const [entry] = await store.queue();
|
|
401
|
+
expect(entry).toMatchObject({ rootId: "b9", method: "POST", path: "pages" });
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("GET {extraModule}/:rootId/{type} lists the root's records", async () => {
|
|
405
|
+
await store.putRecord({
|
|
406
|
+
type: "books",
|
|
407
|
+
id: "b1",
|
|
408
|
+
rootId: "b1",
|
|
409
|
+
resource: { type: "books", id: "b1", attributes: { name: "Book" } },
|
|
410
|
+
updatedAt: null,
|
|
411
|
+
});
|
|
412
|
+
await store.putRecord({
|
|
413
|
+
type: "pages",
|
|
414
|
+
id: "p1",
|
|
415
|
+
rootId: "b1",
|
|
416
|
+
resource: page("p1", "One", "b1"),
|
|
417
|
+
updatedAt: null,
|
|
418
|
+
});
|
|
419
|
+
await store.putRecord({
|
|
420
|
+
type: "pages",
|
|
421
|
+
id: "p2",
|
|
422
|
+
rootId: "b2",
|
|
423
|
+
resource: page("p2", "Two", "b2"),
|
|
424
|
+
updatedAt: null,
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const result = await answer(store, "GET", "books/b1/pages?fetchAll=true", undefined, bookConfig());
|
|
428
|
+
const missing = await answer(store, "GET", "books/b2/pages", undefined, bookConfig());
|
|
429
|
+
|
|
430
|
+
expect(result.status).toBe(200);
|
|
431
|
+
expect(result.body.data.map((resource: any) => resource.id)).toEqual(["p1"]);
|
|
432
|
+
expect(missing.status).toBe(503);
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
describe("afterLocalWrite", () => {
|
|
437
|
+
const hookConfig = (hook: (ctx: OfflineLocalWriteContext) => Promise<void>): OfflineConfig => {
|
|
438
|
+
const config = offlineConfig();
|
|
439
|
+
return {
|
|
440
|
+
...config,
|
|
441
|
+
types: config.types.map((typeConfig) =>
|
|
442
|
+
typeConfig.module.name === "npcs" ? { ...typeConfig, afterLocalWrite: hook } : typeConfig,
|
|
443
|
+
),
|
|
444
|
+
};
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
it("afterLocalWrite runs in the write's transaction with method, resource and previous", async () => {
|
|
448
|
+
const seen: any[] = [];
|
|
449
|
+
const hook = vi.fn(async (ctx: OfflineLocalWriteContext) => {
|
|
450
|
+
seen.push({
|
|
451
|
+
method: ctx.method,
|
|
452
|
+
module: ctx.module.name,
|
|
453
|
+
id: ctx.id,
|
|
454
|
+
resource: ctx.resource,
|
|
455
|
+
previous: ctx.previous,
|
|
456
|
+
stored: (await ctx.store.getRecord("npcs", ctx.id))?.resource ?? null,
|
|
457
|
+
queued: (await ctx.store.queue()).length,
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
const config = hookConfig(hook);
|
|
461
|
+
|
|
462
|
+
await answer(store, "POST", "npcs", { data: npc("n9", "New", "C") }, config);
|
|
463
|
+
await answer(
|
|
464
|
+
store,
|
|
465
|
+
"PUT",
|
|
466
|
+
"npcs/n1",
|
|
467
|
+
{ data: { type: "npcs", id: "n1", attributes: { name: "Alda II" } } },
|
|
468
|
+
config,
|
|
469
|
+
);
|
|
470
|
+
await answer(store, "DELETE", "npcs/n2", undefined, config);
|
|
471
|
+
|
|
472
|
+
const alda2 = { ...npc("n1", "Alda", "C"), attributes: { name: "Alda II", description: "Alda text" } };
|
|
473
|
+
expect(seen).toEqual([
|
|
474
|
+
{
|
|
475
|
+
method: "POST",
|
|
476
|
+
module: "npcs",
|
|
477
|
+
id: "n9",
|
|
478
|
+
resource: npc("n9", "New", "C"),
|
|
479
|
+
previous: null,
|
|
480
|
+
stored: npc("n9", "New", "C"),
|
|
481
|
+
queued: 1,
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
method: "PUT",
|
|
485
|
+
module: "npcs",
|
|
486
|
+
id: "n1",
|
|
487
|
+
resource: alda2,
|
|
488
|
+
previous: npc("n1", "Alda", "C"),
|
|
489
|
+
stored: alda2,
|
|
490
|
+
queued: 2,
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
method: "DELETE",
|
|
494
|
+
module: "npcs",
|
|
495
|
+
id: "n2",
|
|
496
|
+
resource: null,
|
|
497
|
+
previous: npc("n2", "brom", "C"),
|
|
498
|
+
stored: null,
|
|
499
|
+
queued: 3,
|
|
500
|
+
},
|
|
501
|
+
]);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("a hook that throws rolls back the write and the enqueue", async () => {
|
|
505
|
+
const config = hookConfig(async () => {
|
|
506
|
+
throw new Error("hook failed");
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
await expect(answer(store, "POST", "npcs", { data: npc("n9", "New", "C") }, config)).rejects.toThrow(
|
|
510
|
+
"hook failed",
|
|
511
|
+
);
|
|
512
|
+
await expect(
|
|
513
|
+
answer(store, "PUT", "npcs/n1", { data: { type: "npcs", id: "n1", attributes: { name: "X" } } }, config),
|
|
514
|
+
).rejects.toThrow("hook failed");
|
|
515
|
+
await expect(answer(store, "DELETE", "npcs/n2", undefined, config)).rejects.toThrow("hook failed");
|
|
516
|
+
|
|
517
|
+
expect(await store.getRecord("npcs", "n9")).toBeUndefined();
|
|
518
|
+
expect((await store.getRecord("npcs", "n1"))?.resource).toEqual(npc("n1", "Alda", "C"));
|
|
519
|
+
expect(await store.getRecord("npcs", "n2")).toBeDefined();
|
|
520
|
+
expect(await store.queue()).toEqual([]);
|
|
521
|
+
});
|
|
522
|
+
});
|
|
380
523
|
});
|
|
@@ -5,7 +5,7 @@ import { getOfflineState, setOfflineState } from "../state";
|
|
|
5
5
|
import type { DeviceStore } from "../store/DeviceStore";
|
|
6
6
|
import { NeedsConnectionError, type OfflineRoute } from "../types";
|
|
7
7
|
import { setDeviceStoreOpener } from "../config";
|
|
8
|
-
import { Roots, Npcs, setUpOffline, tearDownOffline } from "./offline-fixtures";
|
|
8
|
+
import { bookConfig, Books, Roots, Npcs, setUpOffline, tearDownOffline } from "./offline-fixtures";
|
|
9
9
|
|
|
10
10
|
const API = "http://api.test/";
|
|
11
11
|
|
|
@@ -147,4 +147,18 @@ describe("localTransport", () => {
|
|
|
147
147
|
|
|
148
148
|
expect(response.data.errors[0].code).toBe("needs-connection");
|
|
149
149
|
});
|
|
150
|
+
|
|
151
|
+
it("a queued custom write under an extra-kind module segment takes that root", async () => {
|
|
152
|
+
const answer = vi.fn(async () => ({ status: 200, body: { data: { type: "books", id: "b7" } } }));
|
|
153
|
+
store = await setUpOffline(
|
|
154
|
+
[{ method: "POST", pattern: [Books, ":bookId", "reorder"], answer, queue: true }],
|
|
155
|
+
bookConfig,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const response = await localTransport({ method: "POST", url: `${API}books/b7/reorder`, body: {} });
|
|
159
|
+
|
|
160
|
+
expect(response.status).toBe(200);
|
|
161
|
+
const [entry] = await store.queue();
|
|
162
|
+
expect(entry).toMatchObject({ rootId: "b7", method: "POST", path: "books/b7/reorder" });
|
|
163
|
+
});
|
|
150
164
|
});
|
|
@@ -18,6 +18,16 @@ export const Roots = { name: "roots", model: TestModel };
|
|
|
18
18
|
export const Npcs = { name: "npcs", model: TestModel };
|
|
19
19
|
export const Factions = { name: "factions", model: TestModel };
|
|
20
20
|
export const Characters = { name: "characters", model: TestModel };
|
|
21
|
+
/** A second kind of root record (`extraRootKinds`) and its child type. */
|
|
22
|
+
export const Books = { name: "books", model: TestModel };
|
|
23
|
+
export const Pages = { name: "pages", model: TestModel };
|
|
24
|
+
|
|
25
|
+
export const page = (id: string, name: string, bookId: string) => ({
|
|
26
|
+
type: "pages",
|
|
27
|
+
id,
|
|
28
|
+
attributes: { name },
|
|
29
|
+
relationships: { book: { data: { type: "books", id: bookId } } },
|
|
30
|
+
});
|
|
21
31
|
|
|
22
32
|
export const npc = (id: string, name: string, rootId: string) => ({
|
|
23
33
|
type: "npcs",
|
|
@@ -43,6 +53,20 @@ export function offlineConfig(routes: OfflineRoute[] = []): OfflineConfig {
|
|
|
43
53
|
};
|
|
44
54
|
}
|
|
45
55
|
|
|
56
|
+
/** `offlineConfig` with the books root kind and its types. */
|
|
57
|
+
export function bookConfig(routes: OfflineRoute[] = []): OfflineConfig {
|
|
58
|
+
const config = offlineConfig(routes);
|
|
59
|
+
return {
|
|
60
|
+
...config,
|
|
61
|
+
types: [
|
|
62
|
+
...config.types,
|
|
63
|
+
{ module: Books, fetch: "by-id", readOnly: false, serverOwnedFields: [] },
|
|
64
|
+
{ module: Pages, fetch: "root-list", readOnly: false, serverOwnedFields: [] },
|
|
65
|
+
],
|
|
66
|
+
extraRootKinds: [{ module: Books, listSyncRoots: async () => ["b1"] }],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
46
70
|
/** Two NPCs and a faction in root record C, one NPC in D; the faction is linked to n1. */
|
|
47
71
|
export async function seededStore(): Promise<DeviceStore> {
|
|
48
72
|
const store = await openMemoryDeviceStore();
|
|
@@ -74,11 +98,14 @@ export async function seededStore(): Promise<DeviceStore> {
|
|
|
74
98
|
return store;
|
|
75
99
|
}
|
|
76
100
|
|
|
77
|
-
export async function setUpOffline(
|
|
101
|
+
export async function setUpOffline(
|
|
102
|
+
routes: OfflineRoute[] = [],
|
|
103
|
+
buildConfig: (routes: OfflineRoute[]) => OfflineConfig = offlineConfig,
|
|
104
|
+
): Promise<DeviceStore> {
|
|
78
105
|
resetOfflineState();
|
|
79
106
|
resetOfflineConfig();
|
|
80
107
|
const store = await seededStore();
|
|
81
|
-
configureOffline(
|
|
108
|
+
configureOffline(buildConfig(routes));
|
|
82
109
|
setDeviceStoreOpener(async () => store);
|
|
83
110
|
return store;
|
|
84
111
|
}
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import type { DeviceStore } from "../store/DeviceStore";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
artwork,
|
|
5
|
+
book,
|
|
6
|
+
bookTypes,
|
|
7
|
+
Books,
|
|
8
|
+
fakeCacheStorage,
|
|
9
|
+
npc,
|
|
10
|
+
setUpSync,
|
|
11
|
+
syncTestConfig,
|
|
12
|
+
tearDownSync,
|
|
13
|
+
} from "../sync/__tests__/sync-fixtures";
|
|
4
14
|
import { markOfflineUnavailable, OfflineUnavailableError } from "../config";
|
|
5
15
|
import { writeThrough } from "../write-through";
|
|
6
16
|
|
|
@@ -134,4 +144,20 @@ describe("writeThrough", () => {
|
|
|
134
144
|
|
|
135
145
|
expect(images.has("http://api.test/artworks/a1/image?v=1")).toBe(false);
|
|
136
146
|
});
|
|
147
|
+
|
|
148
|
+
it("an online write under an extra-kind root is stored", async () => {
|
|
149
|
+
tearDownSync();
|
|
150
|
+
store = await setUpSync({
|
|
151
|
+
types: [...syncTestConfig().types, ...bookTypes],
|
|
152
|
+
extraRootKinds: [{ module: Books, listSyncRoots: async () => ["b1"] }],
|
|
153
|
+
});
|
|
154
|
+
await store.putRecord({ type: "books", id: "b1", rootId: "b1", resource: book("b1"), updatedAt: null });
|
|
155
|
+
|
|
156
|
+
await writeThrough(
|
|
157
|
+
{ method: "POST", url: "http://api.test/books/b1/pages" },
|
|
158
|
+
ok({ data: { type: "pages", id: "p5", attributes: { name: "No relationship" } } }),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
expect(await store.getRecord("pages", "p5")).toMatchObject({ rootId: "b1" });
|
|
162
|
+
});
|
|
137
163
|
});
|
package/src/offline/config.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
DEFAULT_SQLITE_MODULE_URL,
|
|
6
6
|
NeedsConnectionError,
|
|
7
7
|
type OfflineConfig,
|
|
8
|
+
type OfflineRootKind,
|
|
8
9
|
} from "./types";
|
|
9
10
|
|
|
10
11
|
// Kept free of store, worker and React imports: `directFetch` reads `isOfflineConfigured()` on
|
|
@@ -159,6 +160,14 @@ export function refreshOfflineDeviceState(): void {
|
|
|
159
160
|
});
|
|
160
161
|
}
|
|
161
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Every kind of root record: the primary one (`rootModule` + `listSyncRoots`) first, then
|
|
165
|
+
* `extraRootKinds`. Library-internal (not exported from the entry).
|
|
166
|
+
*/
|
|
167
|
+
export function rootKinds(config: OfflineConfig): OfflineRootKind[] {
|
|
168
|
+
return [{ module: config.rootModule, listSyncRoots: config.listSyncRoots }, ...(config.extraRootKinds ?? [])];
|
|
169
|
+
}
|
|
170
|
+
|
|
162
171
|
export function getOfflineConfig(): OfflineConfig {
|
|
163
172
|
if (!_config) throw new Error("Offline mode is not configured. Call configureOffline() first.");
|
|
164
173
|
return _config;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CURRENT_USER_STORAGE_KEY } from "../features/user/data/user.storage";
|
|
2
|
-
import { getOfflineConfig } from "./config";
|
|
2
|
+
import { getOfflineConfig, rootKinds } from "./config";
|
|
3
3
|
import { getOfflineState } from "./state";
|
|
4
4
|
import type { DeviceStore, StoredLink, StoredRecord } from "./store/DeviceStore";
|
|
5
5
|
import type {
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
OfflineAnswer,
|
|
8
8
|
OfflineConfig,
|
|
9
9
|
OfflineMethod,
|
|
10
|
+
OfflineRootKind,
|
|
10
11
|
OfflineRoute,
|
|
11
12
|
OfflineTypeConfig,
|
|
12
13
|
} from "./types";
|
|
@@ -402,10 +403,10 @@ async function getOne(ctx: LocalAnswerContext, type: string): Promise<OfflineAns
|
|
|
402
403
|
|
|
403
404
|
async function rootList(
|
|
404
405
|
ctx: LocalAnswerContext,
|
|
405
|
-
|
|
406
|
+
kind: OfflineRootKind,
|
|
406
407
|
typeConfig: OfflineTypeConfig,
|
|
407
408
|
): Promise<OfflineAnswer> {
|
|
408
|
-
const rootType =
|
|
409
|
+
const rootType = kind.module.name;
|
|
409
410
|
const rootId = ctx.params.rootId;
|
|
410
411
|
if (!(await ctx.store.getRecord(rootType, rootId))) return notOnDeviceAnswer(rootType, rootId);
|
|
411
412
|
const records = await ctx.store.listRecords({ type: typeConfig.module.name, rootId });
|
|
@@ -463,17 +464,20 @@ async function relatedList(
|
|
|
463
464
|
// Writes
|
|
464
465
|
// ---------------------------------------------------------------------------------------------
|
|
465
466
|
|
|
466
|
-
/**
|
|
467
|
+
/**
|
|
468
|
+
* The root record a record belongs to: itself (the type of any root kind), a relationship to a
|
|
469
|
+
* root of any kind, or a related stored record.
|
|
470
|
+
*/
|
|
467
471
|
export async function rootIdForResource(
|
|
468
472
|
store: DeviceStore,
|
|
469
473
|
config: OfflineConfig,
|
|
470
474
|
resource: any,
|
|
471
475
|
): Promise<string | undefined> {
|
|
472
|
-
const
|
|
473
|
-
if (resource?.type
|
|
476
|
+
const rootTypes = new Set(rootKinds(config).map((kind) => kind.module.name));
|
|
477
|
+
if (rootTypes.has(resource?.type) && resource.id) return resource.id;
|
|
474
478
|
|
|
475
479
|
const identifiers = Object.values(resource?.relationships ?? {}).flatMap(identifiersOf);
|
|
476
|
-
const root = identifiers.find((identifier) => identifier.type
|
|
480
|
+
const root = identifiers.find((identifier) => rootTypes.has(identifier.type));
|
|
477
481
|
if (root) return root.id;
|
|
478
482
|
|
|
479
483
|
for (const identifier of identifiers) {
|
|
@@ -582,6 +586,17 @@ async function create(ctx: LocalAnswerContext, config: OfflineConfig, type: stri
|
|
|
582
586
|
body: ctx.request.body,
|
|
583
587
|
base: null,
|
|
584
588
|
});
|
|
589
|
+
// The type's hook, inside the write's transaction: a throw rolls the write and the enqueue back.
|
|
590
|
+
if (typeConfig?.afterLocalWrite) {
|
|
591
|
+
await typeConfig.afterLocalWrite({
|
|
592
|
+
store,
|
|
593
|
+
module: typeConfig.module,
|
|
594
|
+
id,
|
|
595
|
+
method: "POST",
|
|
596
|
+
resource,
|
|
597
|
+
previous: null,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
585
600
|
});
|
|
586
601
|
|
|
587
602
|
return singleAnswer(ctx.store, record, 201);
|
|
@@ -599,16 +614,12 @@ async function update(ctx: LocalAnswerContext, config: OfflineConfig, type: stri
|
|
|
599
614
|
// A record created on this device has no server version to merge against.
|
|
600
615
|
const base = existing.localOnly ? null : (existing.base ?? before);
|
|
601
616
|
|
|
617
|
+
const typeConfig = typeConfigOf(config, type);
|
|
618
|
+
const method = ctx.request.method === "PATCH" ? "PATCH" : "PUT";
|
|
602
619
|
const record: StoredRecord = { ...existing, resource, base, localOnly: existing.localOnly };
|
|
603
620
|
await ctx.store.transaction(async (store) => {
|
|
604
621
|
await store.putRecord(record);
|
|
605
|
-
await linkRelationships(
|
|
606
|
-
store,
|
|
607
|
-
typeConfigOf(config, type),
|
|
608
|
-
{ type, id },
|
|
609
|
-
before.relationships ?? {},
|
|
610
|
-
data.relationships ?? {},
|
|
611
|
-
);
|
|
622
|
+
await linkRelationships(store, typeConfig, { type, id }, before.relationships ?? {}, data.relationships ?? {});
|
|
612
623
|
await store.enqueue({
|
|
613
624
|
rootId: existing.rootId,
|
|
614
625
|
method: ctx.request.method,
|
|
@@ -618,12 +629,15 @@ async function update(ctx: LocalAnswerContext, config: OfflineConfig, type: stri
|
|
|
618
629
|
recordId: id,
|
|
619
630
|
base,
|
|
620
631
|
});
|
|
632
|
+
if (typeConfig?.afterLocalWrite) {
|
|
633
|
+
await typeConfig.afterLocalWrite({ store, module: typeConfig.module, id, method, resource, previous: before });
|
|
634
|
+
}
|
|
621
635
|
});
|
|
622
636
|
|
|
623
637
|
return singleAnswer(ctx.store, record);
|
|
624
638
|
}
|
|
625
639
|
|
|
626
|
-
async function remove(ctx: LocalAnswerContext, type: string): Promise<OfflineAnswer> {
|
|
640
|
+
async function remove(ctx: LocalAnswerContext, config: OfflineConfig, type: string): Promise<OfflineAnswer> {
|
|
627
641
|
const id = ctx.params.id;
|
|
628
642
|
const existing = await ctx.store.getRecord(type, id);
|
|
629
643
|
if (!existing) return notOnDeviceAnswer(type, id);
|
|
@@ -643,6 +657,17 @@ async function remove(ctx: LocalAnswerContext, type: string): Promise<OfflineAns
|
|
|
643
657
|
recordId: id,
|
|
644
658
|
base,
|
|
645
659
|
});
|
|
660
|
+
const typeConfig = typeConfigOf(config, type);
|
|
661
|
+
if (typeConfig?.afterLocalWrite) {
|
|
662
|
+
await typeConfig.afterLocalWrite({
|
|
663
|
+
store,
|
|
664
|
+
module: typeConfig.module,
|
|
665
|
+
id,
|
|
666
|
+
method: "DELETE",
|
|
667
|
+
resource: null,
|
|
668
|
+
previous: existing.resource,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
646
671
|
});
|
|
647
672
|
|
|
648
673
|
return { status: 204, body: undefined };
|
|
@@ -676,7 +701,9 @@ export function buildGenericRoutes(config: OfflineConfig): OfflineRoute[] {
|
|
|
676
701
|
for (const typeConfig of config.types) {
|
|
677
702
|
const type = typeConfig.module.name;
|
|
678
703
|
route("GET", [typeConfig.module, ":id"], (ctx) => getOne(ctx, type));
|
|
679
|
-
|
|
704
|
+
for (const kind of rootKinds(config)) {
|
|
705
|
+
route("GET", [kind.module, ":rootId", typeConfig.module], (ctx) => rootList(ctx, kind, typeConfig));
|
|
706
|
+
}
|
|
680
707
|
route(
|
|
681
708
|
"POST",
|
|
682
709
|
[typeConfig.module],
|
|
@@ -695,7 +722,7 @@ export function buildGenericRoutes(config: OfflineConfig): OfflineRoute[] {
|
|
|
695
722
|
route(
|
|
696
723
|
"DELETE",
|
|
697
724
|
[typeConfig.module, ":id"],
|
|
698
|
-
readOnlyGuard(typeConfig, (ctx) => remove(ctx, type)),
|
|
725
|
+
readOnlyGuard(typeConfig, (ctx) => remove(ctx, config, type)),
|
|
699
726
|
);
|
|
700
727
|
}
|
|
701
728
|
for (const typeConfig of config.types) {
|
package/src/offline/index.ts
CHANGED
|
@@ -42,6 +42,8 @@ export {
|
|
|
42
42
|
type ResourceDocumentData,
|
|
43
43
|
} from "./generic-answers";
|
|
44
44
|
export type { OfflineChangeResolution } from "./sync/models/OfflineChangeSetInterface";
|
|
45
|
+
// The sync answer's model: an app reads an extra root kind's sync answer (`OfflineRootKind.syncModule`) with it.
|
|
46
|
+
export { RootSync } from "./sync/models/RootSync";
|
|
45
47
|
|
|
46
48
|
// Device store (Task 5)
|
|
47
49
|
export type * from "./store/DeviceStore";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getClientApiUrl } from "../client/JsonApiClient";
|
|
2
2
|
import type { ApiData } from "../core/interfaces/ApiData";
|
|
3
3
|
import { getApiUrl } from "../unified/JsonApiRequest";
|
|
4
|
-
import { getOfflineConfig, getOfflineStore } from "./config";
|
|
4
|
+
import { getOfflineConfig, getOfflineStore, rootKinds } from "./config";
|
|
5
5
|
import {
|
|
6
6
|
buildGenericRoutes,
|
|
7
7
|
rootIdForResource,
|
|
@@ -132,10 +132,12 @@ export async function localTransport(request: LocalTransportRequest): Promise<Ap
|
|
|
132
132
|
}
|
|
133
133
|
const ctx = { request: offlineRequest, params: match.params, store };
|
|
134
134
|
|
|
135
|
-
// The root record of a custom write (a `<root>/:param` capture
|
|
135
|
+
// The root record of a custom write (a `<root>/:param` capture of any root kind's module, a
|
|
136
|
+
// stored record, or the body).
|
|
137
|
+
const rootTypes = new Set(rootKinds(config).map((kind) => kind.module.name));
|
|
136
138
|
const customRootId = async (route: OfflineRoute): Promise<string | undefined> => {
|
|
137
139
|
const rootIndex = route.pattern.findIndex(
|
|
138
|
-
(segment) => typeof segment !== "string" && segment.name
|
|
140
|
+
(segment) => typeof segment !== "string" && rootTypes.has(segment.name),
|
|
139
141
|
);
|
|
140
142
|
const next = rootIndex >= 0 ? route.pattern[rootIndex + 1] : undefined;
|
|
141
143
|
const rootParam = typeof next === "string" && next.startsWith(":") ? next.slice(1) : undefined;
|
|
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
import { getOfflineState, isReconnecting, setOfflineState } from "../../state";
|
|
3
3
|
import { markOfflineUnavailable, OfflineUnavailableError } from "../../config";
|
|
4
4
|
import type { DeviceStore } from "../../store/DeviceStore";
|
|
5
|
-
import { setUpSync, tearDownSync } from "./sync-fixtures";
|
|
5
|
+
import { book, bookTypes, Books, setUpSync, syncTestConfig, tearDownSync } from "./sync-fixtures";
|
|
6
6
|
|
|
7
7
|
const { drainQueue, syncAll } = vi.hoisted(() => ({ drainQueue: vi.fn(), syncAll: vi.fn() }));
|
|
8
8
|
vi.mock("../drain-queue", () => ({ drainQueue }));
|
|
@@ -168,6 +168,35 @@ describe("startOfflineRuntime", () => {
|
|
|
168
168
|
vi.unstubAllGlobals();
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
+
it("probeApi ignores extra-kind roots", async () => {
|
|
172
|
+
tearDownSync();
|
|
173
|
+
store = await setUpSync({
|
|
174
|
+
syncIntervalMs: 300000,
|
|
175
|
+
types: [...syncTestConfig().types, ...bookTypes],
|
|
176
|
+
extraRootKinds: [{ module: Books, listSyncRoots: async () => ["A1"] }],
|
|
177
|
+
});
|
|
178
|
+
await store.putRecord({ type: "books", id: "A1", rootId: "A1", resource: book("A1"), updatedAt: null });
|
|
179
|
+
await store.enqueue({ rootId: "A1", method: "PATCH", path: "books/A1", body: { data: book("A1") }, base: null });
|
|
180
|
+
await store.putRecord({
|
|
181
|
+
type: "roots",
|
|
182
|
+
id: "C",
|
|
183
|
+
rootId: "C",
|
|
184
|
+
resource: { type: "roots", id: "C" },
|
|
185
|
+
updatedAt: null,
|
|
186
|
+
});
|
|
187
|
+
setOfflineState({ online: false });
|
|
188
|
+
vi.stubGlobal("navigator", { ...navigator, onLine: true });
|
|
189
|
+
directFetch.mockResolvedValue({ ok: false, status: 503, statusText: "", data: undefined, networkError: true });
|
|
190
|
+
|
|
191
|
+
stop = startOfflineRuntime();
|
|
192
|
+
await vi.advanceTimersByTimeAsync(30000);
|
|
193
|
+
await flush();
|
|
194
|
+
|
|
195
|
+
expect(directFetch).toHaveBeenCalledTimes(1);
|
|
196
|
+
expect(directFetch.mock.calls[0][0].url).toBe("http://api.test/roots/C/sync");
|
|
197
|
+
vi.unstubAllGlobals();
|
|
198
|
+
});
|
|
199
|
+
|
|
171
200
|
it("a change queued while the reconnect attempt ran (a resolved conflict) is sent as soon as the session goes online", async () => {
|
|
172
201
|
await store.putRecord({
|
|
173
202
|
type: "roots",
|
|
@@ -19,6 +19,28 @@ export const Roots = { name: "roots", model: TestModel };
|
|
|
19
19
|
export const Npcs = { name: "npcs", model: TestModel };
|
|
20
20
|
export const Artworks = { name: "artworks", model: TestModel };
|
|
21
21
|
export const Templates = { name: "templates", model: TestModel };
|
|
22
|
+
/** A second kind of root record (`extraRootKinds`) and its child type. */
|
|
23
|
+
export const Books = { name: "books", model: TestModel };
|
|
24
|
+
export const Pages = { name: "pages", model: TestModel };
|
|
25
|
+
|
|
26
|
+
/** The types of the second root kind, appended to `syncTestConfig().types` by the specs that use it. */
|
|
27
|
+
export const bookTypes: OfflineConfig["types"] = [
|
|
28
|
+
{ module: Books, fetch: "by-id", readOnly: false, serverOwnedFields: [] },
|
|
29
|
+
{ module: Pages, fetch: "root-list", readOnly: false, serverOwnedFields: [] },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export const book = (id: string, attributes: Record<string, unknown> = {}) => ({
|
|
33
|
+
type: "books",
|
|
34
|
+
id,
|
|
35
|
+
attributes: { name: id, ...attributes },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const page = (id: string, name: string, bookId = "b1") => ({
|
|
39
|
+
type: "pages",
|
|
40
|
+
id,
|
|
41
|
+
attributes: { name },
|
|
42
|
+
relationships: { book: { data: { type: "books", id: bookId } } },
|
|
43
|
+
});
|
|
22
44
|
|
|
23
45
|
const moduleFactory: ModuleFactory = (params) =>
|
|
24
46
|
({ pageUrl: params.pageUrl, name: params.name, model: params.model }) as ModuleWithPermissions;
|