@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.
- package/README.md +134 -9
- package/dist/contract/capabilities.d.ts +49 -0
- package/dist/contract/capabilities.d.ts.map +1 -0
- package/dist/contract/capabilities.js +94 -0
- package/dist/contract/capabilities.js.map +1 -0
- package/dist/contract/events.d.ts +76 -0
- package/dist/contract/events.d.ts.map +1 -0
- package/dist/contract/events.js +62 -0
- package/dist/contract/events.js.map +1 -0
- package/dist/contract/index.d.ts +16 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +16 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/contract/models.d.ts +468 -0
- package/dist/contract/models.d.ts.map +1 -0
- package/dist/contract/models.js +320 -0
- package/dist/contract/models.js.map +1 -0
- package/dist/contract/notifications.d.ts +24 -0
- package/dist/contract/notifications.d.ts.map +1 -0
- package/dist/contract/notifications.js +72 -0
- package/dist/contract/notifications.js.map +1 -0
- package/dist/contract/permissions.d.ts +72 -0
- package/dist/contract/permissions.d.ts.map +1 -0
- package/dist/contract/permissions.js +79 -0
- package/dist/contract/permissions.js.map +1 -0
- package/dist/contract/router.d.ts +1378 -0
- package/dist/contract/router.d.ts.map +1 -0
- package/dist/contract/router.js +266 -0
- package/dist/contract/router.js.map +1 -0
- package/dist/contract/settings.d.ts +20 -0
- package/dist/contract/settings.d.ts.map +1 -0
- package/dist/contract/settings.js +49 -0
- package/dist/contract/settings.js.map +1 -0
- package/dist/server/index.d.ts +8 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +288 -8
- package/dist/server/index.js.map +1 -1
- package/dist/server/jobs.d.ts +48 -0
- package/dist/server/jobs.d.ts.map +1 -0
- package/dist/server/jobs.js +358 -0
- package/dist/server/jobs.js.map +1 -0
- package/dist/server/router.d.ts +1789 -0
- package/dist/server/router.d.ts.map +1 -0
- package/dist/server/router.js +439 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/schema.d.ts +156 -9
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +183 -11
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +139 -0
- package/dist/server/services/assets.d.ts.map +1 -0
- package/dist/server/services/assets.js +429 -0
- package/dist/server/services/assets.js.map +1 -0
- package/dist/server/services/attachments.d.ts +80 -0
- package/dist/server/services/attachments.d.ts.map +1 -0
- package/dist/server/services/attachments.js +182 -0
- package/dist/server/services/attachments.js.map +1 -0
- package/dist/server/services/audience.d.ts +15 -0
- package/dist/server/services/audience.d.ts.map +1 -0
- package/dist/server/services/audience.js +64 -0
- package/dist/server/services/audience.js.map +1 -0
- package/dist/server/services/categories.d.ts +57 -0
- package/dist/server/services/categories.d.ts.map +1 -0
- package/dist/server/services/categories.js +124 -0
- package/dist/server/services/categories.js.map +1 -0
- package/dist/server/services/cursor.d.ts +68 -0
- package/dist/server/services/cursor.d.ts.map +1 -0
- package/dist/server/services/cursor.js +39 -0
- package/dist/server/services/cursor.js.map +1 -0
- package/dist/server/services/custody.d.ts +175 -0
- package/dist/server/services/custody.d.ts.map +1 -0
- package/dist/server/services/custody.js +367 -0
- package/dist/server/services/custody.js.map +1 -0
- package/dist/server/services/db-errors.d.ts +7 -0
- package/dist/server/services/db-errors.d.ts.map +1 -0
- package/dist/server/services/db-errors.js +32 -0
- package/dist/server/services/db-errors.js.map +1 -0
- package/dist/server/services/index.d.ts +26 -0
- package/dist/server/services/index.d.ts.map +1 -0
- package/dist/server/services/index.js +39 -0
- package/dist/server/services/index.js.map +1 -0
- package/dist/server/services/members.d.ts +27 -0
- package/dist/server/services/members.d.ts.map +1 -0
- package/dist/server/services/members.js +39 -0
- package/dist/server/services/members.js.map +1 -0
- package/dist/server/services/notify.d.ts +105 -0
- package/dist/server/services/notify.d.ts.map +1 -0
- package/dist/server/services/notify.js +147 -0
- package/dist/server/services/notify.js.map +1 -0
- package/dist/server/services/offboarding.d.ts +70 -0
- package/dist/server/services/offboarding.d.ts.map +1 -0
- package/dist/server/services/offboarding.js +116 -0
- package/dist/server/services/offboarding.js.map +1 -0
- package/dist/server/services/repairs.d.ts +204 -0
- package/dist/server/services/repairs.d.ts.map +1 -0
- package/dist/server/services/repairs.js +476 -0
- package/dist/server/services/repairs.js.map +1 -0
- package/dist/server/services/search.d.ts +85 -0
- package/dist/server/services/search.d.ts.map +1 -0
- package/dist/server/services/search.js +142 -0
- package/dist/server/services/search.js.map +1 -0
- package/dist/server/services/stats.d.ts +42 -0
- package/dist/server/services/stats.d.ts.map +1 -0
- package/dist/server/services/stats.js +80 -0
- package/dist/server/services/stats.js.map +1 -0
- package/dist/server/services/status.d.ts +102 -0
- package/dist/server/services/status.d.ts.map +1 -0
- package/dist/server/services/status.js +71 -0
- package/dist/server/services/status.js.map +1 -0
- package/migrations/0000_init.sql +12 -3
- package/migrations/0001_rls.sql +24 -0
- package/migrations/0002_custody_and_categories.sql +23 -0
- package/migrations/0003_repairs.sql +23 -0
- package/migrations/0004_platform_surfaces.sql +51 -0
- package/migrations/0005_repair_dates.sql +35 -0
- package/migrations/0006_workspace_registry_read.sql +50 -0
- package/migrations/0007_history_sequence.sql +83 -0
- package/migrations/meta/0000_snapshot.json +40 -13
- package/migrations/meta/0002_snapshot.json +1054 -0
- package/migrations/meta/0003_snapshot.json +1070 -0
- package/migrations/meta/0004_snapshot.json +1130 -0
- package/migrations/meta/0005_snapshot.json +1135 -0
- package/migrations/meta/_journal.json +44 -2
- package/package.json +5 -4
- package/src/client/api-instance.ts +27 -2
- package/src/client/api.ts +1 -1
- package/src/client/bidi.test.ts +148 -0
- package/src/client/bidi.ts +85 -0
- package/src/client/components/AssetDetailPanel.svelte +614 -0
- package/src/client/components/AssetFormDialog.svelte +191 -59
- package/src/client/components/AssetPhoto.svelte +178 -0
- package/src/client/components/AttachmentsSection.svelte +327 -0
- package/src/client/components/CustodyDialog.svelte +201 -0
- package/src/client/components/RepairDialog.svelte +271 -0
- package/src/client/components/RepairsSection.svelte +318 -0
- package/src/client/components/Timeline.svelte +347 -0
- package/src/client/components/TimelineText.svelte +124 -0
- package/src/client/core-api.ts +71 -0
- package/src/client/custody.test.ts +31 -0
- package/src/client/custody.ts +34 -0
- package/src/client/errors.test.ts +365 -0
- package/src/client/errors.ts +201 -0
- package/src/client/i18n.ts +11 -166
- package/src/client/index.ts +8 -1
- package/src/client/links.test.ts +74 -0
- package/src/client/links.ts +44 -0
- package/src/client/members.test.ts +132 -0
- package/src/client/members.ts +116 -0
- package/src/client/messages.test.ts +296 -0
- package/src/client/messages.ts +1424 -0
- package/src/client/mock.test.ts +555 -0
- package/src/client/mock.ts +1261 -52
- package/src/client/module.ts +76 -2
- package/src/client/pages/AssetsPage.svelte +638 -145
- package/src/client/permissions.ts +8 -1
- package/src/client/price.test.ts +254 -0
- package/src/client/price.ts +279 -0
- package/src/client/query.test.ts +58 -0
- package/src/client/query.ts +51 -2
- package/src/client/repairs.test.ts +38 -0
- package/src/client/repairs.ts +38 -0
- package/src/client/settings/CategoriesSettings.svelte +421 -0
- package/src/client/settings/GeneralSettings.svelte +403 -0
- package/src/client/status.ts +29 -0
- package/src/client/timeline.test.ts +175 -0
- package/src/client/timeline.ts +206 -0
- package/src/client/widgets/OverviewWidget.svelte +140 -26
- package/src/client/widgets/RepairsWidget.svelte +124 -0
- package/src/contract/capabilities.ts +99 -0
- package/src/contract/events.ts +83 -0
- package/src/contract/index.ts +16 -0
- package/src/contract/models.ts +360 -0
- package/src/contract/notifications.ts +73 -0
- package/src/contract/permissions.ts +79 -0
- package/src/contract/router.ts +300 -0
- package/src/contract/settings.ts +50 -0
- package/src/module.test.ts +330 -7
- package/src/server/index.ts +318 -8
- package/src/server/inventory.int.test.ts +4374 -0
- package/src/server/jobs.ts +444 -0
- package/src/server/migrations.test.ts +251 -0
- package/src/server/router.ts +574 -0
- package/src/server/schema.ts +184 -10
- package/src/server/services/assets.ts +528 -0
- package/src/server/services/attachments.ts +215 -0
- package/src/server/services/audience.ts +77 -0
- package/src/server/services/categories.ts +136 -0
- package/src/server/services/cursor.ts +104 -0
- package/src/server/services/custody.ts +471 -0
- package/src/server/services/db-errors.ts +42 -0
- package/src/server/services/index.ts +56 -0
- package/src/server/services/members.ts +54 -0
- package/src/server/services/notify.ts +196 -0
- package/src/server/services/offboarding.ts +150 -0
- package/src/server/services/repairs.ts +567 -0
- package/src/server/services/search.ts +166 -0
- package/src/server/services/stats.ts +88 -0
- package/src/server/services/status.test.ts +34 -0
- package/src/server/services/status.ts +143 -0
- package/tsconfig.base.json +22 -0
- package/tsconfig.client.json +1 -1
- package/tsconfig.json +1 -1
- package/vitest.config.ts +18 -3
- package/dist/contract.d.ts +0 -387
- package/dist/contract.d.ts.map +0 -1
- package/dist/contract.js +0 -119
- package/dist/contract.js.map +0 -1
- package/dist/server/_impl.d.ts +0 -427
- package/dist/server/_impl.d.ts.map +0 -1
- package/dist/server/_impl.js +0 -204
- package/dist/server/_impl.js.map +0 -1
- package/src/contract.ts +0 -143
- 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
|
+
}
|