@kernhq/module-inventory 0.1.2 → 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 (213) hide show
  1. package/README.md +134 -9
  2. package/dist/contract/capabilities.d.ts +49 -0
  3. package/dist/contract/capabilities.d.ts.map +1 -0
  4. package/dist/contract/capabilities.js +94 -0
  5. package/dist/contract/capabilities.js.map +1 -0
  6. package/dist/contract/events.d.ts +76 -0
  7. package/dist/contract/events.d.ts.map +1 -0
  8. package/dist/contract/events.js +62 -0
  9. package/dist/contract/events.js.map +1 -0
  10. package/dist/contract/index.d.ts +16 -0
  11. package/dist/contract/index.d.ts.map +1 -0
  12. package/dist/contract/index.js +16 -0
  13. package/dist/contract/index.js.map +1 -0
  14. package/dist/contract/models.d.ts +468 -0
  15. package/dist/contract/models.d.ts.map +1 -0
  16. package/dist/contract/models.js +320 -0
  17. package/dist/contract/models.js.map +1 -0
  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 +72 -0
  23. package/dist/contract/permissions.d.ts.map +1 -0
  24. package/dist/contract/permissions.js +79 -0
  25. package/dist/contract/permissions.js.map +1 -0
  26. package/dist/contract/router.d.ts +1378 -0
  27. package/dist/contract/router.d.ts.map +1 -0
  28. package/dist/contract/router.js +266 -0
  29. package/dist/contract/router.js.map +1 -0
  30. package/dist/contract/settings.d.ts +20 -0
  31. package/dist/contract/settings.d.ts.map +1 -0
  32. package/dist/contract/settings.js +49 -0
  33. package/dist/contract/settings.js.map +1 -0
  34. package/dist/server/index.d.ts +8 -1
  35. package/dist/server/index.d.ts.map +1 -1
  36. package/dist/server/index.js +288 -8
  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 +1789 -0
  43. package/dist/server/router.d.ts.map +1 -0
  44. package/dist/server/router.js +439 -0
  45. package/dist/server/router.js.map +1 -0
  46. package/dist/server/schema.d.ts +156 -9
  47. package/dist/server/schema.d.ts.map +1 -1
  48. package/dist/server/schema.js +183 -11
  49. package/dist/server/schema.js.map +1 -1
  50. package/dist/server/services/assets.d.ts +139 -0
  51. package/dist/server/services/assets.d.ts.map +1 -0
  52. package/dist/server/services/assets.js +429 -0
  53. package/dist/server/services/assets.js.map +1 -0
  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 +26 -0
  79. package/dist/server/services/index.d.ts.map +1 -0
  80. package/dist/server/services/index.js +39 -0
  81. package/dist/server/services/index.js.map +1 -0
  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 +105 -0
  87. package/dist/server/services/notify.d.ts.map +1 -0
  88. package/dist/server/services/notify.js +147 -0
  89. package/dist/server/services/notify.js.map +1 -0
  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/0000_init.sql +12 -3
  111. package/migrations/0001_rls.sql +24 -0
  112. package/migrations/0002_custody_and_categories.sql +23 -0
  113. package/migrations/0003_repairs.sql +23 -0
  114. package/migrations/0004_platform_surfaces.sql +51 -0
  115. package/migrations/0005_repair_dates.sql +35 -0
  116. package/migrations/0006_workspace_registry_read.sql +50 -0
  117. package/migrations/0007_history_sequence.sql +83 -0
  118. package/migrations/meta/0000_snapshot.json +40 -13
  119. package/migrations/meta/0002_snapshot.json +1054 -0
  120. package/migrations/meta/0003_snapshot.json +1070 -0
  121. package/migrations/meta/0004_snapshot.json +1130 -0
  122. package/migrations/meta/0005_snapshot.json +1135 -0
  123. package/migrations/meta/_journal.json +44 -2
  124. package/package.json +5 -4
  125. package/src/client/api-instance.ts +27 -2
  126. package/src/client/api.ts +1 -1
  127. package/src/client/bidi.test.ts +148 -0
  128. package/src/client/bidi.ts +85 -0
  129. package/src/client/components/AssetDetailPanel.svelte +614 -0
  130. package/src/client/components/AssetFormDialog.svelte +191 -59
  131. package/src/client/components/AssetPhoto.svelte +178 -0
  132. package/src/client/components/AttachmentsSection.svelte +327 -0
  133. package/src/client/components/CustodyDialog.svelte +201 -0
  134. package/src/client/components/RepairDialog.svelte +271 -0
  135. package/src/client/components/RepairsSection.svelte +318 -0
  136. package/src/client/components/Timeline.svelte +347 -0
  137. package/src/client/components/TimelineText.svelte +124 -0
  138. package/src/client/core-api.ts +71 -0
  139. package/src/client/custody.test.ts +31 -0
  140. package/src/client/custody.ts +34 -0
  141. package/src/client/errors.test.ts +365 -0
  142. package/src/client/errors.ts +201 -0
  143. package/src/client/i18n.ts +11 -166
  144. package/src/client/index.ts +8 -1
  145. package/src/client/links.test.ts +74 -0
  146. package/src/client/links.ts +44 -0
  147. package/src/client/members.test.ts +132 -0
  148. package/src/client/members.ts +116 -0
  149. package/src/client/messages.test.ts +296 -0
  150. package/src/client/messages.ts +1424 -0
  151. package/src/client/mock.test.ts +555 -0
  152. package/src/client/mock.ts +1261 -52
  153. package/src/client/module.ts +76 -2
  154. package/src/client/pages/AssetsPage.svelte +638 -145
  155. package/src/client/permissions.ts +8 -1
  156. package/src/client/price.test.ts +254 -0
  157. package/src/client/price.ts +279 -0
  158. package/src/client/query.test.ts +58 -0
  159. package/src/client/query.ts +51 -2
  160. package/src/client/repairs.test.ts +38 -0
  161. package/src/client/repairs.ts +38 -0
  162. package/src/client/settings/CategoriesSettings.svelte +421 -0
  163. package/src/client/settings/GeneralSettings.svelte +403 -0
  164. package/src/client/status.ts +29 -0
  165. package/src/client/timeline.test.ts +175 -0
  166. package/src/client/timeline.ts +206 -0
  167. package/src/client/widgets/OverviewWidget.svelte +140 -26
  168. package/src/client/widgets/RepairsWidget.svelte +124 -0
  169. package/src/contract/capabilities.ts +99 -0
  170. package/src/contract/events.ts +83 -0
  171. package/src/contract/index.ts +16 -0
  172. package/src/contract/models.ts +360 -0
  173. package/src/contract/notifications.ts +73 -0
  174. package/src/contract/permissions.ts +79 -0
  175. package/src/contract/router.ts +300 -0
  176. package/src/contract/settings.ts +50 -0
  177. package/src/module.test.ts +330 -7
  178. package/src/server/index.ts +318 -8
  179. package/src/server/inventory.int.test.ts +4374 -0
  180. package/src/server/jobs.ts +444 -0
  181. package/src/server/migrations.test.ts +251 -0
  182. package/src/server/router.ts +574 -0
  183. package/src/server/schema.ts +184 -10
  184. package/src/server/services/assets.ts +528 -0
  185. package/src/server/services/attachments.ts +215 -0
  186. package/src/server/services/audience.ts +77 -0
  187. package/src/server/services/categories.ts +136 -0
  188. package/src/server/services/cursor.ts +104 -0
  189. package/src/server/services/custody.ts +471 -0
  190. package/src/server/services/db-errors.ts +42 -0
  191. package/src/server/services/index.ts +56 -0
  192. package/src/server/services/members.ts +54 -0
  193. package/src/server/services/notify.ts +196 -0
  194. package/src/server/services/offboarding.ts +150 -0
  195. package/src/server/services/repairs.ts +567 -0
  196. package/src/server/services/search.ts +166 -0
  197. package/src/server/services/stats.ts +88 -0
  198. package/src/server/services/status.test.ts +34 -0
  199. package/src/server/services/status.ts +143 -0
  200. package/tsconfig.base.json +22 -0
  201. package/tsconfig.client.json +1 -1
  202. package/tsconfig.json +1 -1
  203. package/vitest.config.ts +18 -3
  204. package/dist/contract.d.ts +0 -387
  205. package/dist/contract.d.ts.map +0 -1
  206. package/dist/contract.js +0 -119
  207. package/dist/contract.js.map +0 -1
  208. package/dist/server/_impl.d.ts +0 -427
  209. package/dist/server/_impl.d.ts.map +0 -1
  210. package/dist/server/_impl.js +0 -204
  211. package/dist/server/_impl.js.map +0 -1
  212. package/src/contract.ts +0 -143
  213. package/src/server/_impl.ts +0 -275
