@xemahq/ui-kernel 0.11.1 → 0.12.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 (257) hide show
  1. package/dist/errors/http-error-status.d.ts +17 -0
  2. package/dist/errors/http-error-status.d.ts.map +1 -0
  3. package/dist/errors/http-error-status.js +59 -0
  4. package/dist/errors/http-error-status.js.map +1 -0
  5. package/dist/errors/index.d.ts +2 -0
  6. package/dist/errors/index.d.ts.map +1 -0
  7. package/dist/errors/index.js +2 -0
  8. package/dist/errors/index.js.map +1 -0
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/lib/biome-host/errors.d.ts.map +1 -1
  14. package/dist/lib/biome-host/errors.js +1 -19
  15. package/dist/lib/biome-host/errors.js.map +1 -1
  16. package/dist/offline/index.d.ts.map +1 -1
  17. package/dist/offline/index.js.map +1 -1
  18. package/dist/offline/online-controller.d.ts.map +1 -1
  19. package/dist/offline/online-controller.js +6 -1
  20. package/dist/offline/online-controller.js.map +1 -1
  21. package/dist/offline/outbox.d.ts.map +1 -1
  22. package/dist/offline/outbox.js +2 -24
  23. package/dist/offline/outbox.js.map +1 -1
  24. package/package.json +1 -2
  25. package/src/index.ts +0 -17
  26. package/src/lib/biome-host/agent-validation.ts +0 -239
  27. package/src/lib/biome-host/biome-builders.ts +0 -112
  28. package/src/lib/biome-host/biome-mode.ts +0 -6
  29. package/src/lib/biome-host/biome-navigation.ts +0 -37
  30. package/src/lib/biome-host/biome-registry.ts +0 -299
  31. package/src/lib/biome-host/biome-scope-hooks.ts +0 -41
  32. package/src/lib/biome-host/biome-scope.tsx +0 -52
  33. package/src/lib/biome-host/biome-scoped-query.ts +0 -134
  34. package/src/lib/biome-host/create-biome-orval-config.ts +0 -82
  35. package/src/lib/biome-host/define-web-biome.spec.ts +0 -221
  36. package/src/lib/biome-host/define-web-biome.ts +0 -222
  37. package/src/lib/biome-host/errors.ts +0 -220
  38. package/src/lib/biome-host/frontend-biome.ts +0 -256
  39. package/src/lib/biome-host/host-bridge.ts +0 -281
  40. package/src/lib/biome-host/host-sources.ts +0 -54
  41. package/src/lib/biome-host/i18n-hooks.ts +0 -51
  42. package/src/lib/biome-host/i18n-port.test.ts +0 -50
  43. package/src/lib/biome-host/i18n-port.ts +0 -96
  44. package/src/lib/biome-host/index.ts +0 -35
  45. package/src/lib/biome-host/realtime-hooks.ts +0 -75
  46. package/src/lib/biome-host/realtime-port.ts +0 -115
  47. package/src/lib/biome-host/session-contributions.ts +0 -296
  48. package/src/lib/biome-host/use-mutation-with-error-toast.ts +0 -90
  49. package/src/lib/biome-host/use-page-state.ts +0 -248
  50. package/src/lib/capabilities/capability-context.ts +0 -25
  51. package/src/lib/capabilities/capability-provider.tsx +0 -73
  52. package/src/lib/capabilities/index.ts +0 -17
  53. package/src/lib/capabilities/types.ts +0 -69
  54. package/src/lib/capabilities/use-capability.ts +0 -79
  55. package/src/lib/contribution-registry/contribution-registry.spec.ts +0 -141
  56. package/src/lib/contribution-registry/contribution-registry.ts +0 -204
  57. package/src/lib/contribution-registry/index.ts +0 -9
  58. package/src/lib/contribution-registry/renderable-registry.ts +0 -142
  59. package/src/lib/system-bus/capability-invoke.ts +0 -92
  60. package/src/lib/system-bus/deeplink.ts +0 -210
  61. package/src/lib/system-bus/enums.ts +0 -86
  62. package/src/lib/system-bus/host-ports.ts +0 -93
  63. package/src/lib/system-bus/index.ts +0 -16
  64. package/src/lib/system-bus/intent-registry.ts +0 -109
  65. package/src/lib/system-bus/intents.ts +0 -113
  66. package/src/lib/system-bus/palette.ts +0 -78
  67. package/src/lib/system-bus/system-bus-builder.ts +0 -159
  68. package/src/lib/system-bus/system-bus.ts +0 -37
  69. package/src/lib/system-bus/windows.ts +0 -51
  70. package/src/offline/id.ts +0 -16
  71. package/src/offline/index.ts +0 -54
  72. package/src/offline/mutation.ts +0 -75
  73. package/src/offline/offline-status-badge.tsx +0 -51
  74. package/src/offline/online-controller.ts +0 -105
  75. package/src/offline/outbox.test.ts +0 -172
  76. package/src/offline/outbox.ts +0 -348
  77. package/src/offline/react-hooks.ts +0 -40
  78. package/src/offline/react.tsx +0 -32
  79. package/src/offline/storage.ts +0 -112
  80. package/src/offline/types.ts +0 -77
  81. package/src/org-db/components/DbResultTable.tsx +0 -176
  82. package/src/org-db/index.ts +0 -1
  83. package/src/pdf/index.ts +0 -11
  84. package/src/registry/index.ts +0 -9
  85. package/src/registry/lib/agent-validation-host.ts +0 -36
  86. package/src/registry/lib/biome-slot.tsx +0 -50
  87. package/src/registry/lib/biomes-enabled-context.ts +0 -20
  88. package/src/registry/lib/extension-points.ts +0 -134
  89. package/src/registry/lib/primitives/SessionEventCard.tsx +0 -164
  90. package/src/registry/lib/primitives/SessionEventTimeline.tsx +0 -95
  91. package/src/registry/lib/primitives/SessionMutationBar.tsx +0 -76
  92. package/src/registry/lib/primitives/SessionTabbedDrawer.tsx +0 -165
  93. package/src/registry/lib/primitives/index.ts +0 -18
  94. package/src/registry/lib/session-context-builder.ts +0 -84
  95. package/src/registry/lib/session-context-provider.tsx +0 -33
  96. package/src/registry/lib/session-context.ts +0 -21
  97. package/src/registry/lib/session-selectors.ts +0 -272
  98. package/src/session/comments/CommentRail.tsx +0 -164
  99. package/src/session/index.ts +0 -3
  100. package/src/session/lib/cn.ts +0 -11
  101. package/src/session/shell/SessionWorkspaceShell.tsx +0 -147
  102. package/src/session-kit/approvals/ApprovalButton.tsx +0 -89
  103. package/src/session-kit/approvals/ApprovalCard.tsx +0 -341
  104. package/src/session-kit/approvals/ApprovalsCenter.tsx +0 -336
  105. package/src/session-kit/approvals/CapabilityApprovalStickyBar.tsx +0 -118
  106. package/src/session-kit/approvals/Obligation.tsx +0 -111
  107. package/src/session-kit/approvals/ScopedBody.tsx +0 -392
  108. package/src/session-kit/approvals/approval-icons.ts +0 -31
  109. package/src/session-kit/approvals/approval-model.ts +0 -204
  110. package/src/session-kit/approvals/index.ts +0 -22
  111. package/src/session-kit/approvals/obligation-display.ts +0 -100
  112. package/src/session-kit/approvals/risk-accent.ts +0 -47
  113. package/src/session-kit/approvals/scope-icons.ts +0 -19
  114. package/src/session-kit/combobox/Combobox.tsx +0 -358
  115. package/src/session-kit/combobox/use-click-outside.ts +0 -21
  116. package/src/session-kit/display/ContextHeader.tsx +0 -148
  117. package/src/session-kit/display/FileDiffCard.tsx +0 -140
  118. package/src/session-kit/display/MD.tsx +0 -162
  119. package/src/session-kit/display/MessageTurn.tsx +0 -157
  120. package/src/session-kit/display/ThinkingPanel.tsx +0 -81
  121. package/src/session-kit/display/TodoChecklist.tsx +0 -120
  122. package/src/session-kit/display/TokenMeter.tsx +0 -89
  123. package/src/session-kit/display/ToolStrip.tsx +0 -278
  124. package/src/session-kit/display/TypingDots.tsx +0 -24
  125. package/src/session-kit/index.ts +0 -44
  126. package/src/session-kit/lib/enums.ts +0 -66
  127. package/src/session-kit/lib/portal-accent.ts +0 -30
  128. package/src/session-kit/lib/status-dot.ts +0 -68
  129. package/src/session-kit/primitives/Avatar.tsx +0 -44
  130. package/src/session-kit/primitives/PortalGlyph.tsx +0 -51
  131. package/src/session-kit/primitives/RiskPill.tsx +0 -95
  132. package/src/session-kit/primitives/ScopeDot.tsx +0 -47
  133. package/src/session-kit/primitives/Segmented.tsx +0 -71
  134. package/src/ui/branding/app-branding-context.ts +0 -20
  135. package/src/ui/branding/app-branding-provider.tsx +0 -123
  136. package/src/ui/branding/sanitize.ts +0 -89
  137. package/src/ui/breakpoints.ts +0 -14
  138. package/src/ui/capture/document-preview.tsx +0 -166
  139. package/src/ui/capture/field-spec-to-zod.test.ts +0 -81
  140. package/src/ui/capture/field-spec-to-zod.ts +0 -174
  141. package/src/ui/capture/field-spec-validate.ts +0 -236
  142. package/src/ui/capture/field-spec.test.ts +0 -49
  143. package/src/ui/capture/field-spec.ts +0 -274
  144. package/src/ui/capture/index.ts +0 -51
  145. package/src/ui/capture/schema-field-input.tsx +0 -620
  146. package/src/ui/capture/schema-form.tsx +0 -149
  147. package/src/ui/capture/submit-values.test.ts +0 -70
  148. package/src/ui/capture/submit-values.ts +0 -63
  149. package/src/ui/capture/use-prefill.ts +0 -186
  150. package/src/ui/capture/use-schema-validation.ts +0 -145
  151. package/src/ui/capture/use-wizard.ts +0 -180
  152. package/src/ui/capture/value-format.ts +0 -32
  153. package/src/ui/capture/wizard.tsx +0 -105
  154. package/src/ui/charts/bar-chart.tsx +0 -139
  155. package/src/ui/charts/chart-frame.tsx +0 -125
  156. package/src/ui/charts/chart-types.ts +0 -52
  157. package/src/ui/charts/chart-utils.ts +0 -190
  158. package/src/ui/charts/charts.test.tsx +0 -204
  159. package/src/ui/charts/donut-chart.tsx +0 -129
  160. package/src/ui/charts/index.ts +0 -27
  161. package/src/ui/charts/line-chart.tsx +0 -202
  162. package/src/ui/charts/sparkline.tsx +0 -109
  163. package/src/ui/chrome/AsyncBoundary.tsx +0 -74
  164. package/src/ui/chrome/EmptyState.tsx +0 -257
  165. package/src/ui/chrome/ErrorCard.tsx +0 -63
  166. package/src/ui/chrome/LoadingState.tsx +0 -61
  167. package/src/ui/chrome/PageHeader.tsx +0 -137
  168. package/src/ui/chrome/RecordCardList.tsx +0 -98
  169. package/src/ui/chrome/StateCard.tsx +0 -150
  170. package/src/ui/chrome/confirm-delete-dialog.tsx +0 -59
  171. package/src/ui/chrome/page-shell.spec.tsx +0 -117
  172. package/src/ui/chrome/page-shell.tsx +0 -221
  173. package/src/ui/chrome/scope-badge.tsx +0 -40
  174. package/src/ui/chrome/status-badge.tsx +0 -79
  175. package/src/ui/cn.ts +0 -32
  176. package/src/ui/design-tokens.ts +0 -372
  177. package/src/ui/hooks/use-debounced-value.ts +0 -16
  178. package/src/ui/hooks/use-is-mobile.ts +0 -28
  179. package/src/ui/index.ts +0 -110
  180. package/src/ui/pdf/pdf-preview.tsx +0 -122
  181. package/src/ui/primitives/alert-dialog.tsx +0 -104
  182. package/src/ui/primitives/async-combobox.tsx +0 -182
  183. package/src/ui/primitives/badge-variants.ts +0 -21
  184. package/src/ui/primitives/badge.tsx +0 -25
  185. package/src/ui/primitives/button-variants.ts +0 -31
  186. package/src/ui/primitives/button.tsx +0 -29
  187. package/src/ui/primitives/card.tsx +0 -43
  188. package/src/ui/primitives/checkbox.tsx +0 -26
  189. package/src/ui/primitives/collapsible.tsx +0 -9
  190. package/src/ui/primitives/dialog.tsx +0 -103
  191. package/src/ui/primitives/dropdown-menu.tsx +0 -179
  192. package/src/ui/primitives/form-stepper.tsx +0 -94
  193. package/src/ui/primitives/form-stepper.types.ts +0 -15
  194. package/src/ui/primitives/input.tsx +0 -22
  195. package/src/ui/primitives/label.tsx +0 -17
  196. package/src/ui/primitives/overflow-tabs.tsx +0 -331
  197. package/src/ui/primitives/popover.tsx +0 -33
  198. package/src/ui/primitives/preview-device-frame.tsx +0 -213
  199. package/src/ui/primitives/radio-group.tsx +0 -36
  200. package/src/ui/primitives/resizable.tsx +0 -67
  201. package/src/ui/primitives/scroll-area.tsx +0 -38
  202. package/src/ui/primitives/select.tsx +0 -143
  203. package/src/ui/primitives/separator.tsx +0 -20
  204. package/src/ui/primitives/sheet.tsx +0 -107
  205. package/src/ui/primitives/skeleton.tsx +0 -99
  206. package/src/ui/primitives/switch.tsx +0 -27
  207. package/src/ui/primitives/table.tsx +0 -72
  208. package/src/ui/primitives/tabs.tsx +0 -53
  209. package/src/ui/primitives/tag-multi-select.tsx +0 -261
  210. package/src/ui/primitives/textarea.tsx +0 -21
  211. package/src/ui/primitives/tooltip.tsx +0 -30
  212. package/src/ui/primitives/url-preview-chrome.tsx +0 -103
  213. package/src/vista/GenericCatalogueSurface.tsx +0 -179
  214. package/src/vista/SurfaceFallbackCard.tsx +0 -109
  215. package/src/vista/VistaPanel.tsx +0 -163
  216. package/src/vista/form-payload.spec.ts +0 -139
  217. package/src/vista/html/CustomUiFrame.spec.tsx +0 -127
  218. package/src/vista/html/CustomUiFrame.tsx +0 -206
  219. package/src/vista/html/custom-ui-frame-bridge.spec.ts +0 -191
  220. package/src/vista/html/custom-ui-frame-bridge.ts +0 -98
  221. package/src/vista/html/custom-ui-frame-contract.ts +0 -88
  222. package/src/vista/html/custom-ui-intents.spec.ts +0 -135
  223. package/src/vista/html/custom-ui-intents.ts +0 -155
  224. package/src/vista/html/custom-ui-srcdoc.spec.ts +0 -151
  225. package/src/vista/html/custom-ui-srcdoc.ts +0 -193
  226. package/src/vista/html/index.ts +0 -13
  227. package/src/vista/index.ts +0 -52
  228. package/src/vista/surface-catalogue.spec.ts +0 -140
  229. package/src/vista/surface-catalogue.ts +0 -90
  230. package/src/vista/surface-icons.ts +0 -57
  231. package/src/vista/surface-registry.ts +0 -56
  232. package/src/vista/surfaces/ChartSurface.tsx +0 -34
  233. package/src/vista/surfaces/FilesSurface.tsx +0 -202
  234. package/src/vista/surfaces/FormSurface.tsx +0 -380
  235. package/src/vista/surfaces/HtmlSurface.tsx +0 -43
  236. package/src/vista/surfaces/TableSurface.tsx +0 -100
  237. package/src/vista/surfaces/UrlSurface.tsx +0 -40
  238. package/src/vista/surfaces/blob-surfaces.tsx +0 -139
  239. package/src/vista/surfaces/elicitation-transport.spec.ts +0 -369
  240. package/src/vista/surfaces/elicitation-transport.ts +0 -367
  241. package/src/vista/surfaces/file-surface-utils.ts +0 -69
  242. package/src/vista/surfaces/index.ts +0 -24
  243. package/src/vista/surfaces/register-surfaces.tsx +0 -139
  244. package/src/vista/surfaces/text-surfaces.tsx +0 -132
  245. package/src/vista/use-vista-tabs.ts +0 -196
  246. package/src/vista/vista-code-renderer-context.ts +0 -16
  247. package/src/vista/vista-code-renderer.tsx +0 -35
  248. package/src/vista/vista-file-context.ts +0 -14
  249. package/src/vista/vista-file-source.tsx +0 -141
  250. package/src/vista/vista-form-context.ts +0 -20
  251. package/src/vista/vista-form-host.tsx +0 -48
  252. package/src/vista/vista-model.ts +0 -137
  253. package/src/vista/vista-open-context.tsx +0 -27
  254. package/src/vista/vista-open-state.ts +0 -11
  255. package/src/vista/vista-payloads.ts +0 -635
  256. package/src/vista/vista-tabs-manifest.ts +0 -138
  257. package/src/vista/vista-tools.ts +0 -232
