@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.
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 +64 -393
  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 +4 -3
  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
@@ -14,11 +14,19 @@ import { drizzle } from 'drizzle-orm/node-postgres'
14
14
  import pg from 'pg'
15
15
  import { afterAll, beforeAll, describe, expect, it } from 'vitest'
16
16
  import { z } from 'zod'
17
- import type { Asset } from '../contract/models.js'
17
+ import { type Asset, MAX_LIVE_CATEGORIES } from '../contract/models.js'
18
18
  import { inventoryModule } from './index.js'
19
19
  import { activeWorkspaces, reconcileStatuses } from './jobs.js'
20
20
  import { inventoryRouter } from './router.js'
21
- import { assetHistory, assets, custodyPeriods, repairs, TENANT_TABLES, workspaces } from './schema.js'
21
+ import {
22
+ assetHistory,
23
+ assets,
24
+ categories as categoriesTable,
25
+ custodyPeriods,
26
+ repairs,
27
+ TENANT_TABLES,
28
+ workspaces,
29
+ } from './schema.js'
22
30
  import { inventoryServices } from './services/index.js'
23
31
 
24
32
  /**
@@ -52,6 +60,19 @@ const WS_PAGE = workspace()
52
60
  const WS_FILTER = workspace()
53
61
  const WS_CUSTODY = workspace()
54
62
  const WS_CAT = workspace()
63
+ /**
64
+ * Its own, because `reorder` insists on being handed **every** live category the workspace has —
65
+ * so a test sharing a workspace with the block above would be ordering whatever that block happened
66
+ * to have created by then, and would break the first time somebody added a case to it.
67
+ */
68
+ const WS_ORDER = workspace()
69
+ /**
70
+ * Its own, because the point of it is two transactions appending to the same list at the same
71
+ * moment — and a workspace shared with `WS_ORDER` would have its sequence rewritten underneath it.
72
+ */
73
+ const WS_UNIQUE = workspace()
74
+ /** Its own, because it fills up: every other block's `create` would be refused inside it. */
75
+ const WS_LIMIT = workspace()
55
76
  const WS_REPAIR = workspace()
56
77
  const WS_FILES = workspace()
57
78
  const WS_STATS = workspace()
@@ -436,6 +457,26 @@ function messageOf(err: unknown): string {
436
457
  return err instanceof Error ? err.message : String(err)
437
458
  }
438
459
 
