@kernhq/module-inventory 0.2.0 → 0.3.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 (193) hide show
  1. package/README.md +104 -25
  2. package/dist/contract/capabilities.d.ts +4 -4
  3. package/dist/contract/capabilities.d.ts.map +1 -1
  4. package/dist/contract/capabilities.js +51 -9
  5. package/dist/contract/capabilities.js.map +1 -1
  6. package/dist/contract/events.d.ts +43 -0
  7. package/dist/contract/events.d.ts.map +1 -1
  8. package/dist/contract/events.js +40 -0
  9. package/dist/contract/events.js.map +1 -1
  10. package/dist/contract/index.d.ts +1 -0
  11. package/dist/contract/index.d.ts.map +1 -1
  12. package/dist/contract/index.js +1 -0
  13. package/dist/contract/index.js.map +1 -1
  14. package/dist/contract/models.d.ts +322 -0
  15. package/dist/contract/models.d.ts.map +1 -1
  16. package/dist/contract/models.js +213 -0
  17. package/dist/contract/models.js.map +1 -1
  18. package/dist/contract/notifications.d.ts +24 -0
  19. package/dist/contract/notifications.d.ts.map +1 -0
  20. package/dist/contract/notifications.js +72 -0
  21. package/dist/contract/notifications.js.map +1 -0
  22. package/dist/contract/permissions.d.ts +52 -2
  23. package/dist/contract/permissions.d.ts.map +1 -1
  24. package/dist/contract/permissions.js +55 -2
  25. package/dist/contract/permissions.js.map +1 -1
  26. package/dist/contract/router.d.ts +1052 -0
  27. package/dist/contract/router.d.ts.map +1 -1
  28. package/dist/contract/router.js +225 -1
  29. package/dist/contract/router.js.map +1 -1
  30. package/dist/contract/settings.d.ts +2 -0
  31. package/dist/contract/settings.d.ts.map +1 -1
  32. package/dist/contract/settings.js +24 -4
  33. package/dist/contract/settings.js.map +1 -1
  34. package/dist/server/index.d.ts +8 -4
  35. package/dist/server/index.d.ts.map +1 -1
  36. package/dist/server/index.js +289 -5
  37. package/dist/server/index.js.map +1 -1
  38. package/dist/server/jobs.d.ts +48 -0
  39. package/dist/server/jobs.d.ts.map +1 -0
  40. package/dist/server/jobs.js +358 -0
  41. package/dist/server/jobs.js.map +1 -0
  42. package/dist/server/router.d.ts +1337 -0
  43. package/dist/server/router.d.ts.map +1 -1
  44. package/dist/server/router.js +363 -7
  45. package/dist/server/router.js.map +1 -1
  46. package/dist/server/schema.d.ts +134 -1
  47. package/dist/server/schema.d.ts.map +1 -1
  48. package/dist/server/schema.js +158 -4
  49. package/dist/server/schema.js.map +1 -1
  50. package/dist/server/services/assets.d.ts +69 -3
  51. package/dist/server/services/assets.d.ts.map +1 -1
  52. package/dist/server/services/assets.js +199 -31
  53. package/dist/server/services/assets.js.map +1 -1
  54. package/dist/server/services/attachments.d.ts +80 -0
  55. package/dist/server/services/attachments.d.ts.map +1 -0
  56. package/dist/server/services/attachments.js +182 -0
  57. package/dist/server/services/attachments.js.map +1 -0
  58. package/dist/server/services/audience.d.ts +15 -0
  59. package/dist/server/services/audience.d.ts.map +1 -0
  60. package/dist/server/services/audience.js +64 -0
  61. package/dist/server/services/audience.js.map +1 -0
  62. package/dist/server/services/categories.d.ts +57 -0
  63. package/dist/server/services/categories.d.ts.map +1 -0
  64. package/dist/server/services/categories.js +124 -0
  65. package/dist/server/services/categories.js.map +1 -0
  66. package/dist/server/services/cursor.d.ts +68 -0
  67. package/dist/server/services/cursor.d.ts.map +1 -0
  68. package/dist/server/services/cursor.js +39 -0
  69. package/dist/server/services/cursor.js.map +1 -0
  70. package/dist/server/services/custody.d.ts +175 -0
  71. package/dist/server/services/custody.d.ts.map +1 -0
  72. package/dist/server/services/custody.js +367 -0
  73. package/dist/server/services/custody.js.map +1 -0
  74. package/dist/server/services/db-errors.d.ts +7 -0
  75. package/dist/server/services/db-errors.d.ts.map +1 -0
  76. package/dist/server/services/db-errors.js +32 -0
  77. package/dist/server/services/db-errors.js.map +1 -0
  78. package/dist/server/services/index.d.ts +16 -0
  79. package/dist/server/services/index.d.ts.map +1 -1
  80. package/dist/server/services/index.js +25 -1
  81. package/dist/server/services/index.js.map +1 -1
  82. package/dist/server/services/members.d.ts +27 -0
  83. package/dist/server/services/members.d.ts.map +1 -0
  84. package/dist/server/services/members.js +39 -0
  85. package/dist/server/services/members.js.map +1 -0
  86. package/dist/server/services/notify.d.ts +43 -1
  87. package/dist/server/services/notify.d.ts.map +1 -1
  88. package/dist/server/services/notify.js +47 -5
  89. package/dist/server/services/notify.js.map +1 -1
  90. package/dist/server/services/offboarding.d.ts +70 -0
  91. package/dist/server/services/offboarding.d.ts.map +1 -0
  92. package/dist/server/services/offboarding.js +116 -0
  93. package/dist/server/services/offboarding.js.map +1 -0
  94. package/dist/server/services/repairs.d.ts +204 -0
  95. package/dist/server/services/repairs.d.ts.map +1 -0
  96. package/dist/server/services/repairs.js +476 -0
  97. package/dist/server/services/repairs.js.map +1 -0
  98. package/dist/server/services/search.d.ts +85 -0
  99. package/dist/server/services/search.d.ts.map +1 -0
  100. package/dist/server/services/search.js +142 -0
  101. package/dist/server/services/search.js.map +1 -0
  102. package/dist/server/services/stats.d.ts +42 -0
  103. package/dist/server/services/stats.d.ts.map +1 -0
  104. package/dist/server/services/stats.js +80 -0
  105. package/dist/server/services/stats.js.map +1 -0
  106. package/dist/server/services/status.d.ts +102 -0
  107. package/dist/server/services/status.d.ts.map +1 -0
  108. package/dist/server/services/status.js +71 -0
  109. package/dist/server/services/status.js.map +1 -0
  110. package/migrations/0002_custody_and_categories.sql +23 -0
  111. package/migrations/0003_repairs.sql +23 -0
  112. package/migrations/0004_platform_surfaces.sql +51 -0
  113. package/migrations/0005_repair_dates.sql +35 -0
  114. package/migrations/0006_workspace_registry_read.sql +50 -0
  115. package/migrations/0007_history_sequence.sql +83 -0
  116. package/migrations/meta/0002_snapshot.json +1054 -0
  117. package/migrations/meta/0003_snapshot.json +1070 -0
  118. package/migrations/meta/0004_snapshot.json +1130 -0
  119. package/migrations/meta/0005_snapshot.json +1135 -0
  120. package/migrations/meta/_journal.json +42 -0
  121. package/package.json +1 -1
  122. package/src/client/api-instance.ts +27 -2
  123. package/src/client/bidi.test.ts +148 -0
  124. package/src/client/bidi.ts +85 -0
  125. package/src/client/components/AssetDetailPanel.svelte +614 -0
  126. package/src/client/components/AssetFormDialog.svelte +71 -22
  127. package/src/client/components/AssetPhoto.svelte +178 -0
  128. package/src/client/components/AttachmentsSection.svelte +327 -0
  129. package/src/client/components/CustodyDialog.svelte +201 -0
  130. package/src/client/components/RepairDialog.svelte +271 -0
  131. package/src/client/components/RepairsSection.svelte +318 -0
  132. package/src/client/components/Timeline.svelte +347 -0
  133. package/src/client/components/TimelineText.svelte +124 -0
  134. package/src/client/core-api.ts +71 -0
  135. package/src/client/custody.test.ts +31 -0
  136. package/src/client/custody.ts +34 -0
  137. package/src/client/errors.test.ts +365 -0
  138. package/src/client/errors.ts +201 -0
  139. package/src/client/links.test.ts +74 -0
  140. package/src/client/links.ts +44 -0
  141. package/src/client/members.test.ts +132 -0
  142. package/src/client/members.ts +116 -0
  143. package/src/client/messages.test.ts +127 -0
  144. package/src/client/messages.ts +1043 -18
  145. package/src/client/mock.test.ts +395 -1
  146. package/src/client/mock.ts +1066 -79
  147. package/src/client/module.ts +54 -0
  148. package/src/client/pages/AssetsPage.svelte +317 -45
  149. package/src/client/permissions.ts +7 -0
  150. package/src/client/price.test.ts +151 -3
  151. package/src/client/price.ts +155 -11
  152. package/src/client/query.ts +36 -0
  153. package/src/client/repairs.test.ts +38 -0
  154. package/src/client/repairs.ts +38 -0
  155. package/src/client/settings/CategoriesSettings.svelte +421 -0
  156. package/src/client/settings/GeneralSettings.svelte +0 -0
  157. package/src/client/status.ts +29 -0
  158. package/src/client/timeline.test.ts +175 -0
  159. package/src/client/timeline.ts +206 -0
  160. package/src/client/widgets/OverviewWidget.svelte +127 -26
  161. package/src/client/widgets/RepairsWidget.svelte +124 -0
  162. package/src/contract/capabilities.ts +53 -9
  163. package/src/contract/events.ts +49 -0
  164. package/src/contract/index.ts +1 -0
  165. package/src/contract/models.ts +237 -0
  166. package/src/contract/notifications.ts +73 -0
  167. package/src/contract/permissions.ts +55 -2
  168. package/src/contract/router.ts +255 -1
  169. package/src/contract/settings.ts +24 -4
  170. package/src/module.test.ts +194 -4
  171. package/src/server/index.ts +311 -4
  172. package/src/server/inventory.int.test.ts +3573 -18
  173. package/src/server/jobs.ts +444 -0
  174. package/src/server/migrations.test.ts +113 -0
  175. package/src/server/router.ts +462 -6
  176. package/src/server/schema.ts +160 -3
  177. package/src/server/services/assets.ts +214 -54
  178. package/src/server/services/attachments.ts +215 -0
  179. package/src/server/services/audience.ts +77 -0
  180. package/src/server/services/categories.ts +136 -0
  181. package/src/server/services/cursor.ts +104 -0
  182. package/src/server/services/custody.ts +471 -0
  183. package/src/server/services/db-errors.ts +42 -0
  184. package/src/server/services/index.ts +34 -1
  185. package/src/server/services/members.ts +54 -0
  186. package/src/server/services/notify.ts +52 -7
  187. package/src/server/services/offboarding.ts +150 -0
  188. package/src/server/services/repairs.ts +567 -0
  189. package/src/server/services/search.ts +166 -0
  190. package/src/server/services/stats.ts +88 -0
  191. package/src/server/services/status.test.ts +34 -0
  192. package/src/server/services/status.ts +143 -0
  193. package/src/client/settings/core-api.ts +0 -32