@@ -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
+ })
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Turning a refusal from the server into a sentence in the reader's own language.
3
+ *
4
+ * **Every actionable failure this module could produce reached the user in English, in all five
5
+ * locales.** Eight call sites were written as `toast.error(error.message || t('common.error'))`,
6
+ * and `error.message` is prose the *server* wrote: "Somebody changed who is holding this a moment
7
+ * before you did. Reload to see where it is now." That is the most useful sentence in the module
8
+ * and a Persian reader was shown it in English, at exactly the moment they needed to understand it.
9
+ * The comments at those call sites even said so — that the server's message was "the actionable
10
+ * one" — which was true, and was an argument for translating it rather than for printing it.
11
+ *
12
+ * The thing a client can translate is the **reason**, not the message. `KernError.conflict(message,
13
+ * reason)` has carried one since it was written, and `kernErrorToORPC` puts it in `data.reason`
14
+ * precisely so a client can branch on it: `inventory.custody.already_held` is a stable token that
15
+ * ships with the contract, where the sentence is prose that changes when somebody rewords it. So:
16
+ *
17
+ * 1. a `reason` this module knows → its own translated sentence;
18
+ * 2. otherwise the error **code** → the sentence that class of failure deserves;
19
+ * 3. otherwise a generic failure, and the server's own words underneath it.
20
+ *
21
+ * Step three is not a shrug. Dropping the server's message entirely would hide the one clue
22
+ * somebody could act on when the reason is one this build has never heard of — a newer server, an
23
+ * error from core rather than from here. It is shown as *detail*, under a sentence that at least
24
+ * names what failed, rather than as the whole of what the interface has to say.
25
+ *
26
+ * Pure and string-free: this file decides *which* key, `i18n.ts` holds the words, and a `.svelte`
27
+ * file cannot be unit-tested. `errorMessage()` at the bottom is the one function the screens call.
28
+ */
29
+
30
+ /** What oRPC hands a screen. `code` is the contract's `ErrorCode`; `data` is what the server put in it. */
31
+ export interface ServerError {
32
+ code?: unknown
33
+ message?: unknown
34
+ data?: unknown
35
+ status?: unknown
36
+ }
37
+
38
+ /**
39
+ * The reason token, out of wherever this particular transport put it.
40
+ *
41
+ * `data.reason` is the real one — that is what `kernErrorToORPC` folds it into. `reason` on the
42
+ * error itself is read too, because that is where a `KernError` thrown in-process carries it, and
43
+ * the mock in this package is neither: a demo that could not reproduce a translated conflict would
44
+ * be a demo that hides the branch this file exists for.
45
+ */
46
+ export function reasonOf(err: unknown): string | null {
47
+ if (!err || typeof err !== 'object') return null
48
+ const e = err as ServerError & { reason?: unknown }
49
+ const data = e.data as { reason?: unknown } | undefined
50
+ const found = (data && typeof data === 'object' ? data.reason : undefined) ?? e.reason
51
+ return typeof found === 'string' && found ? found : null
52
+ }
53
+
54
+ /** The `ErrorCode` the server refused with, if this is one of its errors at all. */
55
+ export function codeOf(err: unknown): string | null {
56
+ if (!err || typeof err !== 'object') return null
57
+ const { code } = err as ServerError
58
+ return typeof code === 'string' && code ? code : null
59
+ }
60
+
61
+ /**
62
+ * Whether a `BAD_REQUEST` is oRPC saying the *input* was malformed, or a service refusing.
63
+ *
64
+ * **Both arrive as `BAD_REQUEST`, and only one of them is about the form.** oRPC throws
65
+ * `ORPCError('BAD_REQUEST', { message: 'Input validation failed', data: { issues } })` when a zod
66
+ * schema rejects the input — that one really is "check what you entered". The ones this module's
67
+ * own services throw are not:
68
+ *
69
+ * - "That person is not a member of this workspace, so nothing can be handed to them."
70
+ * - "That file has not finished uploading yet."
71
+ * - "That file is not one this workspace can attach."
72
+ * - "That page marker is not one this list issued"
73
+ * - "That category is not one this workspace has."
74
+ * - "A repair cannot be sent in the future."
75
+ *
76
+ * Every field on those forms is fine. Sending somebody to re-read them is worse than saying
77
+ * nothing, because it is a confident wrong instruction: nobody finds the mistake, because there
78
+ * isn't one. `issues` is the one structural difference between the two, so it is what decides.
79
+ *
80
+ * This is a stopgap, and the report that came with it says so. The right fix is a **stable reason
81
+ * token** on each of those four — `KernError.badRequest` takes `details` where `conflict` takes a
82
+ * `reason`, so today they carry nothing a client can branch on and the server's English prose is
83
+ * the only clue that survives. Once they carry one, they belong in `REASON_KEYS` with a sentence
84
+ * each in all five languages, and this branch goes back to being about validation alone.
85
+ */
86
+ export function isInputValidation(err: unknown): boolean {
87
+ if (!err || typeof err !== 'object') return false
88
+ const data = (err as ServerError).data as { issues?: unknown } | undefined
89
+ return Boolean(data && typeof data === 'object' && Array.isArray(data.issues))
90
+ }
91
+
92
+ /** Whatever the server said, for the detail line under a failure nothing here recognises. */
93
+ export function messageOf(err: unknown): string | null {
94
+ if (!err) return null
95
+ if (typeof err === 'string') return err.trim() || null
96
+ if (typeof err !== 'object') return null
97
+ const { message } = err as ServerError
98
+ return typeof message === 'string' && message.trim() ? message.trim() : null
99
+ }
100
+
101
+ /**
102
+ * Every reason this module's server actually throws, and the sentence each one earns.
103
+ *
104
+ * Not "every reason imaginable": each key here is one this package's own services produce, and
105
+ * `errors.test.ts` reads them out of `src/server` to hold the two lists together. A sentence for a
106
+ * refusal that cannot happen is dead weight nobody will ever notice is wrong; a refusal with no
107
+ * sentence is English on somebody's screen, which is the defect this file was written for.
108
+ *
109
+ * The wording is what the reader can *do*, in the same voice as the rest of the module. A lost race
110
+ * says to look again; a conflict of state says which handover to use instead.
111
+ */
112
+ const REASON_KEYS: Record<string, string> = {
113
+ 'inventory.custody.conflict': 'error_custody_conflict',
114
+ 'inventory.custody.archived': 'error_custody_archived',
115
+ 'inventory.custody.already_held': 'error_custody_already_held',
116
+ 'inventory.custody.not_held': 'error_custody_not_held',
117
+ 'inventory.asset.still_held': 'error_asset_still_held',
118
+ 'inventory.asset.under_repair': 'error_asset_under_repair',
119
+ 'inventory.repair.already_open': 'error_repair_already_open',
120
+ 'inventory.repair.archived': 'error_repair_archived',
121
+ 'inventory.repair.already_complete': 'error_repair_already_complete',
122
+ 'inventory.repair.returned_before_sent': 'error_repair_returned_before_sent',
123
+ 'inventory.category.name_taken': 'error_category_name_taken',
124
+ }
125
+
126
+ export const reasonKeys = (): readonly string[] => Object.keys(REASON_KEYS)
127
+
128
+ /**
129
+ * The sentence a whole class of failure earns, when no reason narrows it further.
130
+ *
131
+ * `NOT_FOUND` and `MODULE_DISABLED` are the two worth spelling out. A 404 here almost always means
132
+ * somebody else archived or removed the row while this screen was open, so "it is no longer there —
133
+ * reload" is both true and the next step. `MODULE_DISABLED` is what a workspace gets when Inventory
134
+ * is switched off underneath it, and "Forbidden" would be the wrong word for it entirely.
135
+ *
136
+ * A **disabled capability answers 404, not 403** — that is the module contract — so a capability
137
+ * switched off mid-session lands on `NOT_FOUND` here, which is the same "it is not there any more,
138
+ * look again" sentence, and correctly so.
139
+ */
140
+ const CODE_KEYS: Record<string, string> = {
141
+ NOT_FOUND: 'error_not_found',
142
+ FORBIDDEN: 'error_forbidden',
143
+ MODULE_DISABLED: 'error_module_disabled',
144
+ CONFLICT: 'error_conflict',
145
+ // Reached only when the request really did fail validation — `errorLine` peels off the refusals
146
+ // first. `VALIDATION` is here because the code itself is the claim; `BAD_REQUEST` is not.
147
+ BAD_REQUEST: 'error_bad_request',
148
+ VALIDATION: 'error_bad_request',
149
+ UNAUTHORIZED: 'error_unauthorized',
150
+ RATE_LIMITED: 'error_rate_limited',
151
+ UNAVAILABLE: 'error_unavailable',
152
+ }
153
+
154
+ /**
155
+ * Which sentence to show, and whether the server's own words go under it.
156
+ *
157
+ * `detail` is filled only for the fallback: when this module named the failure itself, repeating
158
+ * the server's English beneath its own Persian sentence would undo the translation.
159
+ */
160
+ export interface ErrorLine {
161
+ /** A key for `t()`, always one this module's catalogue has. */
162
+ key: string
163
+ /** The server's own sentence, for a failure nothing here recognised. Null otherwise. */
164
+ detail: string | null
165
+ }
166
+
167
+ export function errorLine(err: unknown): ErrorLine {
168
+ const reason = reasonOf(err)
169
+ const byReason = reason ? REASON_KEYS[reason] : undefined
170
+ if (byReason) return { key: byReason, detail: null }
171
+
172
+ const code = codeOf(err)
173
+
174
+ // A `BAD_REQUEST` with no validation issues on it is a service refusing something, not a schema
175
+ // rejecting a field — so it gets a sentence that does not send anybody back to a form that is
176
+ // fine, and the server's own explanation as the detail. That prose is English until those four
177
+ // refusals carry a reason token; an English clue under a translated sentence is worse than a
178
+ // translated one and much better than a confident wrong instruction.
179
+ if (code === 'BAD_REQUEST' && !isInputValidation(err))
180
+ return { key: 'error_refused', detail: messageOf(err) }
181
+
182
+ const byCode = code ? CODE_KEYS[code] : undefined
183
+ if (byCode) return { key: byCode, detail: null }
184
+
185
+ // Nothing recognised it — a newer server, a failure from core, or the network. The generic
186
+ // sentence names what happened and the server's words are kept as the only actionable clue left.
187
+ return { key: 'error_unknown', detail: messageOf(err) }
188
+ }
189
+
190
+ /**
191
+ * The one call a screen makes: `toast.error(errorMessage(error, t))`.
192
+ *
193
+ * `t` is passed in rather than imported so this file goes on importing nothing — `i18n.ts` reaches
194
+ * `@kernhq/ui`, and that entry point drags a Svelte compiler into whatever imports it, which is
195
+ * what makes a helper untestable.
196
+ */
197
+ export function errorMessage(err: unknown, translate: (key: string) => string): string {
198
+ const line = errorLine(err)
199
+ const sentence = translate(line.key)
200
+ return line.detail ? `${sentence} — ${line.detail}` : sentence
201
+ }