@kernhq/module-inventory 0.1.2 → 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.
Files changed (103) hide show
  1. package/README.md +55 -9
  2. package/dist/contract/capabilities.d.ts +49 -0
  3. package/dist/contract/capabilities.d.ts.map +1 -0
  4. package/dist/contract/capabilities.js +52 -0
  5. package/dist/contract/capabilities.js.map +1 -0
  6. package/dist/contract/events.d.ts +33 -0
  7. package/dist/contract/events.d.ts.map +1 -0
  8. package/dist/contract/events.js +22 -0
  9. package/dist/contract/events.js.map +1 -0
  10. package/dist/contract/index.d.ts +15 -0
  11. package/dist/contract/index.d.ts.map +1 -0
  12. package/dist/contract/index.js +15 -0
  13. package/dist/contract/index.js.map +1 -0
  14. package/dist/contract/models.d.ts +146 -0
  15. package/dist/contract/models.d.ts.map +1 -0
  16. package/dist/contract/models.js +107 -0
  17. package/dist/contract/models.js.map +1 -0
  18. package/dist/contract/permissions.d.ts +22 -0
  19. package/dist/contract/permissions.d.ts.map +1 -0
  20. package/dist/contract/permissions.js +26 -0
  21. package/dist/contract/permissions.js.map +1 -0
  22. package/dist/{contract.d.ts → contract/router.d.ts} +24 -85
  23. package/dist/contract/router.d.ts.map +1 -0
  24. package/dist/contract/router.js +42 -0
  25. package/dist/contract/router.js.map +1 -0
  26. package/dist/contract/settings.d.ts +18 -0
  27. package/dist/contract/settings.d.ts.map +1 -0
  28. package/dist/contract/settings.js +29 -0
  29. package/dist/contract/settings.js.map +1 -0
  30. package/dist/server/index.d.ts +4 -1
  31. package/dist/server/index.d.ts.map +1 -1
  32. package/dist/server/index.js +9 -13
  33. package/dist/server/index.js.map +1 -1
  34. package/dist/server/{_impl.d.ts → router.d.ts} +33 -8
  35. package/dist/server/{_impl.d.ts.map → router.d.ts.map} +1 -1
  36. package/dist/server/router.js +83 -0
  37. package/dist/server/router.js.map +1 -0
  38. package/dist/server/schema.d.ts +25 -11
  39. package/dist/server/schema.d.ts.map +1 -1
  40. package/dist/server/schema.js +28 -10
  41. package/dist/server/schema.js.map +1 -1
  42. package/dist/server/services/assets.d.ts +73 -0
  43. package/dist/server/services/assets.d.ts.map +1 -0
  44. package/dist/server/services/assets.js +261 -0
  45. package/dist/server/services/assets.js.map +1 -0
  46. package/dist/server/services/index.d.ts +10 -0
  47. package/dist/server/services/index.d.ts.map +1 -0
  48. package/dist/server/services/index.js +15 -0
  49. package/dist/server/services/index.js.map +1 -0
  50. package/dist/server/services/notify.d.ts +63 -0
  51. package/dist/server/services/notify.d.ts.map +1 -0
  52. package/dist/server/services/notify.js +105 -0
  53. package/dist/server/services/notify.js.map +1 -0
  54. package/migrations/0000_init.sql +12 -3
  55. package/migrations/0001_rls.sql +24 -0
  56. package/migrations/meta/0000_snapshot.json +40 -13
  57. package/migrations/meta/_journal.json +2 -2
  58. package/package.json +5 -4
  59. package/src/client/api.ts +1 -1
  60. package/src/client/components/AssetFormDialog.svelte +130 -47
  61. package/src/client/i18n.ts +11 -166
  62. package/src/client/index.ts +8 -1
  63. package/src/client/messages.test.ts +169 -0
  64. package/src/client/messages.ts +399 -0
  65. package/src/client/mock.test.ts +161 -0
  66. package/src/client/mock.ts +267 -45
  67. package/src/client/module.ts +22 -2
  68. package/src/client/pages/AssetsPage.svelte +358 -137
  69. package/src/client/permissions.ts +1 -1
  70. package/src/client/price.test.ts +106 -0
  71. package/src/client/price.ts +135 -0
  72. package/src/client/query.test.ts +58 -0
  73. package/src/client/query.ts +15 -2
  74. package/src/client/settings/GeneralSettings.svelte +0 -0
  75. package/src/client/settings/core-api.ts +32 -0
  76. package/src/client/widgets/OverviewWidget.svelte +16 -3
  77. package/src/contract/capabilities.ts +55 -0
  78. package/src/contract/events.ts +34 -0
  79. package/src/contract/index.ts +15 -0
  80. package/src/contract/models.ts +123 -0
  81. package/src/contract/permissions.ts +26 -0
  82. package/src/contract/router.ts +46 -0
  83. package/src/contract/settings.ts +30 -0
  84. package/src/module.test.ts +140 -7
  85. package/src/server/index.ts +16 -13
  86. package/src/server/inventory.int.test.ts +819 -0
  87. package/src/server/migrations.test.ts +138 -0
  88. package/src/server/router.ts +118 -0
  89. package/src/server/schema.ts +27 -10
  90. package/src/server/services/assets.ts +368 -0
  91. package/src/server/services/index.ts +23 -0
  92. package/src/server/services/notify.ts +151 -0
  93. package/tsconfig.base.json +22 -0
  94. package/tsconfig.client.json +1 -1
  95. package/tsconfig.json +1 -1
  96. package/vitest.config.ts +18 -3
  97. package/dist/contract.d.ts.map +0 -1
  98. package/dist/contract.js +0 -119
  99. package/dist/contract.js.map +0 -1
  100. package/dist/server/_impl.js +0 -204
  101. package/dist/server/_impl.js.map +0 -1
  102. package/src/contract.ts +0 -143
  103. 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
+ }
@@ -6,5 +6,5 @@
6
6
  "moduleResolution": "Bundler",
7
7
  "lib": ["ES2023", "DOM", "DOM.Iterable"]
8
8
  },
9
- "include": ["src/client/**/*.ts", "src/client/**/*.svelte", "src/contract.ts"]
9
+ "include": ["src/client/**/*.ts", "src/client/**/*.svelte", "src/contract/**/*.ts"]
10
10
  }
package/tsconfig.json CHANGED
@@ -5,6 +5,6 @@
5
5
  "outDir": "dist",
6
6
  "types": ["node"]
7
7
  },
8
- "include": ["src/contract.ts", "src/server"],
8
+ "include": ["src/contract", "src/server"],
9
9
  "exclude": ["src/**/*.test.ts"]
10
10
  }
package/vitest.config.ts CHANGED
@@ -1,5 +1,20 @@
1
1
  import { defineConfig } from 'vitest/config'
2
2
 
3
- // `passWithNoTests` so a copy of this package that has not written its first test yet still reports a
4
- // green `pnpm test` for the whole workspace instead of failing on "no test files found".
5
- export default defineConfig({ test: { include: ['src/**/*.test.ts'], passWithNoTests: true } })
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
+ })
@@ -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"}