@carlonicora/nextjs-jsonapi 6.13.0 → 6.14.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.
Files changed (153) hide show
  1. package/dist/{BlockNoteEditor-2VLPSEKS.js → BlockNoteEditor-CUNH4LSQ.js} +25 -25
  2. package/dist/{BlockNoteEditor-2VLPSEKS.js.map → BlockNoteEditor-CUNH4LSQ.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-22Q4DJBU.mjs → BlockNoteEditor-QIVV2OB4.mjs} +10 -10
  4. package/dist/{BlockNoteViewer-2XYASA5Y.js → BlockNoteViewer-APBOPT4B.js} +8 -8
  5. package/dist/{BlockNoteViewer-2XYASA5Y.js.map → BlockNoteViewer-APBOPT4B.js.map} +1 -1
  6. package/dist/{BlockNoteViewer-NBW2H6LC.mjs → BlockNoteViewer-HIUAJQRI.mjs} +7 -7
  7. package/dist/JsonApiRequest-G4J2WTSU.js +26 -0
  8. package/dist/{JsonApiRequest-WISJZSDE.js.map → JsonApiRequest-G4J2WTSU.js.map} +1 -1
  9. package/dist/{JsonApiRequest-F6TMJ42G.mjs → JsonApiRequest-IKKHWCPB.mjs} +2 -2
  10. package/dist/billing/index.js +497 -497
  11. package/dist/billing/index.mjs +9 -9
  12. package/dist/{chunk-7DBJTNSU.mjs → chunk-4RPGFQBA.mjs} +2 -2
  13. package/dist/{chunk-BZ5J2FIV.js → chunk-4XLDBOSE.js} +11 -11
  14. package/dist/{chunk-BZ5J2FIV.js.map → chunk-4XLDBOSE.js.map} +1 -1
  15. package/dist/{chunk-27LQDGNP.mjs → chunk-4ZJIUTIV.mjs} +6 -1
  16. package/dist/chunk-4ZJIUTIV.mjs.map +1 -0
  17. package/dist/{chunk-PXDUJ47N.js → chunk-6RCZ64BQ.js} +18 -18
  18. package/dist/{chunk-PXDUJ47N.js.map → chunk-6RCZ64BQ.js.map} +1 -1
  19. package/dist/{chunk-7JZHPMGW.mjs → chunk-A3RB47X4.mjs} +44 -20
  20. package/dist/chunk-A3RB47X4.mjs.map +1 -0
  21. package/dist/{chunk-DOCIFE6A.mjs → chunk-AUD6ZLBB.mjs} +3 -3
  22. package/dist/{chunk-GB6DBFYY.js → chunk-DGN3PR2F.js} +3 -3
  23. package/dist/{chunk-GB6DBFYY.js.map → chunk-DGN3PR2F.js.map} +1 -1
  24. package/dist/{chunk-7ZHTPFFT.js → chunk-DJXSCARG.js} +15 -15
  25. package/dist/{chunk-7ZHTPFFT.js.map → chunk-DJXSCARG.js.map} +1 -1
  26. package/dist/{chunk-QKNGZDSQ.js → chunk-GM4VZTWL.js} +2 -2
  27. package/dist/{chunk-QKNGZDSQ.js.map → chunk-GM4VZTWL.js.map} +1 -1
  28. package/dist/{chunk-2H342HNN.js → chunk-GSRTVIIK.js} +1184 -1178
  29. package/dist/chunk-GSRTVIIK.js.map +1 -0
  30. package/dist/{chunk-47BBY5KA.mjs → chunk-HAW5PNJG.mjs} +4 -4
  31. package/dist/{chunk-YRKIFYOE.mjs → chunk-KTGKW72G.mjs} +27 -8
  32. package/dist/chunk-KTGKW72G.mjs.map +1 -0
  33. package/dist/{chunk-PV4Z5W6V.js → chunk-KV4N7DEG.js} +19 -19
  34. package/dist/{chunk-PV4Z5W6V.js.map → chunk-KV4N7DEG.js.map} +1 -1
  35. package/dist/{chunk-ZLTOUZN2.mjs → chunk-M3N7LPHX.mjs} +4 -4
  36. package/dist/{chunk-DGWOVYEC.mjs → chunk-MQ7TXALH.mjs} +2 -2
  37. package/dist/{chunk-W62MHDWM.js → chunk-N5ZE2Z6I.js} +58 -34
  38. package/dist/chunk-N5ZE2Z6I.js.map +1 -0
  39. package/dist/{chunk-2SV4SS6N.js → chunk-OQQ7CZWT.js} +12 -12
  40. package/dist/{chunk-2SV4SS6N.js.map → chunk-OQQ7CZWT.js.map} +1 -1
  41. package/dist/{chunk-JJMMT24D.mjs → chunk-OUEYZOLB.mjs} +2 -2
  42. package/dist/{chunk-XXP3TQHF.mjs → chunk-OY65RYD3.mjs} +2 -2
  43. package/dist/{chunk-MOGEGWXW.js → chunk-PMESKNKL.js} +868 -868
  44. package/dist/{chunk-MOGEGWXW.js.map → chunk-PMESKNKL.js.map} +1 -1
  45. package/dist/{chunk-B6JGKDP2.js → chunk-QGVVF77L.js} +48 -29
  46. package/dist/chunk-QGVVF77L.js.map +1 -0
  47. package/dist/{chunk-BH6JWIKI.mjs → chunk-QIEF7HIL.mjs} +2 -2
  48. package/dist/{chunk-P445QGOC.mjs → chunk-RFKSJIQB.mjs} +7 -7
  49. package/dist/{chunk-6JUCIWDC.mjs → chunk-UOTHRJD2.mjs} +2 -2
  50. package/dist/{chunk-KMW7UNL2.js → chunk-WCEYT3GG.js} +8 -8
  51. package/dist/{chunk-KMW7UNL2.js.map → chunk-WCEYT3GG.js.map} +1 -1
  52. package/dist/{chunk-MHB36MYE.js → chunk-WUNSG3IN.js} +7 -2
  53. package/dist/chunk-WUNSG3IN.js.map +1 -0
  54. package/dist/{chunk-H4NLDX5G.mjs → chunk-YKNULEHB.mjs} +19 -13
  55. package/dist/chunk-YKNULEHB.mjs.map +1 -0
  56. package/dist/{chunk-Z4POQWFG.js → chunk-ZYSNJKOP.js} +4 -4
  57. package/dist/{chunk-Z4POQWFG.js.map → chunk-ZYSNJKOP.js.map} +1 -1
  58. package/dist/client/index.js +10 -10
  59. package/dist/client/index.mjs +9 -9
  60. package/dist/components/index.js +10 -10
  61. package/dist/components/index.mjs +9 -9
  62. package/dist/contexts/index.js +10 -10
  63. package/dist/contexts/index.mjs +9 -9
  64. package/dist/core/index.js +7 -7
  65. package/dist/core/index.mjs +6 -6
  66. package/dist/email-automations/index.js +99 -99
  67. package/dist/email-automations/index.mjs +9 -9
  68. package/dist/features/analytics/index.js +126 -126
  69. package/dist/features/analytics/index.mjs +9 -9
  70. package/dist/features/analytics/server.js +10 -10
  71. package/dist/features/analytics/server.mjs +8 -8
  72. package/dist/features/blog/index.js +156 -156
  73. package/dist/features/blog/index.mjs +9 -9
  74. package/dist/features/blog/server.js +28 -28
  75. package/dist/features/blog/server.mjs +7 -7
  76. package/dist/features/help/index.js +43 -43
  77. package/dist/features/help/index.mjs +9 -9
  78. package/dist/features/tokenusage/index.js +132 -132
  79. package/dist/features/tokenusage/index.mjs +9 -9
  80. package/dist/index.js +8 -8
  81. package/dist/index.mjs +7 -7
  82. package/dist/{lifecycle-LKGPRBS7.mjs → lifecycle-KI3PCJSE.mjs} +3 -3
  83. package/dist/lifecycle-TO65I7TO.js +23 -0
  84. package/dist/{lifecycle-4GUOTB46.js.map → lifecycle-TO65I7TO.js.map} +1 -1
  85. package/dist/local-transport-4YDJ6FQG.js +15 -0
  86. package/dist/{local-transport-7H3SPZSA.js.map → local-transport-4YDJ6FQG.js.map} +1 -1
  87. package/dist/local-transport-C7YTA4I7.mjs +15 -0
  88. package/dist/offline/index.d.mts +160 -4
  89. package/dist/offline/index.d.ts +160 -4
  90. package/dist/offline/index.js +210 -163
  91. package/dist/offline/index.js.map +1 -1
  92. package/dist/offline/index.mjs +76 -29
  93. package/dist/offline/index.mjs.map +1 -1
  94. package/dist/request-66FGJIHB.mjs +10 -0
  95. package/dist/request-6W6UGKJV.js +10 -0
  96. package/dist/{request-KTBV6HHH.js.map → request-6W6UGKJV.js.map} +1 -1
  97. package/dist/server/index.js +9 -9
  98. package/dist/server/index.mjs +8 -8
  99. package/dist/write-through-BAPS24GL.js +15 -0
  100. package/dist/{write-through-FDQ7N4YI.js.map → write-through-BAPS24GL.js.map} +1 -1
  101. package/dist/write-through-IWQKXYHE.mjs +15 -0
  102. package/package.json +1 -1
  103. package/src/features/onboarding/contexts/OnboardingContext.spec.tsx +42 -1
  104. package/src/features/onboarding/contexts/OnboardingContext.tsx +11 -1
  105. package/src/offline/__tests__/generic-answers.spec.ts +146 -3
  106. package/src/offline/__tests__/local-transport.spec.ts +15 -1
  107. package/src/offline/__tests__/offline-fixtures.ts +29 -2
  108. package/src/offline/__tests__/write-through.spec.ts +27 -1
  109. package/src/offline/config.ts +9 -0
  110. package/src/offline/generic-answers.ts +44 -17
  111. package/src/offline/index.ts +2 -0
  112. package/src/offline/local-transport.ts +5 -3
  113. package/src/offline/sync/__tests__/runtime.spec.ts +30 -1
  114. package/src/offline/sync/__tests__/sync-fixtures.ts +22 -0
  115. package/src/offline/sync/__tests__/sync-root.spec.ts +184 -1
  116. package/src/offline/sync/known-roots.ts +29 -7
  117. package/src/offline/sync/runtime.ts +3 -2
  118. package/src/offline/sync/sync-root.ts +70 -19
  119. package/src/offline/types.ts +39 -1
  120. package/src/offline/write-through.ts +6 -3
  121. package/dist/JsonApiRequest-WISJZSDE.js +0 -26
  122. package/dist/chunk-27LQDGNP.mjs.map +0 -1
  123. package/dist/chunk-2H342HNN.js.map +0 -1
  124. package/dist/chunk-7JZHPMGW.mjs.map +0 -1
  125. package/dist/chunk-B6JGKDP2.js.map +0 -1
  126. package/dist/chunk-H4NLDX5G.mjs.map +0 -1
  127. package/dist/chunk-MHB36MYE.js.map +0 -1
  128. package/dist/chunk-W62MHDWM.js.map +0 -1
  129. package/dist/chunk-YRKIFYOE.mjs.map +0 -1
  130. package/dist/lifecycle-4GUOTB46.js +0 -23
  131. package/dist/local-transport-7H3SPZSA.js +0 -15
  132. package/dist/local-transport-QYFKA6AN.mjs +0 -15
  133. package/dist/request-KTBV6HHH.js +0 -10
  134. package/dist/request-SQOUNWZ4.mjs +0 -10
  135. package/dist/write-through-FDQ7N4YI.js +0 -15
  136. package/dist/write-through-R25VQMH6.mjs +0 -15
  137. /package/dist/{BlockNoteEditor-22Q4DJBU.mjs.map → BlockNoteEditor-QIVV2OB4.mjs.map} +0 -0
  138. /package/dist/{BlockNoteViewer-NBW2H6LC.mjs.map → BlockNoteViewer-HIUAJQRI.mjs.map} +0 -0
  139. /package/dist/{JsonApiRequest-F6TMJ42G.mjs.map → JsonApiRequest-IKKHWCPB.mjs.map} +0 -0
  140. /package/dist/{chunk-7DBJTNSU.mjs.map → chunk-4RPGFQBA.mjs.map} +0 -0
  141. /package/dist/{chunk-DOCIFE6A.mjs.map → chunk-AUD6ZLBB.mjs.map} +0 -0
  142. /package/dist/{chunk-47BBY5KA.mjs.map → chunk-HAW5PNJG.mjs.map} +0 -0
  143. /package/dist/{chunk-ZLTOUZN2.mjs.map → chunk-M3N7LPHX.mjs.map} +0 -0
  144. /package/dist/{chunk-DGWOVYEC.mjs.map → chunk-MQ7TXALH.mjs.map} +0 -0
  145. /package/dist/{chunk-JJMMT24D.mjs.map → chunk-OUEYZOLB.mjs.map} +0 -0
  146. /package/dist/{chunk-XXP3TQHF.mjs.map → chunk-OY65RYD3.mjs.map} +0 -0
  147. /package/dist/{chunk-BH6JWIKI.mjs.map → chunk-QIEF7HIL.mjs.map} +0 -0
  148. /package/dist/{chunk-P445QGOC.mjs.map → chunk-RFKSJIQB.mjs.map} +0 -0
  149. /package/dist/{chunk-6JUCIWDC.mjs.map → chunk-UOTHRJD2.mjs.map} +0 -0
  150. /package/dist/{lifecycle-LKGPRBS7.mjs.map → lifecycle-KI3PCJSE.mjs.map} +0 -0
  151. /package/dist/{local-transport-QYFKA6AN.mjs.map → local-transport-C7YTA4I7.mjs.map} +0 -0
  152. /package/dist/{request-SQOUNWZ4.mjs.map → request-66FGJIHB.mjs.map} +0 -0
  153. /package/dist/{write-through-R25VQMH6.mjs.map → write-through-IWQKXYHE.mjs.map} +0 -0