460
+ /**
461
+ * The stable token a refusal carried, which is the half a *client* reads.
462
+ *
463
+ * The message is prose that changes when somebody rewords it; the reason is what
464
+ * `src/client/errors.ts` branches on to show a translated sentence instead of the server's English.
465
+ * Asserting on it is how a test proves a Persian reader gets Persian. Reached through `cause` and
466
+ * out of `data` for the same reasons `codeOf` is: `kernErrorToORPC` folds it into `data`, while a
467
+ * `KernError` thrown in-process carries it on itself.
468
+ */
469
+ function reasonOf(err: unknown): string | null {
470
+ let cursor: unknown = err
471
+ for (let depth = 0; depth < 5 && cursor; depth++) {
472
+ const found =
473
+ (cursor as { data?: { reason?: unknown } }).data?.reason ?? (cursor as { reason?: unknown }).reason
474
+ if (typeof found === 'string') return found
475
+ cursor = (cursor as { cause?: unknown }).cause
476
+ }
477
+ return null
478
+ }
479
+
439
480
  async function refusedWith(fn: () => Promise<unknown>): Promise<string> {
440
481
  try {
441
482
  await fn()
@@ -447,6 +488,19 @@ async function refusedWith(fn: () => Promise<unknown>): Promise<string> {
447
488
  throw new Error('Expected the call to be refused, but it succeeded')
448
489
  }
449
490
 
491
+ /**
492
+ * The refusal itself, for the cases that assert more than its code — its reason token, or the
493
+ * sentence the server wrote. A call that succeeds is the failure, and says so.
494
+ */
495
+ async function capture(fn: () => Promise<unknown>): Promise<unknown> {
496
+ try {
497
+ await fn()
498
+ } catch (err) {
499
+ return err
500
+ }
501
+ throw new Error('Expected the call to be refused, but it succeeded')
502
+ }
503
+
450
504
  /**
451
505
  * Wait until Postgres says a backend on this database is blocked on a lock.
452
506
  *
@@ -1982,18 +2036,26 @@ describe('reading an asset’s timeline', () => {
1982
2036
  describe('categories', () => {
1983
2037
  // Lazy, for the reason spelled out above: a describe body runs before `beforeAll`.
1984
2038
  const ctx = () => ({ context: asUser(ALICE, WS_CAT) })
1985
- const create = (name: string, order?: number) =>
1986
- call(inv.categories.create, { workspaceId: WS_CAT, name, order }, ctx())
2039
+ const create = (name: string) => call(inv.categories.create, { workspaceId: WS_CAT, name }, ctx())
1987
2040
  const list = (archived = false) => call(inv.categories.list, { workspaceId: WS_CAT, archived }, ctx())
1988
2041
 
1989
- it('orders by position and then by name, so a workspace that never reorders still gets a list', async () => {
1990
- // Every row has order 0 unless somebody says otherwise, so the tiebreak is doing all the work
1991
- // and it has to be the name rather than the id, or the picker is in insertion order.
2042
+ /**
2043
+ * Every new category joins the end, so the list reads back in the order somebody typed it.
2044
+ *
2045
+ * It used to take an optional `order` that defaulted to **0**, which meant every category anybody
2046
+ * added landed at the front tied with everything else, and `list`'s name tiebreak decided where —
2047
+ * so adding "Consumables" to an arranged list dropped it between "Cameras" and "Furniture". The
2048
+ * position is the statement's own business now (`coalesce(max(order), -1) + 1`), and no two live
2049
+ * categories share one.
2050
+ */
2051
+ it('appends each new category, so the list reads back in the order it was typed', async () => {
1992
2052
  await create('Furniture')
1993
2053
  await create('Cameras')
1994
- await create('Laptops', 0)
1995
- await create('Consumables', 5)
1996
- expect((await list()).map((c) => c.name)).toEqual(['Cameras', 'Furniture', 'Laptops', 'Consumables'])
2054
+ await create('Laptops')
2055
+ await create('Consumables')
2056
+ const rows = await list()
2057
+ expect(rows.map((c) => c.name)).toEqual(['Furniture', 'Cameras', 'Laptops', 'Consumables'])
2058
+ expect(new Set(rows.map((c) => c.order)).size, 'and no two of them share a place').toBe(rows.length)
1997
2059
  })
1998
2060
 
1999
2061
  it('refuses a duplicate name with a sentence naming it, not a 500', async () => {
@@ -2113,6 +2175,479 @@ describe('categories', () => {
2113
2175
  })
2114
2176
  })
2115
2177
 
2178
+ /**
2179
+ * Putting the categories in order — the one procedure that writes `order`, and the four ways it
2180
+ * refuses.
2181
+ *
2182
+ * The settings page used to ask an administrator to type a **position number**, with a hint
2183
+ * explaining that lower comes first and that two categories sharing a number fall back to their
2184
+ * names. It is a list somebody drags now, and a drag produces a sequence of ids rather than an
2185
+ * arithmetic problem — so the contract takes the ids and the server renumbers the live set `0…n-1`
2186
+ * inside one transaction.
2187
+ *
2188
+ * Every refusal below exists because the alternative is a silent wrong answer. A list that leaves a
2189
+ * category out is not an instruction to leave it alone: it is an ordering of a workspace that no
2190
+ * longer exists, and completing it would drop the missing category wherever its stale number
2191
+ * happened to land, without telling anybody. The client's answer to the conflict is to reload and
2192
+ * ask again, which is why the reason token is asserted and not just the code — that token is the
2193
+ * only part of a refusal a Persian reader ever sees translated.
2194
+ */
2195
+ describe('putting the categories in order', () => {
2196
+ const ctx = () => ({ context: asUser(ALICE, WS_ORDER) })
2197
+ const create = (name: string) => call(inv.categories.create, { workspaceId: WS_ORDER, name }, ctx())
2198
+ const list = (archived = false) => call(inv.categories.list, { workspaceId: WS_ORDER, archived }, ctx())
2199
+ const reorder = (categoryIds: string[]) =>
2200
+ call(inv.categories.reorder, { workspaceId: WS_ORDER, categoryIds }, ctx())
2201
+ const names = async () => (await list()).map((c) => c.name)
2202
+
2203
+ it('renumbers the whole sequence from the ids it was handed', async () => {
2204
+ await create('Desks')
2205
+ await create('Chairs')
2206
+ await create('Lamps')
2207
+ expect(await names(), 'appended, so this is the order they were typed in').toEqual([
2208
+ 'Desks',
2209
+ 'Chairs',
2210
+ 'Lamps',
2211
+ ])
2212
+
2213
+ const ids = (await list()).map((c) => c.id)
2214
+ const answered = await reorder([ids[2] as string, ids[0] as string, ids[1] as string])
2215
+ expect(answered.map((c) => c.name)).toEqual(['Lamps', 'Desks', 'Chairs'])
2216
+ expect(
2217
+ answered.map((c) => c.order),
2218
+ 'contiguous from zero, with no ties left behind',
2219
+ ).toEqual([0, 1, 2])
2220
+ expect(await names(), 'and the next read agrees').toEqual(['Lamps', 'Desks', 'Chairs'])
2221
+ })
2222
+
2223
+ it('refuses a list naming a category from another workspace, and writes nothing', async () => {
2224
+ const theirs = await call(
2225
+ inv.categories.create,
2226
+ { workspaceId: WS_B, name: 'Not ours to order' },
2227
+ { context: asUser(BOB, WS_B) },
2228
+ )
2229
+ const ids = (await list()).map((c) => c.id)
2230
+ // Permuted as well as poisoned: if the renumbering ran before the check, the order would move.
2231
+ expect(
2232
+ await refusedWith(() => reorder([ids[1] as string, ids[0] as string, ids[2] as string, theirs.id])),
2233
+ 'a category this workspace does not have is missing, not forbidden',
2234
+ ).toBe('NOT_FOUND')
2235
+ expect(await names(), 'nothing was written').toEqual(['Lamps', 'Desks', 'Chairs'])
2236
+
2237
+ // And the other workspace's own category was not renumbered on the way past.
2238
+ const theirsAfter = await call(
2239
+ inv.categories.list,
2240
+ { workspaceId: WS_B, archived: true },
2241
+ { context: asUser(BOB, WS_B) },
2242
+ )
2243
+ expect(theirsAfter.find((c) => c.id === theirs.id)?.order).toBe(theirs.order)
2244
+ })
2245
+
2246
+ it('refuses a list that leaves out a category added while the page was open', async () => {
2247
+ const ids = (await list()).map((c) => c.id)
2248
+ // The other tab.
2249
+ await create('Shelves')
2250
+
2251
+ const attempt = () => reorder([ids[1] as string, ids[0] as string, ids[2] as string])
2252
+ expect(await refusedWith(attempt)).toBe('CONFLICT')
2253
+ await expect(attempt()).rejects.toSatisfy(
2254
+ (err: unknown) => reasonOf(err) === 'inventory.category.order_stale',
2255
+ )
2256
+ expect(
2257
+ await names(),
2258
+ 'refused whole: renumbering three of four would have moved Shelves nowhere anybody chose',
2259
+ ).toEqual(['Lamps', 'Desks', 'Chairs', 'Shelves'])
2260
+ })
2261
+
2262
+ it('refuses a list naming a category archived while the page was open', async () => {
2263
+ const ids = (await list()).map((c) => c.id)
2264
+ const shelves = ids[3] as string
2265
+ await call(inv.categories.archive, { workspaceId: WS_ORDER, categoryId: shelves, archived: true }, ctx())
2266
+
2267
+ await expect(reorder([...ids].reverse())).rejects.toSatisfy(
2268
+ (err: unknown) => codeOf(err) === 'CONFLICT' && reasonOf(err) === 'inventory.category.order_stale',
2269
+ )
2270
+ expect(await names()).toEqual(['Lamps', 'Desks', 'Chairs'])
2271
+
2272
+ // Restored, it joins the end rather than landing back on the number it left with — which, after
2273
+ // three renumberings, belongs to somebody else.
2274
+ await call(inv.categories.archive, { workspaceId: WS_ORDER, categoryId: shelves, archived: false }, ctx())
2275
+ const back = await list()
2276
+ expect(back.map((c) => c.name)).toEqual(['Lamps', 'Desks', 'Chairs', 'Shelves'])
2277
+ expect(new Set(back.map((c) => c.order)).size).toBe(back.length)
2278
+ })
2279
+
2280
+ it('refuses a list that names the same category twice', async () => {
2281
+ const ids = (await list()).map((c) => c.id)
2282
+ expect(await refusedWith(() => reorder([ids[0] as string, ...ids]))).toBe('BAD_REQUEST')
2283
+ expect(await names()).toEqual(['Lamps', 'Desks', 'Chairs', 'Shelves'])
2284
+ })
2285
+
2286
+ it('announces the rows that moved, and stays silent about the ones that did not', async () => {
2287
+ const ids = (await list()).map((c) => c.id)
2288
+ const swapped = [ids[0] as string, ids[1] as string, ids[3] as string, ids[2] as string]
2289
+
2290
+ CHANGES.length = 0
2291
+ await reorder(swapped)
2292
+ expect(new Set(CHANGES.map((c) => c.entity)), 'a category changed, not an asset').toEqual(
2293
+ new Set(['category']),
2294
+ )
2295
+ expect(
2296
+ CHANGES.map((c) => c.id).sort(),
2297
+ 'only the two that swapped — telling every open screen about the other two would describe a write nobody performed',
2298
+ ).toEqual([ids[2] as string, ids[3] as string].sort())
2299
+
2300
+ CHANGES.length = 0
2301
+ await reorder(swapped)
2302
+ expect(CHANGES, 'the same order again moves nothing and announces nothing').toEqual([])
2303
+ })
2304
+
2305
+ /**
2306
+ * Two reorders arriving at once, driven through two transactions held open rather than raced for.
2307
+ *
2308
+ * A `Promise.all` on one event loop usually serialises on a laptop and proves nothing; this is the
2309
+ * interleaving a busy instance produces. B is only released once Postgres says it is *waiting on a
2310
+ * lock*, which is the honest signal that its `select … for update` has queued behind A rather than
2311
+ * having run before it.
2312
+ *
2313
+ * The `for update` is the whole subject. Without it both transactions read the same list, both
2314
+ * pass the completeness check, and their per-row updates land interleaved — leaving a sequence
2315
+ * that is neither of the two orders anybody asked for, with two categories on the same number.
2316
+ * With it, the second one waits, re-reads what the first committed and writes its own ordering on
2317
+ * top: last in wins, whole.
2318
+ */
2319
+ it('lets two reorders arriving at once settle one after the other, never half of each', async () => {
2320
+ const svc = inventoryServices(kernel)
2321
+ const ids = (await list()).map((c) => c.id)
2322
+ const first = [...ids].reverse()
2323
+ const second = [ids[2] as string, ids[0] as string, ids[3] as string, ids[1] as string]
2324
+
2325
+ let aHasLocked!: () => void
2326
+ const locked = new Promise<void>((resolve) => {
2327
+ aHasLocked = resolve
2328
+ })
2329
+ let commitA!: () => void
2330
+ const holdA = new Promise<void>((resolve) => {
2331
+ commitA = resolve
2332
+ })
2333
+
2334
+ const a = kernel.database.withWorkspace(
2335
+ WS_ORDER,
2336
+ async (tx) => {
2337
+ await svc.categories.reorder(tx, WS_ORDER, first)
2338
+ aHasLocked()
2339
+ await holdA
2340
+ },
2341
+ { userId: ALICE },
2342
+ )
2343
+ await locked
2344
+
2345
+ const b = kernel.database.withWorkspace(WS_ORDER, (tx) => svc.categories.reorder(tx, WS_ORDER, second), {
2346
+ userId: BOB,
2347
+ })
2348
+ // Attached now, so a rejection is never an unhandled one while the poll below runs.
2349
+ const bSettled = b.then(
2350
+ () => null,
2351
+ (err: unknown) => err,
2352
+ )
2353
+
2354
+ await waitForBlockedBackend()
2355
+ commitA()
2356
+ await a
2357
+ expect(await bSettled, 'both orderings are valid; the second simply waits its turn').toBeNull()
2358
+
2359
+ const after = await list()
2360
+ expect(
2361
+ after.map((c) => c.id),
2362
+ 'the last one in wins, and wins whole',
2363
+ ).toEqual(second)
2364
+ expect(after.map((c) => c.order)).toEqual([0, 1, 2, 3])
2365
+ })
2366
+ })
2367
+
2368
+ /**
2369
+ * The claim that no two live categories share a place, held to by the database rather than by care.
2370
+ *
2371
+ * The contract said it, the changeset said it and `list`'s own comment said it — and all three were
2372
+ * describing an intention. A category joins the end of the list by taking
2373
+ * `(select coalesce(max("order"), -1) + 1)`, and a restore appends the same way; putting that
2374
+ * subquery *inside* the write removes a round trip and removes no race at all. Under READ COMMITTED
2375
+ * each statement takes its own snapshot, so two transactions appending at the same instant both read
2376
+ * a list without the other's row in it and both take the same number. `reorder`'s `select … for
2377
+ * update` serialises neither of them: a row lock cannot cover a row that does not exist yet.
2378
+ *
2379
+ * Measured before the fix, against this exact block: three live categories on two distinct numbers
2380
+ * after two creates, and five on three after a create raced a restore.
2381
+ *
2382
+ * Two things make the sentence true now, and they answer different questions. The **advisory lock**
2383
+ * per workspace is what makes the ordinary append correct — the second caller waits, re-reads and
2384
+ * takes the next number, so nobody is refused for pressing a button at an unlucky moment. The
2385
+ * **partial unique index** is what makes the claim hold whatever else ever reaches the table.
2386
+ */
2387
+ describe('two live categories can never share a place', () => {
2388
+ const ctx = () => ({ context: asUser(ALICE, WS_UNIQUE) })
2389
+ const create = (name: string) => call(inv.categories.create, { workspaceId: WS_UNIQUE, name }, ctx())
2390
+ const live = () => call(inv.categories.list, { workspaceId: WS_UNIQUE, archived: false }, ctx())
2391
+
2392
+ /**
2393
+ * Two appends driven through two transactions held open, rather than raced for.
2394
+ *
2395
+ * A `Promise.all` on one event loop usually serialises on a laptop and proves nothing. B is only
2396
+ * released once Postgres says a backend is *waiting on a lock*, which is the honest signal that it
2397
+ * has queued behind A rather than having run before it — the same shape as the two-reorder test
2398
+ * above, and the reason this cannot pass by luck.
2399
+ */
2400
+ async function bothAppendAtOnce(firstName: string, second: (tx: Tx) => Promise<unknown>) {
2401
+ const svc = inventoryServices(kernel)
2402
+ let aHasWritten!: () => void
2403
+ const written = new Promise<void>((resolve) => {
2404
+ aHasWritten = resolve
2405
+ })
2406
+ let commitA!: () => void
2407
+ const holdA = new Promise<void>((resolve) => {
2408
+ commitA = resolve
2409
+ })
2410
+
2411
+ const a = kernel.database.withWorkspace(
2412
+ WS_UNIQUE,
2413
+ async (tx) => {
2414
+ const row = await svc.categories.create(tx, WS_UNIQUE, firstName)
2415
+ aHasWritten()
2416
+ await holdA
2417
+ return row
2418
+ },
2419
+ { userId: ALICE },
2420
+ )
2421
+ await written
2422
+
2423
+ const b = kernel.database.withWorkspace(WS_UNIQUE, second, { userId: BOB })
2424
+ // Attached now, so a rejection is never an unhandled one while the poll below runs.
2425
+ const bSettled = b.then(
2426
+ () => null,
2427
+ (err: unknown) => err,
2428
+ )
2429
+
2430
+ await waitForBlockedBackend()
2431
+ commitA()
2432
+ await a
2433
+ return bSettled
2434
+ }
2435
+
2436
+ it('gives two creates that append at the same instant two different places', async () => {
2437
+ await create('Already here')
2438
+ const svc = inventoryServices(kernel)
2439
+
2440
+ const refusal = await bothAppendAtOnce('Raced in first', (tx) =>
2441
+ svc.categories.create(tx, WS_UNIQUE, 'Raced in second'),
2442
+ )
2443
+ expect(refusal, 'the second waits for the first and then appends properly — nobody is refused').toBeNull()
2444
+
2445
+ const rows = await live()
2446
+ expect(new Set(rows.map((c) => c.order)).size, 'every live place is its own').toBe(rows.length)
2447
+ expect(rows.map((c) => c.name)).toEqual(['Already here', 'Raced in first', 'Raced in second'])
2448
+ })
2449
+
2450
+ it('gives a restore racing a create the place after it, not the same one', async () => {
2451
+ const parked = await create('Archived, and coming back')
2452
+ await call(
2453
+ inv.categories.archive,
2454
+ { workspaceId: WS_UNIQUE, categoryId: parked.id, archived: true },
2455
+ ctx(),
2456
+ )
2457
+ const svc = inventoryServices(kernel)
2458
+
2459
+ // A restore appends exactly as a create does, and used to read the same stale maximum.
2460
+ const refusal = await bothAppendAtOnce('Raced past a restore', (tx) =>
2461
+ svc.categories.archive(tx, WS_UNIQUE, parked.id, false),
2462
+ )
2463
+ expect(refusal, 'a restore appends too, and waits its turn the same way').toBeNull()
2464
+
2465
+ const rows = await live()
2466
+ expect(new Set(rows.map((c) => c.order)).size, 'every live place is its own').toBe(rows.length)
2467
+ expect(rows.at(-1)?.name, 'the restore came last, so it is last').toBe('Archived, and coming back')
2468
+ })
2469
+
2470
+ /**
2471
+ * The index itself, asked directly — because the lock above is what keeps the ordinary path off it,
2472
+ * and a guard nothing ever reaches is a guard nobody can tell is missing.
2473
+ */
2474
+ it('refuses two live rows on one number, whatever writes them', async () => {
2475
+ const rows = await live()
2476
+ const first = rows[0]!
2477
+ const second = rows[1]!
2478
+ const name = await constraintViolated(() =>
2479
+ kernel.database.withWorkspace(
2480
+ WS_UNIQUE,
2481
+ (tx) =>
2482
+ tx
2483
+ .update(categoriesTable)
2484
+ .set({ order: first.order })
2485
+ .where(and(eq(categoriesTable.workspaceId, WS_UNIQUE), eq(categoriesTable.id, second.id))),
2486
+ { userId: ALICE },
2487
+ ),
2488
+ )
2489
+ expect(name).toBe('inventory_categories_ws_order_live_uq')
2490
+ })
2491
+
2492
+ /**
2493
+ * And the index is *partial*, which is the half a total unique index would get wrong: an archived
2494
+ * row keeps the number it had when it left, and a live row is renumbered onto it by the very next
2495
+ * reorder. That is not a collision anybody can see — an archived category is in no picker, no
2496
+ * filter and no sequence — so the index must not refuse it.
2497
+ */
2498
+ it('lets an archived category keep a number a live one now holds', async () => {
2499
+ const rows = await live()
2500
+ const first = rows[0]
2501
+ expect(first, 'the block above left at least one live category').toBeDefined()
2502
+ await call(
2503
+ inv.categories.archive,
2504
+ { workspaceId: WS_UNIQUE, categoryId: first!.id, archived: true },
2505
+ ctx(),
2506
+ )
2507
+
2508
+ const remaining = (await live()).map((c) => c.id)
2509
+ await call(inv.categories.reorder, { workspaceId: WS_UNIQUE, categoryIds: remaining }, ctx())
2510
+
2511
+ const all = await call(inv.categories.list, { workspaceId: WS_UNIQUE, archived: true }, ctx())
2512
+ const archivedRow = all.find((c) => c.id === first!.id)
2513
+ expect(archivedRow?.archivedAt, 'still archived').not.toBeNull()
2514
+ const stillLive = all.filter((c) => !c.archivedAt)
2515
+ expect(
2516
+ stillLive.map((c) => c.order),
2517
+ 'renumbered from zero, and free to reuse the number the archived row is sitting on',
2518
+ ).toEqual(stillLive.map((_c, i) => i))
2519
+ expect(
2520
+ stillLive.some((c) => c.order === archivedRow?.order),
2521
+ 'a live row really is sitting on the archived one’s number',
2522
+ ).toBe(true)
2523
+ })
2524
+
2525
+ /**
2526
+ * A swap is the commonest reorder there is, and it is the one a plain unique index refuses.
2527
+ *
2528
+ * Postgres checks a unique *index* row by row rather than at the end of the statement, and there is
2529
+ * no deferrable form to reach for — a unique constraint can be deferred and cannot be partial. So
2530
+ * `reorder` parks every moving row above the sequence first and puts them down afterwards. Without
2531
+ * that, this test is a 23505 during an entirely ordinary drag.
2532
+ */
2533
+ it('swaps two neighbours without ever colliding on the way', async () => {
2534
+ const before = (await live()).map((c) => c.id)
2535
+ expect(before.length, 'a swap needs two').toBeGreaterThan(1)
2536
+ const swapped = [before[1]!, before[0]!, ...before.slice(2)]
2537
+
2538
+ const answered = await call(
2539
+ inv.categories.reorder,
2540
+ { workspaceId: WS_UNIQUE, categoryIds: swapped },
2541
+ ctx(),
2542
+ )
2543
+ expect(answered.map((c) => c.id)).toEqual(swapped)
2544
+ expect(answered.map((c) => c.order)).toEqual(swapped.map((_id, i) => i))
2545
+ })
2546
+
2547
+ /**
2548
+ * And the whole sequence reversed, which parks and lands every row in the list at once.
2549
+ *
2550
+ * The parking values have to clear both the highest number any row holds *and* the last place in
2551
+ * the new sequence; a base that only cleared the first collides the moment a workspace's numbers
2552
+ * are sparser than its row count.
2553
+ */
2554
+ it('reverses the whole list in one call', async () => {
2555
+ const before = (await live()).map((c) => c.id)
2556
+ const reversed = [...before].reverse()
2557
+
2558
+ const answered = await call(
2559
+ inv.categories.reorder,
2560
+ { workspaceId: WS_UNIQUE, categoryIds: reversed },
2561
+ ctx(),
2562
+ )
2563
+ expect(answered.map((c) => c.id)).toEqual(reversed)
2564
+ expect(answered.map((c) => c.order)).toEqual(reversed.map((_id, i) => i))
2565
+ })
2566
+ })
2567
+
2568
+ /**
2569
+ * The ceiling, and the fact that it is stated somewhere rather than only implied.
2570
+ *
2571
+ * `categories.reorder` is handed every live category at once, so its input array carries a bound —
2572
+ * every zod array a client fills has to, or one request can ask the server to hold an arbitrary list.
2573
+ * A bound on the array *alone* is a silent ceiling: a workspace could pass it one category at a time,
2574
+ * because nothing else counted, and the first sign would be that the only procedure able to order
2575
+ * them refuses every call. Nothing on the screen would say why, and there is no other way to reorder.
2576
+ *
2577
+ * So the same constant is enforced where somebody meets it, at the moment they meet it, with a
2578
+ * sentence carrying the number. The two halves are asserted together below, because they are only
2579
+ * worth anything as a pair: the limit is reachable by creating, and a workspace sitting exactly on it
2580
+ * can still reorder every category it has.
2581
+ */
2582
+ describe('the number of categories a workspace can keep', () => {
2583
+ const ctx = () => ({ context: asUser(ALICE, WS_LIMIT) })
2584
+ const create = (name: string) => call(inv.categories.create, { workspaceId: WS_LIMIT, name }, ctx())
2585
+
2586
+ beforeAll(async () => {
2587
+ // Filled with one statement rather than 500 calls: what is under test is the count, not the path
2588
+ // that produced it, and 500 round trips through the router would dominate this file's runtime.
2589
+ await kernel.database.withWorkspace(
2590
+ WS_LIMIT,
2591
+ (tx) =>
2592
+ tx.execute(sql`
2593
+ insert into mod_inventory.categories (workspace_id, name, "order")
2594
+ select ${WS_LIMIT}::uuid, 'Filler ' || n, n - 1
2595
+ from generate_series(1, ${MAX_LIVE_CATEGORIES}) as n
2596
+ `),
2597
+ { userId: ALICE },
2598
+ )
2599
+ }, 60_000)
2600
+
2601
+ it('refuses the one past it, and says what to do about it', async () => {
2602
+ const refusal = await capture(() => create('One too many'))
2603
+ expect(codeOf(refusal)).toBe('CONFLICT')
2604
+ expect(reasonOf(refusal), 'a token, because the sentence a Persian reader sees is the client’s own').toBe(
2605
+ 'inventory.category.limit_reached',
2606
+ )
2607
+ expect(messageOf(refusal), 'and the server names the number rather than hinting at one').toContain(
2608
+ String(MAX_LIVE_CATEGORIES),
2609
+ )
2610
+ })
2611
+
2612
+ it('still lets a workspace sitting on the limit order every category it has', async () => {
2613
+ // The whole point of holding the two to one number: the bound on the array is never the thing a
2614
+ // real workspace runs into first.
2615
+ const rows = await call(inv.categories.list, { workspaceId: WS_LIMIT, archived: false }, ctx())
2616
+ expect(rows).toHaveLength(MAX_LIVE_CATEGORIES)
2617
+ const reordered = [rows.at(-1)!.id, ...rows.slice(0, -1).map((c) => c.id)]
2618
+ const answered = await call(
2619
+ inv.categories.reorder,
2620
+ { workspaceId: WS_LIMIT, categoryIds: reordered },
2621
+ ctx(),
2622
+ )
2623
+ expect(answered.map((c) => c.id)).toEqual(reordered)
2624
+ expect(new Set(answered.map((c) => c.order)).size, 'and no two share a place').toBe(MAX_LIVE_CATEGORIES)
2625
+ })
2626
+
2627
+ it('makes room when one is archived, which is what the refusal tells the reader to do', async () => {
2628
+ const rows = await call(inv.categories.list, { workspaceId: WS_LIMIT, archived: false }, ctx())
2629
+ await call(
2630
+ inv.categories.archive,
2631
+ { workspaceId: WS_LIMIT, categoryId: rows[0]!.id, archived: true },
2632
+ ctx(),
2633
+ )
2634
+ const added = await create('Fits now')
2635
+ expect(added.name).toBe('Fits now')
2636
+
2637
+ // And the freed place cannot be taken twice: restoring the archived one is refused in turn.
2638
+ const refusal = await capture(() =>
2639
+ call(
2640
+ inv.categories.archive,
2641
+ { workspaceId: WS_LIMIT, categoryId: rows[0]!.id, archived: false },
2642
+ ctx(),
2643
+ ),
2644
+ )
2645
+ expect(reasonOf(refusal), 'a restore grows the live set too, so it is held to the same number').toBe(
2646
+ 'inventory.category.limit_reached',
2647
+ )
2648
+ })
2649
+ })
2650
+
2116
2651
  // ---------------------------------------------------------------------------------------------
2117
2652
 
2118
2653
  const sendForRepair = (