@@ -1,37 +0,0 @@
1
- import type { CapabilityBus } from './capability-invoke';
2
- import type { DeeplinkBus } from './deeplink';
3
- import type { IntentBus } from './intents';
4
- import type { PaletteBus } from './palette';
5
- import type { WindowBus } from './windows';
6
-
7
- /**
8
- * `SystemBus` — the framework-agnostic OS-orchestration plane every
9
- * frontend biome reaches through its {@link HostBridge}.
10
- *
11
- * **Pure orchestration. Zero policy.** The SystemBus routes intent,
12
- * navigation, capability invocation, palette, and windowing. It NEVER
13
- * authorizes, denies, or duplicates policy. Every `capability.invoke`
14
- * routes through the backend capability-router, which is the single
15
- * authority on auth / tenancy / policy / audit. The SystemBus's job is to
16
- * carry orchestration metadata faithfully and relay the router's outcome —
17
- * a denial surfaces as an observable result (`output === undefined` +
18
- * `auditId`), never a silent swallow.
19
- *
20
- * The contract is framework-agnostic on purpose: it imports no Vite, no
21
- * Next.js, no React-Router, and no generated transport client. A concrete
22
- * host (Vite SPA today, Next.js tomorrow) implements each sub-bus against
23
- * its router, its capability-router client, and its window manager. Biome
24
- * code is identical across hosts.
25
- */
26
- export interface SystemBus {
27
- /** Capability invocation → backend router (backend enforces; bus adds none). */
28
- readonly capability: CapabilityBus;
29
- /** Cross-biome "Open with… / Send to…" intent registry. */
30
- readonly intents: IntentBus;
31
- /** Command palette = capabilities ∪ registered intents. */
32
- readonly palette: PaletteBus;
33
- /** Typed `xema://` deeplink parse + resolve. */
34
- readonly deeplink: DeeplinkBus;
35
- /** Host window manager (open / focus / close / arrange). */
36
- readonly windows: WindowBus;
37
- }
@@ -1,51 +0,0 @@
1
- import { type WindowArrangement, WindowOp } from './enums';
2
-
3
- /**
4
- * Window-management plane of the SystemBus.
5
- *
6
- * A "window" is a host-managed surface hosting a biome route or object.
7
- * The contract is framework-agnostic: the host (desktop shell, tiling WM,
8
- * tabbed SPA) decides how a window is physically realised, but every host
9
- * exposes the same closed {@link WindowOp} verb set. The SystemBus carries
10
- * no authorization here — windowing is pure shell orchestration.
11
- */
12
-
13
- /** Identifier of an open window, minted by the host on `open`. */
14
- export type WindowId = string;
15
-
16
- /** A request to open a new window onto a deeplink target. */
17
- export interface OpenWindowRequest {
18
- /** The `xema://` deeplink the new window should display. */
19
- readonly target: string;
20
- /** Optional human title; the host falls back to the target's own title. */
21
- readonly title?: string;
22
- }
23
-
24
- /** Snapshot of an open window. */
25
- export interface WindowState {
26
- readonly id: WindowId;
27
- readonly target: string;
28
- readonly title: string;
29
- readonly focused: boolean;
30
- }
31
-
32
- /**
33
- * The `windows` surface of the SystemBus. Each method maps to a closed
34
- * {@link WindowOp}. Operations a host cannot honour reject fail-fast
35
- * rather than silently no-op, so callers never assume a window exists
36
- * when it does not.
37
- */
38
- export interface WindowBus {
39
- /** {@link WindowOp.Open} — open a new window; resolves with its id. */
40
- open(request: OpenWindowRequest): Promise<WindowId>;
41
- /** {@link WindowOp.Focus} — bring a window to the foreground. Rejects on unknown id. */
42
- focus(id: WindowId): Promise<void>;
43
- /** {@link WindowOp.Close} — close a window. Rejects on unknown id. */
44
- close(id: WindowId): Promise<void>;
45
- /** {@link WindowOp.Arrange} — apply a layout to all open windows. */
46
- arrange(arrangement: WindowArrangement): Promise<void>;
47
- /** Current snapshot of open windows (for a window-list UI). */
48
- list(): readonly WindowState[];
49
- /** Subscribe to window-set changes; returns an unsubscribe fn. */
50
- subscribe(listener: () => void): () => void;
51
- }
package/src/offline/id.ts DELETED
@@ -1,16 +0,0 @@
1
- /** RFC-4122 id, using the platform crypto when available (browser + Node 18+),
2
- * with a non-cryptographic fallback for exotic runtimes. */
3
- export function newId(): string {
4
- const c = (globalThis as { crypto?: Crypto }).crypto;
5
- if (c && typeof c.randomUUID === 'function') {
6
- return c.randomUUID();
7
- }
8
- // Fallback — good enough for an outbox entry id (not a security token).
9
- return (
10
- 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
11
- const r = (Math.floor(Math.random() * 16) + Date.now()) % 16;
12
- const v = ch === 'x' ? r : (r & 0x3) | 0x8;
13
- return v.toString(16);
14
- })
15
- );
16
- }
@@ -1,54 +0,0 @@
1
- // ═══════════════════════════════════════════════════════════════════════════
2
- // @xemahq/ui-kernel/offline — generic offline field-capture kit
3
- //
4
- // A durable, provider-agnostic mutation outbox: queue mutations while
5
- // disconnected (IndexedDB) and drain them on reconnect with retry, backoff, and
6
- // idempotency. Pairs with the existing guided-capture kit (`../ui/capture`) and
7
- // the existing mobile off-canvas shell so a form captured in the field (trade
8
- // fair, no signal) is never lost. The immediate-online path is unchanged; only
9
- // a failed/offline send is queued.
10
- // ═══════════════════════════════════════════════════════════════════════════
11
-
12
- export { newId } from './id';
13
- export {
14
- OutboxEntryStatus,
15
- type OutboxEntry,
16
- type OutboxStorage,
17
- type OutboxExecutor,
18
- type OutboxExecuteContext,
19
- type OutboxSnapshot,
20
- } from './types';
21
- export {
22
- InMemoryOutboxStorage,
23
- IndexedDbOutboxStorage,
24
- createDefaultOutboxStorage,
25
- } from './storage';
26
- export {
27
- OnlineController,
28
- isNetworkUnavailableError,
29
- type OnlineListener,
30
- } from './online-controller';
31
- export {
32
- Outbox,
33
- type OutboxOptions,
34
- type OutboxListener,
35
- type EnqueueOptions,
36
- } from './outbox';
37
- export {
38
- createOfflineMutationFn,
39
- type OfflineMutationResult,
40
- type OfflineMutationOptions,
41
- } from './mutation';
42
- export {
43
- OutboxProvider,
44
- type OutboxProviderProps,
45
- } from './react';
46
- export {
47
- useOutbox,
48
- useOutboxSnapshot,
49
- useOnlineStatus,
50
- } from './react-hooks';
51
- export {
52
- OfflineStatusBadge,
53
- type OfflineStatusBadgeProps,
54
- } from './offline-status-badge';
@@ -1,75 +0,0 @@
1
- import { newId } from './id';
2
- import { isNetworkUnavailableError } from './online-controller';
3
-
4
- import type { Outbox } from './outbox';
5
- import type { OutboxExecuteContext } from './types';
6
-
7
- /** Outcome of an offline-aware mutation. */
8
- export interface OfflineMutationResult<TResult> {
9
- /** True when the mutation was queued for later delivery (offline / network
10
- * failure) instead of applied immediately. */
11
- queued: boolean;
12
- /** The outbox entry id when queued (for optimistic UI correlation). */
13
- entryId?: string;
14
- /** The server result when applied immediately (`queued === false`). */
15
- result?: TResult;
16
- }
17
-
18
- export interface OfflineMutationOptions<TPayload, TResult> {
19
- outbox: Outbox;
20
- /** Stable executor key for this mutation type (e.g. "app-forge:create-record"). */
21
- kind: string;
22
- /** Sends the mutation to the backend. Used BOTH for the immediate online path
23
- * and for draining the queue. Pass `ctx.idempotencyKey` to the backend so a
24
- * redelivered mutation is not double-applied. */
25
- send: (payload: TPayload, ctx: OutboxExecuteContext) => Promise<TResult>;
26
- /** Optional label for the pending-items UI. */
27
- label?: (payload: TPayload) => string | undefined;
28
- }
29
-
30
- /**
31
- * Build an offline-aware `mutationFn` (drop-in for `@tanstack/react-query`'s
32
- * `useMutation`). When online it sends immediately; if the send fails because
33
- * the network is unavailable — or the app is already offline — the payload is
34
- * durably queued and the promise RESOLVES (queued: true) so the UI can update
35
- * optimistically. The outbox drains on reconnect. A real server rejection (4xx)
36
- * still throws so the form shows the validation error.
37
- *
38
- * Registers the outbox executor for `kind` once (idempotent).
39
- */
40
- export function createOfflineMutationFn<TPayload, TResult>(
41
- opts: OfflineMutationOptions<TPayload, TResult>,
42
- ): (payload: TPayload) => Promise<OfflineMutationResult<TResult>> {
43
- const { outbox, kind, send, label } = opts;
44
-
45
- if (!outbox.hasExecutor(kind)) {
46
- outbox.registerExecutor<TPayload>(kind, async (payload, ctx) => {
47
- await send(payload, ctx);
48
- });
49
- }
50
-
51
- return async (payload: TPayload): Promise<OfflineMutationResult<TResult>> => {
52
- const idempotencyKey = newId();
53
-
54
- const queue = async (): Promise<OfflineMutationResult<TResult>> => {
55
- const entry = await outbox.enqueue(kind, payload, {
56
- idempotencyKey,
57
- ...(label ? { label: label(payload) } : {}),
58
- });
59
- return { queued: true, entryId: entry.id };
60
- };
61
-
62
- if (!outbox.isOnline()) {
63
- return queue();
64
- }
65
- try {
66
- const result = await send(payload, { idempotencyKey, attempt: 1, entryId: 'direct' });
67
- return { queued: false, result };
68
- } catch (err) {
69
- if (isNetworkUnavailableError(err)) {
70
- return queue();
71
- }
72
- throw err; // genuine server rejection — surface it to the caller
73
- }
74
- };
75
- }
@@ -1,51 +0,0 @@
1
- import { useOutboxSnapshot } from './react-hooks';
2
-
3
- import type { Outbox } from './outbox';
4
-
5
- export interface OfflineStatusBadgeProps {
6
- /** Explicit outbox; falls back to the nearest OutboxProvider. */
7
- outbox?: Outbox;
8
- className?: string;
9
- }
10
-
11
- const BASE =
12
- 'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-body-2 font-medium';
13
-
14
- /**
15
- * A compact connectivity + queue indicator for the offline field-capture kit.
16
- * Renders nothing when online with an empty queue; otherwise surfaces the
17
- * offline / syncing / dead-letter state so the user knows captures are safe and
18
- * will sync. Self-contained (no UI-primitive deps) so it can live in the
19
- * framework-level offline module.
20
- */
21
- export function OfflineStatusBadge({ outbox, className }: OfflineStatusBadgeProps) {
22
- const { online, pendingCount, failedCount, draining } = useOutboxSnapshot(outbox);
23
-
24
- if (online && pendingCount === 0 && failedCount === 0) {
25
- return null;
26
- }
27
-
28
- const cls = (tone: string): string =>
29
- `${BASE} ${tone}${className ? ` ${className}` : ''}`;
30
-
31
- if (failedCount > 0) {
32
- return (
33
- <span className={cls('bg-destructive/10 text-destructive')} role="status">
34
- ⚠ {failedCount} failed to sync
35
- </span>
36
- );
37
- }
38
- if (!online) {
39
- return (
40
- <span className={cls('bg-amber-500/10 text-amber-600')} role="status">
41
- ● Offline{pendingCount > 0 ? ` — ${pendingCount} queued` : ''}
42
- </span>
43
- );
44
- }
45
- // Online with a non-empty queue → actively draining.
46
- return (
47
- <span className={cls('bg-secondary text-secondary-foreground')} role="status">
48
- {draining ? '⟳' : '●'} Syncing {pendingCount}…
49
- </span>
50
- );
51
- }
@@ -1,105 +0,0 @@
1
- export type OnlineListener = (online: boolean) => void;
2
-
3
- /**
4
- * Tracks connectivity via `navigator.onLine` + the window `online`/`offline`
5
- * events. SSR-safe: when there is no `window`/`navigator` it reports online (so
6
- * server rendering never queues) and is inert. `navigator.onLine` only knows
7
- * about the local link, so callers may also mark the controller offline when a
8
- * request fails as network-unavailable (`reportOffline`) and online again after
9
- * a successful send (`reportOnline`).
10
- */
11
- export class OnlineController {
12
- private online: boolean;
13
- private readonly listeners = new Set<OnlineListener>();
14
- private started = false;
15
-
16
- private readonly onOnline = () => this.set(true);
17
- private readonly onOffline = () => this.set(false);
18
-
19
- constructor() {
20
- this.online =
21
- typeof navigator === 'undefined' || typeof navigator.onLine !== 'boolean'
22
- ? true
23
- : navigator.onLine;
24
- }
25
-
26
- isOnline(): boolean {
27
- return this.online;
28
- }
29
-
30
- start(): void {
31
- if (this.started || typeof window === 'undefined') {
32
- return;
33
- }
34
- this.started = true;
35
- window.addEventListener('online', this.onOnline);
36
- window.addEventListener('offline', this.onOffline);
37
- }
38
-
39
- stop(): void {
40
- if (!this.started || typeof window === 'undefined') {
41
- return;
42
- }
43
- this.started = false;
44
- window.removeEventListener('online', this.onOnline);
45
- window.removeEventListener('offline', this.onOffline);
46
- }
47
-
48
- /** Force offline after a network-unavailable request failure. */
49
- reportOffline(): void {
50
- this.set(false);
51
- }
52
-
53
- /** Force online after a successful send (recovers from a stale offline flag). */
54
- reportOnline(): void {
55
- this.set(true);
56
- }
57
-
58
- subscribe(listener: OnlineListener): () => void {
59
- this.listeners.add(listener);
60
- return () => this.listeners.delete(listener);
61
- }
62
-
63
- private set(online: boolean): void {
64
- if (this.online === online) {
65
- return;
66
- }
67
- this.online = online;
68
- for (const l of this.listeners) {
69
- l(online);
70
- }
71
- }
72
- }
73
-
74
- /**
75
- * Best-effort classifier for "the request failed because the network was
76
- * unavailable" (offline, DNS, connection refused) vs a real server rejection.
77
- * A fetch that never reaches the server rejects with a TypeError ("Failed to
78
- * fetch"); an aborted request has name AbortError. Anything with an HTTP status
79
- * is a server response, NOT a network failure.
80
- */
81
- export function isNetworkUnavailableError(err: unknown): boolean {
82
- if (typeof navigator !== 'undefined' && navigator.onLine === false) {
83
- return true;
84
- }
85
- if (!err || typeof err !== 'object') {
86
- return false;
87
- }
88
- const e = err as { name?: string; message?: string; status?: number; response?: unknown };
89
- // A response with a status is a server answer, not a transport failure.
90
- if (typeof e.status === 'number' || e.response !== undefined) {
91
- return false;
92
- }
93
- if (e.name === 'AbortError') {
94
- return false;
95
- }
96
- const msg = (e.message ?? '').toLowerCase();
97
- return (
98
- e.name === 'TypeError' ||
99
- msg.includes('failed to fetch') ||
100
- msg.includes('network') ||
101
- msg.includes('load failed') ||
102
- msg.includes('econnrefused') ||
103
- msg.includes('enotfound')
104
- );
105
- }
@@ -1,172 +0,0 @@
1
- import { describe, expect, it, vi } from 'vitest';
2
-
3
- import { OnlineController } from './online-controller';
4
- import { Outbox, type OutboxOptions } from './outbox';
5
- import { InMemoryOutboxStorage } from './storage';
6
- import { OutboxEntryStatus, type OutboxExecuteContext } from './types';
7
-
8
- const KIND = 'test:mutation';
9
-
10
- function makeOutbox(overrides: Partial<OutboxOptions> = {}): {
11
- outbox: Outbox;
12
- online: OnlineController;
13
- } {
14
- const online = new OnlineController(); // node: navigator undefined ⇒ online
15
- const outbox = new Outbox({
16
- storage: new InMemoryOutboxStorage(),
17
- online,
18
- // Immediate scheduler: retries are driven synchronously within `drain()`.
19
- scheduler: (fn) => fn(),
20
- ...overrides,
21
- });
22
- return { outbox, online };
23
- }
24
-
25
- describe('Outbox', () => {
26
- it('queues while offline and delivers on reconnect', async () => {
27
- const { outbox, online } = makeOutbox();
28
- const send = vi.fn(() => Promise.resolve());
29
- outbox.registerExecutor(KIND, send);
30
-
31
- online.reportOffline();
32
- await outbox.enqueue(KIND, { a: 1 });
33
- expect(send).not.toHaveBeenCalled();
34
- expect(outbox.getSnapshot().pendingCount).toBe(1);
35
-
36
- online.reportOnline();
37
- await outbox.drain();
38
- expect(send).toHaveBeenCalledTimes(1);
39
- expect(outbox.getSnapshot().entries).toHaveLength(0);
40
- });
41
-
42
- it('delivers immediately when online and removes the entry; idempotency key is stable', async () => {
43
- const { outbox } = makeOutbox();
44
- const keys: string[] = [];
45
- outbox.registerExecutor(KIND, (_p, ctx: OutboxExecuteContext) => {
46
- keys.push(ctx.idempotencyKey);
47
- return Promise.resolve();
48
- });
49
- await outbox.enqueue(KIND, { a: 1 });
50
- await outbox.drain();
51
- expect(keys).toHaveLength(1);
52
- expect(keys[0]).toMatch(/[0-9a-f-]{36}/);
53
- expect(outbox.getSnapshot().entries).toHaveLength(0);
54
- });
55
-
56
- it('retries a 5xx and eventually succeeds', async () => {
57
- const { outbox } = makeOutbox();
58
- let calls = 0;
59
- outbox.registerExecutor(KIND, () => {
60
- calls += 1;
61
- if (calls < 3) {
62
- return Promise.reject({ status: 503, message: 'unavailable' });
63
- }
64
- return Promise.resolve();
65
- });
66
- await outbox.enqueue(KIND, { a: 1 });
67
- await outbox.drain();
68
- expect(calls).toBe(3);
69
- expect(outbox.getSnapshot().entries).toHaveLength(0);
70
- });
71
-
72
- it('dead-letters a 4xx immediately (no pointless retries)', async () => {
73
- const { outbox } = makeOutbox();
74
- const send = vi.fn(() =>
75
- Promise.reject({ status: 400, message: 'bad request' }),
76
- );
77
- outbox.registerExecutor(KIND, send);
78
- await outbox.enqueue(KIND, { a: 1 });
79
- await outbox.drain();
80
- expect(send).toHaveBeenCalledTimes(1);
81
- const snap = outbox.getSnapshot();
82
- expect(snap.failedCount).toBe(1);
83
- expect(snap.entries[0]?.status).toBe(OutboxEntryStatus.Failed);
84
- });
85
-
86
- it('dead-letters after exhausting retries on persistent 5xx', async () => {
87
- const { outbox } = makeOutbox({ maxAttempts: 3 });
88
- let calls = 0;
89
- outbox.registerExecutor(KIND, () => {
90
- calls += 1;
91
- return Promise.reject({ status: 503 });
92
- });
93
- await outbox.enqueue(KIND, { a: 1 });
94
- await outbox.drain();
95
- expect(calls).toBe(3);
96
- expect(outbox.getSnapshot().entries[0]?.status).toBe(
97
- OutboxEntryStatus.Failed,
98
- );
99
- });
100
-
101
- it('a network failure never advances the attempt counter or dead-letters', async () => {
102
- // Captured scheduler so the offline-retry loop does not run forever.
103
- const timers: Array<() => void> = [];
104
- const { outbox } = makeOutbox({ scheduler: (fn) => void timers.push(fn) });
105
- let succeed = false;
106
- outbox.registerExecutor(KIND, () => {
107
- if (!succeed) {
108
- return Promise.reject({
109
- name: 'TypeError',
110
- message: 'Failed to fetch',
111
- });
112
- }
113
- return Promise.resolve();
114
- });
115
-
116
- await outbox.enqueue(KIND, { a: 1 });
117
- await outbox.drain();
118
- const parked = outbox.getSnapshot().entries[0];
119
- if (!parked) {
120
- throw new Error(
121
- 'the offline entry was dropped from the outbox instead of parked',
122
- );
123
- }
124
- expect(parked.status).toBe(OutboxEntryStatus.Pending);
125
- expect(parked.attempts).toBe(0); // offline is not the payload's fault
126
- expect(outbox.getSnapshot().failedCount).toBe(0);
127
-
128
- // Reconnect + retry drains it.
129
- succeed = true;
130
- await outbox.drain();
131
- expect(outbox.getSnapshot().entries).toHaveLength(0);
132
- });
133
-
134
- it('delivers queued entries in FIFO order', async () => {
135
- const { outbox, online } = makeOutbox();
136
- const order: number[] = [];
137
- outbox.registerExecutor(KIND, (p: { n: number }) => {
138
- order.push(p.n);
139
- return Promise.resolve();
140
- });
141
- online.reportOffline();
142
- await outbox.enqueue(KIND, { n: 1 });
143
- await outbox.enqueue(KIND, { n: 2 });
144
- await outbox.enqueue(KIND, { n: 3 });
145
- online.reportOnline();
146
- await outbox.drain();
147
- expect(order).toEqual([1, 2, 3]);
148
- });
149
-
150
- it('recovers crash-orphaned inflight entries to pending on start', async () => {
151
- const storage = new InMemoryOutboxStorage();
152
- const ts = 1;
153
- await storage.put({
154
- id: 'orphan',
155
- kind: KIND,
156
- payload: { a: 1 },
157
- idempotencyKey: 'k',
158
- status: OutboxEntryStatus.Inflight, // died mid-send
159
- attempts: 1,
160
- createdAt: ts,
161
- updatedAt: ts,
162
- });
163
- const online = new OnlineController();
164
- online.reportOffline(); // keep it queued so we can observe the recovery
165
- const outbox = new Outbox({ storage, online, scheduler: (fn) => fn() });
166
- await outbox.start();
167
- expect(outbox.getSnapshot().entries[0]?.status).toBe(
168
- OutboxEntryStatus.Pending,
169
- );
170
- outbox.stop();
171
- });
172
- });