@kernhq/module-inventory 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +57 -2
- 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 +2 -1
- 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
|
@@ -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
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
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'
|
|
1995
|
-
await create('Consumables'
|
|
1996
|
-
|
|
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 = (
|