@eifi1/ui-kit 0.45.0 → 0.46.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 (228) hide show
  1. package/README.md +23 -12
  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/components/appearance-labels.d.ts +10 -0
  12. package/dist/components/appearance-labels.js +6 -1
  13. package/dist/components/appearance-labels.js.map +1 -1
  14. package/dist/components/data-table-column-settings.js +2 -2
  15. package/dist/components/data-table-column-settings.js.map +1 -1
  16. package/dist/components/data-table-head.js +2 -2
  17. package/dist/components/data-table-head.js.map +1 -1
  18. package/dist/components/data-table-row-actions.d.ts +18 -2
  19. package/dist/components/data-table-row-actions.js +86 -2
  20. package/dist/components/data-table-row-actions.js.map +1 -1
  21. package/dist/components/data-table-shared.js +9 -2
  22. package/dist/components/data-table-shared.js.map +1 -1
  23. package/dist/components/data-table.d.ts +43 -2
  24. package/dist/components/data-table.js +9 -4
  25. package/dist/components/data-table.js.map +1 -1
  26. package/dist/components/date-format-setting.d.ts +29 -0
  27. package/dist/components/date-format-setting.js +36 -0
  28. package/dist/components/date-format-setting.js.map +1 -0
  29. package/dist/components/swipeable-row.d.ts +8 -0
  30. package/dist/components/swipeable-row.js.map +1 -1
  31. package/dist/components/tabs.d.ts +3 -2
  32. package/dist/components/tabs.js +1 -1
  33. package/dist/components/tabs.js.map +1 -1
  34. package/dist/components/translation-review-editor.js +1 -0
  35. package/dist/components/translation-review-editor.js.map +1 -1
  36. package/dist/components/translation-review-labels.d.ts +5 -0
  37. package/dist/components/translation-review-labels.js +2 -0
  38. package/dist/components/translation-review-labels.js.map +1 -1
  39. package/dist/components/translation-review.d.ts +18 -8
  40. package/dist/components/translation-review.js +64 -7
  41. package/dist/components/translation-review.js.map +1 -1
  42. package/dist/components/write-lock-labels.js +2 -1
  43. package/dist/components/write-lock-labels.js.map +1 -1
  44. package/dist/components/write-lock.d.ts +33 -3
  45. package/dist/components/write-lock.js +9 -0
  46. package/dist/components/write-lock.js.map +1 -1
  47. package/dist/data-table.d.ts +1 -1
  48. package/dist/data-table.js.map +1 -1
  49. package/dist/eslint/index.js +1 -1
  50. package/dist/feedback/feedback-row-detail.d.ts +4 -2
  51. package/dist/feedback/feedback-row-detail.js +17 -26
  52. package/dist/feedback/feedback-row-detail.js.map +1 -1
  53. package/dist/feedback/feedback-swipe.d.ts +18 -0
  54. package/dist/feedback/feedback-swipe.js +10 -1
  55. package/dist/feedback/feedback-swipe.js.map +1 -1
  56. package/dist/i18n/german.js +41 -5
  57. package/dist/i18n/german.js.map +1 -1
  58. package/dist/i18n/kit-labels.d.ts +12 -1
  59. package/dist/i18n/kit-labels.js +8 -2
  60. package/dist/i18n/kit-labels.js.map +1 -1
  61. package/dist/i18n/locales/en.js +40 -6
  62. package/dist/i18n/locales/en.js.map +2 -2
  63. package/dist/i18n/locales/es.js +41 -5
  64. package/dist/i18n/locales/es.js.map +1 -1
  65. package/dist/i18n/locales/fr.js +41 -5
  66. package/dist/i18n/locales/fr.js.map +1 -1
  67. package/dist/i18n/locales/hu.js +41 -5
  68. package/dist/i18n/locales/hu.js.map +1 -1
  69. package/dist/i18n/locales/it.js +41 -5
  70. package/dist/i18n/locales/it.js.map +1 -1
  71. package/dist/i18n/locales/zh.js +41 -5
  72. package/dist/i18n/locales/zh.js.map +1 -1
  73. package/dist/i18n/review.js +3 -1
  74. package/dist/i18n/review.js.map +1 -1
  75. package/dist/index.d.ts +15 -2
  76. package/dist/index.js +35 -0
  77. package/dist/index.js.map +1 -1
  78. package/dist/lib/date-format.d.ts +81 -0
  79. package/dist/lib/date-format.js +56 -0
  80. package/dist/lib/date-format.js.map +1 -0
  81. package/dist/lib/format.d.ts +3 -0
  82. package/dist/lib/format.js +5 -4
  83. package/dist/lib/format.js.map +1 -1
  84. package/dist/notifications/device-zone-report.d.ts +27 -0
  85. package/dist/notifications/device-zone-report.js +24 -0
  86. package/dist/notifications/device-zone-report.js.map +1 -0
  87. package/dist/notifications/feedback-notification-types-labels.d.ts +7 -1
  88. package/dist/notifications/feedback-notification-types-labels.js +4 -1
  89. package/dist/notifications/feedback-notification-types-labels.js.map +1 -1
  90. package/dist/notifications/feedback-notification-types.d.ts +11 -5
  91. package/dist/notifications/feedback-notification-types.js +2 -1
  92. package/dist/notifications/feedback-notification-types.js.map +1 -1
  93. package/dist/notifications/notification-bell.d.ts +33 -0
  94. package/dist/notifications/notification-bell.js +74 -0
  95. package/dist/notifications/notification-bell.js.map +1 -0
  96. package/dist/notifications/notification-inbox.d.ts +77 -0
  97. package/dist/notifications/notification-inbox.js +206 -0
  98. package/dist/notifications/notification-inbox.js.map +1 -0
  99. package/dist/notifications/notification-types-setting.d.ts +6 -4
  100. package/dist/notifications/notification-types-setting.js +103 -45
  101. package/dist/notifications/notification-types-setting.js.map +1 -1
  102. package/dist/notifications/notification-types.d.ts +19 -7
  103. package/dist/notifications/notifications-labels.d.ts +41 -7
  104. package/dist/notifications/notifications-labels.js +18 -1
  105. package/dist/notifications/notifications-labels.js.map +1 -1
  106. package/dist/notifications/push-devices-setting.d.ts +20 -3
  107. package/dist/notifications/push-devices-setting.js +93 -33
  108. package/dist/notifications/push-devices-setting.js.map +1 -1
  109. package/dist/notifications/push-worker.d.ts +108 -0
  110. package/dist/notifications/push-worker.js +62 -0
  111. package/dist/notifications/push-worker.js.map +1 -0
  112. package/dist/notifications/quiet-hours-setting.d.ts +29 -17
  113. package/dist/notifications/quiet-hours-setting.js +9 -41
  114. package/dist/notifications/quiet-hours-setting.js.map +1 -1
  115. package/dist/notifications/time-zone-select.d.ts +5 -2
  116. package/dist/notifications/time-zone-select.js.map +1 -1
  117. package/dist/notifications/use-push-device.d.ts +4 -17
  118. package/dist/notifications/use-push-device.js +7 -15
  119. package/dist/notifications/use-push-device.js.map +1 -1
  120. package/dist/offline/local-first.d.ts +33 -0
  121. package/dist/offline/local-first.js +39 -0
  122. package/dist/offline/local-first.js.map +1 -0
  123. package/dist/offline/query-persistence.d.ts +26 -1
  124. package/dist/offline/query-persistence.js +27 -23
  125. package/dist/offline/query-persistence.js.map +1 -1
  126. package/dist/offline/service-worker-updates.d.ts +46 -0
  127. package/dist/offline/service-worker-updates.js +39 -0
  128. package/dist/offline/service-worker-updates.js.map +1 -0
  129. package/dist/offline.d.ts +5 -2
  130. package/dist/offline.js +11 -2
  131. package/dist/offline.js.map +1 -1
  132. package/dist/push-worker.d.ts +2 -0
  133. package/dist/push-worker.js +14 -0
  134. package/dist/push-worker.js.map +1 -0
  135. package/dist/shell/account-menu-text-size.d.ts +33 -0
  136. package/dist/shell/account-menu-text-size.js +51 -0
  137. package/dist/shell/account-menu-text-size.js.map +1 -0
  138. package/dist/shell/top-bar-actions.d.ts +6 -2
  139. package/dist/shell/top-bar-actions.js +1 -0
  140. package/dist/shell/top-bar-actions.js.map +1 -1
  141. package/dist/shell.d.ts +2 -0
  142. package/dist/shell.js +2 -0
  143. package/dist/shell.js.map +1 -1
  144. package/dist/sync/db.d.ts +76 -0
  145. package/dist/sync/db.js +24 -0
  146. package/dist/sync/db.js.map +1 -0
  147. package/dist/sync/engine.d.ts +124 -0
  148. package/dist/sync/engine.js +474 -0
  149. package/dist/sync/engine.js.map +1 -0
  150. package/dist/sync/hooks.d.ts +30 -0
  151. package/dist/sync/hooks.js +67 -0
  152. package/dist/sync/hooks.js.map +1 -0
  153. package/dist/sync/testing.d.ts +28 -0
  154. package/dist/sync/testing.js +41 -0
  155. package/dist/sync/testing.js.map +1 -0
  156. package/dist/sync/wire.d.ts +50 -0
  157. package/dist/sync/wire.js +2 -0
  158. package/dist/sync/wire.js.map +1 -0
  159. package/dist/sync.d.ts +7 -0
  160. package/dist/sync.js +22 -0
  161. package/dist/sync.js.map +1 -0
  162. package/dist/tour/tour-labels.js +1 -0
  163. package/dist/tour/tour-labels.js.map +1 -1
  164. package/dist/tour/tour.d.ts +30 -0
  165. package/dist/tour/tour.js +50 -6
  166. package/dist/tour/tour.js.map +1 -1
  167. package/package.json +19 -1
  168. package/src/admin/admin-area-labels.ts +13 -0
  169. package/src/admin/admin-overview.tsx +37 -2
  170. package/src/admin/mail-test-card.tsx +7 -3
  171. package/src/components/appearance-labels.ts +16 -0
  172. package/src/components/data-table-column-settings.tsx +2 -2
  173. package/src/components/data-table-head.tsx +2 -2
  174. package/src/components/data-table-row-actions.tsx +118 -2
  175. package/src/components/data-table-shared.tsx +21 -2
  176. package/src/components/data-table.tsx +51 -6
  177. package/src/components/date-format-setting.tsx +64 -0
  178. package/src/components/swipeable-row.tsx +8 -0
  179. package/src/components/tabs.tsx +4 -3
  180. package/src/components/translation-review-editor.tsx +2 -0
  181. package/src/components/translation-review-labels.ts +7 -0
  182. package/src/components/translation-review.tsx +102 -14
  183. package/src/components/write-lock-labels.ts +1 -0
  184. package/src/components/write-lock.tsx +36 -3
  185. package/src/data-table.ts +1 -0
  186. package/src/feedback/feedback-row-detail.tsx +18 -23
  187. package/src/feedback/feedback-swipe.tsx +28 -1
  188. package/src/i18n/german.ts +44 -4
  189. package/src/i18n/kit-labels.tsx +18 -1
  190. package/src/i18n/locales/en.ts +1 -0
  191. package/src/i18n/locales/es.ts +44 -4
  192. package/src/i18n/locales/fr.ts +44 -4
  193. package/src/i18n/locales/hu.ts +44 -4
  194. package/src/i18n/locales/it.ts +44 -4
  195. package/src/i18n/locales/zh.ts +44 -2
  196. package/src/i18n/review.ts +3 -1
  197. package/src/index.ts +42 -0
  198. package/src/lib/date-format.ts +151 -0
  199. package/src/lib/format.ts +10 -4
  200. package/src/notifications/device-zone-report.ts +54 -0
  201. package/src/notifications/feedback-notification-types-labels.ts +10 -1
  202. package/src/notifications/feedback-notification-types.ts +12 -5
  203. package/src/notifications/notification-bell.tsx +116 -0
  204. package/src/notifications/notification-inbox.tsx +304 -0
  205. package/src/notifications/notification-types-setting.tsx +151 -55
  206. package/src/notifications/notification-types.ts +20 -8
  207. package/src/notifications/notifications-labels.ts +69 -11
  208. package/src/notifications/push-devices-setting.tsx +123 -34
  209. package/src/notifications/push-worker.ts +160 -0
  210. package/src/notifications/quiet-hours-setting.tsx +41 -58
  211. package/src/notifications/time-zone-select.tsx +5 -2
  212. package/src/notifications/use-push-device.ts +20 -33
  213. package/src/offline/local-first.ts +76 -0
  214. package/src/offline/query-persistence.ts +57 -24
  215. package/src/offline/service-worker-updates.ts +81 -0
  216. package/src/offline.ts +7 -1
  217. package/src/push-worker.ts +25 -0
  218. package/src/shell/account-menu-text-size.tsx +77 -0
  219. package/src/shell/top-bar-actions.tsx +6 -1
  220. package/src/shell.ts +3 -0
  221. package/src/sync/db.ts +103 -0
  222. package/src/sync/engine.ts +699 -0
  223. package/src/sync/hooks.ts +93 -0
  224. package/src/sync/testing.ts +73 -0
  225. package/src/sync/wire.ts +61 -0
  226. package/src/sync.ts +29 -0
  227. package/src/tour/tour-labels.ts +1 -0
  228. package/src/tour/tour.tsx +100 -7
