@kernhq/module-inventory 0.2.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 +106 -25
- package/dist/contract/capabilities.d.ts +4 -4
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +51 -9
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/events.d.ts +43 -0
- package/dist/contract/events.d.ts.map +1 -1
- package/dist/contract/events.js +40 -0
- package/dist/contract/events.js.map +1 -1
- package/dist/contract/index.d.ts +1 -0
- package/dist/contract/index.d.ts.map +1 -1
- package/dist/contract/index.js +1 -0
- package/dist/contract/index.js.map +1 -1
- package/dist/contract/models.d.ts +345 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +245 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/notifications.d.ts +24 -0
- package/dist/contract/notifications.d.ts.map +1 -0
- package/dist/contract/notifications.js +72 -0
- package/dist/contract/notifications.js.map +1 -0
- package/dist/contract/permissions.d.ts +52 -2
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +55 -2
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/router.d.ts +1108 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +253 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/contract/settings.d.ts +2 -0
- package/dist/contract/settings.d.ts.map +1 -1
- package/dist/contract/settings.js +24 -4
- package/dist/contract/settings.js.map +1 -1
- package/dist/server/index.d.ts +8 -4
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +289 -5
- package/dist/server/index.js.map +1 -1
- package/dist/server/jobs.d.ts +48 -0
- package/dist/server/jobs.d.ts.map +1 -0
- package/dist/server/jobs.js +358 -0
- package/dist/server/jobs.js.map +1 -0
- package/dist/server/router.d.ts +1392 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +385 -7
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +134 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +169 -4
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +69 -3
- package/dist/server/services/assets.d.ts.map +1 -1
- package/dist/server/services/assets.js +199 -31
- package/dist/server/services/assets.js.map +1 -1
- package/dist/server/services/attachments.d.ts +80 -0
- package/dist/server/services/attachments.d.ts.map +1 -0
- package/dist/server/services/attachments.js +182 -0
- package/dist/server/services/attachments.js.map +1 -0
- package/dist/server/services/audience.d.ts +15 -0
- package/dist/server/services/audience.d.ts.map +1 -0
- package/dist/server/services/audience.js +64 -0
- package/dist/server/services/audience.js.map +1 -0
- package/dist/server/services/categories.d.ts +157 -0
- package/dist/server/services/categories.d.ts.map +1 -0
- package/dist/server/services/categories.js +309 -0
- package/dist/server/services/categories.js.map +1 -0
- package/dist/server/services/cursor.d.ts +68 -0
- package/dist/server/services/cursor.d.ts.map +1 -0
- package/dist/server/services/cursor.js +39 -0
- package/dist/server/services/cursor.js.map +1 -0
- package/dist/server/services/custody.d.ts +175 -0
- package/dist/server/services/custody.d.ts.map +1 -0
- package/dist/server/services/custody.js +367 -0
- package/dist/server/services/custody.js.map +1 -0
- package/dist/server/services/db-errors.d.ts +7 -0
- package/dist/server/services/db-errors.d.ts.map +1 -0
- package/dist/server/services/db-errors.js +32 -0
- package/dist/server/services/db-errors.js.map +1 -0
- package/dist/server/services/index.d.ts +16 -0
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +25 -1
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/members.d.ts +27 -0
- package/dist/server/services/members.d.ts.map +1 -0
- package/dist/server/services/members.js +39 -0
- package/dist/server/services/members.js.map +1 -0
- package/dist/server/services/notify.d.ts +43 -1
- package/dist/server/services/notify.d.ts.map +1 -1
- package/dist/server/services/notify.js +47 -5
- package/dist/server/services/notify.js.map +1 -1
- package/dist/server/services/offboarding.d.ts +70 -0
- package/dist/server/services/offboarding.d.ts.map +1 -0
- package/dist/server/services/offboarding.js +116 -0
- package/dist/server/services/offboarding.js.map +1 -0
- package/dist/server/services/repairs.d.ts +204 -0
- package/dist/server/services/repairs.d.ts.map +1 -0
- package/dist/server/services/repairs.js +476 -0
- package/dist/server/services/repairs.js.map +1 -0
- package/dist/server/services/search.d.ts +85 -0
- package/dist/server/services/search.d.ts.map +1 -0
- package/dist/server/services/search.js +142 -0
- package/dist/server/services/search.js.map +1 -0
- package/dist/server/services/stats.d.ts +42 -0
- package/dist/server/services/stats.d.ts.map +1 -0
- package/dist/server/services/stats.js +80 -0
- package/dist/server/services/stats.js.map +1 -0
- package/dist/server/services/status.d.ts +102 -0
- package/dist/server/services/status.d.ts.map +1 -0
- package/dist/server/services/status.js +71 -0
- package/dist/server/services/status.js.map +1 -0
- package/migrations/0002_custody_and_categories.sql +23 -0
- package/migrations/0003_repairs.sql +23 -0
- package/migrations/0004_platform_surfaces.sql +51 -0
- package/migrations/0005_repair_dates.sql +35 -0
- package/migrations/0006_workspace_registry_read.sql +50 -0
- package/migrations/0007_history_sequence.sql +83 -0
- package/migrations/0008_category_order_unique.sql +71 -0
- package/migrations/meta/0002_snapshot.json +1054 -0
- package/migrations/meta/0003_snapshot.json +1070 -0
- package/migrations/meta/0004_snapshot.json +1130 -0
- package/migrations/meta/0005_snapshot.json +1135 -0
- package/migrations/meta/_journal.json +49 -0
- package/package.json +2 -1
- package/src/client/api-instance.ts +27 -2
- package/src/client/bidi.test.ts +148 -0
- package/src/client/bidi.ts +85 -0
- package/src/client/components/AssetDetailPanel.svelte +614 -0
- package/src/client/components/AssetFormDialog.svelte +71 -22
- package/src/client/components/AssetPhoto.svelte +178 -0
- package/src/client/components/AttachmentsSection.svelte +327 -0
- package/src/client/components/CustodyDialog.svelte +201 -0
- package/src/client/components/RepairDialog.svelte +271 -0
- package/src/client/components/RepairsSection.svelte +318 -0
- package/src/client/components/Timeline.svelte +347 -0
- package/src/client/components/TimelineText.svelte +124 -0
- package/src/client/core-api.ts +71 -0
- package/src/client/custody.test.ts +31 -0
- package/src/client/custody.ts +34 -0
- package/src/client/errors.test.ts +395 -0
- package/src/client/errors.ts +229 -0
- package/src/client/links.test.ts +74 -0
- package/src/client/links.ts +44 -0
- package/src/client/members.test.ts +132 -0
- package/src/client/members.ts +116 -0
- package/src/client/messages.test.ts +127 -0
- package/src/client/messages.ts +1106 -18
- package/src/client/mock.test.ts +465 -1
- package/src/client/mock.ts +1105 -79
- package/src/client/module.ts +73 -1
- package/src/client/pages/AssetsPage.svelte +317 -45
- package/src/client/permissions.ts +7 -0
- package/src/client/price.test.ts +151 -3
- package/src/client/price.ts +155 -11
- package/src/client/query.ts +36 -0
- package/src/client/reorder.test.ts +100 -0
- package/src/client/reorder.ts +79 -0
- package/src/client/repairs.test.ts +38 -0
- package/src/client/repairs.ts +38 -0
- package/src/client/sequence.test.ts +248 -0
- package/src/client/sequence.ts +185 -0
- package/src/client/settings/CategoriesSettings.svelte +746 -0
- package/src/client/settings/GeneralSettings.svelte +0 -0
- package/src/client/status.ts +29 -0
- package/src/client/timeline.test.ts +175 -0
- package/src/client/timeline.ts +206 -0
- package/src/client/widgets/OverviewWidget.svelte +127 -26
- package/src/client/widgets/RepairsWidget.svelte +124 -0
- package/src/contract/capabilities.ts +53 -9
- package/src/contract/events.ts +49 -0
- package/src/contract/index.ts +1 -0
- package/src/contract/models.ts +270 -0
- package/src/contract/notifications.ts +73 -0
- package/src/contract/permissions.ts +55 -2
- package/src/contract/router.ts +284 -1
- package/src/contract/settings.ts +24 -4
- package/src/module.test.ts +217 -4
- package/src/server/index.ts +311 -4
- package/src/server/inventory.int.test.ts +4109 -19
- package/src/server/jobs.ts +444 -0
- package/src/server/migrations.test.ts +253 -2
- package/src/server/router.ts +486 -6
- package/src/server/schema.ts +171 -3
- package/src/server/services/assets.ts +214 -54
- package/src/server/services/attachments.ts +215 -0
- package/src/server/services/audience.ts +77 -0
- package/src/server/services/categories.ts +337 -0
- package/src/server/services/cursor.ts +104 -0
- package/src/server/services/custody.ts +471 -0
- package/src/server/services/db-errors.ts +42 -0
- package/src/server/services/index.ts +34 -1
- package/src/server/services/members.ts +54 -0
- package/src/server/services/notify.ts +52 -7
- package/src/server/services/offboarding.ts +150 -0
- package/src/server/services/repairs.ts +567 -0
- package/src/server/services/search.ts +166 -0
- package/src/server/services/stats.ts +88 -0
- package/src/server/services/status.test.ts +34 -0
- package/src/server/services/status.ts +143 -0
- package/src/client/settings/core-api.ts +0 -32
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audience.d.ts","sourceRoot":"","sources":["../../../src/server/services/audience.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAA;AAuC5C;;;;;;;;;;;GAWG;AACH,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,MAAM,EAAE,CAAC,CAoBnB"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who to tell when the thing that happened belongs to the workspace rather than to a person.
|
|
3
|
+
*
|
|
4
|
+
* Custody notifications have an obvious recipient — the person the item was handed to. A warranty
|
|
5
|
+
* running out on a spare in a cupboard, or a repair nobody has chased, does not: it belongs to
|
|
6
|
+
* whoever looks after the register. There is no "the office manager" field, and inventing one would
|
|
7
|
+
* be a setting somebody has to fill in before the feature works at all, so the question is asked of
|
|
8
|
+
* the permission system instead: **who may actually do the thing this message is asking for.**
|
|
9
|
+
*
|
|
10
|
+
* A warranty notice asks somebody to renew or replace, which is `inventory.asset.manage`. An overdue
|
|
11
|
+
* repair asks somebody to chase the vendor, which is `inventory.repair.manage`. Somebody leaving
|
|
12
|
+
* with a laptop asks somebody to take it back, which is `inventory.custody.manage`. Each message
|
|
13
|
+
* names the key it needs rather than settling for "the admins", because a workspace that gave its
|
|
14
|
+
* office manager a custom role did that on purpose.
|
|
15
|
+
*/
|
|
16
|
+
/** Most senior first, so the cap below keeps the people most likely to act. */
|
|
17
|
+
const ROLE_RANK = { owner: 3, admin: 2, member: 1, guest: 0 };
|
|
18
|
+
/**
|
|
19
|
+
* How many people one workspace-level notification may reach.
|
|
20
|
+
*
|
|
21
|
+
* A message sent to five thousand people is not a notification, it is a mailing list nobody reads —
|
|
22
|
+
* and on a large instance every member holds `inventory.asset.manage` by default, so an uncapped
|
|
23
|
+
* audience would be exactly that. Twenty is well past any real "who looks after the register" group
|
|
24
|
+
* and small enough that the check below stays a handful of calls rather than one per seat.
|
|
25
|
+
*
|
|
26
|
+
* The cap is applied **after** sorting by role, so the people it keeps are the senior ones rather
|
|
27
|
+
* than whichever rows the database happened to return first.
|
|
28
|
+
*/
|
|
29
|
+
const RECIPIENT_CAP = 20;
|
|
30
|
+
/**
|
|
31
|
+
* The members of a workspace who genuinely hold a permission.
|
|
32
|
+
*
|
|
33
|
+
* Genuinely, not "hold the role it defaults to": `kernel.authz.can` reads custom roles and scope
|
|
34
|
+
* bindings as well as the built-in defaults, so a workspace that took `inventory.repair.manage` away
|
|
35
|
+
* from `member` and gave it to one custom role is answered correctly. Guessing from the role would
|
|
36
|
+
* have been one local call instead of a handful, and would have quietly told the wrong people.
|
|
37
|
+
*
|
|
38
|
+
* Every failure here is swallowed: this decides an audience for a best-effort notification, and a
|
|
39
|
+
* member whose principal core cannot produce right now is one fewer recipient, not a reason for a
|
|
40
|
+
* nightly sweep to stop half-way through a workspace.
|
|
41
|
+
*/
|
|
42
|
+
export async function membersWithPermission(kernel, workspaceId, permission) {
|
|
43
|
+
const members = await kernel
|
|
44
|
+
.call('core.workspaces.members', { workspaceId })
|
|
45
|
+
.catch(() => []);
|
|
46
|
+
const ordered = [...members].sort((a, b) => (ROLE_RANK[b.role] ?? 0) - (ROLE_RANK[a.role] ?? 0));
|
|
47
|
+
const allowed = [];
|
|
48
|
+
for (const member of ordered) {
|
|
49
|
+
if (allowed.length >= RECIPIENT_CAP)
|
|
50
|
+
break;
|
|
51
|
+
const principal = await kernel
|
|
52
|
+
.call('core.users.principal', { userId: member.userId })
|
|
53
|
+
.catch(() => null);
|
|
54
|
+
if (!principal)
|
|
55
|
+
continue;
|
|
56
|
+
const can = await kernel.authz
|
|
57
|
+
.can(principal, permission, { kind: 'workspace', workspaceId })
|
|
58
|
+
.catch(() => false);
|
|
59
|
+
if (can)
|
|
60
|
+
allowed.push(member.userId);
|
|
61
|
+
}
|
|
62
|
+
return allowed;
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=audience.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audience.js","sourceRoot":"","sources":["../../../src/server/services/audience.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;GAcG;AAEH,+EAA+E;AAC/E,MAAM,SAAS,GAAgC,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,CAAA;AAE1F;;;;;;;;;;GAUG;AACH,MAAM,aAAa,GAAG,EAAE,CAAA;AAOxB;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB,CACzC,MAAc,EACd,WAAmB,EACnB,UAAkB;IAElB,MAAM,OAAO,GAAG,MAAM,MAAM;SACzB,IAAI,CAAW,yBAAyB,EAAE,EAAE,WAAW,EAAE,CAAC;SAC1D,KAAK,CAAC,GAAG,EAAE,CAAC,EAAc,CAAC,CAAA;IAE9B,MAAM,OAAO,GAAG,CAAC,GAAG,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IAEhG,MAAM,OAAO,GAAa,EAAE,CAAA;IAC5B,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,IAAI,OAAO,CAAC,MAAM,IAAI,aAAa;YAAE,MAAK;QAC1C,MAAM,SAAS,GAAG,MAAM,MAAM;aAC3B,IAAI,CAAmB,sBAAsB,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC;aACzE,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAA;QACpB,IAAI,CAAC,SAAS;YAAE,SAAQ;QACxB,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,KAAK;aAC3B,GAAG,CAAC,SAAS,EAAE,UAAU,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,WAAW,EAAE,CAAC;aAC9D,KAAK,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,CAAA;QACrB,IAAI,GAAG;YAAE,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;IACtC,CAAC;IACD,OAAO,OAAO,CAAA;AAChB,CAAC"}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { type Tx } from '@kernhq/kernel';
|
|
2
|
+
import { type Category as CategoryModel } from '../../contract/models.js';
|
|
3
|
+
import { categories } from '../schema.js';
|
|
4
|
+
type Row = typeof categories.$inferSelect;
|
|
5
|
+
/** What `reorder` returns: the live sequence as it now stands, and which rows actually moved. */
|
|
6
|
+
export interface Reordered {
|
|
7
|
+
rows: Row[];
|
|
8
|
+
/** Only the ids whose `order` changed — a change event for a row that did not move is a lie. */
|
|
9
|
+
moved: string[];
|
|
10
|
+
}
|
|
11
|
+
/** The wire shape: drizzle gives Date objects for timestamps, the contract promises ISO strings. */
|
|
12
|
+
export declare function toCategory(row: Row): CategoryModel;
|
|
13
|
+
/**
|
|
14
|
+
* How a workspace groups what it owns.
|
|
15
|
+
*
|
|
16
|
+
* Small on purpose. A category is a name and a position in a list; everything interesting about an
|
|
17
|
+
* asset belongs to the asset. The one thing here worth reading twice is that nothing deletes — see
|
|
18
|
+
* `archive` below.
|
|
19
|
+
*/
|
|
20
|
+
export declare class CategoryService {
|
|
21
|
+
/**
|
|
22
|
+
* Ordered by `order` and then by name.
|
|
23
|
+
*
|
|
24
|
+
* `order` is the sequence somebody dragged their categories into, and no two **live** categories
|
|
25
|
+
* share a number: `inventory_categories_ws_order_live_uq` is what makes that true rather than
|
|
26
|
+
* intended. So for the live set the tiebreak never fires.
|
|
27
|
+
*
|
|
28
|
+
* It stays because `list` also reads the archived rows, and those are outside the index: an
|
|
29
|
+
* archived category keeps the number it had when it left, and the very next reorder renumbers a
|
|
30
|
+
* live row onto it. A duplicate must sort the same way twice, and a name is the only column a
|
|
31
|
+
* person could predict.
|
|
32
|
+
*/
|
|
33
|
+
list(tx: Tx, workspaceId: string, includeArchived: boolean): Promise<CategoryModel[]>;
|
|
34
|
+
get(tx: Tx, workspaceId: string, categoryId: string): Promise<Row>;
|
|
35
|
+
/**
|
|
36
|
+
* A new category joins the **end** of the sequence, and a duplicate name is a `CONFLICT` with the
|
|
37
|
+
* name in it, never a 500.
|
|
38
|
+
*
|
|
39
|
+
* The unique index is what actually decides the name — checking first and inserting after is a
|
|
40
|
+
* race that two people adding "Laptops" at once will find — so the check is the insert, and the
|
|
41
|
+
* driver's 23505 is translated into a sentence rather than shown as "Failed query: insert into
|
|
42
|
+
* mod_inventory.categories …".
|
|
43
|
+
*
|
|
44
|
+
* The position is decided the same way, in the statement rather than around it. It used to be an
|
|
45
|
+
* optional number the caller passed and defaulted to 0, so every category anybody added landed at
|
|
46
|
+
* the *front*, tied with whatever was already there, and the list resolved the tie by name — a
|
|
47
|
+
* new category appearing in the middle of a sequence somebody had arranged by hand.
|
|
48
|
+
*
|
|
49
|
+
* **The subquery is not what makes the number unique — the lock above it is.** This used to say
|
|
50
|
+
* that a subquery inside the insert stopped two creates in flight from taking the same maximum,
|
|
51
|
+
* and that is false: under READ COMMITTED each statement takes its own snapshot, so both read a
|
|
52
|
+
* list without the other's row in it and both appended to the same place. `lockAppends` is what
|
|
53
|
+
* serialises them, and `inventory_categories_ws_order_live_uq` is what refuses the pair if
|
|
54
|
+
* anything ever reaches the table around it.
|
|
55
|
+
*/
|
|
56
|
+
create(tx: Tx, workspaceId: string, name: string): Promise<Row>;
|
|
57
|
+
/** A rename, and nothing else — the sequence is `reorder`'s to write. */
|
|
58
|
+
update(tx: Tx, workspaceId: string, categoryId: string, patch: {
|
|
59
|
+
name?: string;
|
|
60
|
+
}): Promise<Row>;
|
|
61
|
+
/**
|
|
62
|
+
* Archive and restore, which are one procedure because they are one column.
|
|
63
|
+
*
|
|
64
|
+
* **Nothing here deletes, and that is the decision this file exists to record.**
|
|
65
|
+
* `assets.category_id` carries no foreign key — a module keeps its ids plain — so a delete would
|
|
66
|
+
* leave every asset filed under this category pointing at a row that is not there: a blank column
|
|
67
|
+
* on the row, a picker that cannot explain what the asset used to be, and an `asset_history`
|
|
68
|
+
* entry saying "category changed to <nothing>". None of it recoverable, all of it caused by a
|
|
69
|
+
* settings screen. An archived category disappears from every picker and every filter and leaves
|
|
70
|
+
* each asset able to say what it is.
|
|
71
|
+
*
|
|
72
|
+
* **A restore appends**, for the reason `create` appends. The row kept the position it had when
|
|
73
|
+
* it left, and every live category has been renumbered since — so putting it back where its old
|
|
74
|
+
* number points lands it in the middle of somebody's arrangement, tied with whatever is there
|
|
75
|
+
* now. The end of the list is the one place a person can find it again. Archiving leaves the
|
|
76
|
+
* number alone: it is out of every list that reads it, and it is about to be overwritten anyway.
|
|
77
|
+
*
|
|
78
|
+
* A restore appends, so it races exactly as `create` does and is serialised the same way — and it
|
|
79
|
+
* is the one place other than `create` where the live set grows, so it is the other place the
|
|
80
|
+
* limit is enforced. Archiving needs neither: it takes a row out of the live set, and out of the
|
|
81
|
+
* partial index with it.
|
|
82
|
+
*/
|
|
83
|
+
archive(tx: Tx, workspaceId: string, categoryId: string, archived: boolean): Promise<Row>;
|
|
84
|
+
/**
|
|
85
|
+
* The sequence, rewritten from the ids somebody put it in — the only thing that writes `order`.
|
|
86
|
+
*
|
|
87
|
+
* Three refusals before a single row is touched, and all three are the same idea: this call
|
|
88
|
+
* describes the whole live list, so a list that does not match the workspace is not a partial
|
|
89
|
+
* instruction to be completed, it is an ordering of something else.
|
|
90
|
+
*
|
|
91
|
+
* - **an id twice** — arithmetic that cannot be carried out, `BAD_REQUEST`;
|
|
92
|
+
* - **an id that is not this workspace's** — `NOT_FOUND`, the answer `update` and `archive`
|
|
93
|
+
* already give for one, and the answer that does not confirm the row exists elsewhere;
|
|
94
|
+
* - **a live category the list does not name, or an archived one it does** — somebody added,
|
|
95
|
+
* archived or restored a category while this page was open. Renumbering what was named would
|
|
96
|
+
* put the missing one wherever its stale number happened to land, silently. `CONFLICT` with
|
|
97
|
+
* `inventory.category.order_stale`, which the client turns into "reload and try again".
|
|
98
|
+
*
|
|
99
|
+
* All of it inside one transaction, opened by the router, so a refusal writes nothing and a
|
|
100
|
+
* renumbering is never half-applied. The `for update` is what makes two reorders arriving at once
|
|
101
|
+
* queue rather than interleave — without it both read the same list, both pass the check, and the
|
|
102
|
+
* writes of one land between the writes of the other, which is how a sequence ends up being
|
|
103
|
+
* neither of the two orders anybody asked for. Locking in id order is what stops two of them
|
|
104
|
+
* taking the same rows in opposite orders and deadlocking.
|
|
105
|
+
*/
|
|
106
|
+
reorder(tx: Tx, workspaceId: string, categoryIds: string[]): Promise<Reordered>;
|
|
107
|
+
/**
|
|
108
|
+
* One past the highest position this workspace has used, archived rows counted.
|
|
109
|
+
*
|
|
110
|
+
* Archived rows count because one of them can be restored, and a restored category landing on a
|
|
111
|
+
* live one's number is the tie this whole change exists to remove.
|
|
112
|
+
*
|
|
113
|
+
* **Only correct under `lockAppends`.** A subquery inside the write saves a round trip and settles
|
|
114
|
+
* nothing about concurrency: under READ COMMITTED it is evaluated against the snapshot its own
|
|
115
|
+
* statement started with, so two transactions appending at the same instant read the same maximum
|
|
116
|
+
* and take the same number. That is the defect `0008` exists for.
|
|
117
|
+
*/
|
|
118
|
+
private static appended;
|
|
119
|
+
/**
|
|
120
|
+
* Hold the right to append to this workspace's list until the transaction ends.
|
|
121
|
+
*
|
|
122
|
+
* An advisory lock rather than `select … for update`, because the thing being protected is the
|
|
123
|
+
* *next* number rather than any row that exists: a workspace with no categories at all has no row
|
|
124
|
+
* to lock, and two creates against it would still collide. Taken per workspace, so two workspaces
|
|
125
|
+
* adding a category at the same moment never wait for each other.
|
|
126
|
+
*
|
|
127
|
+
* The first key is a constant for this list, so another module taking an advisory lock on the same
|
|
128
|
+
* workspace does not queue behind this one by accident.
|
|
129
|
+
*
|
|
130
|
+
* It cannot deadlock against `reorder`, which takes row locks and never asks for this one — so
|
|
131
|
+
* there is no pair of waits pointing at each other.
|
|
132
|
+
*/
|
|
133
|
+
private static lockAppends;
|
|
134
|
+
/**
|
|
135
|
+
* Refuse the one that would take a workspace past `MAX_LIVE_CATEGORIES`, and say so.
|
|
136
|
+
*
|
|
137
|
+
* The number exists because `categories.reorder` is handed every live category at once and that
|
|
138
|
+
* array needs a bound. Leaving the bound only on the array is a silent ceiling: a workspace could
|
|
139
|
+
* pass it one category at a time and then discover that the only procedure that can order them is
|
|
140
|
+
* the one it can no longer call. Enforced here, the array can always name every live category a
|
|
141
|
+
* workspace is allowed to have.
|
|
142
|
+
*
|
|
143
|
+
* **Live rows, not every row ever made**, so that archiving one frees a place — which is what the
|
|
144
|
+
* refusal tells the reader to do, and the advice has to be true.
|
|
145
|
+
*/
|
|
146
|
+
private static roomForOneMore;
|
|
147
|
+
/**
|
|
148
|
+
* The unique index refused it, or something else did and must not be disguised.
|
|
149
|
+
*
|
|
150
|
+
* Rethrowing the original for anything that is not this constraint matters: turning every failed
|
|
151
|
+
* insert into "that name is taken" would hide a real fault behind a sentence a person would act
|
|
152
|
+
* on by renaming something, for ever.
|
|
153
|
+
*/
|
|
154
|
+
private static nameTaken;
|
|
155
|
+
}
|
|
156
|
+
export {};
|
|
157
|
+
//# sourceMappingURL=categories.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"categories.d.ts","sourceRoot":"","sources":["../../../src/server/services/categories.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,KAAK,EAAE,EAAU,MAAM,gBAAgB,CAAA;AAE3D,OAAO,EAAE,KAAK,QAAQ,IAAI,aAAa,EAAuB,MAAM,0BAA0B,CAAA;AAC9F,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAGzC,KAAK,GAAG,GAAG,OAAO,UAAU,CAAC,YAAY,CAAA;AAKzC,iGAAiG;AACjG,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,GAAG,EAAE,CAAA;IACX,gGAAgG;IAChG,KAAK,EAAE,MAAM,EAAE,CAAA;CAChB;AAED,oGAAoG;AACpG,wBAAgB,UAAU,CAAC,GAAG,EAAE,GAAG,GAAG,aAAa,CAUlD;AAED;;;;;;GAMG;AACH,qBAAa,eAAe;IAC1B;;;;;;;;;;;OAWG;IACG,IAAI,CAAC,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,eAAe,EAAE,OAAO,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC;IAWrF,GAAG,CAAC,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC;IASxE;;;;;;;;;;;;;;;;;;;;OAoBG;IACG,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC;IAcrE,yEAAyE;IACnE,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,GAAG,CAAC;IAoBrG;;;;;;;;;;;;;;;;;;;;;OAqBG;IACG,OAAO,CAAC,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC;IAkB/F;;;;;;;;;;;;;;;;;;;;;OAqBG;IACG,OAAO,CAAC,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,SAAS,CAAC;IA0ErF;;;;;;;;;;OAUG;IACH,OAAO,CAAC,MAAM,CAAC,QAAQ;IAIvB;;;;;;;;;;;;;OAaG;mBACkB,WAAW;IAMhC;;;;;;;;;;;OAWG;mBACkB,cAAc;IAYnC;;;;;;OAMG;IACH,OAAO,CAAC,MAAM,CAAC,SAAS;CAOzB"}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { KernError, uuidv7 } from '@kernhq/kernel';
|
|
2
|
+
import { and, asc, count, eq, isNull, sql } from 'drizzle-orm';
|
|
3
|
+
import { MAX_LIVE_CATEGORIES } from '../../contract/models.js';
|
|
4
|
+
import { categories } from '../schema.js';
|
|
5
|
+
import { violated } from './db-errors.js';
|
|
6
|
+
/** The unique index `0000_init.sql` put on (workspace_id, name). */
|
|
7
|
+
const NAME_TAKEN = 'inventory_categories_ws_name_uq';
|
|
8
|
+
/** The wire shape: drizzle gives Date objects for timestamps, the contract promises ISO strings. */
|
|
9
|
+
export function toCategory(row) {
|
|
10
|
+
return {
|
|
11
|
+
id: row.id,
|
|
12
|
+
workspaceId: row.workspaceId,
|
|
13
|
+
name: row.name,
|
|
14
|
+
order: row.order,
|
|
15
|
+
createdAt: row.createdAt.toISOString(),
|
|
16
|
+
updatedAt: row.updatedAt.toISOString(),
|
|
17
|
+
archivedAt: row.archivedAt?.toISOString() ?? null,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* How a workspace groups what it owns.
|
|
22
|
+
*
|
|
23
|
+
* Small on purpose. A category is a name and a position in a list; everything interesting about an
|
|
24
|
+
* asset belongs to the asset. The one thing here worth reading twice is that nothing deletes — see
|
|
25
|
+
* `archive` below.
|
|
26
|
+
*/
|
|
27
|
+
export class CategoryService {
|
|
28
|
+
/**
|
|
29
|
+
* Ordered by `order` and then by name.
|
|
30
|
+
*
|
|
31
|
+
* `order` is the sequence somebody dragged their categories into, and no two **live** categories
|
|
32
|
+
* share a number: `inventory_categories_ws_order_live_uq` is what makes that true rather than
|
|
33
|
+
* intended. So for the live set the tiebreak never fires.
|
|
34
|
+
*
|
|
35
|
+
* It stays because `list` also reads the archived rows, and those are outside the index: an
|
|
36
|
+
* archived category keeps the number it had when it left, and the very next reorder renumbers a
|
|
37
|
+
* live row onto it. A duplicate must sort the same way twice, and a name is the only column a
|
|
38
|
+
* person could predict.
|
|
39
|
+
*/
|
|
40
|
+
async list(tx, workspaceId, includeArchived) {
|
|
41
|
+
const filters = [eq(categories.workspaceId, workspaceId)];
|
|
42
|
+
if (!includeArchived)
|
|
43
|
+
filters.push(isNull(categories.archivedAt));
|
|
44
|
+
const rows = await tx
|
|
45
|
+
.select()
|
|
46
|
+
.from(categories)
|
|
47
|
+
.where(and(...filters))
|
|
48
|
+
.orderBy(asc(categories.order), asc(categories.name));
|
|
49
|
+
return rows.map(toCategory);
|
|
50
|
+
}
|
|
51
|
+
async get(tx, workspaceId, categoryId) {
|
|
52
|
+
const [row] = await tx
|
|
53
|
+
.select()
|
|
54
|
+
.from(categories)
|
|
55
|
+
.where(and(eq(categories.workspaceId, workspaceId), eq(categories.id, categoryId)));
|
|
56
|
+
if (!row)
|
|
57
|
+
throw KernError.notFound('Category');
|
|
58
|
+
return row;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A new category joins the **end** of the sequence, and a duplicate name is a `CONFLICT` with the
|
|
62
|
+
* name in it, never a 500.
|
|
63
|
+
*
|
|
64
|
+
* The unique index is what actually decides the name — checking first and inserting after is a
|
|
65
|
+
* race that two people adding "Laptops" at once will find — so the check is the insert, and the
|
|
66
|
+
* driver's 23505 is translated into a sentence rather than shown as "Failed query: insert into
|
|
67
|
+
* mod_inventory.categories …".
|
|
68
|
+
*
|
|
69
|
+
* The position is decided the same way, in the statement rather than around it. It used to be an
|
|
70
|
+
* optional number the caller passed and defaulted to 0, so every category anybody added landed at
|
|
71
|
+
* the *front*, tied with whatever was already there, and the list resolved the tie by name — a
|
|
72
|
+
* new category appearing in the middle of a sequence somebody had arranged by hand.
|
|
73
|
+
*
|
|
74
|
+
* **The subquery is not what makes the number unique — the lock above it is.** This used to say
|
|
75
|
+
* that a subquery inside the insert stopped two creates in flight from taking the same maximum,
|
|
76
|
+
* and that is false: under READ COMMITTED each statement takes its own snapshot, so both read a
|
|
77
|
+
* list without the other's row in it and both appended to the same place. `lockAppends` is what
|
|
78
|
+
* serialises them, and `inventory_categories_ws_order_live_uq` is what refuses the pair if
|
|
79
|
+
* anything ever reaches the table around it.
|
|
80
|
+
*/
|
|
81
|
+
async create(tx, workspaceId, name) {
|
|
82
|
+
await CategoryService.lockAppends(tx, workspaceId);
|
|
83
|
+
await CategoryService.roomForOneMore(tx, workspaceId);
|
|
84
|
+
try {
|
|
85
|
+
const [row] = await tx
|
|
86
|
+
.insert(categories)
|
|
87
|
+
.values({ id: uuidv7(), workspaceId, name, order: CategoryService.appended(workspaceId) })
|
|
88
|
+
.returning();
|
|
89
|
+
return row;
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
throw CategoryService.nameTaken(err, name);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** A rename, and nothing else — the sequence is `reorder`'s to write. */
|
|
96
|
+
async update(tx, workspaceId, categoryId, patch) {
|
|
97
|
+
const previous = await this.get(tx, workspaceId, categoryId);
|
|
98
|
+
// `undefined` means "not mentioned". The column is not nullable, so there is no "clear it" here
|
|
99
|
+
// and no reason for the `null`-versus-`undefined` care `assets.update` needs.
|
|
100
|
+
const values = {
|
|
101
|
+
name: patch.name ?? previous.name,
|
|
102
|
+
updatedAt: new Date(),
|
|
103
|
+
};
|
|
104
|
+
try {
|
|
105
|
+
const [row] = await tx
|
|
106
|
+
.update(categories)
|
|
107
|
+
.set(values)
|
|
108
|
+
.where(and(eq(categories.workspaceId, workspaceId), eq(categories.id, categoryId)))
|
|
109
|
+
.returning();
|
|
110
|
+
return row;
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
throw CategoryService.nameTaken(err, values.name);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Archive and restore, which are one procedure because they are one column.
|
|
118
|
+
*
|
|
119
|
+
* **Nothing here deletes, and that is the decision this file exists to record.**
|
|
120
|
+
* `assets.category_id` carries no foreign key — a module keeps its ids plain — so a delete would
|
|
121
|
+
* leave every asset filed under this category pointing at a row that is not there: a blank column
|
|
122
|
+
* on the row, a picker that cannot explain what the asset used to be, and an `asset_history`
|
|
123
|
+
* entry saying "category changed to <nothing>". None of it recoverable, all of it caused by a
|
|
124
|
+
* settings screen. An archived category disappears from every picker and every filter and leaves
|
|
125
|
+
* each asset able to say what it is.
|
|
126
|
+
*
|
|
127
|
+
* **A restore appends**, for the reason `create` appends. The row kept the position it had when
|
|
128
|
+
* it left, and every live category has been renumbered since — so putting it back where its old
|
|
129
|
+
* number points lands it in the middle of somebody's arrangement, tied with whatever is there
|
|
130
|
+
* now. The end of the list is the one place a person can find it again. Archiving leaves the
|
|
131
|
+
* number alone: it is out of every list that reads it, and it is about to be overwritten anyway.
|
|
132
|
+
*
|
|
133
|
+
* A restore appends, so it races exactly as `create` does and is serialised the same way — and it
|
|
134
|
+
* is the one place other than `create` where the live set grows, so it is the other place the
|
|
135
|
+
* limit is enforced. Archiving needs neither: it takes a row out of the live set, and out of the
|
|
136
|
+
* partial index with it.
|
|
137
|
+
*/
|
|
138
|
+
async archive(tx, workspaceId, categoryId, archived) {
|
|
139
|
+
if (!archived) {
|
|
140
|
+
await CategoryService.lockAppends(tx, workspaceId);
|
|
141
|
+
await CategoryService.roomForOneMore(tx, workspaceId);
|
|
142
|
+
}
|
|
143
|
+
const [row] = await tx
|
|
144
|
+
.update(categories)
|
|
145
|
+
.set({
|
|
146
|
+
archivedAt: archived ? new Date() : null,
|
|
147
|
+
...(archived ? {} : { order: CategoryService.appended(workspaceId) }),
|
|
148
|
+
updatedAt: new Date(),
|
|
149
|
+
})
|
|
150
|
+
.where(and(eq(categories.workspaceId, workspaceId), eq(categories.id, categoryId)))
|
|
151
|
+
.returning();
|
|
152
|
+
if (!row)
|
|
153
|
+
throw KernError.notFound('Category');
|
|
154
|
+
return row;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The sequence, rewritten from the ids somebody put it in — the only thing that writes `order`.
|
|
158
|
+
*
|
|
159
|
+
* Three refusals before a single row is touched, and all three are the same idea: this call
|
|
160
|
+
* describes the whole live list, so a list that does not match the workspace is not a partial
|
|
161
|
+
* instruction to be completed, it is an ordering of something else.
|
|
162
|
+
*
|
|
163
|
+
* - **an id twice** — arithmetic that cannot be carried out, `BAD_REQUEST`;
|
|
164
|
+
* - **an id that is not this workspace's** — `NOT_FOUND`, the answer `update` and `archive`
|
|
165
|
+
* already give for one, and the answer that does not confirm the row exists elsewhere;
|
|
166
|
+
* - **a live category the list does not name, or an archived one it does** — somebody added,
|
|
167
|
+
* archived or restored a category while this page was open. Renumbering what was named would
|
|
168
|
+
* put the missing one wherever its stale number happened to land, silently. `CONFLICT` with
|
|
169
|
+
* `inventory.category.order_stale`, which the client turns into "reload and try again".
|
|
170
|
+
*
|
|
171
|
+
* All of it inside one transaction, opened by the router, so a refusal writes nothing and a
|
|
172
|
+
* renumbering is never half-applied. The `for update` is what makes two reorders arriving at once
|
|
173
|
+
* queue rather than interleave — without it both read the same list, both pass the check, and the
|
|
174
|
+
* writes of one land between the writes of the other, which is how a sequence ends up being
|
|
175
|
+
* neither of the two orders anybody asked for. Locking in id order is what stops two of them
|
|
176
|
+
* taking the same rows in opposite orders and deadlocking.
|
|
177
|
+
*/
|
|
178
|
+
async reorder(tx, workspaceId, categoryIds) {
|
|
179
|
+
const named = new Set(categoryIds);
|
|
180
|
+
if (named.size !== categoryIds.length)
|
|
181
|
+
throw KernError.badRequest('That list of categories names the same one more than once.');
|
|
182
|
+
const current = await tx
|
|
183
|
+
.select()
|
|
184
|
+
.from(categories)
|
|
185
|
+
.where(eq(categories.workspaceId, workspaceId))
|
|
186
|
+
.orderBy(asc(categories.id))
|
|
187
|
+
.for('update');
|
|
188
|
+
const known = new Map(current.map((row) => [row.id, row]));
|
|
189
|
+
if (categoryIds.some((id) => !known.has(id)))
|
|
190
|
+
throw KernError.notFound('Category');
|
|
191
|
+
const live = current.filter((row) => !row.archivedAt);
|
|
192
|
+
if (live.some((row) => !named.has(row.id)) || categoryIds.some((id) => known.get(id)?.archivedAt))
|
|
193
|
+
throw KernError.conflict('The categories changed while this list was open, so this order was not saved. Reload the list and arrange it again.', 'inventory.category.order_stale');
|
|
194
|
+
// Only the rows that actually move are touched — a workspace has tens of categories, they are
|
|
195
|
+
// already locked, and a change event for a row whose position did not change would tell every
|
|
196
|
+
// screen in the workspace about a write that did not happen. `moved` is settled here, before a
|
|
197
|
+
// single write, so it stays the honest list whatever the two passes below do.
|
|
198
|
+
const now = new Date();
|
|
199
|
+
const going = categoryIds
|
|
200
|
+
.map((id, index) => ({ id, index }))
|
|
201
|
+
.filter(({ id, index }) => known.get(id)?.order !== index);
|
|
202
|
+
const moved = going.map(({ id }) => id);
|
|
203
|
+
/**
|
|
204
|
+
* Parked out of the way first, and only then put down where they belong.
|
|
205
|
+
*
|
|
206
|
+
* `inventory_categories_ws_order_live_uq` is a plain unique index, and Postgres checks one of
|
|
207
|
+
* those row by row rather than at the end of the statement. There is no deferrable form to reach
|
|
208
|
+
* for either: a unique *constraint* can be deferred and cannot be partial, and this one has to be
|
|
209
|
+
* partial. So the single-pass loop writes a collision the moment two rows swap — putting the
|
|
210
|
+
* first on 1 while the second still holds 1 — and a swap is the commonest reorder there is.
|
|
211
|
+
*
|
|
212
|
+
* `park` sits above both the highest number any row in this workspace holds **and** the last
|
|
213
|
+
* place in the new sequence. That is what makes the two passes safe: the parked values are
|
|
214
|
+
* distinct from one another and from every row staying put, and the `0…n-1` the second pass
|
|
215
|
+
* writes into is empty, because every live row that could have been sitting there is either
|
|
216
|
+
* parked or already on the number it is being given.
|
|
217
|
+
*
|
|
218
|
+
* Only the rows that actually move are written, so a reorder that shifts one row does not stamp
|
|
219
|
+
* `updated_at` across the whole list — and `updated_at` is left off the parking pass, which is
|
|
220
|
+
* bookkeeping rather than a change anybody made.
|
|
221
|
+
*/
|
|
222
|
+
if (going.length > 0) {
|
|
223
|
+
const park = Math.max(...current.map((row) => row.order), categoryIds.length - 1) + 1;
|
|
224
|
+
for (const [offset, { id }] of going.entries()) {
|
|
225
|
+
await tx
|
|
226
|
+
.update(categories)
|
|
227
|
+
.set({ order: park + offset })
|
|
228
|
+
.where(and(eq(categories.workspaceId, workspaceId), eq(categories.id, id)));
|
|
229
|
+
}
|
|
230
|
+
for (const { id, index } of going) {
|
|
231
|
+
await tx
|
|
232
|
+
.update(categories)
|
|
233
|
+
.set({ order: index, updatedAt: now })
|
|
234
|
+
.where(and(eq(categories.workspaceId, workspaceId), eq(categories.id, id)));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const rows = await tx
|
|
238
|
+
.select()
|
|
239
|
+
.from(categories)
|
|
240
|
+
.where(and(eq(categories.workspaceId, workspaceId), isNull(categories.archivedAt)))
|
|
241
|
+
.orderBy(asc(categories.order), asc(categories.name));
|
|
242
|
+
return { rows, moved };
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* One past the highest position this workspace has used, archived rows counted.
|
|
246
|
+
*
|
|
247
|
+
* Archived rows count because one of them can be restored, and a restored category landing on a
|
|
248
|
+
* live one's number is the tie this whole change exists to remove.
|
|
249
|
+
*
|
|
250
|
+
* **Only correct under `lockAppends`.** A subquery inside the write saves a round trip and settles
|
|
251
|
+
* nothing about concurrency: under READ COMMITTED it is evaluated against the snapshot its own
|
|
252
|
+
* statement started with, so two transactions appending at the same instant read the same maximum
|
|
253
|
+
* and take the same number. That is the defect `0008` exists for.
|
|
254
|
+
*/
|
|
255
|
+
static appended(workspaceId) {
|
|
256
|
+
return sql `(select coalesce(max(${categories.order}), -1) + 1 from ${categories} where ${categories.workspaceId} = ${workspaceId})`;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Hold the right to append to this workspace's list until the transaction ends.
|
|
260
|
+
*
|
|
261
|
+
* An advisory lock rather than `select … for update`, because the thing being protected is the
|
|
262
|
+
* *next* number rather than any row that exists: a workspace with no categories at all has no row
|
|
263
|
+
* to lock, and two creates against it would still collide. Taken per workspace, so two workspaces
|
|
264
|
+
* adding a category at the same moment never wait for each other.
|
|
265
|
+
*
|
|
266
|
+
* The first key is a constant for this list, so another module taking an advisory lock on the same
|
|
267
|
+
* workspace does not queue behind this one by accident.
|
|
268
|
+
*
|
|
269
|
+
* It cannot deadlock against `reorder`, which takes row locks and never asks for this one — so
|
|
270
|
+
* there is no pair of waits pointing at each other.
|
|
271
|
+
*/
|
|
272
|
+
static async lockAppends(tx, workspaceId) {
|
|
273
|
+
await tx.execute(sql `select pg_advisory_xact_lock(hashtext('mod_inventory.categories.order'), hashtext(${workspaceId}))`);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Refuse the one that would take a workspace past `MAX_LIVE_CATEGORIES`, and say so.
|
|
277
|
+
*
|
|
278
|
+
* The number exists because `categories.reorder` is handed every live category at once and that
|
|
279
|
+
* array needs a bound. Leaving the bound only on the array is a silent ceiling: a workspace could
|
|
280
|
+
* pass it one category at a time and then discover that the only procedure that can order them is
|
|
281
|
+
* the one it can no longer call. Enforced here, the array can always name every live category a
|
|
282
|
+
* workspace is allowed to have.
|
|
283
|
+
*
|
|
284
|
+
* **Live rows, not every row ever made**, so that archiving one frees a place — which is what the
|
|
285
|
+
* refusal tells the reader to do, and the advice has to be true.
|
|
286
|
+
*/
|
|
287
|
+
static async roomForOneMore(tx, workspaceId) {
|
|
288
|
+
const [row] = await tx
|
|
289
|
+
.select({ n: count() })
|
|
290
|
+
.from(categories)
|
|
291
|
+
.where(and(eq(categories.workspaceId, workspaceId), isNull(categories.archivedAt)));
|
|
292
|
+
if ((row?.n ?? 0) < MAX_LIVE_CATEGORIES)
|
|
293
|
+
return;
|
|
294
|
+
throw KernError.conflict(`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.`, 'inventory.category.limit_reached');
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* The unique index refused it, or something else did and must not be disguised.
|
|
298
|
+
*
|
|
299
|
+
* Rethrowing the original for anything that is not this constraint matters: turning every failed
|
|
300
|
+
* insert into "that name is taken" would hide a real fault behind a sentence a person would act
|
|
301
|
+
* on by renaming something, for ever.
|
|
302
|
+
*/
|
|
303
|
+
static nameTaken(err, name) {
|
|
304
|
+
if (!violated(err, NAME_TAKEN))
|
|
305
|
+
return err;
|
|
306
|
+
return KernError.conflict(`This workspace already has a category called “${name}”.`, 'inventory.category.name_taken');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
//# sourceMappingURL=categories.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"categories.js","sourceRoot":"","sources":["../../../src/server/services/categories.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one page boundary this module issues, for every list it pages.
|
|
3
|
+
*
|
|
4
|
+
* **The bookmarked row's id, and the sort it was issued under.** Nothing else — in particular not
|
|
5
|
+
* the sort key itself, which is what it used to carry. That earlier shape (`{k: <sort key>, i:
|
|
6
|
+
* <id>}`) was wrong three separate ways, each of them reachable by anyone who could type into the
|
|
7
|
+
* address bar:
|
|
8
|
+
*
|
|
9
|
+
* - **It could not be trusted.** `decode` checked only that `i` was a *string*, and `i` is
|
|
10
|
+
* interpolated into `(col, id) < ($1, $2::uuid)`. `{"k":"x","i":"not-a-uuid"}` therefore
|
|
11
|
+
* reached Postgres as a 22P02 nobody caught — an unhandled 500 and an error-level log line per
|
|
12
|
+
* request, at the 600-a-minute the rate limiter allows.
|
|
13
|
+
* - **It was not bound to its sort.** A cursor issued under `sort=recent` replayed under
|
|
14
|
+
* `sort=code` compared a uuid against a code, so page two came back equal to page one and
|
|
15
|
+
* "Load more" never ended.
|
|
16
|
+
* - **It was unbounded.** `sort=name` on a 200-character Persian name encoded to 602 characters,
|
|
17
|
+
* and `Cursor` in `@kernhq/contracts` is `max(512)` — so a long enough name broke "Load more"
|
|
18
|
+
* with a validation error, in exactly the locales least likely to be tested.
|
|
19
|
+
*
|
|
20
|
+
* Carrying the id alone and reading the sort key back from that row in SQL answers all three: the
|
|
21
|
+
* cursor is a fixed ~60 characters whatever the name, a value that is not a uuid is refused before
|
|
22
|
+
* it is anywhere near the database, and a cursor whose sort disagrees with the request is refused
|
|
23
|
+
* rather than quietly misread. Base64 so nothing in the product is tempted to read it — it is a
|
|
24
|
+
* bookmark, not an offset, and its shape is this file's business.
|
|
25
|
+
*
|
|
26
|
+
* It lives here rather than in `assets.ts` because the repair list pages too, and a second bookmark
|
|
27
|
+
* format would be a second set of those three bugs. `src/client/mock.ts` mirrors this byte for
|
|
28
|
+
* byte; read them as one pair and change them as one pair.
|
|
29
|
+
*
|
|
30
|
+
* The asset timeline is the one list that does **not** use this one: it is ordered by a sequence
|
|
31
|
+
* rather than by a row id, so its bookmark is `SeqBookmark` below.
|
|
32
|
+
*/
|
|
33
|
+
export interface Bookmark<S extends string> {
|
|
34
|
+
i: string;
|
|
35
|
+
s: S;
|
|
36
|
+
}
|
|
37
|
+
export declare const encodeMark: <S extends string>(mark: Bookmark<S>) => string;
|
|
38
|
+
export declare function decodeMark<S extends string>(cursor: string, sort: S): Bookmark<S>;
|
|
39
|
+
/**
|
|
40
|
+
* The other page boundary: a row's **sequence number**, and the sort it was issued under.
|
|
41
|
+
*
|
|
42
|
+
* The asset timeline is ordered by `asset_history.seq` rather than by the row id, because a uuidv7
|
|
43
|
+
* is only ordered to the millisecond and two entries written inside one — an attach and a detach, a
|
|
44
|
+
* create and its first entry — sort by ten random bytes. `schema.ts` argues that at the column. A
|
|
45
|
+
* bookmark into that ordering has to name the sequence value, so it is a number rather than a uuid
|
|
46
|
+
* and needs its own codec: handing `decodeMark` a number would have it refused as "not a uuid", and
|
|
47
|
+
* relaxing `decodeMark` to accept either is how one of the two shapes ends up unvalidated.
|
|
48
|
+
*
|
|
49
|
+
* It keeps all three properties the uuid bookmark has, and for the same reasons:
|
|
50
|
+
*
|
|
51
|
+
* - **bounded** — a base64url of `{"n":<integer>,"s":"recent"}` is around forty characters
|
|
52
|
+
* whatever the row, well inside `Cursor`'s `max(512)` in `@kernhq/contracts`;
|
|
53
|
+
* - **checked before it reaches SQL** — anything that is not a safe positive integer is refused
|
|
54
|
+
* here rather than interpolated into a `bigint` comparison for Postgres to reject as a 22P02;
|
|
55
|
+
* - **bound to its sort** — a bookmark issued under one ordering is meaningless under another,
|
|
56
|
+
* and reading it anyway is what made "Load more" loop for ever.
|
|
57
|
+
*
|
|
58
|
+
* `Number.isSafeInteger` is the upper bound as well as the type check: the column is a `bigint` and
|
|
59
|
+
* the sequence would have to hand out nine quadrillion history rows to reach it, but a cursor
|
|
60
|
+
* arrives from outside and `JSON.parse` will happily produce `1e400`.
|
|
61
|
+
*/
|
|
62
|
+
export interface SeqBookmark<S extends string> {
|
|
63
|
+
n: number;
|
|
64
|
+
s: S;
|
|
65
|
+
}
|
|
66
|
+
export declare const encodeSeqMark: <S extends string>(mark: SeqBookmark<S>) => string;
|
|
67
|
+
export declare function decodeSeqMark<S extends string>(cursor: string, sort: S): SeqBookmark<S>;
|
|
68
|
+
//# sourceMappingURL=cursor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cursor.d.ts","sourceRoot":"","sources":["../../../src/server/services/cursor.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAM,WAAW,QAAQ,CAAC,CAAC,SAAS,MAAM;IACxC,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,CAAC,CAAA;CACL;AAED,eAAO,MAAM,UAAU,GAAI,CAAC,SAAS,MAAM,EAAE,MAAM,QAAQ,CAAC,CAAC,CAAC,KAAG,MACA,CAAA;AAKjE,wBAAgB,UAAU,CAAC,CAAC,SAAS,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,CAcjF;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,WAAW,WAAW,CAAC,CAAC,SAAS,MAAM;IAC3C,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,CAAC,CAAA;CACL;AAED,eAAO,MAAM,aAAa,GAAI,CAAC,SAAS,MAAM,EAAE,MAAM,WAAW,CAAC,CAAC,CAAC,KAAG,MACN,CAAA;AAEjE,wBAAgB,aAAa,CAAC,CAAC,SAAS,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,GAAG,WAAW,CAAC,CAAC,CAAC,CAWvF"}
|