@kernhq/module-inventory 0.3.0 → 0.4.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 (42) hide show
  1. package/README.md +7 -5
  2. package/dist/contract/models.d.ts +24 -1
  3. package/dist/contract/models.d.ts.map +1 -1
  4. package/dist/contract/models.js +35 -3
  5. package/dist/contract/models.js.map +1 -1
  6. package/dist/contract/router.d.ts +58 -2
  7. package/dist/contract/router.d.ts.map +1 -1
  8. package/dist/contract/router.js +29 -1
  9. package/dist/contract/router.js.map +1 -1
  10. package/dist/server/router.d.ts +57 -2
  11. package/dist/server/router.d.ts.map +1 -1
  12. package/dist/server/router.js +23 -1
  13. package/dist/server/router.js.map +1 -1
  14. package/dist/server/schema.d.ts.map +1 -1
  15. package/dist/server/schema.js +11 -0
  16. package/dist/server/schema.js.map +1 -1
  17. package/dist/server/services/categories.d.ts +110 -10
  18. package/dist/server/services/categories.d.ts.map +1 -1
  19. package/dist/server/services/categories.js +198 -13
  20. package/dist/server/services/categories.js.map +1 -1
  21. package/migrations/0008_category_order_unique.sql +71 -0
  22. package/migrations/meta/_journal.json +7 -0
  23. package/package.json +2 -1
  24. package/src/client/errors.test.ts +30 -0
  25. package/src/client/errors.ts +31 -3
  26. package/src/client/messages.ts +82 -19
  27. package/src/client/mock.test.ts +71 -1
  28. package/src/client/mock.ts +51 -12
  29. package/src/client/module.ts +19 -1
  30. package/src/client/reorder.test.ts +100 -0
  31. package/src/client/reorder.ts +79 -0
  32. package/src/client/sequence.test.ts +248 -0
  33. package/src/client/sequence.ts +185 -0
  34. package/src/client/settings/CategoriesSettings.svelte +430 -105
  35. package/src/contract/models.ts +36 -3
  36. package/src/contract/router.ts +29 -0
  37. package/src/module.test.ts +23 -0
  38. package/src/server/inventory.int.test.ts +545 -10
  39. package/src/server/migrations.test.ts +140 -2
  40. package/src/server/router.ts +25 -1
  41. package/src/server/schema.ts +11 -0
  42. package/src/server/services/categories.ts +221 -20
@@ -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
- /** Where it sits in a picker. Equal orders fall back to the name, so a workspace that never
55
- * reorders anything still gets an alphabetical list rather than an arbitrary one. */
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
  *
@@ -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
  /**
@@ -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
  })