@eifi1/ui-kit 0.45.0 → 0.46.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.
Files changed (238) hide show
  1. package/README.md +26 -14
  2. package/dist/admin/admin-area-labels.d.ts +9 -0
  3. package/dist/admin/admin-area-labels.js +5 -1
  4. package/dist/admin/admin-area-labels.js.map +1 -1
  5. package/dist/admin/admin-overview.d.ts +16 -1
  6. package/dist/admin/admin-overview.js +12 -1
  7. package/dist/admin/admin-overview.js.map +1 -1
  8. package/dist/admin/mail-test-card.d.ts +3 -2
  9. package/dist/admin/mail-test-card.js +3 -1
  10. package/dist/admin/mail-test-card.js.map +1 -1
  11. package/dist/admin/user-roster.d.ts +6 -2
  12. package/dist/admin/user-roster.js +7 -3
  13. package/dist/admin/user-roster.js.map +1 -1
  14. package/dist/components/appearance-labels.d.ts +10 -0
  15. package/dist/components/appearance-labels.js +6 -1
  16. package/dist/components/appearance-labels.js.map +1 -1
  17. package/dist/components/data-table-column-settings.js +2 -2
  18. package/dist/components/data-table-column-settings.js.map +1 -1
  19. package/dist/components/data-table-head.js +2 -2
  20. package/dist/components/data-table-head.js.map +1 -1
  21. package/dist/components/data-table-row-actions.d.ts +35 -2
  22. package/dist/components/data-table-row-actions.js +98 -5
  23. package/dist/components/data-table-row-actions.js.map +1 -1
  24. package/dist/components/data-table-shared.js +9 -2
  25. package/dist/components/data-table-shared.js.map +1 -1
  26. package/dist/components/data-table.d.ts +58 -2
  27. package/dist/components/data-table.js +11 -4
  28. package/dist/components/data-table.js.map +1 -1
  29. package/dist/components/date-format-setting.d.ts +29 -0
  30. package/dist/components/date-format-setting.js +36 -0
  31. package/dist/components/date-format-setting.js.map +1 -0
  32. package/dist/components/row-actions.d.ts +12 -5
  33. package/dist/components/row-actions.js +11 -4
  34. package/dist/components/row-actions.js.map +1 -1
  35. package/dist/components/swipeable-row.d.ts +8 -0
  36. package/dist/components/swipeable-row.js.map +1 -1
  37. package/dist/components/tabs.d.ts +3 -2
  38. package/dist/components/tabs.js +1 -1
  39. package/dist/components/tabs.js.map +1 -1
  40. package/dist/components/translation-review-editor.js +1 -0
  41. package/dist/components/translation-review-editor.js.map +1 -1
  42. package/dist/components/translation-review-labels.d.ts +5 -0
  43. package/dist/components/translation-review-labels.js +2 -0
  44. package/dist/components/translation-review-labels.js.map +1 -1
  45. package/dist/components/translation-review.d.ts +18 -8
  46. package/dist/components/translation-review.js +67 -8
  47. package/dist/components/translation-review.js.map +1 -1
  48. package/dist/components/write-lock-labels.js +2 -1
  49. package/dist/components/write-lock-labels.js.map +1 -1
  50. package/dist/components/write-lock.d.ts +33 -3
  51. package/dist/components/write-lock.js +9 -0
  52. package/dist/components/write-lock.js.map +1 -1
  53. package/dist/data-table.d.ts +2 -1
  54. package/dist/data-table.js +2 -0
  55. package/dist/data-table.js.map +1 -1
  56. package/dist/eslint/index.js +1 -1
  57. package/dist/feedback/feedback-row-detail.d.ts +4 -2
  58. package/dist/feedback/feedback-row-detail.js +17 -26
  59. package/dist/feedback/feedback-row-detail.js.map +1 -1
  60. package/dist/feedback/feedback-swipe.d.ts +18 -0
  61. package/dist/feedback/feedback-swipe.js +10 -1
  62. package/dist/feedback/feedback-swipe.js.map +1 -1
  63. package/dist/i18n/german.js +41 -5
  64. package/dist/i18n/german.js.map +1 -1
  65. package/dist/i18n/kit-labels.d.ts +12 -1
  66. package/dist/i18n/kit-labels.js +8 -2
  67. package/dist/i18n/kit-labels.js.map +1 -1
  68. package/dist/i18n/locales/en.js +40 -6
  69. package/dist/i18n/locales/en.js.map +2 -2
  70. package/dist/i18n/locales/es.js +41 -5
  71. package/dist/i18n/locales/es.js.map +1 -1
  72. package/dist/i18n/locales/fr.js +41 -5
  73. package/dist/i18n/locales/fr.js.map +1 -1
  74. package/dist/i18n/locales/hu.js +41 -5
  75. package/dist/i18n/locales/hu.js.map +1 -1
  76. package/dist/i18n/locales/it.js +41 -5
  77. package/dist/i18n/locales/it.js.map +1 -1
  78. package/dist/i18n/locales/zh.js +41 -5
  79. package/dist/i18n/locales/zh.js.map +1 -1
  80. package/dist/i18n/review.js +3 -1
  81. package/dist/i18n/review.js.map +1 -1
  82. package/dist/index.d.ts +17 -4
  83. package/dist/index.js +38 -1
  84. package/dist/index.js.map +1 -1
  85. package/dist/lib/date-format.d.ts +81 -0
  86. package/dist/lib/date-format.js +56 -0
  87. package/dist/lib/date-format.js.map +1 -0
  88. package/dist/lib/format.d.ts +3 -0
  89. package/dist/lib/format.js +5 -4
  90. package/dist/lib/format.js.map +1 -1
  91. package/dist/notifications/device-zone-report.d.ts +34 -0
  92. package/dist/notifications/device-zone-report.js +31 -0
  93. package/dist/notifications/device-zone-report.js.map +1 -0
  94. package/dist/notifications/feedback-notification-types-labels.d.ts +7 -1
  95. package/dist/notifications/feedback-notification-types-labels.js +4 -1
  96. package/dist/notifications/feedback-notification-types-labels.js.map +1 -1
  97. package/dist/notifications/feedback-notification-types.d.ts +24 -5
  98. package/dist/notifications/feedback-notification-types.js +7 -2
  99. package/dist/notifications/feedback-notification-types.js.map +1 -1
  100. package/dist/notifications/notification-bell.d.ts +33 -0
  101. package/dist/notifications/notification-bell.js +74 -0
  102. package/dist/notifications/notification-bell.js.map +1 -0
  103. package/dist/notifications/notification-inbox.d.ts +86 -0
  104. package/dist/notifications/notification-inbox.js +206 -0
  105. package/dist/notifications/notification-inbox.js.map +1 -0
  106. package/dist/notifications/notification-types-setting.d.ts +6 -4
  107. package/dist/notifications/notification-types-setting.js +103 -45
  108. package/dist/notifications/notification-types-setting.js.map +1 -1
  109. package/dist/notifications/notification-types.d.ts +19 -7
  110. package/dist/notifications/notifications-labels.d.ts +44 -8
  111. package/dist/notifications/notifications-labels.js +18 -1
  112. package/dist/notifications/notifications-labels.js.map +1 -1
  113. package/dist/notifications/push-devices-setting.d.ts +20 -3
  114. package/dist/notifications/push-devices-setting.js +93 -33
  115. package/dist/notifications/push-devices-setting.js.map +1 -1
  116. package/dist/notifications/push-worker.d.ts +108 -0
  117. package/dist/notifications/push-worker.js +62 -0
  118. package/dist/notifications/push-worker.js.map +1 -0
  119. package/dist/notifications/quiet-hours-setting.d.ts +29 -17
  120. package/dist/notifications/quiet-hours-setting.js +9 -41
  121. package/dist/notifications/quiet-hours-setting.js.map +1 -1
  122. package/dist/notifications/time-zone-select.d.ts +8 -4
  123. package/dist/notifications/time-zone-select.js +2 -7
  124. package/dist/notifications/time-zone-select.js.map +1 -1
  125. package/dist/notifications/use-push-device.d.ts +4 -17
  126. package/dist/notifications/use-push-device.js +7 -15
  127. package/dist/notifications/use-push-device.js.map +1 -1
  128. package/dist/offline/local-first.d.ts +42 -0
  129. package/dist/offline/local-first.js +53 -0
  130. package/dist/offline/local-first.js.map +1 -0
  131. package/dist/offline/query-persistence.d.ts +26 -1
  132. package/dist/offline/query-persistence.js +27 -23
  133. package/dist/offline/query-persistence.js.map +1 -1
  134. package/dist/offline/service-worker-updates.d.ts +46 -0
  135. package/dist/offline/service-worker-updates.js +39 -0
  136. package/dist/offline/service-worker-updates.js.map +1 -0
  137. package/dist/offline.d.ts +5 -2
  138. package/dist/offline.js +12 -2
  139. package/dist/offline.js.map +1 -1
  140. package/dist/push-worker.d.ts +2 -0
  141. package/dist/push-worker.js +14 -0
  142. package/dist/push-worker.js.map +1 -0
  143. package/dist/shell/account-menu-text-size.d.ts +33 -0
  144. package/dist/shell/account-menu-text-size.js +51 -0
  145. package/dist/shell/account-menu-text-size.js.map +1 -0
  146. package/dist/shell/top-bar-actions.d.ts +6 -2
  147. package/dist/shell/top-bar-actions.js +1 -0
  148. package/dist/shell/top-bar-actions.js.map +1 -1
  149. package/dist/shell.d.ts +2 -0
  150. package/dist/shell.js +2 -0
  151. package/dist/shell.js.map +1 -1
  152. package/dist/sync/db.d.ts +93 -0
  153. package/dist/sync/db.js +33 -0
  154. package/dist/sync/db.js.map +1 -0
  155. package/dist/sync/engine.d.ts +126 -0
  156. package/dist/sync/engine.js +474 -0
  157. package/dist/sync/engine.js.map +1 -0
  158. package/dist/sync/hooks.d.ts +35 -0
  159. package/dist/sync/hooks.js +67 -0
  160. package/dist/sync/hooks.js.map +1 -0
  161. package/dist/sync/testing.d.ts +28 -0
  162. package/dist/sync/testing.js +41 -0
  163. package/dist/sync/testing.js.map +1 -0
  164. package/dist/sync/wire.d.ts +50 -0
  165. package/dist/sync/wire.js +2 -0
  166. package/dist/sync/wire.js.map +1 -0
  167. package/dist/sync.d.ts +7 -0
  168. package/dist/sync.js +25 -0
  169. package/dist/sync.js.map +1 -0
  170. package/dist/tour/tour-labels.js +1 -0
  171. package/dist/tour/tour-labels.js.map +1 -1
  172. package/dist/tour/tour.d.ts +30 -0
  173. package/dist/tour/tour.js +50 -6
  174. package/dist/tour/tour.js.map +1 -1
  175. package/package.json +19 -1
  176. package/src/admin/admin-area-labels.ts +13 -0
  177. package/src/admin/admin-overview.tsx +37 -2
  178. package/src/admin/mail-test-card.tsx +7 -3
  179. package/src/admin/user-roster.tsx +12 -3
  180. package/src/components/appearance-labels.ts +16 -0
  181. package/src/components/data-table-column-settings.tsx +2 -2
  182. package/src/components/data-table-head.tsx +2 -2
  183. package/src/components/data-table-row-actions.tsx +145 -5
  184. package/src/components/data-table-shared.tsx +21 -2
  185. package/src/components/data-table.tsx +68 -6
  186. package/src/components/date-format-setting.tsx +64 -0
  187. package/src/components/row-actions.tsx +33 -9
  188. package/src/components/swipeable-row.tsx +8 -0
  189. package/src/components/tabs.tsx +4 -3
  190. package/src/components/translation-review-editor.tsx +2 -0
  191. package/src/components/translation-review-labels.ts +7 -0
  192. package/src/components/translation-review.tsx +105 -15
  193. package/src/components/write-lock-labels.ts +1 -0
  194. package/src/components/write-lock.tsx +36 -3
  195. package/src/data-table.ts +4 -0
  196. package/src/feedback/feedback-row-detail.tsx +18 -23
  197. package/src/feedback/feedback-swipe.tsx +28 -1
  198. package/src/i18n/german.ts +44 -4
  199. package/src/i18n/kit-labels.tsx +18 -1
  200. package/src/i18n/locales/en.ts +1 -0
  201. package/src/i18n/locales/es.ts +44 -4
  202. package/src/i18n/locales/fr.ts +44 -4
  203. package/src/i18n/locales/hu.ts +44 -4
  204. package/src/i18n/locales/it.ts +44 -4
  205. package/src/i18n/locales/zh.ts +44 -2
  206. package/src/i18n/review.ts +3 -1
  207. package/src/index.ts +50 -1
  208. package/src/lib/date-format.ts +151 -0
  209. package/src/lib/format.ts +10 -4
  210. package/src/notifications/device-zone-report.ts +67 -0
  211. package/src/notifications/feedback-notification-types-labels.ts +10 -1
  212. package/src/notifications/feedback-notification-types.ts +30 -5
  213. package/src/notifications/notification-bell.tsx +116 -0
  214. package/src/notifications/notification-inbox.tsx +314 -0
  215. package/src/notifications/notification-types-setting.tsx +151 -55
  216. package/src/notifications/notification-types.ts +20 -8
  217. package/src/notifications/notifications-labels.ts +72 -12
  218. package/src/notifications/push-devices-setting.tsx +123 -34
  219. package/src/notifications/push-worker.ts +160 -0
  220. package/src/notifications/quiet-hours-setting.tsx +41 -58
  221. package/src/notifications/time-zone-select.tsx +9 -10
  222. package/src/notifications/use-push-device.ts +20 -33
  223. package/src/offline/local-first.ts +100 -0
  224. package/src/offline/query-persistence.ts +57 -24
  225. package/src/offline/service-worker-updates.ts +81 -0
  226. package/src/offline.ts +7 -1
  227. package/src/push-worker.ts +25 -0
  228. package/src/shell/account-menu-text-size.tsx +77 -0
  229. package/src/shell/top-bar-actions.tsx +6 -1
  230. package/src/shell.ts +3 -0
  231. package/src/sync/db.ts +125 -0
  232. package/src/sync/engine.ts +701 -0
  233. package/src/sync/hooks.ts +98 -0
  234. package/src/sync/testing.ts +73 -0
  235. package/src/sync/wire.ts +61 -0
  236. package/src/sync.ts +29 -0
  237. package/src/tour/tour-labels.ts +1 -0
  238. package/src/tour/tour.tsx +100 -7