@@ -147,7 +147,17 @@ export function OnboardingProvider({
147
147
 
148
148
  const move = (direction: "next" | "previous", from: number) => {
149
149
  const target = tourSteps[direction === "next" ? from + 1 : from - 1];
150
- const go = () => (direction === "next" ? driverRef.current?.moveNext() : driverRef.current?.movePrevious());
150
+ const go = () => {
151
+ const instance = driverRef.current;
152
+ if (!instance) return;
153
+ if (direction === "next") instance.moveNext();
154
+ else instance.movePrevious();
155
+ /* driver.js skips steps whose target is missing (skipMissingElement); when every
156
+ step left is missing it destroys itself without calling onDestroyed (it only
157
+ does once its 400ms transition has run). The tour is then over: tear it down, as
158
+ "dismissed" because the user saw none of the remaining steps. */
159
+ if (driverRef.current === instance && !instance.isActive()) teardown("dismissed");
160
+ };
151
161
  if (target?.showDelay) setTimeout(go, target.showDelay);
152
162
  else go();
153
163
  };
@@ -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(routes: OfflineRoute[] = []): Promise<DeviceStore> {
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(offlineConfig(routes));
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 { artwork, fakeCacheStorage, npc, setUpSync, tearDownSync } from "../sync/__tests__/sync-fixtures";
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
  });
@@ -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
- config: OfflineConfig,
406
+ kind: OfflineRootKind,
406
407
  typeConfig: OfflineTypeConfig,
407
408
  ): Promise<OfflineAnswer> {
408
- const rootType = config.rootModule.name;
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
- /** The root record a record belongs to: itself (root type), a relationship to the root, or a related stored record. */
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 rootType = config.rootModule.name;
473
- if (resource?.type === rootType && resource.id) return resource.id;
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 === rootType);
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
- route("GET", [config.rootModule, ":rootId", typeConfig.module], (ctx) => rootList(ctx, config, typeConfig));
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) {
@@ -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, a stored record, or the body).
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 === config.rootModule.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;