@kernhq/module-inventory 0.2.0 → 0.3.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 +104 -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 +322 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +213 -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 +1052 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +225 -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 +1337 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +363 -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 +158 -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 +57 -0
- package/dist/server/services/categories.d.ts.map +1 -0
- package/dist/server/services/categories.js +124 -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/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 +42 -0
- package/package.json +1 -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 +365 -0
- package/src/client/errors.ts +201 -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 +1043 -18
- package/src/client/mock.test.ts +395 -1
- package/src/client/mock.ts +1066 -79
- package/src/client/module.ts +54 -0
- 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/repairs.test.ts +38 -0
- package/src/client/repairs.ts +38 -0
- package/src/client/settings/CategoriesSettings.svelte +421 -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 +237 -0
- package/src/contract/notifications.ts +73 -0
- package/src/contract/permissions.ts +55 -2
- package/src/contract/router.ts +255 -1
- package/src/contract/settings.ts +24 -4
- package/src/module.test.ts +194 -4
- package/src/server/index.ts +311 -4
- package/src/server/inventory.int.test.ts +3573 -18
- package/src/server/jobs.ts +444 -0
- package/src/server/migrations.test.ts +113 -0
- package/src/server/router.ts +462 -6
- package/src/server/schema.ts +160 -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 +136 -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,166 @@
|
|
|
1
|
+
import type { core } from '@kernhq/contracts'
|
|
2
|
+
import type { Kernel, Tx } from '@kernhq/kernel'
|
|
3
|
+
import { and, eq, isNull, sql } from 'drizzle-orm'
|
|
4
|
+
import { MODULE_ID } from '../../contract/models.js'
|
|
5
|
+
import { assets, categories } from '../schema.js'
|
|
6
|
+
import type { NotifyService } from './notify.js'
|
|
7
|
+
|
|
8
|
+
/** One page of the full reindex. Big enough to be worth a round trip, small enough to hold. */
|
|
9
|
+
const SCAN_BATCH = 500
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How an asset is referred to from outside this module.
|
|
13
|
+
*
|
|
14
|
+
* One function, because a search hit and a resolved `inventory:asset:<id>` reference have to be the
|
|
15
|
+
* same thing — somebody who finds a laptop in the command palette and somebody who follows a link to
|
|
16
|
+
* it from a chat message are looking at the same row and must see the same words for it.
|
|
17
|
+
*/
|
|
18
|
+
export const assetUrl = (assetId: string) => `/inventory?asset=${assetId}`
|
|
19
|
+
export const ASSET_ICON = 'briefcase'
|
|
20
|
+
|
|
21
|
+
/** The columns a document is built from, and the one join it needs. */
|
|
22
|
+
export interface IndexableAsset {
|
|
23
|
+
asset: typeof assets.$inferSelect
|
|
24
|
+
/** The category's name, resolved at read time — never copied into `assets`, so a rename lands. */
|
|
25
|
+
categoryName: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What a person types into the command palette, and what an asset has to answer it with.
|
|
30
|
+
*
|
|
31
|
+
* An asset tag read off a sticker is the first of these — `INV-0042` is what somebody has in their
|
|
32
|
+
* hand — so it leads the title, exactly as tracker leads with an issue key. The rest is everything
|
|
33
|
+
* printed on or near the thing: its name, its serial number, where it is kept, and what the
|
|
34
|
+
* workspace files it under.
|
|
35
|
+
*
|
|
36
|
+
* **The category is indexed by name, not by id.** "chair" finds every chair filed under Furniture
|
|
37
|
+
* only if the word Furniture is in the document; an id in `attributes` is a filter, not a search
|
|
38
|
+
* term. The cost is that renaming a category leaves its assets' documents naming the old word until
|
|
39
|
+
* each one is next written, which is a staleness worth stating and not worth a workspace-wide
|
|
40
|
+
* rewrite on every rename — `core.search.reindex` is the thing that fixes it, and it exists.
|
|
41
|
+
*
|
|
42
|
+
* `acl` is null: an asset is visible to anybody with `inventory.asset.view`, which is a workspace
|
|
43
|
+
* permission and not a per-row one, so there is no narrower audience to name.
|
|
44
|
+
*/
|
|
45
|
+
export function assetSearchDocument(workspaceId: string, row: IndexableAsset): core.SearchDocument {
|
|
46
|
+
const body = [row.asset.description, row.asset.serialNumber, row.asset.location, row.categoryName]
|
|
47
|
+
.map((part) => part?.trim())
|
|
48
|
+
.filter((part): part is string => Boolean(part))
|
|
49
|
+
.join('\n')
|
|
50
|
+
return {
|
|
51
|
+
workspaceId: workspaceId as core.SearchDocument['workspaceId'],
|
|
52
|
+
object: { module: MODULE_ID, type: 'asset', id: row.asset.id },
|
|
53
|
+
title: `${row.asset.code} ${row.asset.name}`,
|
|
54
|
+
body: body || null,
|
|
55
|
+
url: assetUrl(row.asset.id),
|
|
56
|
+
icon: ASSET_ICON,
|
|
57
|
+
acl: null,
|
|
58
|
+
updatedAt: row.asset.updatedAt.toISOString(),
|
|
59
|
+
attributes: {
|
|
60
|
+
code: row.asset.code,
|
|
61
|
+
status: row.asset.status,
|
|
62
|
+
categoryId: row.asset.categoryId,
|
|
63
|
+
custodianUserId: row.asset.custodianUserId,
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The one select every read here makes: the asset, plus its category's current name. */
|
|
69
|
+
const withCategoryName = (tx: Tx) =>
|
|
70
|
+
tx
|
|
71
|
+
.select({ asset: assets, categoryName: categories.name })
|
|
72
|
+
.from(assets)
|
|
73
|
+
.leftJoin(
|
|
74
|
+
categories,
|
|
75
|
+
and(eq(categories.id, assets.categoryId), eq(categories.workspaceId, assets.workspaceId)),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Putting assets into the workspace-wide search index, and taking them out again.
|
|
80
|
+
*
|
|
81
|
+
* `NotifyService` has carried `index`/`unindex` since the module was written and nothing called
|
|
82
|
+
* either, because `objectTypes` had been taken off the manifest — a declared type with no indexer
|
|
83
|
+
* and no resolver renders a link to nothing, which reads to a person as a broken product rather
|
|
84
|
+
* than as a feature that has not shipped. Both halves arrive together here.
|
|
85
|
+
*
|
|
86
|
+
* Every method is best-effort by way of `NotifyService`: an asset must not fail to save because the
|
|
87
|
+
* core search service is briefly away. The register's own row is authoritative and a later
|
|
88
|
+
* `core.search.reindex` repairs whatever was missed, which is exactly what that job is for.
|
|
89
|
+
*/
|
|
90
|
+
export class SearchService {
|
|
91
|
+
constructor(
|
|
92
|
+
private readonly kernel: Kernel,
|
|
93
|
+
private readonly notify: NotifyService,
|
|
94
|
+
) {}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* One asset as a document, or `null` for one the index should not hold.
|
|
98
|
+
*
|
|
99
|
+
* `null` for a row that is gone **and** for one that is archived, because the indexer treats null
|
|
100
|
+
* as "remove this": an archived asset is one the workspace has said it no longer tracks, so
|
|
101
|
+
* finding it in the command palette would offer a door to something deliberately put away.
|
|
102
|
+
* Restoring it writes the row again, which reindexes it.
|
|
103
|
+
*/
|
|
104
|
+
async load(workspaceId: string, assetId: string): Promise<core.SearchDocument | null> {
|
|
105
|
+
return this.kernel.database.withWorkspace(workspaceId, async (tx) => {
|
|
106
|
+
const [row] = await withCategoryName(tx)
|
|
107
|
+
.where(and(eq(assets.workspaceId, workspaceId), eq(assets.id, assetId)))
|
|
108
|
+
.limit(1)
|
|
109
|
+
if (!row || row.asset.archivedAt) return null
|
|
110
|
+
return assetSearchDocument(workspaceId, row)
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Every live asset of a workspace, in pages, for a full reindex.
|
|
116
|
+
*
|
|
117
|
+
* Keyset by id rather than `offset`: a scan of a big workspace runs for a while, and an offset
|
|
118
|
+
* walk over a table somebody is writing to repeats and drops rows. Ids are uuidv7 and unique, so
|
|
119
|
+
* one column is a page boundary two rows cannot share — the same reasoning `assets.list` gives.
|
|
120
|
+
*
|
|
121
|
+
* One transaction per page rather than one for the whole scan: holding a pooled connection open
|
|
122
|
+
* across a reindex of ten thousand rows is how a service runs out of connections doing
|
|
123
|
+
* maintenance.
|
|
124
|
+
*/
|
|
125
|
+
async *scan(workspaceId: string): AsyncIterable<core.SearchDocument> {
|
|
126
|
+
let cursor: string | null = null
|
|
127
|
+
for (;;) {
|
|
128
|
+
const rows: IndexableAsset[] = await this.kernel.database.withWorkspace(workspaceId, (tx) =>
|
|
129
|
+
withCategoryName(tx)
|
|
130
|
+
.where(
|
|
131
|
+
and(
|
|
132
|
+
eq(assets.workspaceId, workspaceId),
|
|
133
|
+
isNull(assets.archivedAt),
|
|
134
|
+
cursor ? sql`${assets.id} > ${cursor}` : sql`true`,
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
.orderBy(assets.id)
|
|
138
|
+
.limit(SCAN_BATCH),
|
|
139
|
+
)
|
|
140
|
+
if (!rows.length) return
|
|
141
|
+
for (const row of rows) yield assetSearchDocument(workspaceId, row)
|
|
142
|
+
cursor = rows.at(-1)?.asset.id ?? null
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Bring the index into step with one asset, whichever direction that means.
|
|
148
|
+
*
|
|
149
|
+
* Called after the transaction has committed, never inside it — the index is another service's
|
|
150
|
+
* table, and telling it about a row a rollback then took away cannot be retracted. It is the
|
|
151
|
+
* reason `router.ts` announces from outside the transaction and this is announced beside the
|
|
152
|
+
* event and the realtime change.
|
|
153
|
+
*/
|
|
154
|
+
async reindex(workspaceId: string, assetId: string): Promise<void> {
|
|
155
|
+
const document = await this.load(workspaceId, assetId).catch((err) => {
|
|
156
|
+
this.kernel.log.warn(
|
|
157
|
+
{ err: err instanceof Error ? err.message : err, workspaceId, assetId },
|
|
158
|
+
'inventory: could not read an asset to reindex it',
|
|
159
|
+
)
|
|
160
|
+
return undefined
|
|
161
|
+
})
|
|
162
|
+
if (document === undefined) return
|
|
163
|
+
if (document) await this.notify.index([document])
|
|
164
|
+
else await this.notify.unindex(workspaceId, 'asset', [assetId])
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Tx } from '@kernhq/kernel'
|
|
2
|
+
import { and, eq, isNull, sql } from 'drizzle-orm'
|
|
3
|
+
import { AssetStatus, type InventoryStats } from '../../contract/models.js'
|
|
4
|
+
import { assets, repairs } from '../schema.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The register in numbers.
|
|
8
|
+
*
|
|
9
|
+
* It exists because the assets page had no honest number to print. `assets.list` is keyset-paged and
|
|
10
|
+
* never counts, so the count line said "50 assets" for a workspace with 214 of them and then "100
|
|
11
|
+
* assets" after *Load more* — a number that is simply wrong, on the line whose whole job is to be
|
|
12
|
+
* the number. A `count(*)` bolted onto every list page would be the expensive answer to that; one
|
|
13
|
+
* cheap procedure the page calls once is the right one, and it also gives the dashboard card
|
|
14
|
+
* something worth showing.
|
|
15
|
+
*
|
|
16
|
+
* **`total` counts what the list shows by default — live rows.** `archived` sits beside it rather
|
|
17
|
+
* than inside it, because a count line that silently included archived rows would disagree with the
|
|
18
|
+
* list underneath it.
|
|
19
|
+
*/
|
|
20
|
+
export class StatsService {
|
|
21
|
+
/**
|
|
22
|
+
* @param repairsOn whether this workspace has the `repairs` capability. Resolved by the caller
|
|
23
|
+
* *before* the transaction opens, because it is a settings read over the broker.
|
|
24
|
+
*/
|
|
25
|
+
async summary(tx: Tx, workspaceId: string, repairsOn: boolean): Promise<InventoryStats> {
|
|
26
|
+
const [totals] = await tx
|
|
27
|
+
.select({
|
|
28
|
+
total: sql<number>`count(*) filter (where ${assets.archivedAt} is null)`.mapWith(Number),
|
|
29
|
+
archived: sql<number>`count(*) filter (where ${assets.archivedAt} is not null)`.mapWith(Number),
|
|
30
|
+
unassigned:
|
|
31
|
+
sql<number>`count(*) filter (where ${assets.archivedAt} is null and ${assets.custodianUserId} is null)`.mapWith(
|
|
32
|
+
Number,
|
|
33
|
+
),
|
|
34
|
+
})
|
|
35
|
+
.from(assets)
|
|
36
|
+
.where(eq(assets.workspaceId, workspaceId))
|
|
37
|
+
|
|
38
|
+
const grouped = await tx
|
|
39
|
+
.select({ status: assets.status, n: sql<number>`count(*)`.mapWith(Number) })
|
|
40
|
+
.from(assets)
|
|
41
|
+
.where(and(eq(assets.workspaceId, workspaceId), isNull(assets.archivedAt)))
|
|
42
|
+
.groupBy(assets.status)
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Zero-filled over the whole enum, so a screen can render the set without asking which statuses
|
|
46
|
+
* happen to exist in this workspace. A missing key and a zero look the same on a card and are
|
|
47
|
+
* not the same thing to a `Record`.
|
|
48
|
+
*/
|
|
49
|
+
const byStatus = Object.fromEntries(
|
|
50
|
+
AssetStatus.options.map((status) => [status, 0]),
|
|
51
|
+
) as InventoryStats['byStatus']
|
|
52
|
+
for (const row of grouped) if (row.status in byStatus) byStatus[row.status as AssetStatus] = row.n
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
total: totals?.total ?? 0,
|
|
56
|
+
archived: totals?.archived ?? 0,
|
|
57
|
+
byStatus,
|
|
58
|
+
outForRepair: repairsOn ? await this.away(tx, workspaceId) : null,
|
|
59
|
+
unassigned: totals?.unassigned ?? 0,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* How many live items are at a repairer, counted from the **repair rows** rather than from
|
|
65
|
+
* `assets.status`.
|
|
66
|
+
*
|
|
67
|
+
* `byStatus.under_repair` is the same number, and deliberately arrives a different way: that one
|
|
68
|
+
* reads the cached column every list filters on, this one reads the fact the column is derived
|
|
69
|
+
* from. `deriveStatus` is what keeps them equal, and `inventory.int.test.ts` asserts they are —
|
|
70
|
+
* which is a real check on the derivation rather than two ways of writing the same query.
|
|
71
|
+
*
|
|
72
|
+
* **Joined to `assets` for the live rows, which it did not used to be.** The old version leaned on
|
|
73
|
+
* `AssetService.archive` refusing to retire an item that is away, so every open repair was assumed
|
|
74
|
+
* to belong to a live asset. That refusal is now withdrawn while the workspace has the `repairs`
|
|
75
|
+
* capability off — it named a procedure answering 404, and leaving it in place is what stranded
|
|
76
|
+
* the asset — so an archived row with an open repair is reachable, and `byStatus.under_repair`
|
|
77
|
+
* (live rows only) would have disagreed with this number the moment the capability came back on.
|
|
78
|
+
* Two counts of one thing have to be counted over one set.
|
|
79
|
+
*/
|
|
80
|
+
private async away(tx: Tx, workspaceId: string): Promise<number> {
|
|
81
|
+
const [row] = await tx
|
|
82
|
+
.select({ n: sql<number>`count(*)`.mapWith(Number) })
|
|
83
|
+
.from(repairs)
|
|
84
|
+
.innerJoin(assets, and(eq(assets.id, repairs.assetId), eq(assets.workspaceId, repairs.workspaceId)))
|
|
85
|
+
.where(and(eq(repairs.workspaceId, workspaceId), isNull(repairs.returnedOn), isNull(assets.archivedAt)))
|
|
86
|
+
return row?.n ?? 0
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { deriveStatus } from './status.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one rule that two services would otherwise each write their own version of.
|
|
6
|
+
*
|
|
7
|
+
* `CustodyService.stamp` wrote `userId ? 'assigned' : 'in_stock'` unconditionally before repairs
|
|
8
|
+
* existed, which is correct right up to the moment an item can be somewhere. This is the arithmetic
|
|
9
|
+
* of `status`, tested where it is cheap; `inventory.int.test.ts` proves the same combinations end to
|
|
10
|
+
* end through the procedures that write them.
|
|
11
|
+
*/
|
|
12
|
+
describe('deriveStatus', () => {
|
|
13
|
+
it('is in stock when nobody has it and nothing is being fixed', () => {
|
|
14
|
+
expect(deriveStatus({ custodianUserId: null, awayForRepair: false })).toBe('in_stock')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('is assigned when somebody has it', () => {
|
|
18
|
+
expect(deriveStatus({ custodianUserId: 'ada', awayForRepair: false })).toBe('assigned')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('is under repair whether or not somebody still has it', () => {
|
|
22
|
+
// The whole point of the rule: a repair says *where* the thing is, and custody says *who is
|
|
23
|
+
// answerable for it*. A laptop at the workshop is still Dan's, and the status has to show the
|
|
24
|
+
// thing somebody looking for it needs to know.
|
|
25
|
+
expect(deriveStatus({ custodianUserId: null, awayForRepair: true })).toBe('under_repair')
|
|
26
|
+
expect(deriveStatus({ custodianUserId: 'dan', awayForRepair: true })).toBe('under_repair')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('goes back to the custodian rather than to stock when a repair ends', () => {
|
|
30
|
+
// The failure this prevents is silent: completing a repair straight to `in_stock` releases
|
|
31
|
+
// whoever was answerable for the item without anybody deciding to.
|
|
32
|
+
expect(deriveStatus({ custodianUserId: 'dan', awayForRepair: false })).toBe('assigned')
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { KernError, type Tx } from '@kernhq/kernel'
|
|
2
|
+
import { and, eq, isNull } from 'drizzle-orm'
|
|
3
|
+
import type { AssetStatus } from '../../contract/models.js'
|
|
4
|
+
import { assets, repairs } from '../schema.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What an asset's status is, decided in one place from the facts that decide it.
|
|
8
|
+
*
|
|
9
|
+
* `assets.status` is stored rather than computed, because every list filter asks for it — but it is
|
|
10
|
+
* **derived**, and the moment two services each wrote their own version of the derivation they
|
|
11
|
+
* disagreed. That is not hypothetical: `CustodyService.stamp` wrote `userId ? 'assigned' :
|
|
12
|
+
* 'in_stock'` unconditionally, so handing over a laptop that was sitting at the repairer would have
|
|
13
|
+
* announced it as back in the office. Both services now read this function, so there is one answer
|
|
14
|
+
* and a test can hold it.
|
|
15
|
+
*
|
|
16
|
+
* ## The rule, written down because it is a decision rather than an inevitability
|
|
17
|
+
*
|
|
18
|
+
* **Custody and repair are independent facts, and repair wins the status column.** An item can be
|
|
19
|
+
* out for repair *while still assigned to somebody*: Dan's laptop goes to the workshop, Dan is still
|
|
20
|
+
* answerable for it, the custody period stays open and `custodian_user_id` stays set. Only `status`
|
|
21
|
+
* moves, because `status` is the column that answers *where is it*, and "with a repairer" is the
|
|
22
|
+
* answer somebody looking for it needs. Custody is answered by `custodian_user_id`, which is a
|
|
23
|
+
* different column and is not touched.
|
|
24
|
+
*
|
|
25
|
+
* Two consequences follow, and both are deliberate:
|
|
26
|
+
*
|
|
27
|
+
* - **No custody procedure refuses an item that is away for repair.** Refusing `assign` would mean
|
|
28
|
+
* refusing `return` too, and somebody leaving the company while their laptop is at the workshop
|
|
29
|
+
* must still be able to hand it back. All three verbs recompute the status through this function
|
|
30
|
+
* instead, so a return during a repair leaves the item `under_repair` with nobody holding it,
|
|
31
|
+
* which is exactly true.
|
|
32
|
+
* - **Completing a repair does not blindly go to `in_stock`.** It goes back to whatever custody
|
|
33
|
+
* says — `assigned` when somebody still holds it — because the repair never released them.
|
|
34
|
+
*
|
|
35
|
+
* `reserved`, `lost` and `retired` are not derivable from anything this module records: they are set
|
|
36
|
+
* by hand by the features that will own them, and nothing writes them today. When something does,
|
|
37
|
+
* it either belongs in this function with a fact of its own or it does not belong in this column.
|
|
38
|
+
*
|
|
39
|
+
* ## The third fact, which is not about the item at all
|
|
40
|
+
*
|
|
41
|
+
* **`under_repair` belongs to the `repairs` capability, so a workspace that has switched repairs
|
|
42
|
+
* off never has an asset in it.** That is not cosmetic tidying, it is the thing that stopped an
|
|
43
|
+
* asset being stranded: with the capability off, `repairs.complete` answers 404, so the row that
|
|
44
|
+
* decides `under_repair` can never be closed — and the item sat in a status nothing could move it
|
|
45
|
+
* out of, behind an archive that refused it for the same reason. Reading the switch here is what
|
|
46
|
+
* makes the capability reversible in both directions rather than one: switch repairs off and the
|
|
47
|
+
* next thing that touches the asset derives its status from custody alone; switch it back on and
|
|
48
|
+
* the open repair asserts itself again, because nothing was destroyed to get out of it.
|
|
49
|
+
*
|
|
50
|
+
* The switch is read *before* the transaction opens, like every other settings lookup in this
|
|
51
|
+
* module, and handed down as `repairsOn` — a `kernel.settings` call is a broker round trip and
|
|
52
|
+
* awaiting one while holding a pooled connection is the failure `AssetService.codeFormat`
|
|
53
|
+
* documents. `jobs.ts` reconciles the rows nobody happens to touch.
|
|
54
|
+
*/
|
|
55
|
+
export interface StatusFacts {
|
|
56
|
+
/** `assets.custodian_user_id` — who is answerable for it, which a repair does not change. */
|
|
57
|
+
custodianUserId: string | null
|
|
58
|
+
/** An **open repair this workspace is recording** — see `awayForRepair` below. */
|
|
59
|
+
awayForRepair: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function deriveStatus(facts: StatusFacts): AssetStatus {
|
|
63
|
+
if (facts.awayForRepair) return 'under_repair'
|
|
64
|
+
return facts.custodianUserId ? 'assigned' : 'in_stock'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Is this item away for a repair the workspace still records?
|
|
69
|
+
*
|
|
70
|
+
* Two facts, and the capability is checked first so the query is skipped entirely for a workspace
|
|
71
|
+
* that does not record repairs. Every caller that decides a status goes through this rather than
|
|
72
|
+
* through `openRepairId` directly, so there is one answer to "is it away" and a test can hold it.
|
|
73
|
+
*/
|
|
74
|
+
export async function awayForRepair(
|
|
75
|
+
tx: Tx,
|
|
76
|
+
workspaceId: string,
|
|
77
|
+
assetId: string,
|
|
78
|
+
repairsOn: boolean,
|
|
79
|
+
): Promise<boolean> {
|
|
80
|
+
if (!repairsOn) return false
|
|
81
|
+
return (await openRepairId(tx, workspaceId, assetId)) !== undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The open repair for one asset, if there is one — the query behind `awayForRepair`.
|
|
86
|
+
*
|
|
87
|
+
* At most one exists by construction: `inventory_repairs_one_open_uq` is a unique index on
|
|
88
|
+
* `(asset_id) where returned_on is null`. It lives here rather than in `RepairService` so that
|
|
89
|
+
* `CustodyService` can read the fact without depending on the whole repair service, and so that
|
|
90
|
+
* every caller decides a status the same way — read both facts, then call `deriveStatus`, never
|
|
91
|
+
* assume one of them.
|
|
92
|
+
*/
|
|
93
|
+
/**
|
|
94
|
+
* The asset row, locked for the rest of the transaction — the serialisation point for `status`.
|
|
95
|
+
*
|
|
96
|
+
* **`status` is derived from two facts written by two different services, so the derivation needs a
|
|
97
|
+
* lock even though neither fact does.** `CustodyService` decides it from custody *and* the open
|
|
98
|
+
* repair; `RepairService` decides it from the repair *and* the custodian. Each used to read the
|
|
99
|
+
* other's fact without a lock, so a handover and a repair completing at the same instant
|
|
100
|
+
* interleaved into a status that matched neither: the second writer had read the first one's fact
|
|
101
|
+
* before it was written, derived from a snapshot that no longer existed, and overwrote the answer.
|
|
102
|
+
* That is a lost update in the plain sense — two transactions, one column, one of them silently
|
|
103
|
+
* discarded.
|
|
104
|
+
*
|
|
105
|
+
* Both paths now take this lock **before** reading either fact, so the second one waits, re-reads
|
|
106
|
+
* what the first committed, and derives from the state that actually exists. `for('update')`
|
|
107
|
+
* re-evaluates the row at the latest committed version once the lock is granted, which is exactly
|
|
108
|
+
* what makes the re-read honest rather than a repeat of the stale snapshot.
|
|
109
|
+
*
|
|
110
|
+
* **It is not the arbiter of "already held" or "already away".** Those stay with the exclusion
|
|
111
|
+
* constraint and the partial unique index, for the reason both services' docblocks give: locking
|
|
112
|
+
* the asset first would serialise two handovers into two *successful* handovers. This lock is taken
|
|
113
|
+
* after the row that decides the race has been written, so it orders the bookkeeping without
|
|
114
|
+
* deciding the contest.
|
|
115
|
+
*/
|
|
116
|
+
export async function lockAsset(
|
|
117
|
+
tx: Tx,
|
|
118
|
+
workspaceId: string,
|
|
119
|
+
assetId: string,
|
|
120
|
+
): Promise<typeof assets.$inferSelect> {
|
|
121
|
+
const [row] = await tx
|
|
122
|
+
.select()
|
|
123
|
+
.from(assets)
|
|
124
|
+
.where(and(eq(assets.workspaceId, workspaceId), eq(assets.id, assetId)))
|
|
125
|
+
.for('update')
|
|
126
|
+
if (!row) throw KernError.notFound('Asset')
|
|
127
|
+
return row
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function openRepairId(
|
|
131
|
+
tx: Tx,
|
|
132
|
+
workspaceId: string,
|
|
133
|
+
assetId: string,
|
|
134
|
+
): Promise<string | undefined> {
|
|
135
|
+
const [row] = await tx
|
|
136
|
+
.select({ id: repairs.id })
|
|
137
|
+
.from(repairs)
|
|
138
|
+
.where(
|
|
139
|
+
and(eq(repairs.workspaceId, workspaceId), eq(repairs.assetId, assetId), isNull(repairs.returnedOn)),
|
|
140
|
+
)
|
|
141
|
+
.limit(1)
|
|
142
|
+
return row?.id
|
|
143
|
+
}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The slice of core's API this module reaches for, named by shape rather than imported.
|
|
3
|
-
*
|
|
4
|
-
* A module talks to another module through `kernel.call()` on the server; on the client the shell
|
|
5
|
-
* hands over its own configured core client, and typing the seam structurally keeps the dependency
|
|
6
|
-
* pointing one way — inventory does not import core's router type, and core does not know inventory
|
|
7
|
-
* exists.
|
|
8
|
-
*
|
|
9
|
-
* Keep it to what is actually called. A wide type here is a promise about core's surface that this
|
|
10
|
-
* module has no standing to make. Two procedures are all the settings page needs, and both are
|
|
11
|
-
* gated on `core.modules.manage` in `core/src/modules/core/router.ts`.
|
|
12
|
-
*/
|
|
13
|
-
export interface CoreApi {
|
|
14
|
-
workspaces: {
|
|
15
|
-
modules: {
|
|
16
|
-
list(input: { workspaceId: string }): Promise<
|
|
17
|
-
Array<{
|
|
18
|
-
manifest: { id: string }
|
|
19
|
-
state: {
|
|
20
|
-
enabled: boolean
|
|
21
|
-
settings?: Record<string, unknown>
|
|
22
|
-
}
|
|
23
|
-
}>
|
|
24
|
-
>
|
|
25
|
-
updateSettings(input: {
|
|
26
|
-
workspaceId: string
|
|
27
|
-
moduleId: string
|
|
28
|
-
settings: Record<string, unknown>
|
|
29
|
-
}): Promise<unknown>
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|