@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
@@ -53,14 +53,17 @@ function migrationFiles(): string[] {
53
53
  * folder may use a dollar-quoted body: a breakpoint inside `do $$ … end $$` cuts the function in
54
54
  * half, and the error is `unterminated dollar-quoted string`, which does not sound like what it is.
55
55
  */
56
- async function apply(file: string): Promise<Array<{ statement: string; error: string }>> {
56
+ async function apply(
57
+ file: string,
58
+ client: pg.Client = db,
59
+ ): Promise<Array<{ statement: string; error: string }>> {
57
60
  const sql = readFileSync(join(MIGRATIONS, file), 'utf8')
58
61
  const failures: Array<{ statement: string; error: string }> = []
59
62
  for (const raw of sql.split('--> statement-breakpoint')) {
60
63
  const statement = raw.trim()
61
64
  if (!statement || statement.split('\n').every((l) => l.trim().startsWith('--'))) continue
62
65
  try {
63
- await db.query(statement)
66
+ await client.query(statement)
64
67
  } catch (err) {
65
68
  failures.push({
66
69
  statement: statement.slice(0, 120).replace(/\s+/g, ' '),
@@ -237,6 +240,26 @@ describe('the migration folder', () => {
237
240
  expect(new Set(rows.map((r) => r.seq)).size, 'three distinct sequence values').toBe(3)
238
241
  })
239
242
 
243
+ /**
244
+ * The index that makes "no two live categories share a place" a fact rather than an intention.
245
+ *
246
+ * Partial on purpose, and the `WHERE` is the half worth asserting: an archived category keeps the
247
+ * number it had when it left, and the next reorder renumbers a live row straight onto it. A total
248
+ * unique index would refuse that entirely correct pair, during a migration, which is a host service
249
+ * that does not boot rather than a settings screen that misbehaves.
250
+ */
251
+ it('keeps the partial index that makes two live categories on one place impossible', async () => {
252
+ const { rows } = await db.query<{ indexdef: string }>(
253
+ `select indexdef from pg_indexes
254
+ where schemaname = 'mod_inventory' and indexname = 'inventory_categories_ws_order_live_uq'`,
255
+ )
256
+ // Exactly one after the replay: the `if not exists` is added by hand in `0008`.
257
+ expect(rows).toHaveLength(1)
258
+ expect(rows[0]?.indexdef).toContain('UNIQUE')
259
+ expect(rows[0]?.indexdef).toMatch(/workspace_id.*order/s)
260
+ expect(rows[0]?.indexdef, 'live rows only').toContain('WHERE (archived_at IS NULL)')
261
+ })
262
+
240
263
  it('keeps the partial index that makes two open repairs impossible', async () => {
241
264
  // drizzle emits a bare `CREATE UNIQUE INDEX` for this one; the `if not exists` is added by
242
265
  // hand in `0003_repairs.sql`, and a replay is the only thing that proves it is really there.
@@ -249,3 +272,118 @@ describe('the migration folder', () => {
249
272
  expect(rows[0]?.indexdef).toContain('WHERE (returned_on IS NULL)')
250
273
  })
251
274
  })
275
+
276
+ /**
277
+ * The upgrade that has rows the new index would refuse — which is every instance that ran 0.2.0.
278
+ *
279
+ * A fresh database can never exercise this, and a fresh database is what every test above uses: the
280
+ * folder is applied in order, so by the time `0008` runs nothing has had a chance to write a
281
+ * duplicate. That is the shape of test that passes while the release it is guarding takes production
282
+ * down. `CREATE UNIQUE INDEX` meeting two live rows on one number throws, a module's migrations are
283
+ * the first thing the kernel runs, and `core` hosts five modules and never binds its port.
284
+ *
285
+ * So this builds the database an existing instance actually has — the folder up to `0007`, with
286
+ * duplicates written into it the way the append race wrote them — and then applies `0008` alone.
287
+ */
288
+ describe('upgrading a database that already has two live categories on one place', () => {
289
+ const DUPES_DB = `${DB_NAME}_dupes`
290
+ const WS = '00000000-0000-4000-8000-00000000d0d0'
291
+ const OTHER = '00000000-0000-4000-8000-00000000d0d1'
292
+ let dupes: pg.Client
293
+
294
+ beforeAll(async () => {
295
+ await admin.query(`create database "${DUPES_DB}"`)
296
+ const url = new URL(BASE_URL)
297
+ url.pathname = `/${DUPES_DB}`
298
+ dupes = new pg.Client({ connectionString: url.toString() })
299
+ await dupes.connect()
300
+
301
+ // Everything an instance on 0.2.0 has, and nothing this migration adds.
302
+ for (const file of migrationFiles().filter((f) => f < '0008')) {
303
+ expect(await apply(file, dupes), `${file}, on the pre-upgrade database`).toEqual([])
304
+ }
305
+
306
+ // Three live categories, two of them on place 1 — exactly what two appends at the same instant
307
+ // used to leave behind — plus an archived row sitting on a number a live row also holds, which
308
+ // the repair must not touch and the index must not refuse.
309
+ await dupes.query(
310
+ `insert into mod_inventory.categories (workspace_id, name, "order", archived_at) values
311
+ ($1,'Desks',0,null), ($1,'Chairs',1,null), ($1,'Lamps',1,null),
312
+ ($1,'Retired',0,now()),
313
+ ($2,'Untouched',7,null)`,
314
+ [WS, OTHER],
315
+ )
316
+ }, 120_000)
317
+
318
+ afterAll(async () => {
319
+ await dupes?.end().catch(() => undefined)
320
+ await admin?.query(`drop database if exists "${DUPES_DB}" with (force)`).catch(() => undefined)
321
+ })
322
+
323
+ it('renumbers the duplicates instead of failing the boot', async () => {
324
+ expect(await apply('0008_category_order_unique.sql', dupes), 'the upgrade itself').toEqual([])
325
+
326
+ const { rows } = await dupes.query<{ name: string; order: number }>(
327
+ `select name, "order" from mod_inventory.categories
328
+ where workspace_id = $1 and archived_at is null order by "order"`,
329
+ [WS],
330
+ )
331
+ expect(
332
+ rows.map((r) => [r.name, r.order]),
333
+ 'walked in the order the screen already showed them — ("order", "name") — so nothing visibly moved',
334
+ ).toEqual([
335
+ ['Desks', 0],
336
+ ['Chairs', 1],
337
+ ['Lamps', 2],
338
+ ])
339
+ })
340
+
341
+ it('leaves a workspace that had no duplicate exactly as it was', async () => {
342
+ const { rows } = await dupes.query<{ order: number }>(
343
+ `select "order" from mod_inventory.categories where workspace_id = $1`,
344
+ [OTHER],
345
+ )
346
+ expect(
347
+ rows.map((r) => r.order),
348
+ 'a sparse but valid sequence is not a defect to tidy up',
349
+ ).toEqual([7])
350
+ })
351
+
352
+ it('leaves the archived row on the number a live row now holds', async () => {
353
+ const { rows } = await dupes.query<{ order: number }>(
354
+ `select "order" from mod_inventory.categories
355
+ where workspace_id = $1 and archived_at is not null`,
356
+ [WS],
357
+ )
358
+ expect(
359
+ rows.map((r) => r.order),
360
+ 'outside the partial index, so outside the repair',
361
+ ).toEqual([0])
362
+ })
363
+
364
+ it('applies a second time, because a replay must not take down the host service', async () => {
365
+ expect(await apply('0008_category_order_unique.sql', dupes), 'replayed').toEqual([])
366
+ const { rows } = await dupes.query<{ name: string; order: number }>(
367
+ `select name, "order" from mod_inventory.categories
368
+ where workspace_id = $1 and archived_at is null order by "order"`,
369
+ [WS],
370
+ )
371
+ expect(
372
+ rows.map((r) => [r.name, r.order]),
373
+ 'and it changed nothing the second time',
374
+ ).toEqual([
375
+ ['Desks', 0],
376
+ ['Chairs', 1],
377
+ ['Lamps', 2],
378
+ ])
379
+ })
380
+
381
+ it('refuses a duplicate from then on', async () => {
382
+ await expect(
383
+ dupes.query(
384
+ `update mod_inventory.categories set "order" = 0 where workspace_id = $1 and name = 'Chairs'`,
385
+ [WS],
386
+ ),
387
+ ).rejects.toThrow(/inventory_categories_ws_order_live_uq/)
388
+ })
389
+ })
@@ -399,7 +399,7 @@ export function inventoryRouter(kernel: Kernel) {
399
399
  .use(requires('inventory.category.manage'))
400
400
  .handler(async ({ input, context }) => {
401
401
  const row = await run(context, input.workspaceId, (tx) =>
402
- svc.categories.create(tx, input.workspaceId, input.name, input.order ?? 0),
402
+ svc.categories.create(tx, input.workspaceId, input.name),
403
403
  )
404
404
  // No event: nothing outside this module has an opinion about a workspace's own filing.
405
405
  // The realtime change is what the settings page, the filter and the form picker need.
@@ -431,6 +431,30 @@ export function inventoryRouter(kernel: Kernel) {
431
431
  await svc.notify.change(input.workspaceId, 'category', row.id, 'updated')
432
432
  return toCategory(row)
433
433
  }),
434
+
435
+ /**
436
+ * The sequence, rewritten from the ids somebody dragged it into.
437
+ *
438
+ * **One change per row that actually moved, and no kernel event** — the same two decisions
439
+ * the three procedures above make, for the same two reasons. Moving one category down a list
440
+ * of ten renumbers the handful between where it was and where it went, and each of those rows
441
+ * really did change; announcing the ones that did not would tell every open screen in the
442
+ * workspace about a write nobody performed. And nothing outside this module has an opinion
443
+ * about the order a workspace keeps its own filing in, so there is nothing to emit an event
444
+ * to: `inventoryEvents` stays the set of things another module could plausibly react to.
445
+ *
446
+ * After the commit, like everything else here. A change announced from inside the transaction
447
+ * describes rows a rollback then takes away, and it cannot be retracted.
448
+ */
449
+ reorder: scoped.categories.reorder
450
+ .use(requires('inventory.category.manage'))
451
+ .handler(async ({ input, context }) => {
452
+ const { rows, moved } = await run(context, input.workspaceId, (tx) =>
453
+ svc.categories.reorder(tx, input.workspaceId, input.categoryIds),
454
+ )
455
+ for (const id of moved) await svc.notify.change(input.workspaceId, 'category', id, 'updated')
456
+ return rows.map(toCategory)
457
+ }),
434
458
  },
435
459
 
436
460
  /**
@@ -111,6 +111,17 @@ export const categories = schema.table(
111
111
  // two rows a picker cannot tell apart the moment somebody restores the second.
112
112
  uniqueIndex('inventory_categories_ws_name_uq').on(t.workspaceId, t.name),
113
113
  index('inventory_categories_ws_idx').on(t.workspaceId, t.order),
114
+ /**
115
+ * One live category per place, which the contract claims and nothing enforced until `0008`.
116
+ *
117
+ * **Partial, over the live rows only.** An archived category keeps the number it had when it was
118
+ * archived and the next reorder renumbers a live row onto it — a collision nobody can see, since
119
+ * an archived category is in no picker, no filter and no sequence. A total unique index would
120
+ * refuse that entirely correct pair.
121
+ */
122
+ uniqueIndex('inventory_categories_ws_order_live_uq')
123
+ .on(t.workspaceId, t.order)
124
+ .where(sql`${t.archivedAt} is null`),
114
125
  ],
115
126
  )
116
127
 
@@ -1,6 +1,6 @@
1
1
  import { KernError, type Tx, uuidv7 } from '@kernhq/kernel'
2
- import { and, asc, eq, isNull } from 'drizzle-orm'
3
- import type { Category as CategoryModel } from '../../contract/models.js'
2
+ import { and, asc, count, eq, isNull, sql } from 'drizzle-orm'
3
+ import { type Category as CategoryModel, MAX_LIVE_CATEGORIES } from '../../contract/models.js'
4
4
  import { categories } from '../schema.js'
5
5
  import { violated } from './db-errors.js'
6
6
 
@@ -9,6 +9,13 @@ type Row = typeof categories.$inferSelect
9
9
  /** The unique index `0000_init.sql` put on (workspace_id, name). */
10
10
  const NAME_TAKEN = 'inventory_categories_ws_name_uq'
11
11
 
12
+ /** What `reorder` returns: the live sequence as it now stands, and which rows actually moved. */
13
+ export interface Reordered {
14
+ rows: Row[]
15
+ /** Only the ids whose `order` changed — a change event for a row that did not move is a lie. */
16
+ moved: string[]
17
+ }
18
+
12
19
  /** The wire shape: drizzle gives Date objects for timestamps, the contract promises ISO strings. */
13
20
  export function toCategory(row: Row): CategoryModel {
14
21
  return {
@@ -31,9 +38,16 @@ export function toCategory(row: Row): CategoryModel {
31
38
  */
32
39
  export class CategoryService {
33
40
  /**
34
- * Ordered by `order` and then by name, which is what makes a workspace that never touches the
35
- * order field still get an alphabetical picker rather than an arbitrary one — every row has
36
- * `order` 0, so the tiebreak is doing all the work and has to be a name rather than an id.
41
+ * Ordered by `order` and then by name.
42
+ *
43
+ * `order` is the sequence somebody dragged their categories into, and no two **live** categories
44
+ * share a number: `inventory_categories_ws_order_live_uq` is what makes that true rather than
45
+ * intended. So for the live set the tiebreak never fires.
46
+ *
47
+ * It stays because `list` also reads the archived rows, and those are outside the index: an
48
+ * archived category keeps the number it had when it left, and the very next reorder renumbers a
49
+ * live row onto it. A duplicate must sort the same way twice, and a name is the only column a
50
+ * person could predict.
37
51
  */
38
52
  async list(tx: Tx, workspaceId: string, includeArchived: boolean): Promise<CategoryModel[]> {
39
53
  const filters = [eq(categories.workspaceId, workspaceId)]
@@ -56,34 +70,47 @@ export class CategoryService {
56
70
  }
57
71
 
58
72
  /**
59
- * A duplicate name is a `CONFLICT` with the name in it, never a 500.
73
+ * A new category joins the **end** of the sequence, and a duplicate name is a `CONFLICT` with the
74
+ * name in it, never a 500.
60
75
  *
61
- * The unique index is what actually decides — checking first and inserting after is a race that
62
- * two people adding "Laptops" at once will find — so the check is the insert, and the driver's
63
- * 23505 is translated into a sentence rather than shown as "Failed query: insert into
76
+ * The unique index is what actually decides the name — checking first and inserting after is a
77
+ * race that two people adding "Laptops" at once will find — so the check is the insert, and the
78
+ * driver's 23505 is translated into a sentence rather than shown as "Failed query: insert into
64
79
  * mod_inventory.categories …".
80
+ *
81
+ * The position is decided the same way, in the statement rather than around it. It used to be an
82
+ * optional number the caller passed and defaulted to 0, so every category anybody added landed at
83
+ * the *front*, tied with whatever was already there, and the list resolved the tie by name — a
84
+ * new category appearing in the middle of a sequence somebody had arranged by hand.
85
+ *
86
+ * **The subquery is not what makes the number unique — the lock above it is.** This used to say
87
+ * that a subquery inside the insert stopped two creates in flight from taking the same maximum,
88
+ * and that is false: under READ COMMITTED each statement takes its own snapshot, so both read a
89
+ * list without the other's row in it and both appended to the same place. `lockAppends` is what
90
+ * serialises them, and `inventory_categories_ws_order_live_uq` is what refuses the pair if
91
+ * anything ever reaches the table around it.
65
92
  */
66
- async create(tx: Tx, workspaceId: string, name: string, order: number): Promise<Row> {
93
+ async create(tx: Tx, workspaceId: string, name: string): Promise<Row> {
94
+ await CategoryService.lockAppends(tx, workspaceId)
95
+ await CategoryService.roomForOneMore(tx, workspaceId)
67
96
  try {
68
- const [row] = await tx.insert(categories).values({ id: uuidv7(), workspaceId, name, order }).returning()
97
+ const [row] = await tx
98
+ .insert(categories)
99
+ .values({ id: uuidv7(), workspaceId, name, order: CategoryService.appended(workspaceId) })
100
+ .returning()
69
101
  return row!
70
102
  } catch (err) {
71
103
  throw CategoryService.nameTaken(err, name)
72
104
  }
73
105
  }
74
106
 
75
- async update(
76
- tx: Tx,
77
- workspaceId: string,
78
- categoryId: string,
79
- patch: { name?: string; order?: number },
80
- ): Promise<Row> {
107
+ /** A rename, and nothing else — the sequence is `reorder`'s to write. */
108
+ async update(tx: Tx, workspaceId: string, categoryId: string, patch: { name?: string }): Promise<Row> {
81
109
  const previous = await this.get(tx, workspaceId, categoryId)
82
- // `undefined` means "not mentioned". Neither field is nullable, so there is no "clear it" here
110
+ // `undefined` means "not mentioned". The column is not nullable, so there is no "clear it" here
83
111
  // and no reason for the `null`-versus-`undefined` care `assets.update` needs.
84
112
  const values = {
85
113
  name: patch.name ?? previous.name,
86
- order: patch.order ?? previous.order,
87
114
  updatedAt: new Date(),
88
115
  }
89
116
  try {
@@ -108,17 +135,191 @@ export class CategoryService {
108
135
  * entry saying "category changed to <nothing>". None of it recoverable, all of it caused by a
109
136
  * settings screen. An archived category disappears from every picker and every filter and leaves
110
137
  * each asset able to say what it is.
138
+ *
139
+ * **A restore appends**, for the reason `create` appends. The row kept the position it had when
140
+ * it left, and every live category has been renumbered since — so putting it back where its old
141
+ * number points lands it in the middle of somebody's arrangement, tied with whatever is there
142
+ * now. The end of the list is the one place a person can find it again. Archiving leaves the
143
+ * number alone: it is out of every list that reads it, and it is about to be overwritten anyway.
144
+ *
145
+ * A restore appends, so it races exactly as `create` does and is serialised the same way — and it
146
+ * is the one place other than `create` where the live set grows, so it is the other place the
147
+ * limit is enforced. Archiving needs neither: it takes a row out of the live set, and out of the
148
+ * partial index with it.
111
149
  */
112
150
  async archive(tx: Tx, workspaceId: string, categoryId: string, archived: boolean): Promise<Row> {
151
+ if (!archived) {
152
+ await CategoryService.lockAppends(tx, workspaceId)
153
+ await CategoryService.roomForOneMore(tx, workspaceId)
154
+ }
113
155
  const [row] = await tx
114
156
  .update(categories)
115
- .set({ archivedAt: archived ? new Date() : null, updatedAt: new Date() })
157
+ .set({
158
+ archivedAt: archived ? new Date() : null,
159
+ ...(archived ? {} : { order: CategoryService.appended(workspaceId) }),
160
+ updatedAt: new Date(),
161
+ })
116
162
  .where(and(eq(categories.workspaceId, workspaceId), eq(categories.id, categoryId)))
117
163
  .returning()
118
164
  if (!row) throw KernError.notFound('Category')
119
165
  return row
120
166
  }
121
167
 
168
+ /**
169
+ * The sequence, rewritten from the ids somebody put it in — the only thing that writes `order`.
170
+ *
171
+ * Three refusals before a single row is touched, and all three are the same idea: this call
172
+ * describes the whole live list, so a list that does not match the workspace is not a partial
173
+ * instruction to be completed, it is an ordering of something else.
174
+ *
175
+ * - **an id twice** — arithmetic that cannot be carried out, `BAD_REQUEST`;
176
+ * - **an id that is not this workspace's** — `NOT_FOUND`, the answer `update` and `archive`
177
+ * already give for one, and the answer that does not confirm the row exists elsewhere;
178
+ * - **a live category the list does not name, or an archived one it does** — somebody added,
179
+ * archived or restored a category while this page was open. Renumbering what was named would
180
+ * put the missing one wherever its stale number happened to land, silently. `CONFLICT` with
181
+ * `inventory.category.order_stale`, which the client turns into "reload and try again".
182
+ *
183
+ * All of it inside one transaction, opened by the router, so a refusal writes nothing and a
184
+ * renumbering is never half-applied. The `for update` is what makes two reorders arriving at once
185
+ * queue rather than interleave — without it both read the same list, both pass the check, and the
186
+ * writes of one land between the writes of the other, which is how a sequence ends up being
187
+ * neither of the two orders anybody asked for. Locking in id order is what stops two of them
188
+ * taking the same rows in opposite orders and deadlocking.
189
+ */
190
+ async reorder(tx: Tx, workspaceId: string, categoryIds: string[]): Promise<Reordered> {
191
+ const named = new Set(categoryIds)
192
+ if (named.size !== categoryIds.length)
193
+ throw KernError.badRequest('That list of categories names the same one more than once.')
194
+
195
+ const current = await tx
196
+ .select()
197
+ .from(categories)
198
+ .where(eq(categories.workspaceId, workspaceId))
199
+ .orderBy(asc(categories.id))
200
+ .for('update')
201
+ const known = new Map(current.map((row) => [row.id, row]))
202
+
203
+ if (categoryIds.some((id) => !known.has(id))) throw KernError.notFound('Category')
204
+ const live = current.filter((row) => !row.archivedAt)
205
+ if (live.some((row) => !named.has(row.id)) || categoryIds.some((id) => known.get(id)?.archivedAt))
206
+ throw KernError.conflict(
207
+ 'The categories changed while this list was open, so this order was not saved. Reload the list and arrange it again.',
208
+ 'inventory.category.order_stale',
209
+ )
210
+
211
+ // Only the rows that actually move are touched — a workspace has tens of categories, they are
212
+ // already locked, and a change event for a row whose position did not change would tell every
213
+ // screen in the workspace about a write that did not happen. `moved` is settled here, before a
214
+ // single write, so it stays the honest list whatever the two passes below do.
215
+ const now = new Date()
216
+ const going = categoryIds
217
+ .map((id, index) => ({ id, index }))
218
+ .filter(({ id, index }) => known.get(id)?.order !== index)
219
+ const moved = going.map(({ id }) => id)
220
+
221
+ /**
222
+ * Parked out of the way first, and only then put down where they belong.
223
+ *
224
+ * `inventory_categories_ws_order_live_uq` is a plain unique index, and Postgres checks one of
225
+ * those row by row rather than at the end of the statement. There is no deferrable form to reach
226
+ * for either: a unique *constraint* can be deferred and cannot be partial, and this one has to be
227
+ * partial. So the single-pass loop writes a collision the moment two rows swap — putting the
228
+ * first on 1 while the second still holds 1 — and a swap is the commonest reorder there is.
229
+ *
230
+ * `park` sits above both the highest number any row in this workspace holds **and** the last
231
+ * place in the new sequence. That is what makes the two passes safe: the parked values are
232
+ * distinct from one another and from every row staying put, and the `0…n-1` the second pass
233
+ * writes into is empty, because every live row that could have been sitting there is either
234
+ * parked or already on the number it is being given.
235
+ *
236
+ * Only the rows that actually move are written, so a reorder that shifts one row does not stamp
237
+ * `updated_at` across the whole list — and `updated_at` is left off the parking pass, which is
238
+ * bookkeeping rather than a change anybody made.
239
+ */
240
+ if (going.length > 0) {
241
+ const park = Math.max(...current.map((row) => row.order), categoryIds.length - 1) + 1
242
+ for (const [offset, { id }] of going.entries()) {
243
+ await tx
244
+ .update(categories)
245
+ .set({ order: park + offset })
246
+ .where(and(eq(categories.workspaceId, workspaceId), eq(categories.id, id)))
247
+ }
248
+ for (const { id, index } of going) {
249
+ await tx
250
+ .update(categories)
251
+ .set({ order: index, updatedAt: now })
252
+ .where(and(eq(categories.workspaceId, workspaceId), eq(categories.id, id)))
253
+ }
254
+ }
255
+
256
+ const rows = await tx
257
+ .select()
258
+ .from(categories)
259
+ .where(and(eq(categories.workspaceId, workspaceId), isNull(categories.archivedAt)))
260
+ .orderBy(asc(categories.order), asc(categories.name))
261
+ return { rows, moved }
262
+ }
263
+
264
+ /**
265
+ * One past the highest position this workspace has used, archived rows counted.
266
+ *
267
+ * Archived rows count because one of them can be restored, and a restored category landing on a
268
+ * live one's number is the tie this whole change exists to remove.
269
+ *
270
+ * **Only correct under `lockAppends`.** A subquery inside the write saves a round trip and settles
271
+ * nothing about concurrency: under READ COMMITTED it is evaluated against the snapshot its own
272
+ * statement started with, so two transactions appending at the same instant read the same maximum
273
+ * and take the same number. That is the defect `0008` exists for.
274
+ */
275
+ private static appended(workspaceId: string) {
276
+ return sql<number>`(select coalesce(max(${categories.order}), -1) + 1 from ${categories} where ${categories.workspaceId} = ${workspaceId})`
277
+ }
278
+
279
+ /**
280
+ * Hold the right to append to this workspace's list until the transaction ends.
281
+ *
282
+ * An advisory lock rather than `select … for update`, because the thing being protected is the
283
+ * *next* number rather than any row that exists: a workspace with no categories at all has no row
284
+ * to lock, and two creates against it would still collide. Taken per workspace, so two workspaces
285
+ * adding a category at the same moment never wait for each other.
286
+ *
287
+ * The first key is a constant for this list, so another module taking an advisory lock on the same
288
+ * workspace does not queue behind this one by accident.
289
+ *
290
+ * It cannot deadlock against `reorder`, which takes row locks and never asks for this one — so
291
+ * there is no pair of waits pointing at each other.
292
+ */
293
+ private static async lockAppends(tx: Tx, workspaceId: string): Promise<void> {
294
+ await tx.execute(
295
+ sql`select pg_advisory_xact_lock(hashtext('mod_inventory.categories.order'), hashtext(${workspaceId}))`,
296
+ )
297
+ }
298
+
299
+ /**
300
+ * Refuse the one that would take a workspace past `MAX_LIVE_CATEGORIES`, and say so.
301
+ *
302
+ * The number exists because `categories.reorder` is handed every live category at once and that
303
+ * array needs a bound. Leaving the bound only on the array is a silent ceiling: a workspace could
304
+ * pass it one category at a time and then discover that the only procedure that can order them is
305
+ * the one it can no longer call. Enforced here, the array can always name every live category a
306
+ * workspace is allowed to have.
307
+ *
308
+ * **Live rows, not every row ever made**, so that archiving one frees a place — which is what the
309
+ * refusal tells the reader to do, and the advice has to be true.
310
+ */
311
+ private static async roomForOneMore(tx: Tx, workspaceId: string): Promise<void> {
312
+ const [row] = await tx
313
+ .select({ n: count() })
314
+ .from(categories)
315
+ .where(and(eq(categories.workspaceId, workspaceId), isNull(categories.archivedAt)))
316
+ if ((row?.n ?? 0) < MAX_LIVE_CATEGORIES) return
317
+ throw KernError.conflict(
318
+ `This workspace already has ${MAX_LIVE_CATEGORIES} categories, which is as many as Inventory keeps in one order. Archive one it no longer uses to make room.`,
319
+ 'inventory.category.limit_reached',
320
+ )
321
+ }
322
+
122
323
  /**
123
324
  * The unique index refused it, or something else did and must not be disguised.
124
325
  *