@@ -0,0 +1,699 @@
1
+ import { httpStatusOf } from "../auth/auth-errors";
2
+ import { isOfflineError, stampAfter } from "../offline/local-first";
3
+ import { isSharedComputer } from "../offline/shared-computer";
4
+ import type { OutboxChange, OutboxStatus, SyncDb, SyncedRow } from "./db";
5
+ import type { SyncRequestBody, SyncResponseBody, SyncWireChange } from "./wire";
6
+
7
+ /*
8
+ * The sync engine (docs/local-first-harmonization.md §2.3): kastlan's handover client,
9
+ * generalised (D10). Keksdose's SERVER rules and wire shapes; the client policies are ka's —
10
+ * a new edit merges into the row's last `pending` upsert, a refused change is held per change
11
+ * (`waiting` / `blocked` / `rejected`), a capped backoff on errors, the queue kept at sign-out.
12
+ *
13
+ * A round, per scope in `scopes()` order (read every round, D11): take the scope's queue and
14
+ * mark it `sending`; post `{scope, last_knowledge, changes, refusals, client_now, paged?}`
15
+ * with `client_now` read fresh (D2 amendment); apply the answer in ONE transaction, only if
16
+ * the generation is unchanged (D15); follow `page_cursor` with pull-only requests, the cursor
17
+ * written with the last page only (D12). Then the app's `steps`, then one more pass if a step
18
+ * changed something. One round at a time; a request during a round runs once more after it.
19
+ */
20
+
21
+ export interface SyncCounts {
22
+ pending: number;
23
+ waiting: number;
24
+ blocked: number;
25
+ rejected: number;
26
+ }
27
+
28
+ export interface SyncStepContext {
29
+ signal: AbortSignal;
30
+ /** False once `forget()` bumped the generation: the step writes nothing more (D15). */
31
+ isCurrent(): boolean;
32
+ }
33
+
34
+ export interface SyncStepResult {
35
+ /** The step met no connection: the round ends offline. */
36
+ offline?: boolean;
37
+ /** One more push/pull in the same round (kastlan's second pass after its photos). */
38
+ changed?: boolean;
39
+ /** What the step keeps on the device, added to the engine's counts. */
40
+ counts?: Partial<SyncCounts>;
41
+ }
42
+
43
+ /** Work after the push/pull, such as kastlan's photo upload. */
44
+ export interface SyncStep {
45
+ name: string;
46
+ run(db: SyncDb, ctx: SyncStepContext): Promise<SyncStepResult>;
47
+ /** Its counts read from the device between rounds (a photo taken offline shows at once). */
48
+ counts?(db: SyncDb): Promise<Partial<SyncCounts>>;
49
+ }
50
+
51
+ export interface SyncEngineState {
52
+ phase: "idle" | "syncing" | "offline" | "error";
53
+ online: boolean;
54
+ counts: SyncCounts;
55
+ /** When a scope's walk last completed (ISO), kept in `meta` (D9). */
56
+ lastSyncedAt: string | null;
57
+ /** When the next attempt after an error runs (epoch ms), or `null`. */
58
+ nextRetryAt: number | null;
59
+ }
60
+
61
+ export interface SyncRoundResult {
62
+ /** `paused`: `paused()` held the round. `dropped`: `forget()` ran during it, its answer unused. */
63
+ outcome: "synced" | "offline" | "error" | "paused" | "dropped";
64
+ /** Changes this round set `waiting`, `blocked` or `rejected`. */
65
+ waiting: number;
66
+ blocked: number;
67
+ rejected: number;
68
+ error?: unknown;
69
+ }
70
+
71
+ export interface SyncEngineOptions {
72
+ db: SyncDb;
73
+ /** The scopes this session's role holds, in round order (kastlan: handover, meetings, meters,
74
+ * maintenance, contacts). Read at the start of every round: roles are per company. */
75
+ scopes: () => readonly string[];
76
+ /** Posts one request (`POST /sync`) and answers its body; throws as the app's client does. */
77
+ send: (body: SyncRequestBody, signal: AbortSignal) => Promise<SyncResponseBody>;
78
+ /** Default {@link isOfflineError}: no answer, or the session refresh's passing 401. */
79
+ isOffline?: (err: unknown) => boolean;
80
+ /** A whole request refused for the plan → every sent change `waiting` with this code.
81
+ * Default: a 402 whose body names a `code` (or `detail.code`). */
82
+ planCode?: (err: unknown) => string | null;
83
+ /** `refused.code`s kept as `waiting` and sent again every round; any other code is
84
+ * `blocked`, sent again only on `retryBlocked()`. Default `["billing_read_only"]`. */
85
+ holdCodes?: readonly string[];
86
+ /** Ask for a paged pull and follow its cursor (D12). */
87
+ paged?: boolean;
88
+ steps?: readonly SyncStep[];
89
+ /** No round while this holds (kastlan: a practice tour fakes the server's answers). */
90
+ paused?: () => boolean;
91
+ /** The poll (30 s) and the debounce after a write (800 ms). */
92
+ pollMs?: number;
93
+ soonMs?: number;
94
+ /** The waits after consecutive errors; the last repeats (D13). */
95
+ backoffMs?: readonly number[];
96
+ /** Default `isSharedComputer`: on a computer marked shared the engine does not start (D15). */
97
+ sharedComputer?: () => boolean;
98
+ }
99
+
100
+ export interface SyncEngine {
101
+ /** Into the copy at once and into the queue, in the scope the caller names (D11). An edit
102
+ * merges into the row's last `pending` upsert; deleting a row that never reached the
103
+ * server forgets its queue; stamped with `stampAfter`. */
104
+ write(scope: string, entity: string, id: string, op: "upsert" | "delete", data?: Record<string, unknown>): Promise<void>;
105
+ start(): void;
106
+ /** Stops the poll and the timers, aborts the round in flight and drops its answer (the
107
+ * queue is laid back). A new engine's round on the same database waits for it. */
108
+ stop(): void;
109
+ /** Sync soon (debounced): after a write. */
110
+ requestSync(): void;
111
+ /** "Sync now": resets the backoff, runs a round (after the one under way). */
112
+ syncNow(): Promise<SyncRoundResult>;
113
+ /** A round that sends the `blocked` changes too. */
114
+ retryBlocked(): Promise<void>;
115
+ /** Drop the rejected changes (of one scope) and pull that scope afresh. */
116
+ discardRejected(scope?: string): Promise<void>;
117
+ /** The rejected changes with their reasons, for the app's banner. */
118
+ rejected(): Promise<OutboxChange[]>;
119
+ /** Sign-out: bump the generation (a round in flight writes nothing more), then clear the
120
+ * rows and `meta` — and the outbox and the app's tables unless `keepQueue` (D15). */
121
+ forget(opts: { keepQueue: boolean }): Promise<void>;
122
+ getState(): SyncEngineState;
123
+ subscribe(listener: () => void): () => void;
124
+ }
125
+
126
+ export const DEFAULT_SYNC_BACKOFF_MS: readonly number[] = [5_000, 15_000, 60_000, 300_000];
127
+ export const DEFAULT_HOLD_CODES: readonly string[] = ["billing_read_only"];
128
+
129
+ const LAST_SYNCED_AT = "lastSyncedAt";
130
+ const lastKnowledgeKey = (scope: string) => `lastKnowledge:${scope}`;
131
+ const UNSENT: readonly OutboxStatus[] = ["pending", "sending", "waiting", "blocked"];
132
+ const ZERO: SyncCounts = { pending: 0, waiting: 0, blocked: 0, rejected: 0 };
133
+
134
+ /** The started engine of each database: one engine per database (D15). */
135
+ const running = new Map<string, SyncEngine>();
136
+
137
+ /** The round in flight on each database, of whichever engine: a new engine's round waits
138
+ * for it, so two never take the same queue (a company switch A→B→A within one round). */
139
+ const flights = new Map<string, Promise<unknown>>();
140
+
141
+ /** The engines {@link syncEngineFor} keeps, by database name. */
142
+ const kept = new Map<string, { db: SyncDb; engine: SyncEngine }>();
143
+
144
+ /** The generation changed under a round: its answer is dropped. */
145
+ class Dropped extends Error {}
146
+
147
+ /** A row created on this device and never echoed by the server carries `local: true`. */
148
+ type CopyRow = SyncedRow & { local?: boolean };
149
+
150
+ function browserOnline(): boolean {
151
+ return typeof navigator === "undefined" || navigator.onLine !== false;
152
+ }
153
+
154
+ function defaultPlanCode(err: unknown): string | null {
155
+ if (httpStatusOf(err) !== 402) return null;
156
+ const body = (err as { response?: { data?: { code?: unknown; detail?: { code?: unknown } } } }).response?.data;
157
+ const code = typeof body?.code === "string" ? body.code : body?.detail?.code;
158
+ return typeof code === "string" ? code : null;
159
+ }
160
+
161
+ /** A refusal's `code` and `detail` from an error's body: `{code, detail}` or FastAPI's
162
+ * `{detail: {code, detail}}`. */
163
+ function refusalOf(err: unknown): { code?: string; detail?: string } {
164
+ const body = (err as { response?: { data?: unknown } }).response?.data as
165
+ | { code?: unknown; detail?: unknown }
166
+ | undefined;
167
+ const inner = body?.detail && typeof body.detail === "object" ? (body.detail as { code?: unknown; detail?: unknown }) : body;
168
+ const text = (v: unknown) => (typeof v === "string" && v ? v : undefined);
169
+ return { code: text(inner?.code), detail: text(inner?.detail) };
170
+ }
171
+
172
+ function latest(stamps: unknown[]): string | undefined {
173
+ let best: string | undefined;
174
+ for (const s of stamps) {
175
+ if (typeof s !== "string" || !Number.isFinite(Date.parse(s))) continue;
176
+ if (best === undefined || Date.parse(s) > Date.parse(best)) best = s;
177
+ }
178
+ return best;
179
+ }
180
+
181
+ const toWire = ({ entity, id, op, data, updated_at }: OutboxChange): SyncWireChange =>
182
+ op === "delete" ? { entity, id, op, updated_at } : { entity, id, op, data, updated_at };
183
+
184
+ /**
185
+ * The engine on one database (D15: one engine per database; a company switch builds a new
186
+ * engine on the new database and stops the old one). Nothing runs until `start()`
187
+ * (`useSyncWorker`), and `start()` does nothing on a computer marked shared.
188
+ */
189
+ export function createSyncEngine(options: SyncEngineOptions): SyncEngine {
190
+ const { db } = options;
191
+ const isOffline = options.isOffline ?? isOfflineError;
192
+ const planCode = options.planCode ?? defaultPlanCode;
193
+ const holdCodes = new Set(options.holdCodes ?? DEFAULT_HOLD_CODES);
194
+ const steps = options.steps ?? [];
195
+ const pollMs = options.pollMs ?? 30_000;
196
+ const soonMs = options.soonMs ?? 800;
197
+ const backoffMs = options.backoffMs?.length ? options.backoffMs : DEFAULT_SYNC_BACKOFF_MS;
198
+ const sharedComputer = options.sharedComputer ?? isSharedComputer;
199
+
200
+ let generation = 0;
201
+ let state: SyncEngineState = { phase: "idle", online: browserOnline(), counts: ZERO, lastSyncedAt: null, nextRetryAt: null };
202
+ const listeners = new Set<() => void>();
203
+ let started = false;
204
+ let poll: ReturnType<typeof setInterval> | null = null;
205
+ let soon: ReturnType<typeof setTimeout> | null = null;
206
+ let retry: ReturnType<typeof setTimeout> | null = null;
207
+ let failures = 0;
208
+ let current: Promise<SyncRoundResult> | null = null;
209
+ let again = false;
210
+ let controller: AbortController | null = null;
211
+ let stepCounts: Record<string, Partial<SyncCounts>> = {};
212
+
213
+ function set(next: Partial<SyncEngineState>) {
214
+ state = { ...state, ...next };
215
+ listeners.forEach((listener) => listener());
216
+ }
217
+
218
+ /** The counts and `lastSyncedAt` read again from the device. Never throws: a closed or
219
+ * deleted database leaves the last counts standing. */
220
+ function refreshCounts(): Promise<void> {
221
+ return readCounts().catch(() => undefined);
222
+ }
223
+
224
+ async function readCounts(): Promise<void> {
225
+ const gen = generation;
226
+ const count = (status: OutboxStatus) => db.outbox.where("status").equals(status).count();
227
+ const [pending, sending, waiting, blocked, rejected] = await Promise.all(
228
+ (["pending", "sending", "waiting", "blocked", "rejected"] as const).map(count),
229
+ );
230
+ const fromSteps = { ...stepCounts };
231
+ for (const step of steps) if (step.counts) fromSteps[step.name] = await step.counts(db);
232
+ const last = (await db.meta.get(LAST_SYNCED_AT))?.value;
233
+ if (gen !== generation) return;
234
+ if (Object.keys(fromSteps).length) stepCounts = fromSteps;
235
+ const counts = { pending: pending + sending, waiting, blocked, rejected };
236
+ for (const extra of Object.values(stepCounts)) {
237
+ for (const key of Object.keys(counts) as (keyof SyncCounts)[]) counts[key] += extra[key] ?? 0;
238
+ }
239
+ set({ counts, lastSyncedAt: typeof last === "string" ? last : null });
240
+ }
241
+
242
+ // ── The queue ──────────────────────────────────────────────────────────────
243
+
244
+ /** Mark the scope's queue as in the request (a `sending` left by a closed tab goes back
245
+ * first); answers it with the statuses it had. */
246
+ function takeQueue(scope: string, withBlocked: boolean): Promise<OutboxChange[]> {
247
+ return db.transaction("rw", db.outbox, async () => {
248
+ await db.outbox
249
+ .where("scope")
250
+ .equals(scope)
251
+ .and((c) => c.status === "sending")
252
+ .modify({ status: "pending" });
253
+ const take: OutboxStatus[] = withBlocked ? ["pending", "waiting", "blocked"] : ["pending", "waiting"];
254
+ const queue = (await db.outbox.where("scope").equals(scope).toArray())
255
+ .filter((c) => take.includes(c.status))
256
+ .sort((a, b) => a.seq! - b.seq!);
257
+ await db.outbox.bulkUpdate(queue.map((c) => ({ key: c.seq!, changes: { status: "sending" as const } })));
258
+ return queue;
259
+ });
260
+ }
261
+
262
+ /** The request failed: each change back to the status it had. */
263
+ function restore(sent: OutboxChange[]) {
264
+ return db.outbox.bulkUpdate(sent.map((c) => ({ key: c.seq!, changes: { status: c.status } })));
265
+ }
266
+
267
+ function mark(changes: OutboxChange[], fields: Partial<OutboxChange>) {
268
+ return db.outbox.bulkUpdate(changes.map((c) => ({ key: c.seq!, changes: fields })));
269
+ }
270
+
271
+ /** Put one page's rows in the copy, settle the sent changes, lay the unsent ones back. */
272
+ async function apply(scope: string, answer: SyncResponseBody, sent: OutboxChange[], gen: number, lastPage: boolean) {
273
+ return db.transaction("rw", db.rows, db.outbox, db.meta, async () => {
274
+ if (gen !== generation) throw new Dropped();
275
+ const refused = answer.refused ?? null;
276
+ const refusedIds = new Set(refused?.change_ids ?? []);
277
+ const rejections = new Map((answer.rejections ?? []).map((r) => [r.change_id, r]));
278
+ const kept = sent.filter((c) => refusedIds.has(c.id));
279
+ const rejected = sent.filter((c) => !refusedIds.has(c.id) && rejections.has(c.id));
280
+ await db.outbox.bulkDelete(sent.filter((c) => !refusedIds.has(c.id) && !rejections.has(c.id)).map((c) => c.seq!));
281
+ const hold = refused != null && holdCodes.has(refused.code);
282
+ if (refused) await mark(kept, { status: hold ? "waiting" : "blocked", code: refused.code, reason: refused.detail });
283
+ for (const change of rejected) {
284
+ const why = rejections.get(change.id)!;
285
+ await db.outbox.update(change.seq!, { status: "rejected", code: why.code, reason: why.detail });
286
+ }
287
+ const pulled = new Set<string>();
288
+ for (const change of answer.changes) {
289
+ pulled.add(`${change.entity}\u0000${change.id}`);
290
+ if (change.op === "delete") await db.rows.delete([change.entity, change.id]);
291
+ else {
292
+ const row: CopyRow = {
293
+ entity: change.entity,
294
+ id: change.id,
295
+ pk: change.pk ?? null,
296
+ local: false,
297
+ data: change.data ?? {},
298
+ // The server's stamp rides beside `data`: kept, so the next edit is stamped after it.
299
+ updated_at: change.updated_at ?? null,
300
+ };
301
+ await db.rows.put(row);
302
+ }
303
+ }
304
+ // A pull never undoes what was typed while the request was out.
305
+ const unsent = (await db.outbox.where("status").anyOf(UNSENT).toArray())
306
+ .filter((c) => pulled.has(`${c.entity}\u0000${c.id}`))
307
+ .sort((a, b) => a.seq! - b.seq!);
308
+ for (const change of unsent) {
309
+ if (change.op === "delete") {
310
+ await db.rows.delete([change.entity, change.id]);
311
+ continue;
312
+ }
313
+ const row = (await db.rows.get([change.entity, change.id])) as CopyRow | undefined;
314
+ await db.rows.put({ ...row, entity: change.entity, id: change.id, pk: row?.pk ?? null, data: { ...row?.data, ...change.data } });
315
+ }
316
+ if (lastPage) {
317
+ await db.meta.put({ key: lastKnowledgeKey(scope), value: answer.server_knowledge });
318
+ await db.meta.put({ key: LAST_SYNCED_AT, value: new Date().toISOString() });
319
+ }
320
+ return {
321
+ waiting: hold ? kept.length : 0,
322
+ blocked: refused && !hold ? kept.length : 0,
323
+ rejected: rejected.length,
324
+ };
325
+ });
326
+ }
327
+
328
+ /** A scope the role no longer holds (403): its queue is rejected and it leaves the round.
329
+ * The server's `code` and `detail` are kept (a demo's `demo_read_only`); without them,
330
+ * `forbidden` and "Not allowed to sync <scope>". */
331
+ async function loseScope(scope: string, err: unknown): Promise<number> {
332
+ const why = refusalOf(err);
333
+ return db.transaction("rw", db.outbox, async () => {
334
+ const queue = (await db.outbox.where("scope").equals(scope).toArray()).filter((c) => UNSENT.includes(c.status));
335
+ await mark(queue, {
336
+ status: "rejected",
337
+ code: why.code ?? "forbidden",
338
+ reason: why.code && why.detail ? why.detail : `Not allowed to sync ${scope}`,
339
+ });
340
+ return queue.length;
341
+ });
342
+ }
343
+
344
+ type ScopeResult = { offline: true } | { offline: false; waiting: number; blocked: number; rejected: number };
345
+
346
+ async function syncScope(scope: string, gen: number, signal: AbortSignal, withBlocked: boolean): Promise<ScopeResult> {
347
+ const sent = await takeQueue(scope, withBlocked);
348
+ const last = Number((await db.meta.get(lastKnowledgeKey(scope)))?.value ?? 0) || 0;
349
+ const body = (changes: SyncWireChange[], page_cursor: SyncRequestBody["page_cursor"]): SyncRequestBody => ({
350
+ scope,
351
+ last_knowledge: last,
352
+ changes,
353
+ refusals: true,
354
+ client_now: new Date().toISOString(),
355
+ ...(options.paged ? { paged: true, page_cursor } : {}),
356
+ });
357
+ let answer: SyncResponseBody;
358
+ try {
359
+ answer = await options.send(body(sent.map(toWire), null), signal);
360
+ } catch (err) {
361
+ await restore(sent);
362
+ if (gen !== generation) throw new Dropped();
363
+ if (isOffline(err)) return { offline: true };
364
+ const plan = planCode(err);
365
+ if (plan) {
366
+ // The whole request refused for the plan: every change waits for it.
367
+ await mark(sent, { status: "waiting", code: plan, reason: plan });
368
+ return { offline: false, waiting: sent.length, blocked: 0, rejected: 0 };
369
+ }
370
+ if (httpStatusOf(err) === 403) return { offline: false, waiting: 0, blocked: 0, rejected: await loseScope(scope, err) };
371
+ throw err;
372
+ }
373
+ let cursor = options.paged ? (answer.page_cursor ?? null) : null;
374
+ let tally: ScopeResult;
375
+ try {
376
+ tally = { offline: false, ...(await apply(scope, answer, sent, gen, cursor == null)) };
377
+ } catch (err) {
378
+ if (err instanceof Dropped) await restore(sent);
379
+ throw err;
380
+ }
381
+ // The rest of a paged pull: pull-only; the cursor is written with the last page alone,
382
+ // so an interrupted walk starts again from the old one (D12).
383
+ while (cursor) {
384
+ let page: SyncResponseBody;
385
+ try {
386
+ page = await options.send(body([], cursor), signal);
387
+ } catch (err) {
388
+ if (gen !== generation) throw new Dropped();
389
+ if (isOffline(err)) return { offline: true };
390
+ throw err;
391
+ }
392
+ cursor = page.page_cursor ?? null;
393
+ await apply(scope, page, [], gen, cursor == null);
394
+ }
395
+ return tally;
396
+ }
397
+
398
+ // ── The round ──────────────────────────────────────────────────────────────
399
+
400
+ async function round(withBlocked: boolean, gen: number): Promise<SyncRoundResult> {
401
+ const result: SyncRoundResult = { outcome: "synced", waiting: 0, blocked: 0, rejected: 0 };
402
+ if (gen !== generation) return { ...result, outcome: "dropped" };
403
+ if (options.paused?.()) return { ...result, outcome: "paused" };
404
+ if (!browserOnline()) return { ...result, outcome: "offline" };
405
+ const own = new AbortController();
406
+ controller = own;
407
+ set({ phase: "syncing" });
408
+ try {
409
+ for (let pass = 0; ; pass++) {
410
+ for (const scope of options.scopes()) {
411
+ if (gen !== generation) throw new Dropped();
412
+ const r = await syncScope(scope, gen, own.signal, withBlocked);
413
+ if (r.offline) return { ...result, outcome: "offline" };
414
+ result.waiting += r.waiting;
415
+ result.blocked += r.blocked;
416
+ result.rejected += r.rejected;
417
+ }
418
+ if (pass > 0) break;
419
+ let changed = false;
420
+ for (const step of steps) {
421
+ if (gen !== generation) throw new Dropped();
422
+ const ran = await step.run(db, { signal: own.signal, isCurrent: () => gen === generation });
423
+ if (gen !== generation) throw new Dropped();
424
+ if (ran.counts) stepCounts = { ...stepCounts, [step.name]: ran.counts };
425
+ if (ran.offline) return { ...result, outcome: "offline" };
426
+ if (ran.changed) changed = true;
427
+ }
428
+ if (!changed) break;
429
+ }
430
+ return result;
431
+ } catch (err) {
432
+ if (err instanceof Dropped || gen !== generation) return { ...result, outcome: "dropped" };
433
+ return { ...result, outcome: "error", error: err };
434
+ } finally {
435
+ if (controller === own) controller = null;
436
+ }
437
+ }
438
+
439
+ function clearRetry() {
440
+ if (retry) clearTimeout(retry);
441
+ retry = null;
442
+ }
443
+
444
+ function resetBackoff() {
445
+ failures = 0;
446
+ clearRetry();
447
+ if (state.nextRetryAt !== null) set({ nextRetryAt: null });
448
+ }
449
+
450
+ const inBackoff = () => state.nextRetryAt !== null && Date.now() < state.nextRetryAt;
451
+
452
+ function settle(result: SyncRoundResult) {
453
+ switch (result.outcome) {
454
+ case "synced":
455
+ failures = 0;
456
+ clearRetry();
457
+ set({ phase: "idle", online: true, nextRetryAt: null });
458
+ break;
459
+ case "offline":
460
+ // No retry timer offline: the `online` event or the poll brings the next round (D13).
461
+ clearRetry();
462
+ set({ phase: "offline", online: false, nextRetryAt: null });
463
+ break;
464
+ case "error": {
465
+ const wait = backoffMs[Math.min(failures, backoffMs.length - 1)];
466
+ failures += 1;
467
+ clearRetry();
468
+ if (started) {
469
+ retry = setTimeout(() => {
470
+ retry = null;
471
+ void run(false);
472
+ }, wait);
473
+ }
474
+ set({ phase: "error", nextRetryAt: started ? Date.now() + wait : null });
475
+ break;
476
+ }
477
+ case "dropped":
478
+ set({ phase: "idle" });
479
+ break;
480
+ case "paused":
481
+ break;
482
+ }
483
+ }
484
+
485
+ function run(withBlocked: boolean): Promise<SyncRoundResult> {
486
+ if (current) {
487
+ again = true;
488
+ return current;
489
+ }
490
+ const before = flights.get(db.name);
491
+ // The generation is read before any wait: a stop() while this waits drops the round, so
492
+ // it never sends this copy's queue with another account's client nor pulls into it.
493
+ const gen = generation;
494
+ const flight = (async () => {
495
+ // Another engine's round on this database (stopped, still settling) ends first.
496
+ if (before) await before.catch(() => undefined);
497
+ try {
498
+ const result = await round(withBlocked, gen);
499
+ settle(result);
500
+ await refreshCounts();
501
+ return result;
502
+ } finally {
503
+ current = null;
504
+ if (again) {
505
+ again = false;
506
+ tick();
507
+ }
508
+ }
509
+ })();
510
+ current = flight;
511
+ flights.set(db.name, flight);
512
+ const done = () => {
513
+ if (flights.get(db.name) === flight) flights.delete(db.name);
514
+ };
515
+ void flight.then(done, done);
516
+ return flight;
517
+ }
518
+
519
+ /** A round from the poll, a write or a request during a round: not while backing off. */
520
+ function tick() {
521
+ if (started && !inBackoff()) void run(false);
522
+ }
523
+
524
+ async function runFresh(withBlocked: boolean): Promise<SyncRoundResult> {
525
+ resetBackoff();
526
+ if (current) {
527
+ again = false;
528
+ await current.catch(() => undefined);
529
+ }
530
+ return run(withBlocked);
531
+ }
532
+
533
+ const onOnline = () => {
534
+ set({ online: true });
535
+ resetBackoff();
536
+ void run(false);
537
+ };
538
+ const onOffline = () => set({ online: false });
539
+
540
+ const engine: SyncEngine = {
541
+ async write(scope, entity, id, op, data = {}) {
542
+ await db.transaction("rw", db.rows, db.outbox, async () => {
543
+ const row = (await db.rows.get([entity, id])) as CopyRow | undefined;
544
+ const ofRow = await db.outbox.where("[entity+id]").equals([entity, id]).toArray();
545
+ ofRow.sort((a, b) => a.seq! - b.seq!);
546
+ const updated_at = stampAfter(latest([row?.updated_at, row?.data.updated_at, ...ofRow.map((c) => c.updated_at)]));
547
+ // A new edit to the row moves a rejected change out of the way (D13).
548
+ const rejected = ofRow.filter((c) => c.scope === scope && c.status === "rejected");
549
+ if (op === "delete") {
550
+ await db.rows.delete([entity, id]);
551
+ const neverSynced = row != null && (row.local ?? row.pk == null);
552
+ if (neverSynced && !ofRow.some((c) => c.status === "sending")) {
553
+ // Never reached the server: forgetting its queue is enough — a waiting change
554
+ // included, or it would create the row once the plan is back.
555
+ await db.outbox.bulkDelete(ofRow.map((c) => c.seq!));
556
+ return;
557
+ }
558
+ await db.outbox.bulkDelete(rejected.map((c) => c.seq!));
559
+ await db.outbox.add({ scope, entity, id, op, data: {}, updated_at, status: "pending" });
560
+ return;
561
+ }
562
+ const next: CopyRow = { ...row, entity, id, pk: row?.pk ?? null, data: { ...row?.data, ...data } };
563
+ if (!row) next.local = true;
564
+ await db.rows.put(next);
565
+ const carried = Object.assign({}, ...rejected.filter((c) => c.op === "upsert").map((c) => c.data)) as Record<string, unknown>;
566
+ await db.outbox.bulkDelete(rejected.map((c) => c.seq!));
567
+ const last = ofRow.filter((c) => c.scope === scope && c.status === "pending").at(-1);
568
+ if (last && last.op === "upsert") {
569
+ // Several edits before a round go up as one change, with the last one's time.
570
+ await db.outbox.update(last.seq!, { data: { ...carried, ...last.data, ...data }, updated_at });
571
+ } else {
572
+ await db.outbox.add({ scope, entity, id, op, data: { ...carried, ...data }, updated_at, status: "pending" });
573
+ }
574
+ });
575
+ engine.requestSync();
576
+ },
577
+
578
+ start() {
579
+ if (started || sharedComputer()) return;
580
+ const other = running.get(db.name);
581
+ if (other && other !== engine) other.stop();
582
+ running.set(db.name, engine);
583
+ started = true;
584
+ if (typeof window !== "undefined") {
585
+ window.addEventListener("online", onOnline);
586
+ window.addEventListener("offline", onOffline);
587
+ }
588
+ poll = setInterval(tick, pollMs);
589
+ void refreshCounts();
590
+ void run(false);
591
+ },
592
+
593
+ stop() {
594
+ if (!started) return;
595
+ started = false;
596
+ // The round in flight writes nothing more: its request is aborted and its answer
597
+ // dropped, the queue laid back; the next engine's round waits for it to settle.
598
+ generation += 1;
599
+ controller?.abort();
600
+ if (running.get(db.name) === engine) running.delete(db.name);
601
+ if (poll) clearInterval(poll);
602
+ poll = null;
603
+ if (soon) clearTimeout(soon);
604
+ soon = null;
605
+ clearRetry();
606
+ if (typeof window !== "undefined") {
607
+ window.removeEventListener("online", onOnline);
608
+ window.removeEventListener("offline", onOffline);
609
+ }
610
+ },
611
+
612
+ requestSync() {
613
+ void refreshCounts();
614
+ if (!started) return;
615
+ if (soon) clearTimeout(soon);
616
+ soon = setTimeout(() => {
617
+ soon = null;
618
+ tick();
619
+ }, soonMs);
620
+ },
621
+
622
+ syncNow: () => runFresh(false),
623
+
624
+ async retryBlocked() {
625
+ await runFresh(true);
626
+ },
627
+
628
+ async discardRejected(scope) {
629
+ const gone = (await db.outbox.where("status").equals("rejected").toArray()).filter((c) => !scope || c.scope === scope);
630
+ if (gone.length === 0) return;
631
+ await db.transaction("rw", db.rows, db.outbox, db.meta, async () => {
632
+ await db.outbox.bulkDelete(gone.map((c) => c.seq!));
633
+ for (const change of gone) {
634
+ const others = await db.outbox.where("[entity+id]").equals([change.entity, change.id]).count();
635
+ if (others === 0) await db.rows.delete([change.entity, change.id]);
636
+ }
637
+ // The scope pulls afresh, so the copy shows what the server holds.
638
+ for (const s of new Set(gone.map((c) => c.scope))) await db.meta.put({ key: lastKnowledgeKey(s), value: 0 });
639
+ });
640
+ await refreshCounts();
641
+ engine.requestSync();
642
+ },
643
+
644
+ async rejected() {
645
+ return db.outbox.where("status").equals("rejected").sortBy("seq");
646
+ },
647
+
648
+ async forget({ keepQueue }) {
649
+ generation += 1;
650
+ controller?.abort();
651
+ const tables = keepQueue ? [db.rows, db.meta] : db.tables;
652
+ await db.transaction("rw", tables, async () => {
653
+ for (const table of tables) await table.clear();
654
+ });
655
+ stepCounts = {};
656
+ set({ lastSyncedAt: null });
657
+ await refreshCounts();
658
+ },
659
+
660
+ getState: () => state,
661
+
662
+ subscribe(listener) {
663
+ listeners.add(listener);
664
+ return () => listeners.delete(listener);
665
+ },
666
+ };
667
+ return engine;
668
+ }
669
+
670
+ /**
671
+ * The engine kept for `options.db` (by its name), built on first use: one engine per
672
+ * database for the session (D15), so a company switch A→B→A finds A's engine again instead
673
+ * of building a second one beside a round in flight. The first call's options stand: read
674
+ * changing values (scopes, the client) through functions. A database object opened afresh
675
+ * under the same name replaces the kept engine (the old one is stopped).
676
+ */
677
+ export function syncEngineFor(options: SyncEngineOptions): SyncEngine {
678
+ const name = options.db.name;
679
+ const found = kept.get(name);
680
+ if (found && found.db === options.db) return found.engine;
681
+ found?.engine.stop();
682
+ const engine = createSyncEngine(options);
683
+ kept.set(name, { db: options.db, engine });
684
+ return engine;
685
+ }
686
+
687
+ /**
688
+ * Let go of the engine {@link syncEngineFor} keeps for the database `name` (0.46): it is
689
+ * stopped, its database closed and forgotten, so a later `syncEngineFor` builds afresh. Call it
690
+ * on sign-out (and for a company left for good): every database visited in a tab otherwise
691
+ * keeps its engine and an open IndexedDB connection until a reload. Nothing kept: a no-op.
692
+ */
693
+ export function releaseSyncEngine(name: string): void {
694
+ const found = kept.get(name);
695
+ if (!found) return;
696
+ kept.delete(name);
697
+ found.engine.stop();
698
+ found.db.close();
699
+ }