@kernhq/module-inventory 0.1.1 → 0.2.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 +55 -9
- package/dist/contract/capabilities.d.ts +49 -0
- package/dist/contract/capabilities.d.ts.map +1 -0
- package/dist/contract/capabilities.js +52 -0
- package/dist/contract/capabilities.js.map +1 -0
- package/dist/contract/events.d.ts +33 -0
- package/dist/contract/events.d.ts.map +1 -0
- package/dist/contract/events.js +22 -0
- package/dist/contract/events.js.map +1 -0
- package/dist/contract/index.d.ts +15 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +15 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/contract/models.d.ts +146 -0
- package/dist/contract/models.d.ts.map +1 -0
- package/dist/contract/models.js +107 -0
- package/dist/contract/models.js.map +1 -0
- package/dist/contract/permissions.d.ts +22 -0
- package/dist/contract/permissions.d.ts.map +1 -0
- package/dist/contract/permissions.js +26 -0
- package/dist/contract/permissions.js.map +1 -0
- package/dist/{contract.d.ts → contract/router.d.ts} +24 -85
- package/dist/contract/router.d.ts.map +1 -0
- package/dist/contract/router.js +42 -0
- package/dist/contract/router.js.map +1 -0
- package/dist/contract/settings.d.ts +18 -0
- package/dist/contract/settings.d.ts.map +1 -0
- package/dist/contract/settings.js +29 -0
- package/dist/contract/settings.js.map +1 -0
- package/dist/server/index.d.ts +4 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +9 -13
- package/dist/server/index.js.map +1 -1
- package/dist/server/{_impl.d.ts → router.d.ts} +33 -8
- package/dist/server/{_impl.d.ts.map → router.d.ts.map} +1 -1
- package/dist/server/router.js +83 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/schema.d.ts +25 -11
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +28 -10
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +73 -0
- package/dist/server/services/assets.d.ts.map +1 -0
- package/dist/server/services/assets.js +261 -0
- package/dist/server/services/assets.js.map +1 -0
- package/dist/server/services/index.d.ts +10 -0
- package/dist/server/services/index.d.ts.map +1 -0
- package/dist/server/services/index.js +15 -0
- package/dist/server/services/index.js.map +1 -0
- package/dist/server/services/notify.d.ts +63 -0
- package/dist/server/services/notify.d.ts.map +1 -0
- package/dist/server/services/notify.js +105 -0
- package/dist/server/services/notify.js.map +1 -0
- package/migrations/0000_init.sql +12 -3
- package/migrations/0001_rls.sql +24 -0
- package/migrations/meta/0000_snapshot.json +40 -13
- package/migrations/meta/_journal.json +2 -2
- package/package.json +12 -9
- package/src/client/api.ts +1 -1
- package/src/client/components/AssetFormDialog.svelte +130 -47
- package/src/client/i18n.ts +11 -166
- package/src/client/index.ts +8 -1
- package/src/client/messages.test.ts +169 -0
- package/src/client/messages.ts +399 -0
- package/src/client/mock.test.ts +161 -0
- package/src/client/mock.ts +267 -45
- package/src/client/module.ts +22 -2
- package/src/client/pages/AssetsPage.svelte +358 -137
- package/src/client/permissions.ts +1 -1
- package/src/client/price.test.ts +106 -0
- package/src/client/price.ts +135 -0
- package/src/client/query.test.ts +58 -0
- package/src/client/query.ts +15 -2
- package/src/client/settings/GeneralSettings.svelte +0 -0
- package/src/client/settings/core-api.ts +32 -0
- package/src/client/widgets/OverviewWidget.svelte +16 -3
- package/src/contract/capabilities.ts +55 -0
- package/src/contract/events.ts +34 -0
- package/src/contract/index.ts +15 -0
- package/src/contract/models.ts +123 -0
- package/src/contract/permissions.ts +26 -0
- package/src/contract/router.ts +46 -0
- package/src/contract/settings.ts +30 -0
- package/src/module.test.ts +140 -7
- package/src/server/index.ts +16 -13
- package/src/server/inventory.int.test.ts +819 -0
- package/src/server/migrations.test.ts +138 -0
- package/src/server/router.ts +118 -0
- package/src/server/schema.ts +27 -10
- package/src/server/services/assets.ts +368 -0
- package/src/server/services/index.ts +23 -0
- package/src/server/services/notify.ts +151 -0
- package/tsconfig.base.json +22 -0
- package/tsconfig.client.json +1 -1
- package/tsconfig.json +1 -1
- package/vitest.config.ts +18 -3
- package/dist/contract.d.ts.map +0 -1
- package/dist/contract.js +0 -119
- package/dist/contract.js.map +0 -1
- package/dist/server/_impl.js +0 -204
- package/dist/server/_impl.js.map +0 -1
- package/src/contract.ts +0 -143
- package/src/server/_impl.ts +0 -275
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { KernError, type Kernel, type Tx, uuidv7 } from '@kernhq/kernel'
|
|
2
|
+
import { and, asc, desc, eq, ilike, isNull, or, sql } from 'drizzle-orm'
|
|
3
|
+
import { alias, type PgColumn } from 'drizzle-orm/pg-core'
|
|
4
|
+
import type {
|
|
5
|
+
AssetCreateInput,
|
|
6
|
+
Asset as AssetModel,
|
|
7
|
+
AssetPatchInput,
|
|
8
|
+
AssetSort,
|
|
9
|
+
} from '../../contract/models.js'
|
|
10
|
+
import { InventorySettings } from '../../contract/settings.js'
|
|
11
|
+
import { assets, counters } from '../schema.js'
|
|
12
|
+
import type { HistoryInput, NotifyService } from './notify.js'
|
|
13
|
+
|
|
14
|
+
type Row = typeof assets.$inferSelect
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What a mutation wrote, and what may only leave the module once it has committed.
|
|
18
|
+
*
|
|
19
|
+
* `asset_history` is written inside the caller's transaction and is authoritative; the mirror of it
|
|
20
|
+
* in core's activity feed is not, and used to be fired off with `void` while the transaction was
|
|
21
|
+
* still open. A rollback then left the workspace's feed showing an event for an id that does not
|
|
22
|
+
* exist. Everything else this module announces — the event, the realtime change — already waits for
|
|
23
|
+
* the commit, so the activity record is handed back here and flushed beside them.
|
|
24
|
+
*/
|
|
25
|
+
export interface Written {
|
|
26
|
+
row: Row
|
|
27
|
+
/** `null` when nothing happened worth recording, which is what an update that changed nothing is. */
|
|
28
|
+
activity: HistoryInput | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** How this workspace spells an asset tag. Read before the transaction opens — see `codeFormat`. */
|
|
32
|
+
export interface CodeFormat {
|
|
33
|
+
prefix: string
|
|
34
|
+
pad: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The wire shape: drizzle gives Date objects for timestamps, the contract promises ISO strings. */
|
|
38
|
+
export function toAsset(row: Row): AssetModel {
|
|
39
|
+
return {
|
|
40
|
+
id: row.id,
|
|
41
|
+
workspaceId: row.workspaceId as AssetModel['workspaceId'],
|
|
42
|
+
code: row.code,
|
|
43
|
+
name: row.name,
|
|
44
|
+
description: row.description,
|
|
45
|
+
categoryId: row.categoryId,
|
|
46
|
+
status: row.status as AssetModel['status'],
|
|
47
|
+
custodianUserId: row.custodianUserId,
|
|
48
|
+
custodySince: row.custodySince?.toISOString() ?? null,
|
|
49
|
+
serialNumber: row.serialNumber,
|
|
50
|
+
location: row.location,
|
|
51
|
+
purchasedOn: row.purchasedOn ?? null,
|
|
52
|
+
purchasedFrom: row.purchasedFrom,
|
|
53
|
+
priceMinor: row.priceMinor,
|
|
54
|
+
currency: row.currency,
|
|
55
|
+
warrantyUntil: row.warrantyUntil ?? null,
|
|
56
|
+
photoFileId: row.photoFileId,
|
|
57
|
+
custom: row.custom ?? {},
|
|
58
|
+
createdAt: row.createdAt.toISOString(),
|
|
59
|
+
updatedAt: row.updatedAt.toISOString(),
|
|
60
|
+
archivedAt: row.archivedAt?.toISOString() ?? null,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A page boundary, as the client sees it: **the bookmarked row's id, and the sort it was issued
|
|
66
|
+
* under**. Nothing else — in particular not the sort key itself, which is what it used to carry.
|
|
67
|
+
*
|
|
68
|
+
* That earlier shape (`{k: <sort key>, i: <id>}`) was wrong three separate ways, each of them
|
|
69
|
+
* reachable by anyone who could type into the address bar:
|
|
70
|
+
*
|
|
71
|
+
* - **It could not be trusted.** `decode` checked only that `i` was a *string*, and `i` is
|
|
72
|
+
* interpolated into `(col, id) < ($1, $2::uuid)`. `{"k":"x","i":"not-a-uuid"}` therefore
|
|
73
|
+
* reached Postgres as a 22P02 nobody caught — an unhandled 500 and an error-level log line per
|
|
74
|
+
* request, at the 600-a-minute the rate limiter allows.
|
|
75
|
+
* - **It was not bound to its sort.** A cursor issued under `sort=recent` replayed under
|
|
76
|
+
* `sort=code` compared a uuid against a code, so page two came back equal to page one and
|
|
77
|
+
* "Load more" never ended.
|
|
78
|
+
* - **It was unbounded.** `sort=name` on a 200-character Persian name encoded to 602 characters,
|
|
79
|
+
* and `Cursor` in `@kernhq/contracts` is `max(512)` — so a long enough name broke "Load more"
|
|
80
|
+
* with a validation error, in exactly the locales least likely to be tested.
|
|
81
|
+
*
|
|
82
|
+
* Carrying the id alone and reading the sort key back from that row in SQL answers all three: the
|
|
83
|
+
* cursor is a fixed ~60 characters whatever the name, a value that is not a uuid is refused before
|
|
84
|
+
* it is anywhere near the database, and a cursor whose sort disagrees with the request is refused
|
|
85
|
+
* rather than quietly misread. Base64 so nothing in the product is tempted to read it — it is a
|
|
86
|
+
* bookmark, not an offset, and its shape is this file's business.
|
|
87
|
+
*/
|
|
88
|
+
interface Bookmark {
|
|
89
|
+
i: string
|
|
90
|
+
s: AssetSort
|
|
91
|
+
}
|
|
92
|
+
const encode = (b: Bookmark) => Buffer.from(JSON.stringify(b), 'utf8').toString('base64url')
|
|
93
|
+
|
|
94
|
+
/** Cheap and total, and it runs before the value can reach a `::uuid` cast. */
|
|
95
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
96
|
+
|
|
97
|
+
function decode(cursor: string, sort: AssetSort): Bookmark {
|
|
98
|
+
const refuse = () => KernError.badRequest('That page marker is not one this list issued')
|
|
99
|
+
let parsed: Partial<Bookmark> | null
|
|
100
|
+
try {
|
|
101
|
+
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as Partial<Bookmark> | null
|
|
102
|
+
} catch {
|
|
103
|
+
throw refuse()
|
|
104
|
+
}
|
|
105
|
+
if (typeof parsed?.i !== 'string' || !UUID.test(parsed.i)) throw refuse()
|
|
106
|
+
// One comparison covers both a sort this list never issues and a sort it issued under a
|
|
107
|
+
// *different* request. Either way the bookmark cannot be read against the ordering asked for,
|
|
108
|
+
// and pretending otherwise is what made "Load more" loop for ever.
|
|
109
|
+
if (parsed.s !== sort) throw refuse()
|
|
110
|
+
return { i: parsed.i, s: sort }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* `%`, `_` and `\` are pattern syntax to `ilike`, not text.
|
|
115
|
+
*
|
|
116
|
+
* Unescaped, a search for `50%` matched every row in the workspace and a search for `_hair` matched
|
|
117
|
+
* "Chair" — both of which read as a broken search rather than as a clever one. Escaped rather than
|
|
118
|
+
* stripped: somebody typing a per-cent sign means the character.
|
|
119
|
+
*/
|
|
120
|
+
const contains = (q: string) => `%${q.replace(/[\\%_]/g, '\\$&')}%`
|
|
121
|
+
|
|
122
|
+
/** Which column an ordering sorts on, for the list itself and for the bookmarked row alike. */
|
|
123
|
+
const sortKey = <T extends { id: PgColumn; name: PgColumn; code: PgColumn }>(t: T, sort: AssetSort) =>
|
|
124
|
+
sort === 'name' ? t.name : sort === 'code' ? t.code : t.id
|
|
125
|
+
|
|
126
|
+
export interface ListInput {
|
|
127
|
+
workspaceId: string
|
|
128
|
+
limit: number
|
|
129
|
+
cursor?: string
|
|
130
|
+
q?: string
|
|
131
|
+
categoryId?: string
|
|
132
|
+
status?: string
|
|
133
|
+
custodianUserId?: string
|
|
134
|
+
archived: boolean
|
|
135
|
+
sort: AssetSort
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export class AssetService {
|
|
139
|
+
constructor(
|
|
140
|
+
private readonly kernel: Kernel,
|
|
141
|
+
private readonly notify: NotifyService,
|
|
142
|
+
) {}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* How this workspace spells an asset tag.
|
|
146
|
+
*
|
|
147
|
+
* **Called before the transaction opens, never inside one.** `kernel.settings.module` is a
|
|
148
|
+
* `core.settings.getModule` call over the broker, and awaiting a remote service while holding a
|
|
149
|
+
* pooled connection *and* the counter row lock is two failures waiting: the pool starves under
|
|
150
|
+
* concurrent creates, and a create fails outright whenever core is briefly away — which is the
|
|
151
|
+
* one thing `NotifyService`'s own docblock says a mutation here must never do.
|
|
152
|
+
*/
|
|
153
|
+
async codeFormat(workspaceId: string): Promise<CodeFormat> {
|
|
154
|
+
const settings = await this.kernel.settings.module(workspaceId, 'inventory', InventorySettings)
|
|
155
|
+
return { prefix: settings.assetCodePrefix, pad: settings.assetCodePad }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The next asset tag for a workspace.
|
|
160
|
+
*
|
|
161
|
+
* One narrow row per workspace and key, incremented under the insert's conflict lock — two
|
|
162
|
+
* concurrent creates each read the value their own statement returned, so codes stay unique
|
|
163
|
+
* without a retry loop. `INV-0042`, because people say asset tags out loud. Nothing in here
|
|
164
|
+
* leaves the database, which is what keeps the lock short.
|
|
165
|
+
*/
|
|
166
|
+
async nextCode(tx: Tx, workspaceId: string, format: CodeFormat): Promise<string> {
|
|
167
|
+
const [row] = await tx
|
|
168
|
+
.insert(counters)
|
|
169
|
+
.values({ workspaceId, key: 'asset_code', value: 1 })
|
|
170
|
+
.onConflictDoUpdate({
|
|
171
|
+
target: [counters.workspaceId, counters.key],
|
|
172
|
+
set: { value: sql`${counters.value} + 1` },
|
|
173
|
+
})
|
|
174
|
+
.returning()
|
|
175
|
+
return `${format.prefix}${String(row!.value).padStart(format.pad, '0')}`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async list(tx: Tx, input: ListInput): Promise<{ items: AssetModel[]; nextCursor: string | null }> {
|
|
179
|
+
const filters = [eq(assets.workspaceId, input.workspaceId)]
|
|
180
|
+
if (input.q) {
|
|
181
|
+
// A code is something somebody reads off a sticker; matching name, code and serial loosely
|
|
182
|
+
// matters more than word-splitting here. Full-text arrives with the core indexer.
|
|
183
|
+
const pattern = contains(input.q)
|
|
184
|
+
filters.push(
|
|
185
|
+
or(
|
|
186
|
+
ilike(assets.name, pattern),
|
|
187
|
+
ilike(assets.code, pattern),
|
|
188
|
+
ilike(sql`coalesce(${assets.serialNumber}, '')`, pattern),
|
|
189
|
+
)!,
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
if (input.categoryId) filters.push(eq(assets.categoryId, input.categoryId))
|
|
193
|
+
if (input.status) filters.push(eq(assets.status, input.status))
|
|
194
|
+
if (input.custodianUserId) filters.push(eq(assets.custodianUserId, input.custodianUserId))
|
|
195
|
+
if (!input.archived) filters.push(isNull(assets.archivedAt))
|
|
196
|
+
|
|
197
|
+
// `recent` sorts by id rather than by `created_at`: ids are uuidv7, so they carry the clock,
|
|
198
|
+
// and one indexed unique column is a cheaper and unambiguous page boundary than a timestamp
|
|
199
|
+
// two rows can share. Ordered to the millisecond and no finer — the kernel's `uuidv7()` fills
|
|
200
|
+
// its last ten bytes from `randomUUID()` with no counter — so two assets created inside one
|
|
201
|
+
// millisecond come back in a stable but arbitrary order relative to each other. That is fine
|
|
202
|
+
// for a list and fatal for a test that expects creation order; see `inventory.int.test.ts`.
|
|
203
|
+
const column = sortKey(assets, input.sort)
|
|
204
|
+
const descending = input.sort === 'recent'
|
|
205
|
+
|
|
206
|
+
if (input.cursor) {
|
|
207
|
+
const mark = decode(input.cursor, input.sort)
|
|
208
|
+
/**
|
|
209
|
+
* The sort key comes back out of the bookmarked row rather than out of the cursor, which is
|
|
210
|
+
* what keeps the cursor small and stops it disagreeing with the row it names. Scoped by
|
|
211
|
+
* `workspace_id` as well as by id, so a cursor cannot be used to probe another workspace's
|
|
212
|
+
* ordering — this module reaches the database as a superuser in `core`, with RLS bypassed.
|
|
213
|
+
*
|
|
214
|
+
* A row deleted between two pages leaves the subquery empty, the row comparison NULL and the
|
|
215
|
+
* page empty: the list simply ends. That is deliberate, and preferred to a 400 — somebody
|
|
216
|
+
* else archiving a row while you read is an ordinary race, not a malformed request, and an
|
|
217
|
+
* error toast on "Load more" would be a worse answer than a list that has run out.
|
|
218
|
+
*/
|
|
219
|
+
const marker = alias(assets, 'page_marker')
|
|
220
|
+
const bookmarked = tx
|
|
221
|
+
.select({ key: sortKey(marker, input.sort), id: marker.id })
|
|
222
|
+
.from(marker)
|
|
223
|
+
.where(and(eq(marker.id, mark.i), eq(marker.workspaceId, input.workspaceId)))
|
|
224
|
+
filters.push(
|
|
225
|
+
descending
|
|
226
|
+
? sql`(${column}, ${assets.id}) < (${bookmarked})`
|
|
227
|
+
: sql`(${column}, ${assets.id}) > (${bookmarked})`,
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const order = descending ? [desc(column), desc(assets.id)] : [asc(column), asc(assets.id)]
|
|
232
|
+
|
|
233
|
+
const rows = await tx
|
|
234
|
+
.select()
|
|
235
|
+
.from(assets)
|
|
236
|
+
.where(and(...filters))
|
|
237
|
+
.orderBy(...order)
|
|
238
|
+
.limit(input.limit + 1)
|
|
239
|
+
|
|
240
|
+
const items = rows.slice(0, input.limit)
|
|
241
|
+
const last = items.at(-1)
|
|
242
|
+
const nextCursor = rows.length > input.limit && last ? encode({ i: last.id, s: input.sort }) : null
|
|
243
|
+
return { items: items.map(toAsset), nextCursor }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async get(tx: Tx, workspaceId: string, assetId: string): Promise<Row> {
|
|
247
|
+
const [row] = await tx
|
|
248
|
+
.select()
|
|
249
|
+
.from(assets)
|
|
250
|
+
.where(and(eq(assets.workspaceId, workspaceId), eq(assets.id, assetId)))
|
|
251
|
+
if (!row) throw KernError.notFound('Asset')
|
|
252
|
+
return row
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async create(
|
|
256
|
+
tx: Tx,
|
|
257
|
+
workspaceId: string,
|
|
258
|
+
actorId: string | null,
|
|
259
|
+
input: AssetCreateInput,
|
|
260
|
+
format: CodeFormat,
|
|
261
|
+
): Promise<Written> {
|
|
262
|
+
const [row] = await tx
|
|
263
|
+
.insert(assets)
|
|
264
|
+
.values({
|
|
265
|
+
id: uuidv7(),
|
|
266
|
+
workspaceId,
|
|
267
|
+
code: await this.nextCode(tx, workspaceId, format),
|
|
268
|
+
name: input.name,
|
|
269
|
+
description: input.description,
|
|
270
|
+
categoryId: input.categoryId ?? null,
|
|
271
|
+
serialNumber: input.serialNumber ?? null,
|
|
272
|
+
location: input.location ?? null,
|
|
273
|
+
purchasedFrom: input.purchasedFrom ?? null,
|
|
274
|
+
purchasedOn: input.purchasedOn ?? null,
|
|
275
|
+
warrantyUntil: input.warrantyUntil ?? null,
|
|
276
|
+
priceMinor: input.priceMinor ?? null,
|
|
277
|
+
currency: input.currency ?? null,
|
|
278
|
+
photoFileId: input.photoFileId ?? null,
|
|
279
|
+
})
|
|
280
|
+
.returning()
|
|
281
|
+
const activity: HistoryInput = { workspaceId, assetId: row!.id, actorId, action: 'created' }
|
|
282
|
+
await this.notify.history(tx, activity)
|
|
283
|
+
return { row: row!, activity }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Fields a person edits directly, and therefore the fields the timeline reports a diff for. */
|
|
287
|
+
private static readonly EDITABLE = [
|
|
288
|
+
'name',
|
|
289
|
+
'description',
|
|
290
|
+
'categoryId',
|
|
291
|
+
'serialNumber',
|
|
292
|
+
'location',
|
|
293
|
+
'purchasedFrom',
|
|
294
|
+
'purchasedOn',
|
|
295
|
+
'warrantyUntil',
|
|
296
|
+
'priceMinor',
|
|
297
|
+
'currency',
|
|
298
|
+
'photoFileId',
|
|
299
|
+
] as const
|
|
300
|
+
|
|
301
|
+
async update(
|
|
302
|
+
tx: Tx,
|
|
303
|
+
workspaceId: string,
|
|
304
|
+
actorId: string | null,
|
|
305
|
+
assetId: string,
|
|
306
|
+
input: AssetPatchInput,
|
|
307
|
+
): Promise<Written> {
|
|
308
|
+
const [prev] = await tx
|
|
309
|
+
.select()
|
|
310
|
+
.from(assets)
|
|
311
|
+
.where(and(eq(assets.workspaceId, workspaceId), eq(assets.id, assetId)))
|
|
312
|
+
.for('update')
|
|
313
|
+
if (!prev) throw KernError.notFound('Asset')
|
|
314
|
+
|
|
315
|
+
// `undefined` means "not mentioned"; `null` means "clear it". Collapsing the two is how an
|
|
316
|
+
// edit of one field quietly wipes the others.
|
|
317
|
+
const patch: Record<string, unknown> = { updatedAt: new Date() }
|
|
318
|
+
for (const field of AssetService.EDITABLE) {
|
|
319
|
+
const value = (input as Record<string, unknown>)[field]
|
|
320
|
+
patch[field] = value !== undefined ? (value ?? null) : prev[field]
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Filtered by workspace as well as by id. The `select … for update` three statements up already
|
|
324
|
+
// carries the predicate, so this is not reachable today — but `core` connects as a superuser
|
|
325
|
+
// with RLS bypassed, which makes the predicate in the statement the only barrier there is. A
|
|
326
|
+
// barrier that holds only because of what another statement happens to do is not one.
|
|
327
|
+
const [row] = await tx
|
|
328
|
+
.update(assets)
|
|
329
|
+
.set(patch)
|
|
330
|
+
.where(and(eq(assets.workspaceId, workspaceId), eq(assets.id, assetId)))
|
|
331
|
+
.returning()
|
|
332
|
+
|
|
333
|
+
const iso = (v: unknown) => (v instanceof Date ? v.toISOString() : v) ?? null
|
|
334
|
+
const changes = AssetService.EDITABLE.filter((f) => iso(patch[f]) !== iso(prev[f])).map((f) => ({
|
|
335
|
+
field: f,
|
|
336
|
+
from: iso(prev[f]),
|
|
337
|
+
to: iso(patch[f]),
|
|
338
|
+
}))
|
|
339
|
+
if (changes.length === 0) return { row: row!, activity: null }
|
|
340
|
+
|
|
341
|
+
const activity: HistoryInput = { workspaceId, assetId, actorId, action: 'updated', changes }
|
|
342
|
+
await this.notify.history(tx, activity)
|
|
343
|
+
return { row: row!, activity }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async archive(
|
|
347
|
+
tx: Tx,
|
|
348
|
+
workspaceId: string,
|
|
349
|
+
actorId: string | null,
|
|
350
|
+
assetId: string,
|
|
351
|
+
archived: boolean,
|
|
352
|
+
): Promise<Written> {
|
|
353
|
+
const [row] = await tx
|
|
354
|
+
.update(assets)
|
|
355
|
+
.set({ archivedAt: archived ? new Date() : null, updatedAt: new Date() })
|
|
356
|
+
.where(and(eq(assets.workspaceId, workspaceId), eq(assets.id, assetId)))
|
|
357
|
+
.returning()
|
|
358
|
+
if (!row) throw KernError.notFound('Asset')
|
|
359
|
+
const activity: HistoryInput = {
|
|
360
|
+
workspaceId,
|
|
361
|
+
assetId,
|
|
362
|
+
actorId,
|
|
363
|
+
action: archived ? 'retired' : 'restored',
|
|
364
|
+
}
|
|
365
|
+
await this.notify.history(tx, activity)
|
|
366
|
+
return { row, activity }
|
|
367
|
+
}
|
|
368
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Kernel } from '@kernhq/kernel'
|
|
2
|
+
import { AssetService } from './assets.js'
|
|
3
|
+
import { NotifyService } from './notify.js'
|
|
4
|
+
|
|
5
|
+
export interface InventoryServices {
|
|
6
|
+
notify: NotifyService
|
|
7
|
+
assets: AssetService
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const cache = new WeakMap<Kernel, InventoryServices>()
|
|
11
|
+
|
|
12
|
+
/** One service graph per kernel instance; the router, jobs and procedures all share it. */
|
|
13
|
+
export function inventoryServices(kernel: Kernel): InventoryServices {
|
|
14
|
+
const existing = cache.get(kernel)
|
|
15
|
+
if (existing) return existing
|
|
16
|
+
|
|
17
|
+
const notify = new NotifyService(kernel)
|
|
18
|
+
const assets = new AssetService(kernel, notify)
|
|
19
|
+
|
|
20
|
+
const services: InventoryServices = { notify, assets }
|
|
21
|
+
cache.set(kernel, services)
|
|
22
|
+
return services
|
|
23
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { core, EntityChange, ObjectRef } from '@kernhq/contracts'
|
|
2
|
+
import { type Kernel, type Tx, uuidv7 } from '@kernhq/kernel'
|
|
3
|
+
import { MODULE_ID } from '../../contract/models.js'
|
|
4
|
+
import { assetHistory } from '../schema.js'
|
|
5
|
+
|
|
6
|
+
export interface HistoryInput {
|
|
7
|
+
workspaceId: string
|
|
8
|
+
assetId: string
|
|
9
|
+
actorId: string | null
|
|
10
|
+
action: string
|
|
11
|
+
changes?: Array<{ field: string; from: unknown; to: unknown }>
|
|
12
|
+
data?: Record<string, unknown>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface NotifyInput {
|
|
16
|
+
workspaceId: string
|
|
17
|
+
userIds: Iterable<string>
|
|
18
|
+
type: string
|
|
19
|
+
title: string
|
|
20
|
+
body?: string | null
|
|
21
|
+
object?: ObjectRef | null
|
|
22
|
+
url?: string | null
|
|
23
|
+
data?: Record<string, unknown>
|
|
24
|
+
groupKey?: string | null
|
|
25
|
+
actorId?: string | null
|
|
26
|
+
/** never notify the person who caused the change */
|
|
27
|
+
exclude?: Iterable<string | null | undefined>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Side effects that leave the module: activity, notifications, realtime and the search index.
|
|
32
|
+
*
|
|
33
|
+
* All of them are best-effort. An inventory mutation must not fail because the core service is
|
|
34
|
+
* briefly unavailable, so every cross-module call is swallowed and logged — the local
|
|
35
|
+
* `asset_history` row is written inside the caller's transaction and remains the authoritative
|
|
36
|
+
* record of what happened.
|
|
37
|
+
*/
|
|
38
|
+
export class NotifyService {
|
|
39
|
+
constructor(private readonly kernel: Kernel) {}
|
|
40
|
+
|
|
41
|
+
private async best<T>(what: string, fn: () => Promise<T>): Promise<T | null> {
|
|
42
|
+
try {
|
|
43
|
+
return await fn()
|
|
44
|
+
} catch (err) {
|
|
45
|
+
this.kernel.log.warn(
|
|
46
|
+
{ err: err instanceof Error ? err.message : err, what },
|
|
47
|
+
'inventory side effect failed',
|
|
48
|
+
)
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Append to the asset's own history, inside the caller's transaction.
|
|
55
|
+
*
|
|
56
|
+
* This row is the authoritative record and belongs in the transaction it describes — if the write
|
|
57
|
+
* rolls back, so does its history. The mirror of it in core's activity feed is `activity()`, and
|
|
58
|
+
* it deliberately does **not** happen here: it used to, fired off with `void` while the
|
|
59
|
+
* transaction was still open, so a rollback left the workspace's feed showing an event for an id
|
|
60
|
+
* that never existed. The caller flushes it after the commit, beside the event and the realtime
|
|
61
|
+
* change, which is what everything else in this module already does.
|
|
62
|
+
*/
|
|
63
|
+
async history(tx: Tx, input: HistoryInput): Promise<void> {
|
|
64
|
+
await tx.insert(assetHistory).values({
|
|
65
|
+
id: uuidv7(),
|
|
66
|
+
workspaceId: input.workspaceId,
|
|
67
|
+
assetId: input.assetId,
|
|
68
|
+
actorId: input.actorId,
|
|
69
|
+
action: input.action,
|
|
70
|
+
changes: input.changes ?? [],
|
|
71
|
+
data: input.data ?? {},
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The workspace-wide activity feed's copy. Best-effort, and only ever after the commit. */
|
|
76
|
+
async activity(input: HistoryInput): Promise<void> {
|
|
77
|
+
await this.best('activity.record', () =>
|
|
78
|
+
this.kernel.call('core.activity.record', {
|
|
79
|
+
workspaceId: input.workspaceId,
|
|
80
|
+
module: MODULE_ID,
|
|
81
|
+
object: { module: MODULE_ID, type: 'asset', id: input.assetId },
|
|
82
|
+
action: input.action,
|
|
83
|
+
actorId: input.actorId,
|
|
84
|
+
changes: input.changes ?? [],
|
|
85
|
+
data: input.data ?? {},
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async notify(input: NotifyInput): Promise<void> {
|
|
91
|
+
const excluded = new Set([...(input.exclude ?? [])].filter(Boolean) as string[])
|
|
92
|
+
const targets = [...new Set(input.userIds)].filter((id) => id && !excluded.has(id))
|
|
93
|
+
if (!targets.length) return
|
|
94
|
+
await Promise.all(
|
|
95
|
+
targets.map((userId) =>
|
|
96
|
+
this.best('notifications.create', () =>
|
|
97
|
+
this.kernel.call('core.notifications.create', {
|
|
98
|
+
userId,
|
|
99
|
+
workspaceId: input.workspaceId,
|
|
100
|
+
module: MODULE_ID,
|
|
101
|
+
type: input.type,
|
|
102
|
+
title: input.title,
|
|
103
|
+
body: input.body ?? null,
|
|
104
|
+
object: input.object ?? null,
|
|
105
|
+
url: input.url ?? null,
|
|
106
|
+
data: input.data ?? {},
|
|
107
|
+
groupKey: input.groupKey ?? null,
|
|
108
|
+
actorId: input.actorId ?? null,
|
|
109
|
+
} as core.CreateNotification),
|
|
110
|
+
),
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Realtime cache invalidation for connected clients. */
|
|
116
|
+
async change(
|
|
117
|
+
workspaceId: string,
|
|
118
|
+
entity: string,
|
|
119
|
+
id: string,
|
|
120
|
+
op: EntityChange['op'],
|
|
121
|
+
opts: { patch?: Record<string, unknown>; scope?: Record<string, string> } = {},
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
await this.best('realtime.change', () =>
|
|
124
|
+
this.kernel.realtime.change(workspaceId, {
|
|
125
|
+
module: MODULE_ID,
|
|
126
|
+
entity,
|
|
127
|
+
id,
|
|
128
|
+
op,
|
|
129
|
+
...(opts.patch ? { patch: opts.patch } : {}),
|
|
130
|
+
...(opts.scope ? { scope: opts.scope } : {}),
|
|
131
|
+
}),
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Nothing calls `index`/`unindex` yet: `objectTypes` came off the module definition until there
|
|
136
|
+
// is a resolver and an indexer to stand behind it. They are kept as they are, copied from
|
|
137
|
+
// tracker, and are wired up by the change that declares the object type again.
|
|
138
|
+
async index(documents: core.SearchDocument[]): Promise<void> {
|
|
139
|
+
if (!documents.length) return
|
|
140
|
+
await this.best('search.index', () => this.kernel.call('core.search.index', { documents }))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async unindex(workspaceId: string, type: string, ids: string[]): Promise<void> {
|
|
144
|
+
if (!ids.length) return
|
|
145
|
+
await this.best('search.remove', () =>
|
|
146
|
+
this.kernel.call('core.search.remove', {
|
|
147
|
+
refs: ids.map((id) => ({ workspaceId, object: { module: MODULE_ID, type, id } })),
|
|
148
|
+
}),
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@kernhq/tsconfig/base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"target": "ES2023",
|
|
5
|
+
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "NodeNext",
|
|
7
|
+
"moduleResolution": "NodeNext",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noUncheckedIndexedAccess": true,
|
|
10
|
+
"exactOptionalPropertyTypes": false,
|
|
11
|
+
"verbatimModuleSyntax": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"resolveJsonModule": true,
|
|
16
|
+
"forceConsistentCasingInFileNames": true,
|
|
17
|
+
"declaration": true,
|
|
18
|
+
"declarationMap": true,
|
|
19
|
+
"sourceMap": true,
|
|
20
|
+
"useDefineForClassFields": true
|
|
21
|
+
}
|
|
22
|
+
}
|
package/tsconfig.client.json
CHANGED
package/tsconfig.json
CHANGED
package/vitest.config.ts
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { defineConfig } from 'vitest/config'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
/**
|
|
4
|
+
* `passWithNoTests` so a copy of this package that has not written its first test yet still reports
|
|
5
|
+
* a green `pnpm test` instead of failing on "no test files found".
|
|
6
|
+
*
|
|
7
|
+
* The timeouts and `fileParallelism: false` are for the integration suite: it creates a scratch
|
|
8
|
+
* database per file, applies every migration and drops it again. Two files doing that at once
|
|
9
|
+
* against one server is slower than doing it in turn, and the default 5s timeout expires during the
|
|
10
|
+
* migration rather than during anything being tested.
|
|
11
|
+
*/
|
|
12
|
+
export default defineConfig({
|
|
13
|
+
test: {
|
|
14
|
+
passWithNoTests: true,
|
|
15
|
+
include: ['src/**/*.test.ts'],
|
|
16
|
+
testTimeout: 60_000,
|
|
17
|
+
hookTimeout: 120_000,
|
|
18
|
+
fileParallelism: false,
|
|
19
|
+
},
|
|
20
|
+
})
|
package/dist/contract.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"contract.d.ts","sourceRoot":"","sources":["../src/contract.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB;;;;;;GAMG;AAEH,wGAAwG;AACxG,eAAO,MAAM,SAAS,cAAc,CAAA;AAEpC;;;;;GAKG;AACH,eAAO,MAAM,WAAW;;;;;EAA8D,CAAA;AACtF,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAA;AAErD,eAAO,MAAM,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;iBA2BhB,CAAA;AACF,MAAM,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,KAAK,CAAC,CAAA;AAiBzC,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoC7B,CAAA;AACD,MAAM,MAAM,iBAAiB,GAAG,OAAO,iBAAiB,CAAA;AAExD,8GAA8G;AAC9G,eAAO,MAAM,eAAe;;;;;;;;;;;;;CAa3B,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,oBAAoB;;;;;;;;;;;;EAe/B,CAAA"}
|