@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
|
@@ -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(
|
|
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
|
|
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
|
+
})
|
package/src/server/router.ts
CHANGED
|
@@ -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
|
|
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
|
/**
|
package/src/server/schema.ts
CHANGED
|
@@ -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
|
|
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
|
|
35
|
-
*
|
|
36
|
-
* `order`
|
|
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
|
|
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
|
|
62
|
-
* two people adding "Laptops" at once will find — so the check is the insert, and the
|
|
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
|
|
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
|
|
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
|
-
|
|
76
|
-
|
|
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".
|
|
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({
|
|
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
|
*
|