@@ -0,0 +1,25 @@
1
+ // `@eifi1/ui-kit/push-worker` — the service worker's half of Web Push (0.46,
2
+ // docs/notifications-harmonization.md §10.6 rule 5): re-registering a subscription the browser
3
+ // rotated. A worker bundle imports from here, never from the main barrel, which reaches React
4
+ // and the DOM components:
5
+ //
6
+ // // sw.ts
7
+ // import { handlePushSubscriptionChange } from "@eifi1/ui-kit/push-worker";
8
+ //
9
+ // Worker-safe: this entry reaches no package and no module but src/notifications/push-worker.ts
10
+ // (packaging-contract.test.ts, scripts/check-module-graph.mjs, scripts/verify-package.mjs).
11
+ // The main barrel keeps re-exporting the same names, so an existing import keeps working.
12
+ export {
13
+ PUSH_SUBSCRIPTION_CHANGED,
14
+ handlePushSubscriptionChange,
15
+ pushSubscriptionInput,
16
+ resubscribeAfterChange,
17
+ } from "./notifications/push-worker";
18
+ export type {
19
+ PushReplaceInput,
20
+ PushResubscribeOutcome,
21
+ PushSubscriptionChangeEventLike,
22
+ PushSubscriptionChangeOptions,
23
+ PushSubscriptionInput,
24
+ PushWorkerScope,
25
+ } from "./notifications/push-worker";
@@ -0,0 +1,77 @@
1
+ import { AArrowDown, AArrowUp, ALargeSmall } from "lucide-react";
2
+ import { IconButton } from "../components/icon-button";
3
+ import { useAppearanceLabels, type AppearanceLabels } from "../components/appearance-labels";
4
+ import type { LabelOverride } from "../i18n/kit-labels";
5
+ import { TEXT_SIZES, useTextSize, type TextSize } from "../theme/text-size";
6
+
7
+ export interface AccountMenuTextSizeProps {
8
+ /** The size in force. Default: the one on `<html>` ({@link useTextSize}), which is the
9
+ * size whoever applied it put there. */
10
+ value?: TextSize;
11
+ /**
12
+ * A step: the next size down or up. The app applies it and keeps it as its Settings ▸
13
+ * Appearance does — `useAccountAppearance`'s `pickTextSize` (this device, and the
14
+ * account when signed in), with `appearance.saveFailed` as the toast when the account
15
+ * refused. The kit does not apply it itself, so the two places can never disagree.
16
+ */
17
+ onPick: (size: TextSize) => void;
18
+ /** The row's name. Default: `appearance.textSize` ("Text size"). */
19
+ label?: string;
20
+ /** The `appearance` words (`textSize`, `textSizes`, `textSmaller`, `textLarger`). */
21
+ labels?: LabelOverride<AppearanceLabels>;
22
+ }
23
+
24
+ /**
25
+ * The text size one tap away, INSIDE the account menu (0.46, keksdose live #426, its
26
+ * `TextSizeRow`): the row's icon and name, the size in force, and A− / A+, which step
27
+ * through {@link TEXT_SIZES} (Normal → Large → Extra large). A− is disabled at the
28
+ * smallest, A+ at the largest. The browser's pinch zoom stays as it is — this is the
29
+ * quick path to the setting, not a replacement for zoom.
30
+ *
31
+ * It renders its own `<li>`: drop it into `TopBarActionMenu`'s `children` beside
32
+ * `AccountMenuLanguage`. The menu promotes the two buttons to `menuitem`s, so ↑/↓ reach
33
+ * them like every other row, and it stays open on a step: the person sees the page grow
34
+ * behind it and can step again. The size's name is a polite live region, so a step is
35
+ * announced.
36
+ */
37
+ export function AccountMenuTextSize({ value, onPick, label, labels: labelsProp }: AccountMenuTextSizeProps) {
38
+ const labels = useAppearanceLabels(labelsProp);
39
+ const onHtml = useTextSize().size;
40
+ const size = value ?? onHtml;
41
+ const index = TEXT_SIZES.indexOf(size);
42
+ const step = (by: -1 | 1) => {
43
+ const next = TEXT_SIZES[index + by];
44
+ if (next) onPick(next);
45
+ };
46
+ return (
47
+ <li data-account-menu-text-size="" className="flex items-center gap-2 px-3 py-1.5 text-sm">
48
+ <ALargeSmall aria-hidden className="size-4 shrink-0 text-muted" />
49
+ <span className="min-w-0 flex-1 truncate">
50
+ {label ?? labels.textSize}
51
+ <span className="ms-2 text-xs text-muted" aria-live="polite">
52
+ {labels.textSizes[size]}
53
+ </span>
54
+ </span>
55
+ <IconButton
56
+ size="sm"
57
+ label={labels.textSmaller}
58
+ labelVisible={false}
59
+ tooltip={false}
60
+ disabled={index <= 0}
61
+ onClick={() => step(-1)}
62
+ >
63
+ <AArrowDown />
64
+ </IconButton>
65
+ <IconButton
66
+ size="sm"
67
+ label={labels.textLarger}
68
+ labelVisible={false}
69
+ tooltip={false}
70
+ disabled={index >= TEXT_SIZES.length - 1}
71
+ onClick={() => step(1)}
72
+ >
73
+ <AArrowUp />
74
+ </IconButton>
75
+ </li>
76
+ );
77
+ }
@@ -24,6 +24,10 @@ export interface TopBarActionsProps {
24
24
  dev?: ReactNode;
25
25
  /** kastlan's platform button. */
26
26
  platform?: ReactNode;
27
+ /** 0.46 (docs/notifications-harmonization.md §10.1): `NotificationBell`, right before the
28
+ * account menu. Optional per app: an app shows the bell or an unread dot on the avatar,
29
+ * never both (keksdose keeps its dot and leaves this empty). */
30
+ notifications?: ReactNode;
27
31
  /** The account menu. When it is set the signed-out slots are not drawn. */
28
32
  account?: ReactNode;
29
33
  /** Signed out (§6.6): the bar's `LanguageMenu`, since there is no account menu to hold
@@ -45,6 +49,7 @@ export const TOP_BAR_ACTION_ORDER = [
45
49
  "sync",
46
50
  "dev",
47
51
  "platform",
52
+ "notifications",
48
53
  "account",
49
54
  "language",
50
55
  "signIn",
@@ -55,7 +60,7 @@ const present = (node: ReactNode) => node !== undefined && node !== null && node
55
60
  /**
56
61
  * The top bar's actions in the one order every app shares (§2.1): company switcher ·
57
62
  * Search · Theme · Feedback · Tours (· Assistant) · Sync · dev role · dev plan · platform ·
58
- * account menu — and, signed out, Language · Sign in where the account menu goes (§6.6).
63
+ * notifications (the bell, 0.46) · account menu — and, signed out, Language · Sign in where the account menu goes (§6.6).
59
64
  * The app fills the slots it has; an empty slot is skipped, so it leaves no gap.
60
65
  *
61
66
  * It draws a fragment, no box of its own: hand it to `TopBar`'s `actions`, whose row
package/src/shell.ts CHANGED
@@ -82,6 +82,9 @@ export { TopBarActions, TOP_BAR_ACTION_ORDER } from "./shell/top-bar-actions";
82
82
  export type { TopBarActionsProps } from "./shell/top-bar-actions";
83
83
  export { AccountMenuLanguage } from "./shell/account-menu-language";
84
84
  export type { AccountMenuLanguageProps, AccountMenuLanguageOption } from "./shell/account-menu-language";
85
+ // 0.46.0 (keksdose live #426): A− / A+ for the text size, beside the language.
86
+ export { AccountMenuTextSize } from "./shell/account-menu-text-size";
87
+ export type { AccountMenuTextSizeProps } from "./shell/account-menu-text-size";
85
88
  export { TourMenu } from "./shell/tour-menu";
86
89
  export type { TourMenuProps, TourMenuTour } from "./shell/tour-menu";
87
90
  export { SyncStatusIndicator, syncState } from "./shell/sync-status-indicator";
package/src/sync/db.ts ADDED
@@ -0,0 +1,125 @@
1
+ import Dexie, { type Table, type Transaction } from "dexie";
2
+
3
+ /*
4
+ * The device's copy for the sync engine (docs/local-first-harmonization.md §2.3, D10): ONE
5
+ * Dexie database with the engine's three tables — the rows as `/sync` carries them, the
6
+ * outbox of changes made here, and a key/value `meta` — plus the app's own (kastlan's
7
+ * `photos`). A generic store, not keksdose's per-entity tables.
8
+ */
9
+
10
+ /** A row as `/sync` carries it: the entity (a table name), its sync id, the server's id
11
+ * once it has one, and its fields. */
12
+ export interface SyncedRow {
13
+ entity: string;
14
+ id: string;
15
+ pk: number | null;
16
+ data: Record<string, unknown>;
17
+ /** The server's stamp of the row, as the pull carried it BESIDE `data` (kastlan's wire);
18
+ * the next edit here is stamped after it (`stampAfter`, D2). */
19
+ updated_at?: string | null;
20
+ }
21
+
22
+ /**
23
+ * Where a change made here stands. `pending`: queued. `sending`: in the request now out (a
24
+ * new edit starts a change of its own). `waiting`: refused with a hold code (a lapsed plan,
25
+ * `holdCodes`), sent again every round. `blocked`: refused with any other code, kept, sent
26
+ * again only on `retryBlocked()`. `rejected`: the server's rules refused it (`reason` says
27
+ * why); never sent again.
28
+ */
29
+ export type OutboxStatus = "pending" | "sending" | "waiting" | "blocked" | "rejected";
30
+
31
+ /** A change made on this device, for `/sync`, in the scope its caller named (D11). */
32
+ export interface OutboxChange {
33
+ seq?: number;
34
+ scope: string;
35
+ entity: string;
36
+ id: string;
37
+ op: "upsert" | "delete";
38
+ data: Record<string, unknown>;
39
+ /** The edit's time on this device (D2), stamped with `stampAfter`. */
40
+ updated_at: string;
41
+ status: OutboxStatus;
42
+ /** The server's words: a rejection's `detail`, a refusal's `detail`. */
43
+ reason?: string;
44
+ /** The server's code: a rejection's or a refusal's `code`. */
45
+ code?: string;
46
+ }
47
+
48
+ export interface SyncMetaEntry {
49
+ key: string;
50
+ value: unknown;
51
+ }
52
+
53
+ /** The engine's database: its three tables, and the app's own under the names it gave. */
54
+ export type SyncDb = Dexie & {
55
+ rows: Table<SyncedRow, [string, string]>;
56
+ outbox: Table<OutboxChange, number>;
57
+ meta: Table<SyncMetaEntry, string>;
58
+ };
59
+
60
+ /** A schema step of an existing database (kastlan's v2: `scope` on the outbox). */
61
+ export interface SyncDbUpgrade {
62
+ version: number;
63
+ /** Stores this version adds or changes, beside the engine's and `extraStores`. */
64
+ stores?: Record<string, string | null>;
65
+ upgrade?(tx: Transaction): Promise<void> | void;
66
+ }
67
+
68
+ export interface CreateSyncDbOptions {
69
+ /** The app's own tables, Dexie schema strings: kastlan `{ photos: "id, roomId, status" }`. */
70
+ extraStores?: Record<string, string>;
71
+ /** Later versions of a database that exists on devices already, oldest first. */
72
+ upgrades?: readonly SyncDbUpgrade[];
73
+ }
74
+
75
+ /** The engine's own tables. `scope` is indexed: a round takes one scope's queue (D11). */
76
+ export const SYNC_DB_STORES = {
77
+ rows: "[entity+id], entity",
78
+ outbox: "++seq, [entity+id], status, scope",
79
+ meta: "key",
80
+ } as const;
81
+
82
+ /**
83
+ * The `rows` schema with an `[entity+pk]` index (0.46.1), opt-in: the D16 pages start from a
84
+ * server id (`/meters/7`) and {@link rowByPk} finds its copy row by this index. Not in
85
+ * {@link SYNC_DB_STORES}, so no database on a device changes until its app names it in a
86
+ * version of its own — kastlan, whose devices are on v2: `upgrades: [..., { version: 3,
87
+ * stores: SYNC_DB_PK_INDEX }]`. A database new to the devices may take it at once:
88
+ * `extraStores: { ...SYNC_DB_PK_INDEX }`.
89
+ */
90
+ export const SYNC_DB_PK_INDEX = {
91
+ rows: "[entity+id], entity, [entity+pk]",
92
+ } as const;
93
+
94
+ /**
95
+ * The copy row of the server's id `pk` in `entity` (0.46.1): by the `[entity+pk]` index when
96
+ * the database has it ({@link SYNC_DB_PK_INDEX}), else by a scan of the entity's rows.
97
+ * `undefined` when the copy holds no such row.
98
+ */
99
+ export async function rowByPk(db: SyncDb, entity: string, pk: number): Promise<SyncedRow | undefined> {
100
+ if (db.rows.schema.idxByName["[entity+pk]"]) return db.rows.where("[entity+pk]").equals([entity, pk]).first();
101
+ return db.rows.where("entity").equals(entity).filter((row) => row.pk === pk).first();
102
+ }
103
+
104
+ /**
105
+ * Open (lazily, on first use) the sync database `name`: the engine's tables plus the app's
106
+ * `extraStores`, at version 1 or the last of `upgrades`. One database per account (kastlan:
107
+ * per account and company, `kastlan-handover-<user>-<company>`), and one engine on it (D15).
108
+ *
109
+ * An app whose database predates the engine names its next version in `upgrades` and moves
110
+ * its data there — kastlan: `{ version: 2, upgrade: (tx) => tx.table("outbox").toCollection()
111
+ * .modify({ scope: "handover" }) }`. Dexie diffs the schema against the one on the device.
112
+ * Name the app's own type for its tables: `createSyncDb<HandoverDb>(name, { extraStores })`.
113
+ */
114
+ export function createSyncDb<D extends SyncDb = SyncDb>(name: string, options: CreateSyncDbOptions = {}): D {
115
+ const db = new Dexie(name) as D;
116
+ let stores: Record<string, string | null> = { ...SYNC_DB_STORES, ...options.extraStores };
117
+ db.version(1).stores(stores);
118
+ const upgrades = [...(options.upgrades ?? [])].sort((a, b) => a.version - b.version);
119
+ for (const step of upgrades) {
120
+ stores = { ...stores, ...step.stores };
121
+ const version = db.version(step.version).stores(stores);
122
+ if (step.upgrade) version.upgrade(step.upgrade);
123
+ }
124
+ return db;
125
+ }