@@ -0,0 +1,124 @@
1
+ <script lang="ts">
2
+ import { t } from '../i18n.js'
3
+
4
+ /**
5
+ * The two versions of a long-text field, behind a disclosure.
6
+ *
7
+ * A description is up to eight thousand characters, and the timeline rendered a change to one as
8
+ * "Description changed from … to …" with both versions inline: sixteen kilobytes of somebody's
9
+ * prose in one row of a 440px panel, with every entry below it pushed off the screen. The line
10
+ * above this now says only *that* it changed, and the text lives here.
11
+ *
12
+ * **A component rather than a bare `<details>` in the timeline, for one reason:** `{#if open}`.
13
+ * `<details>` keeps its content in the document whether or not it is open, so fifty entries would
14
+ * still be four hundred kilobytes of text nodes the browser has to build and lay out. Binding
15
+ * `open` and gating on it means the prose exists only while somebody is reading it, and the
16
+ * disclosure is still a real `<details>` — keyboard-reachable, announced as a disclosure, and
17
+ * open by default in a printed page. Nothing is hidden: it is one keystroke away and shown whole,
18
+ * because truncating what somebody deliberately opened would be the same defect one layer down.
19
+ *
20
+ * `before` is null when there was nothing there — a description filled in for the first time has
21
+ * no previous version, and an empty "Before" block would invent one.
22
+ */
23
+ interface Props {
24
+ before: string | null
25
+ after: string
26
+ }
27
+ const { before, after }: Props = $props()
28
+
29
+ let open = $state(false)
30
+ </script>
31
+
32
+ <details class="disclosure" bind:open>
33
+ <summary>{t('history_show_text')}</summary>
34
+ {#if open}
35
+ <div class="body">
36
+ {#if before !== null}
37
+ <p class="label">{t('history_text_before')}</p>
38
+ <!-- `pre-wrap`, so the paragraphs somebody typed are the paragraphs they read back. -->
39
+ <blockquote class="text was">{before}</blockquote>
40
+ {/if}
41
+ <p class="label">{t('history_text_after')}</p>
42
+ <blockquote class="text">{after}</blockquote>
43
+ </div>
44
+ {/if}
45
+ </details>
46
+
47
+ <style>
48
+ .disclosure {
49
+ margin-top: 3px;
50
+ font-size: 12px;
51
+ }
52
+ summary {
53
+ /* `list-style` on the summary is what removes the native marker in Firefox; the ::-webkit rule
54
+ below is what removes it in Safari and Chrome. Neither alone does both. */
55
+ list-style: none;
56
+ display: inline-flex;
57
+ align-items: center;
58
+ gap: 4px;
59
+ width: fit-content;
60
+ cursor: pointer;
61
+ color: var(--kern-ink-600);
62
+ /* WCAG 2.5.8 wants 24px and a line of 12px text is about 16. The padding grows the hit area
63
+ and the equal negative margin gives the space back, so nothing moves. */
64
+ padding-block: 4px;
65
+ margin-block: -4px;
66
+ border-radius: var(--kern-r-sm);
67
+ }
68
+ summary::-webkit-details-marker {
69
+ display: none;
70
+ }
71
+ summary:hover {
72
+ color: var(--kern-ink-900);
73
+ }
74
+ /* The disclosure triangle, drawn rather than borrowed, so it turns with the reading direction:
75
+ `▸` in an Arabic panel points the wrong way, and a rotated glyph does not. */
76
+ summary::before {
77
+ content: '';
78
+ width: 0;
79
+ height: 0;
80
+ border-block: 3.5px solid transparent;
81
+ border-inline-start: 5px solid currentColor;
82
+ transition: transform 120ms ease;
83
+ }
84
+ .disclosure[open] summary::before {
85
+ /* Logical would be ideal and there is no logical rotation; the sign is flipped in RTL below. */
86
+ transform: rotate(90deg);
87
+ }
88
+ :global([dir='rtl']) .disclosure[open] summary::before {
89
+ transform: rotate(-90deg);
90
+ }
91
+ .body {
92
+ display: flex;
93
+ flex-direction: column;
94
+ gap: 2px;
95
+ margin-top: 6px;
96
+ }
97
+ .label {
98
+ margin: 4px 0 0;
99
+ font-size: 11px;
100
+ font-weight: 600;
101
+ letter-spacing: 0.03em;
102
+ text-transform: uppercase;
103
+ /* Muted with a colour, never opacity: a faded label is unreadable whatever its token says. */
104
+ color: var(--kern-ink-500);
105
+ }
106
+ .text {
107
+ margin: 0;
108
+ padding-inline-start: 8px;
109
+ border-inline-start: 2px solid var(--kern-border-hairline);
110
+ font-size: 12.5px;
111
+ line-height: 1.55;
112
+ color: var(--kern-ink-700);
113
+ white-space: pre-wrap;
114
+ overflow-wrap: anywhere;
115
+ /* A description somebody typed decides its own direction: `plaintext` takes it from the
116
+ text's own first strong character, so an English paragraph inside a Persian panel reads
117
+ left to right and keeps its own punctuation. `isolate` would not — on a block it leaves
118
+ the paragraph direction inherited. */
119
+ unicode-bidi: plaintext;
120
+ }
121
+ .was {
122
+ color: var(--kern-ink-500);
123
+ }
124
+ </style>
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The slice of core's API this module reaches for, named by shape rather than imported.
3
+ *
4
+ * A module talks to another module through `kernel.call()` on the server; on the client the shell
5
+ * hands over its own configured core client, and typing the seam structurally keeps the dependency
6
+ * pointing one way — inventory does not import core's router type, and core does not know inventory
7
+ * exists.
8
+ *
9
+ * Keep it to what is actually called. A wide type here is a promise about core's surface that this
10
+ * module has no standing to make: `Member` in core carries a role, role ids, group ids, a status
11
+ * and an invitation trail, and naming any of that here would make this module's screens break when
12
+ * a field it never reads changes shape. Four procedures are all of it, and each is gated in
13
+ * `core/src/modules/core/router.ts` — the two settings calls on `core.modules.manage`, the member
14
+ * list on `core.members.view`, the download URL on the file's own workspace membership.
15
+ */
16
+
17
+ /**
18
+ * A workspace member, as thinly as a picker and an avatar need one.
19
+ *
20
+ * `name` is nullable in core — somebody invited and not yet signed in has an email and nothing
21
+ * else — so every screen here falls back to the email rather than rendering an empty label.
22
+ */
23
+ export interface CoreMember {
24
+ userId: string
25
+ user: {
26
+ id: string
27
+ name: string | null
28
+ email: string
29
+ avatarUrl?: string | null
30
+ }
31
+ }
32
+
33
+ export interface CoreApi {
34
+ workspaces: {
35
+ members: {
36
+ /** Paged in core; this module asks for one large page and keeps the people, not the envelope. */
37
+ list(input: { workspaceId: string; limit?: number }): Promise<{ items: CoreMember[] }>
38
+ }
39
+ modules: {
40
+ list(input: { workspaceId: string }): Promise<
41
+ Array<{
42
+ manifest: { id: string }
43
+ state: {
44
+ enabled: boolean
45
+ settings?: Record<string, unknown>
46
+ }
47
+ }>
48
+ >
49
+ updateSettings(input: {
50
+ workspaceId: string
51
+ moduleId: string
52
+ settings: Record<string, unknown>
53
+ }): Promise<unknown>
54
+ }
55
+ }
56
+ /**
57
+ * Reading a file back.
58
+ *
59
+ * This module records that an asset has a file and never touches a byte — the bytes are core's,
60
+ * the URL is core's to sign, and it is signed for the person asking rather than for this module.
61
+ * `thumbnail: true` is what the asset photo asks for; a full-size photo in a 440px panel is a
62
+ * megabyte nobody looks at.
63
+ */
64
+ files: {
65
+ downloadUrl(input: {
66
+ id: string
67
+ disposition?: 'inline' | 'attachment'
68
+ thumbnail?: boolean
69
+ }): Promise<{ url: string }>
70
+ }
71
+ }
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { custodyActions } from './custody.js'
3
+
4
+ /**
5
+ * What the panel is allowed to offer.
6
+ *
7
+ * A decision rather than rendering, and wrong in a way nothing else would catch: a button that
8
+ * always produces a conflict is a door that will not open. What a stored uuid reads as is the
9
+ * neighbouring question and lives in `members.test.ts`, beside the file that answers it.
10
+ */
11
+ describe('custodyActions', () => {
12
+ it('offers handing over when nobody has it, and handing on or back when somebody does', () => {
13
+ expect(custodyActions({ held: false, archived: false, may: true })).toEqual(['assign'])
14
+ expect(custodyActions({ held: true, archived: false, may: true })).toEqual(['transfer', 'return'])
15
+ })
16
+
17
+ it('offers nothing without the permission — hidden, not disabled', () => {
18
+ expect(custodyActions({ held: false, archived: false, may: false })).toEqual([])
19
+ expect(custodyActions({ held: true, archived: false, may: false })).toEqual([])
20
+ })
21
+
22
+ it('offers nothing on an archived item, which the server refuses anyway', () => {
23
+ expect(custodyActions({ held: false, archived: true, may: true })).toEqual([])
24
+ expect(custodyActions({ held: true, archived: true, may: true })).toEqual([])
25
+ })
26
+
27
+ it('never offers assigning something that is already held', () => {
28
+ // The one combination that would 409 every single time it was pressed.
29
+ expect(custodyActions({ held: true, archived: false, may: true })).not.toContain('assign')
30
+ })
31
+ })
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Which handover an asset can actually take right now.
3
+ *
4
+ * Three verbs and not one, because the server has three procedures and refuses the wrong one with
5
+ * its own sentence: assigning something somebody already holds is a mistake worth naming, and
6
+ * handing on something nobody holds is a different mistake. The interface's job is not to offer
7
+ * either — a button that always produces a conflict is a door that will not open.
8
+ *
9
+ * A `.ts` file rather than a `const` inside the panel for the reason `timeline.ts` and `price.ts`
10
+ * are: this is a fact with a right answer, and a `.svelte` file cannot be unit-tested.
11
+ */
12
+ export type CustodyAction = 'assign' | 'transfer' | 'return'
13
+
14
+ export interface CustodyState {
15
+ /** Somebody is holding it — `assets.custodian_user_id` is set. */
16
+ held: boolean
17
+ /** The workspace has taken it out of the register; the server refuses every handover. */
18
+ archived: boolean
19
+ /** This person holds `inventory.custody.manage`. */
20
+ may: boolean
21
+ }
22
+
23
+ /**
24
+ * What to offer, in the order the buttons appear.
25
+ *
26
+ * Empty for somebody without the permission — hide what a person may never do — and empty for an
27
+ * archived item, where the panel says *why* instead. An archived item deliberately offers nothing
28
+ * rather than a disabled row of buttons: the reason is one sentence, and one sentence beats three
29
+ * controls that cannot be pressed.
30
+ */
31
+ export function custodyActions(state: CustodyState): CustodyAction[] {
32
+ if (!state.may || state.archived) return []
33
+ return state.held ? ['transfer', 'return'] : ['assign']
34
+ }
@@ -0,0 +1,365 @@
1
+ import { readdirSync, readFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { describe, expect, it } from 'vitest'
5
+ import {
6
+ codeOf,
7
+ errorLine,
8
+ errorMessage,
9
+ isInputValidation,
10
+ messageOf,
11
+ reasonKeys,
12
+ reasonOf,
13
+ } from './errors.js'
14
+ import { en } from './messages.js'
15
+
16
+ /**
17
+ * Every actionable failure a person can hit, in a language they chose.
18
+ *
19
+ * Eight call sites read `toast.error(error.message || t('common.error'))`, and `error.message` is a
20
+ * sentence the *server* wrote in English. So a Persian reader losing a race on a handover was shown
21
+ * "Somebody changed who is holding this a moment before you did. Reload to see where it is now." —
22
+ * the single most useful sentence this module has, in the wrong language, at the exact moment they
23
+ * needed to read it.
24
+ *
25
+ * Three things are checked here, and the third is the one that lasts:
26
+ *
27
+ * 1. the reason token is found wherever a transport puts it, and the code when there is none;
28
+ * 2. every key this file can name exists in the catalogue, so none renders as its own key;
29
+ * 3. **every reason the server actually throws has a sentence here** — read out of
30
+ * `src/server`, so a refusal added next month fails in this file rather than reaching
31
+ * somebody's screen in English.
32
+ */
33
+ const conflict = (reason: string, message = 'English prose the server wrote.') => ({
34
+ code: 'CONFLICT',
35
+ message,
36
+ data: { reason },
37
+ })
38
+
39
+ describe('reading what the server sent', () => {
40
+ it('finds the reason where `kernErrorToORPC` puts it', () => {
41
+ // The reason is folded into `data` rather than sent beside it, because `data` is what oRPC
42
+ // already carries to the client.
43
+ expect(reasonOf(conflict('inventory.custody.conflict'))).toBe('inventory.custody.conflict')
44
+ })
45
+
46
+ it('finds it on the error itself too, which is where the in-memory mock carries it', () => {
47
+ expect(reasonOf({ code: 'CONFLICT', reason: 'inventory.custody.not_held' })).toBe(
48
+ 'inventory.custody.not_held',
49
+ )
50
+ })
51
+
52
+ it('has no reason for a plain failure, rather than inventing one', () => {
53
+ expect(reasonOf(new Error('network'))).toBe(null)
54
+ expect(reasonOf(null)).toBe(null)
55
+ expect(reasonOf({ data: { reason: 42 } })).toBe(null)
56
+ })
57
+
58
+ it('reads the code, and keeps the server sentence for the fallback line', () => {
59
+ expect(codeOf({ code: 'NOT_FOUND' })).toBe('NOT_FOUND')
60
+ expect(codeOf(new Error('boom'))).toBe(null)
61
+ expect(messageOf(new Error(' boom '))).toBe('boom')
62
+ expect(messageOf({ message: ' ' })).toBe(null)
63
+ })
64
+ })
65
+
66
+ describe('which sentence a refusal earns', () => {
67
+ it('translates the conflict on a handover, which is the message that mattered most', () => {
68
+ expect(errorLine(conflict('inventory.custody.conflict'))).toEqual({
69
+ key: 'error_custody_conflict',
70
+ detail: null,
71
+ })
72
+ })
73
+
74
+ it('drops the server prose once it has named the failure itself', () => {
75
+ // Keeping it would print an English sentence under the Persian one, undoing the translation.
76
+ expect(errorLine(conflict('inventory.category.name_taken')).detail).toBe(null)
77
+ })
78
+
79
+ /**
80
+ * A **disabled capability answers 404, not 403** — the module contract says so, and the shell has
81
+ * already hidden the navigation for it. So a workspace that switches `repairs` off mid-session
82
+ * lands here, and "it is no longer there, reload" is the right sentence for it.
83
+ */
84
+ it('falls back to the class of failure when there is no reason', () => {
85
+ expect(errorLine({ code: 'NOT_FOUND', message: 'Asset not found' })).toEqual({
86
+ key: 'error_not_found',
87
+ detail: null,
88
+ })
89
+ expect(errorLine({ code: 'MODULE_DISABLED', message: 'Module inventory is disabled' }).key).toBe(
90
+ 'error_module_disabled',
91
+ )
92
+ expect(errorLine({ code: 'FORBIDDEN' }).key).toBe('error_forbidden')
93
+ })
94
+
95
+ it('prefers the reason over the code, because it is the more specific fact', () => {
96
+ expect(errorLine(conflict('inventory.repair.already_open')).key).toBe('error_repair_already_open')
97
+ expect(errorLine({ code: 'CONFLICT' }).key).toBe('error_conflict')
98
+ })
99
+
100
+ /**
101
+ * A reason a newer server invented, an error from core, a socket that died. The generic sentence
102
+ * says what happened and the server's words are kept as the only actionable clue left — as
103
+ * *detail*, under a sentence that is at least in the reader's language.
104
+ */
105
+ it('keeps the server words only for a failure it does not recognise', () => {
106
+ const unknown = { code: 'CONFLICT', message: 'Some newer refusal.', data: { reason: 'inventory.x.y' } }
107
+ // A reason nothing here maps falls through to the code, which is still translated.
108
+ expect(errorLine(unknown).key).toBe('error_conflict')
109
+ expect(errorLine(new Error('Failed to fetch'))).toEqual({
110
+ key: 'error_unknown',
111
+ detail: 'Failed to fetch',
112
+ })
113
+ })
114
+
115
+ it('says something rather than nothing for a failure with no words at all', () => {
116
+ expect(errorLine({})).toEqual({ key: 'error_unknown', detail: null })
117
+ expect(errorMessage({}, (key) => key)).toBe('error_unknown')
118
+ })
119
+
120
+ /**
121
+ * The four refusals that were told to go and check a form where nothing was wrong.
122
+ *
123
+ * `KernError.badRequest` and oRPC's own schema failure both come out as `BAD_REQUEST`, and the
124
+ * client mapped every one of them to "Something in the form was not accepted. Check what you
125
+ * entered." The server was actually saying "That person is not a member of this workspace", "That
126
+ * file has not finished uploading yet", "That file is not one this workspace can attach" and
127
+ * "That page marker is not one this list issued" — none of which is a field somebody typed
128
+ * wrong. A confident wrong instruction is worse than a vague right one: nobody finds the mistake,
129
+ * because there isn't one.
130
+ *
131
+ * `data.issues` is the only structural difference between the two, and oRPC always sets it —
132
+ * `validateInput` throws `ORPCError('BAD_REQUEST', { data: { issues } })`.
133
+ */
134
+ describe('a refusal is not a form error', () => {
135
+ const refusal = (message: string) => ({ code: 'BAD_REQUEST', message })
136
+
137
+ it('tells a validation failure from a service refusing', () => {
138
+ expect(isInputValidation({ code: 'BAD_REQUEST', data: { issues: [{ message: 'too long' }] } })).toBe(
139
+ true,
140
+ )
141
+ expect(isInputValidation(refusal('That file has not finished uploading yet.'))).toBe(false)
142
+ expect(isInputValidation({ data: { issues: 'nope' } })).toBe(false)
143
+ expect(isInputValidation(null)).toBe(false)
144
+ })
145
+
146
+ it('keeps "check what you entered" for the failure that really is about the form', () => {
147
+ expect(
148
+ errorLine({
149
+ code: 'BAD_REQUEST',
150
+ message: 'Input validation failed',
151
+ data: { issues: [{ message: 'Too long' }] },
152
+ }),
153
+ ).toEqual({ key: 'error_bad_request', detail: null })
154
+ // `VALIDATION` is the code making that claim itself, so it needs no issues to be believed.
155
+ expect(errorLine({ code: 'VALIDATION' }).key).toBe('error_bad_request')
156
+ })
157
+
158
+ it('does not send anybody to the form for any of the four the server actually refuses', () => {
159
+ for (const message of [
160
+ 'That person is not a member of this workspace, so nothing can be handed to them.',
161
+ 'That file has not finished uploading yet.',
162
+ 'That file is not one this workspace can attach.',
163
+ 'That page marker is not one this list issued',
164
+ ]) {
165
+ expect(errorLine(refusal(message))).toEqual({ key: 'error_refused', detail: message })
166
+ }
167
+ })
168
+
169
+ /**
170
+ * Until those four carry a stable `reason`, the server's own sentence is the only thing that
171
+ * says *which* refusal it was — so it is kept, as detail under a translated line. Dropping it
172
+ * would leave "That was not accepted." and nothing else.
173
+ */
174
+ it('keeps the server’s explanation, because nothing else identifies the refusal', () => {
175
+ expect(errorMessage(refusal('That file has not finished uploading yet.'), (key) => `<${key}>`)).toBe(
176
+ '<error_refused> — That file has not finished uploading yet.',
177
+ )
178
+ // And says something rather than nothing when there are no words at all.
179
+ expect(errorLine({ code: 'BAD_REQUEST' })).toEqual({ key: 'error_refused', detail: null })
180
+ })
181
+
182
+ /**
183
+ * The moment the server does carry one, that reason wins and the sentence is fully translated.
184
+ * Nothing is required of this file then except an entry in `REASON_KEYS` — this checks the
185
+ * precedence is already the right way round.
186
+ */
187
+ it('prefers a reason over the refusal fallback, once the server carries one', () => {
188
+ expect(
189
+ errorLine({
190
+ code: 'BAD_REQUEST',
191
+ message: 'This item is archived. Restore it before handing it over.',
192
+ data: { reason: 'inventory.custody.archived' },
193
+ }),
194
+ ).toEqual({ key: 'error_custody_archived', detail: null })
195
+ })
196
+ })
197
+
198
+ it('joins the sentence and the detail into one line for the toast', () => {
199
+ expect(errorMessage(new Error('Failed to fetch'), (key) => `<${key}>`)).toBe(
200
+ '<error_unknown> — Failed to fetch',
201
+ )
202
+ expect(errorMessage(conflict('inventory.custody.not_held'), (key) => `<${key}>`)).toBe(
203
+ '<error_custody_not_held>',
204
+ )
205
+ })
206
+ })
207
+
208
+ // ------------------------------------------------------- the two lists that have to stay in step
209
+
210
+ const HERE = dirname(fileURLToPath(import.meta.url))
211
+ const SERVER = join(HERE, '..', 'server')
212
+
213
+ /**
214
+ * Every reason literal the server passes to a `KernError`, dug out of the source.
215
+ *
216
+ * Deliberately not a grep for `'inventory.…'`: permission keys, capability ids and notification
217
+ * types all look exactly like a reason token, and matching them would demand a sentence for things
218
+ * that are not refusals. This reads the argument position instead — the second of `conflict` and
219
+ * `notFound`, the fourth of the constructor — which is the only place a reason can be.
220
+ */
221
+ function serverReasons(): string[] {
222
+ const found = new Set<string>()
223
+ const files = readdirSync(SERVER, { recursive: true, encoding: 'utf8' }).filter(
224
+ (file) => file.endsWith('.ts') && !file.endsWith('.test.ts'),
225
+ )
226
+ for (const file of files) {
227
+ const source = readFileSync(join(SERVER, file), 'utf8')
228
+ for (const [call, position] of [
229
+ ['KernError.conflict(', 1],
230
+ ['KernError.notFound(', 1],
231
+ ['new KernError(', 3],
232
+ ] as const) {
233
+ let at = source.indexOf(call)
234
+ while (at !== -1) {
235
+ const args = argumentsOf(source, at + call.length - 1)
236
+ const literal = args?.[position]?.trim().match(/^'([^']+)'$/)?.[1]
237
+ if (literal?.startsWith('inventory.')) found.add(literal)
238
+ at = source.indexOf(call, at + 1)
239
+ }
240
+ }
241
+ }
242
+ return [...found].sort()
243
+ }
244
+
245
+ /**
246
+ * The arguments of a call, split on the commas that are actually arguments.
247
+ *
248
+ * **String-aware, and that is not fussiness.** The first version split on every top-level comma and
249
+ * missed `inventory.custody.not_held` — because its message is "Nobody is holding this item, so
250
+ * there is nothing to hand on. Assign it instead." and the comma inside the sentence made the
251
+ * reason the *third* argument. A scanner that silently finds fewer things than there are is worse
252
+ * than no scanner: it passes.
253
+ */
254
+ function argumentsOf(source: string, open: number): string[] | null {
255
+ const parts: string[] = []
256
+ let depth = 0
257
+ let quote: string | null = null
258
+ let start = open + 1
259
+ for (let i = open; i < source.length; i++) {
260
+ const ch = source[i]
261
+ if (quote) {
262
+ if (ch === '\\') i++
263
+ else if (ch === quote) quote = null
264
+ continue
265
+ }
266
+ if (ch === "'" || ch === '"' || ch === '`') quote = ch
267
+ else if (ch === '(' || ch === '{' || ch === '[') depth++
268
+ else if (ch === ')' || ch === '}' || ch === ']') {
269
+ depth--
270
+ if (depth === 0) {
271
+ parts.push(source.slice(start, i))
272
+ return parts
273
+ }
274
+ } else if (ch === ',' && depth === 1) {
275
+ parts.push(source.slice(start, i))
276
+ start = i + 1
277
+ }
278
+ }
279
+ return null
280
+ }
281
+
282
+ /**
283
+ * Every sentence the server passes to `KernError.badRequest`, dug out of the source.
284
+ *
285
+ * The same trick `serverReasons` uses and for the same reason: the list grows. It was four when
286
+ * this was written — a non-member, an unfinished upload, a file from another workspace, a page
287
+ * marker this list never issued — and by the time it was finished it was six. None of them is a
288
+ * form-validation failure, and every one of them used to arrive as "check what you entered".
289
+ */
290
+ function serverBadRequests(): string[] {
291
+ const found = new Set<string>()
292
+ const call = 'KernError.badRequest('
293
+ const files = readdirSync(SERVER, { recursive: true, encoding: 'utf8' }).filter(
294
+ (file) => file.endsWith('.ts') && !file.endsWith('.test.ts'),
295
+ )
296
+ for (const file of files) {
297
+ const source = readFileSync(join(SERVER, file), 'utf8')
298
+ let at = source.indexOf(call)
299
+ while (at !== -1) {
300
+ const literal = argumentsOf(source, at + call.length - 1)?.[0]
301
+ ?.trim()
302
+ .match(/^'((?:[^'\\]|\\.)*)'$/)?.[1]
303
+ if (literal) found.add(literal)
304
+ at = source.indexOf(call, at + 1)
305
+ }
306
+ }
307
+ return [...found].sort()
308
+ }
309
+
310
+ describe('the catalogue keeps up with the server', () => {
311
+ /**
312
+ * Not one of the module's own `BAD_REQUEST` refusals is about a field somebody typed, so not one
313
+ * of them may land on `error_bad_request`. This reads them out of `src/server` rather than
314
+ * listing them, so a refusal added next month is covered the day it is written — and if one ever
315
+ * *is* a form error, it fails here and asks for a `reason` instead of a reworded sentence.
316
+ */
317
+ it('sends no refusal the server actually throws back to the form', () => {
318
+ const refusals = serverBadRequests()
319
+ expect(refusals.length).toBeGreaterThan(3)
320
+ for (const message of refusals)
321
+ expect({ message, line: errorLine({ code: 'BAD_REQUEST', message }) }).toEqual({
322
+ message,
323
+ line: { key: 'error_refused', detail: message },
324
+ })
325
+ })
326
+
327
+ it('has a translated sentence for every reason the server throws', () => {
328
+ // If this fails, a refusal was added to `src/server` without a sentence — and it will reach a
329
+ // Persian, Arabic, German or Turkish reader as English prose. Add the reason to `REASON_KEYS`
330
+ // and the sentence to all five bundles.
331
+ const missing = serverReasons().filter((reason) => !reasonKeys().includes(reason))
332
+ expect(missing).toEqual([])
333
+ })
334
+
335
+ it('is reading the server rather than passing on an empty sweep', () => {
336
+ // An empty result would make the check above pass for ever. The services throw eleven of these.
337
+ expect(serverReasons().length).toBeGreaterThan(8)
338
+ })
339
+
340
+ it('names no reason the server does not throw', () => {
341
+ // The other direction: a sentence for a refusal that cannot happen is dead weight nobody will
342
+ // ever notice has gone wrong.
343
+ const server = serverReasons()
344
+ expect(reasonKeys().filter((key) => !server.includes(key))).toEqual([])
345
+ })
346
+
347
+ it('names a key the catalogue actually has, for every reason and every code', () => {
348
+ const keys = new Set<string>()
349
+ for (const reason of reasonKeys()) keys.add(errorLine(conflict(reason)).key)
350
+ for (const code of [
351
+ 'NOT_FOUND',
352
+ 'FORBIDDEN',
353
+ 'MODULE_DISABLED',
354
+ 'CONFLICT',
355
+ 'BAD_REQUEST',
356
+ 'VALIDATION',
357
+ 'UNAUTHORIZED',
358
+ 'RATE_LIMITED',
359
+ 'UNAVAILABLE',
360
+ 'INTERNAL',
361
+ ])
362
+ keys.add(errorLine({ code }).key)
363
+ for (const key of keys) expect({ key, has: `inventory.${key}` in en }).toEqual({ key, has: true })
364
+ })
365
+ })