@kernhq/module-inventory 0.3.0 → 0.4.1
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 +7 -5
- package/dist/contract/models.d.ts +24 -1
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +35 -3
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/router.d.ts +58 -2
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +29 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/server/router.d.ts +64 -393
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +23 -1
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +11 -0
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/categories.d.ts +110 -10
- package/dist/server/services/categories.d.ts.map +1 -1
- package/dist/server/services/categories.js +198 -13
- package/dist/server/services/categories.js.map +1 -1
- package/migrations/0008_category_order_unique.sql +71 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +4 -3
- package/src/client/errors.test.ts +30 -0
- package/src/client/errors.ts +31 -3
- package/src/client/messages.ts +82 -19
- package/src/client/mock.test.ts +71 -1
- package/src/client/mock.ts +51 -12
- package/src/client/module.ts +19 -1
- package/src/client/reorder.test.ts +100 -0
- package/src/client/reorder.ts +79 -0
- package/src/client/sequence.test.ts +248 -0
- package/src/client/sequence.ts +185 -0
- package/src/client/settings/CategoriesSettings.svelte +430 -105
- package/src/contract/models.ts +36 -3
- package/src/contract/router.ts +29 -0
- package/src/module.test.ts +23 -0
- package/src/server/inventory.int.test.ts +545 -10
- package/src/server/migrations.test.ts +140 -2
- package/src/server/router.ts +25 -1
- package/src/server/schema.ts +11 -0
- package/src/server/services/categories.ts +221 -20
package/src/contract/models.ts
CHANGED
|
@@ -51,8 +51,17 @@ export const Category = z.object({
|
|
|
51
51
|
id: z.uuid(),
|
|
52
52
|
workspaceId: WorkspaceId,
|
|
53
53
|
name: z.string().min(1).max(120),
|
|
54
|
-
/**
|
|
55
|
-
*
|
|
54
|
+
/**
|
|
55
|
+
* Where it sits in the sequence. **Storage, not a setting.**
|
|
56
|
+
*
|
|
57
|
+
* Nobody thinks about their categories as integers, so no screen shows this number and no input
|
|
58
|
+
* accepts one: `categories.reorder` takes the ids in the order somebody dragged them into and
|
|
59
|
+
* renumbers the live ones `0…n-1` in one transaction. `create` and a restore append, so two live
|
|
60
|
+
* categories never share a value — which is the state a "position" field invited on every save.
|
|
61
|
+
*
|
|
62
|
+
* The name is still the tiebreak in `list`, because a workspace seeded before this existed, or a
|
|
63
|
+
* row written by hand, can still hold a duplicate; it just stops being the ordinary case.
|
|
64
|
+
*/
|
|
56
65
|
order: z.number().int(),
|
|
57
66
|
createdAt: z.string(),
|
|
58
67
|
updatedAt: z.string(),
|
|
@@ -60,12 +69,36 @@ export const Category = z.object({
|
|
|
60
69
|
})
|
|
61
70
|
export type Category = z.infer<typeof Category>
|
|
62
71
|
|
|
72
|
+
/**
|
|
73
|
+
* What a person types when they add or rename a category: a name, and nothing else.
|
|
74
|
+
*
|
|
75
|
+
* `order` used to be here, optional, and it was the only way to move a category — a number field on
|
|
76
|
+
* a settings form, with a hint explaining that lower comes first and that ties fall back to names.
|
|
77
|
+
* The sequence is written by `categories.reorder` now, so an `order` on create or update would be a
|
|
78
|
+
* second way to set the same thing, disagreeing with the first the moment anybody used it.
|
|
79
|
+
*/
|
|
63
80
|
export const CategoryInput = z.object({
|
|
64
81
|
name: z.string().trim().min(1).max(120),
|
|
65
|
-
order: z.number().int().min(0).max(9999).optional(),
|
|
66
82
|
})
|
|
67
83
|
export type CategoryInput = z.infer<typeof CategoryInput>
|
|
68
84
|
|
|
85
|
+
/**
|
|
86
|
+
* How many categories one workspace may have live at once — and the reason it is a *stated* limit.
|
|
87
|
+
*
|
|
88
|
+
* `categories.reorder` has to name every live category exactly once, so its input array needs a
|
|
89
|
+
* bound; every zod array that a client fills does, or a single request can ask the server to hold an
|
|
90
|
+
* arbitrary list in memory. A bound on that array alone is a **silent ceiling**: a workspace with
|
|
91
|
+
* more live categories than the number could still create, rename and archive them, and only
|
|
92
|
+
* reordering would fail — the one procedure with no other way to do the job.
|
|
93
|
+
*
|
|
94
|
+
* So the same number is enforced where somebody meets it. `categories.create` and a restore both
|
|
95
|
+
* refuse with `inventory.category.limit_reached` once a workspace holds this many live categories,
|
|
96
|
+
* which is a sentence naming the number at the moment it matters. Archiving one frees a place, which
|
|
97
|
+
* is why the limit counts the **live** rows rather than every row ever made: the advice the refusal
|
|
98
|
+
* gives has to be true.
|
|
99
|
+
*/
|
|
100
|
+
export const MAX_LIVE_CATEGORIES = 500
|
|
101
|
+
|
|
69
102
|
/**
|
|
70
103
|
* One stretch of time during which one person held one asset.
|
|
71
104
|
*
|
package/src/contract/router.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
CustodyPeriod,
|
|
14
14
|
CustodyResult,
|
|
15
15
|
InventoryStats,
|
|
16
|
+
MAX_LIVE_CATEGORIES,
|
|
16
17
|
RepairInput,
|
|
17
18
|
RepairListItem,
|
|
18
19
|
RepairPatchInput,
|
|
@@ -163,6 +164,34 @@ export const inventoryContract = {
|
|
|
163
164
|
.route({ method: 'POST', path: '/categories/{categoryId}/archive', tags: t })
|
|
164
165
|
.input(ws.extend({ categoryId: z.uuid(), archived: z.boolean().default(true) }))
|
|
165
166
|
.output(Category),
|
|
167
|
+
/**
|
|
168
|
+
* The whole sequence, in the order somebody put it in — the only thing that writes `order`.
|
|
169
|
+
*
|
|
170
|
+
* **The ids, not positions.** A position number is a database column with a form around it: two
|
|
171
|
+
* categories can hold the same one, nobody thinks about their filing as integers, and the screen
|
|
172
|
+
* that asked for one had to explain how ties break. A list of ids says exactly what the person
|
|
173
|
+
* did, whatever they did it with — a drag, or the move-up and move-down buttons beside it.
|
|
174
|
+
*
|
|
175
|
+
* **It must name every live category the workspace has, exactly once.** A partial list is not
|
|
176
|
+
* treated as "leave the rest alone": somebody added a category in another tab while this page
|
|
177
|
+
* was open, and the ordering in hand no longer describes the workspace. Renumbering what it does
|
|
178
|
+
* name would drop the new one somewhere nobody chose, silently — so the whole call is refused
|
|
179
|
+
* with `inventory.category.order_stale` and the page reloads and asks again. Naming an archived
|
|
180
|
+
* category is the same mistake from the other side and gets the same answer; an id from another
|
|
181
|
+
* workspace is `NOT_FOUND`, exactly as `update` and `archive` answer for one.
|
|
182
|
+
*
|
|
183
|
+
* Answers the live sequence as it now stands, so a caller needs no second read.
|
|
184
|
+
*
|
|
185
|
+
* **The bound is `MAX_LIVE_CATEGORIES`, and it is the same number `create` refuses at.** A bound
|
|
186
|
+
* on this array with nothing enforcing it elsewhere is a silent ceiling: a workspace could grow
|
|
187
|
+
* past it one category at a time and then find that the only procedure that can order them is
|
|
188
|
+
* the one it can no longer call. Held to the same number at the point of creation, the array can
|
|
189
|
+
* always name every live category a workspace is allowed to have.
|
|
190
|
+
*/
|
|
191
|
+
reorder: baseContract
|
|
192
|
+
.route({ method: 'POST', path: '/categories/reorder', tags: t })
|
|
193
|
+
.input(ws.extend({ categoryIds: z.array(z.uuid()).min(1).max(MAX_LIVE_CATEGORIES) }))
|
|
194
|
+
.output(z.array(Category)),
|
|
166
195
|
},
|
|
167
196
|
|
|
168
197
|
/**
|
package/src/module.test.ts
CHANGED
|
@@ -399,4 +399,27 @@ describe('the platform surfaces the module declares', () => {
|
|
|
399
399
|
for (const { type } of inventoryModule.definition.notificationTypes ?? [])
|
|
400
400
|
expect({ type, sent: sources.includes(`'${type}'`) }).toEqual({ type, sent: true })
|
|
401
401
|
})
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The client manifest's `name` is read on screen, so it has to be a getter over `t`.
|
|
405
|
+
*
|
|
406
|
+
* The dashboard's widget picker heads this module's group with `mod.name` directly, and the shell's
|
|
407
|
+
* settings rail falls back to it for a module whose navigation it cannot read — so an English
|
|
408
|
+
* literal there is a Latin word sitting in an otherwise Persian panel. `name` is typed as a plain
|
|
409
|
+
* `string` on `ClientModule`, which is exactly why nothing else catches this: a literal and a
|
|
410
|
+
* getter are the same type.
|
|
411
|
+
*
|
|
412
|
+
* Read as text rather than imported, and honest about being crude. `src/client/module.ts` reaches
|
|
413
|
+
* `@kernhq/ui`, which drags a Svelte compiler into whatever imports it, and this package's vitest
|
|
414
|
+
* runs plain Node — the same reason `errors.ts` and `messages.ts` are kept importable and the
|
|
415
|
+
* components are not. It proves the shape, not the rendering.
|
|
416
|
+
*/
|
|
417
|
+
it('gives the client manifest a translated name rather than an English literal', () => {
|
|
418
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
419
|
+
const source = readFileSync(join(here, 'client', 'module.ts'), 'utf8')
|
|
420
|
+
expect(source, 'a getter, so the language is the one on screen at read time').toMatch(
|
|
421
|
+
/get name\(\)\s*\{\s*return t\('nav'\)/,
|
|
422
|
+
)
|
|
423
|
+
expect(source, 'and no literal left beside it').not.toMatch(/^\s*name: '/m)
|
|
424
|
+
})
|
|
402
425
|
})
|