@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,138 @@
1
+ import { readdirSync, readFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import pg from 'pg'
5
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
6
+ import { TENANT_TABLES } from './schema.js'
7
+
8
+ /**
9
+ * The migration folder, applied to a database created from nothing — and then applied again.
10
+ *
11
+ * The integration suite already proves the folder *applies*, because `kernel.start()` runs it. It
12
+ * does not prove the folder is **replayable**, and that is the property that took `core` down: the
13
+ * kernel migrates every module at boot, so one migration that throws does not degrade its own
14
+ * feature — `core` hosts five modules and never binds its port. A regenerated
15
+ * `migrations/meta/_journal.json` is enough to cause a replay, because every entry gets a `when`
16
+ * newer than the rows already in `mod_inventory.__migrations`.
17
+ *
18
+ * Three things here are deliberate, and each of them is a way this test could have been vacuously
19
+ * green instead:
20
+ *
21
+ * 1. **A scratch database, created here.** Running against a database somebody has already migrated
22
+ * proves nothing — it is the same shape of mistake as asserting that `migrateModule` succeeds
23
+ * twice against a schema that already exists.
24
+ * 2. **Every statement is executed separately and every failure is collected**, rather than throwing
25
+ * on the first. Guarding one class of statement and re-running tells you only about the next one;
26
+ * collecting them says how much is actually unguarded. In `module-hr` that difference was 203
27
+ * statements hiding behind the policies somebody had just fixed.
28
+ * 3. **Policies are asserted as `(tablename, policyname)` pairs, not as a count per table.** "One
29
+ * policy per table" is the wrong invariant — a table may legitimately carry several — and a
30
+ * duplicate pair is exactly what a replay produces.
31
+ */
32
+
33
+ const HERE = dirname(fileURLToPath(import.meta.url))
34
+ const MIGRATIONS = join(HERE, '../../migrations')
35
+
36
+ const BASE_URL = process.env.DATABASE_URL ?? 'postgres://kern:kern@localhost:5432/kern'
37
+ const DB_NAME = `kern_inventory_replay_${Date.now().toString(36)}`
38
+
39
+ let admin: pg.Client
40
+ let db: pg.Client
41
+
42
+ /** The folder in the order the kernel applies it — by filename, which is why they are numbered. */
43
+ function migrationFiles(): string[] {
44
+ return readdirSync(MIGRATIONS)
45
+ .filter((f) => f.endsWith('.sql'))
46
+ .sort()
47
+ }
48
+
49
+ /**
50
+ * Apply one file statement by statement, returning every failure rather than the first.
51
+ *
52
+ * `--> statement-breakpoint` is drizzle's separator. Splitting on it is also why nothing in this
53
+ * folder may use a dollar-quoted body: a breakpoint inside `do $$ … end $$` cuts the function in
54
+ * half, and the error is `unterminated dollar-quoted string`, which does not sound like what it is.
55
+ */
56
+ async function apply(file: string): Promise<Array<{ statement: string; error: string }>> {
57
+ const sql = readFileSync(join(MIGRATIONS, file), 'utf8')
58
+ const failures: Array<{ statement: string; error: string }> = []
59
+ for (const raw of sql.split('--> statement-breakpoint')) {
60
+ const statement = raw.trim()
61
+ if (!statement || statement.split('\n').every((l) => l.trim().startsWith('--'))) continue
62
+ try {
63
+ await db.query(statement)
64
+ } catch (err) {
65
+ failures.push({
66
+ statement: statement.slice(0, 120).replace(/\s+/g, ' '),
67
+ error: err instanceof Error ? err.message : String(err),
68
+ })
69
+ }
70
+ }
71
+ return failures
72
+ }
73
+
74
+ beforeAll(async () => {
75
+ admin = new pg.Client({ connectionString: BASE_URL })
76
+ await admin.connect()
77
+ await admin.query(`create database "${DB_NAME}"`)
78
+ const url = new URL(BASE_URL)
79
+ url.pathname = `/${DB_NAME}`
80
+ db = new pg.Client({ connectionString: url.toString() })
81
+ await db.connect()
82
+ }, 120_000)
83
+
84
+ afterAll(async () => {
85
+ await db?.end().catch(() => undefined)
86
+ await admin?.query(`drop database if exists "${DB_NAME}" with (force)`).catch(() => undefined)
87
+ await admin?.end().catch(() => undefined)
88
+ })
89
+
90
+ describe('the migration folder', () => {
91
+ it('applies to a database created from nothing', async () => {
92
+ for (const file of migrationFiles()) {
93
+ expect(await apply(file), `${file}, first pass`).toEqual([])
94
+ }
95
+ })
96
+
97
+ it('applies a second time, because a replay must not take down the host service', async () => {
98
+ for (const file of migrationFiles()) {
99
+ expect(await apply(file), `${file}, replay`).toEqual([])
100
+ }
101
+ })
102
+
103
+ it('leaves exactly one of every policy after the replay', async () => {
104
+ const { rows } = await db.query<{ tablename: string; policyname: string }>(
105
+ `select tablename, policyname from pg_policies where schemaname = 'mod_inventory'`,
106
+ )
107
+ const seen = rows.map((r) => `${r.tablename}.${r.policyname}`)
108
+ expect([...new Set(seen)].sort(), 'a duplicate pair is what a replay produces').toEqual(seen.sort())
109
+ // And the policies are the ones the module declares it has.
110
+ for (const table of TENANT_TABLES)
111
+ expect(seen, `mod_inventory.${table} has its policy`).toContain(`${table}.${table}_ws_isolation`)
112
+ })
113
+
114
+ it('forces row-level security on every table that carries a policy', async () => {
115
+ // Restricted to tables that have one: a module may legitimately keep an unsecured table, and
116
+ // asserting over every table in the schema would fail on drizzle's own `__migrations`.
117
+ const { rows } = await db.query<{ relname: string; forced: boolean }>(
118
+ `select c.relname, c.relforcerowsecurity as forced
119
+ from pg_class c
120
+ join pg_namespace n on n.oid = c.relnamespace
121
+ where n.nspname = 'mod_inventory'
122
+ and c.relkind = 'r'
123
+ and exists (select 1 from pg_policies p
124
+ where p.schemaname = 'mod_inventory' and p.tablename = c.relname)`,
125
+ )
126
+ expect(rows.length, 'no policies found at all — the RLS migration did not run').toBeGreaterThan(0)
127
+ for (const row of rows) expect(row.forced, `mod_inventory.${row.relname} forces RLS`).toBe(true)
128
+ })
129
+
130
+ it('keeps the exclusion constraint that makes two open custody periods impossible', async () => {
131
+ const { rows } = await db.query<{ n: string }>(
132
+ `select count(*) as n from pg_constraint where conname = 'inventory_custody_no_overlap'`,
133
+ )
134
+ // Exactly one: `add constraint` is not idempotent on its own, so a replay without the
135
+ // preceding `drop constraint if exists` would either throw or leave two.
136
+ expect(Number(rows[0]?.n)).toBe(1)
137
+ })
138
+ })
@@ -0,0 +1,118 @@
1
+ import type { WorkspaceId } from '@kernhq/contracts'
2
+ import {
3
+ defineModule,
4
+ defineServerModule,
5
+ type Kernel,
6
+ packageVersion,
7
+ type RequestContext,
8
+ requires,
9
+ type Tx,
10
+ workspaceScoped,
11
+ } from '@kernhq/kernel'
12
+ import { implement } from '@orpc/server'
13
+ import { inventoryContract, inventoryEvents, MODULE_ID } from '../contract/index.js'
14
+ import { toAsset, type Written } from './services/assets.js'
15
+ import { inventoryServices } from './services/index.js'
16
+
17
+ /**
18
+ * oRPC router for `/api/inventory`.
19
+ *
20
+ * Deliberately thin: it opens the workspace-bound transaction and hands straight over to a service,
21
+ * which is where the logic lives. Kept apart from `index.ts` so `module.test.ts` can walk it as data
22
+ * without booting a kernel.
23
+ *
24
+ * Two middlewares on every procedure, and the test fails if either is missing: `workspaceScoped`
25
+ * (a real membership, and the module switched on for that workspace) and `requires` (the permission
26
+ * this particular call needs). A capability adds a third, between them.
27
+ */
28
+ export { defineModule, defineServerModule, packageVersion }
29
+
30
+ const os = implement(inventoryContract).$context<RequestContext>()
31
+
32
+ export function inventoryRouter(kernel: Kernel) {
33
+ const svc = inventoryServices(kernel)
34
+ const scoped = os.use(workspaceScoped(MODULE_ID))
35
+
36
+ /**
37
+ * Everything a write announces, once the transaction that wrote it has actually committed.
38
+ *
39
+ * Three things, and all three are after the commit on purpose: the core activity feed's copy of
40
+ * the history row, the event for whatever reacts later, and the realtime change that redraws a
41
+ * screen somebody is looking at now. A mutation that skips them leaves the product believing the
42
+ * old answer; a mutation that fires them *inside* the transaction tells the rest of the instance
43
+ * about a row a rollback then took away, which is worse — it cannot be retracted.
44
+ */
45
+ const announce = async (
46
+ workspaceId: WorkspaceId,
47
+ written: Written,
48
+ event: (typeof inventoryEvents)[keyof typeof inventoryEvents],
49
+ op: 'created' | 'updated' | 'deleted',
50
+ actorId: string | null,
51
+ ) => {
52
+ const assetId = written.row.id
53
+ if (written.activity) await svc.notify.activity(written.activity)
54
+ await kernel.emit(event, { assetId, workspaceId }, { workspaceId, actorId: actorId ?? undefined })
55
+ await svc.notify.change(workspaceId, 'asset', assetId, op)
56
+ }
57
+
58
+ /** Every handler is this: a workspace-bound transaction, tagged with who is asking, and a service. */
59
+ const run = <T>(context: RequestContext, workspaceId: string, fn: (tx: Tx) => Promise<T>): Promise<T> =>
60
+ kernel.database.withWorkspace(workspaceId, fn, { userId: context.principal.userId })
61
+
62
+ return os.router({
63
+ assets: {
64
+ list: scoped.assets.list
65
+ .use(requires('inventory.asset.view'))
66
+ .handler(({ input, context }) => run(context, input.workspaceId, (tx) => svc.assets.list(tx, input))),
67
+
68
+ get: scoped.assets.get
69
+ .use(requires('inventory.asset.view'))
70
+ .handler(({ input, context }) =>
71
+ run(context, input.workspaceId, async (tx) =>
72
+ toAsset(await svc.assets.get(tx, input.workspaceId, input.assetId)),
73
+ ),
74
+ ),
75
+
76
+ create: scoped.assets.create
77
+ .use(requires('inventory.asset.manage'))
78
+ .handler(async ({ input, context }) => {
79
+ const actorId = context.principal.userId
80
+ // Module settings are read *before* the transaction opens. They come from core over the
81
+ // broker, and awaiting that inside the transaction holds a pooled connection and the
82
+ // counter row lock across a network call to another service.
83
+ const format = await svc.assets.codeFormat(input.workspaceId)
84
+ const written = await run(context, input.workspaceId, (tx) =>
85
+ svc.assets.create(tx, input.workspaceId, actorId, input, format),
86
+ )
87
+ await announce(input.workspaceId, written, inventoryEvents.assetCreated, 'created', actorId)
88
+ return toAsset(written.row)
89
+ }),
90
+
91
+ update: scoped.assets.update
92
+ .use(requires('inventory.asset.manage'))
93
+ .handler(async ({ input, context }) => {
94
+ const actorId = context.principal.userId
95
+ const written = await run(context, input.workspaceId, (tx) =>
96
+ svc.assets.update(tx, input.workspaceId, actorId, input.assetId, input),
97
+ )
98
+ await announce(input.workspaceId, written, inventoryEvents.assetUpdated, 'updated', actorId)
99
+ return toAsset(written.row)
100
+ }),
101
+
102
+ archive: scoped.assets.archive
103
+ .use(requires('inventory.asset.manage'))
104
+ .handler(async ({ input, context }) => {
105
+ const actorId = context.principal.userId
106
+ const written = await run(context, input.workspaceId, (tx) =>
107
+ svc.assets.archive(tx, input.workspaceId, actorId, input.assetId, input.archived),
108
+ )
109
+ // This procedure is the restore path too, and it used to announce `archived` either way —
110
+ // telling every subscriber that a restored item had just been retired, while the module's
111
+ // own history row beside it said `restored`.
112
+ const event = input.archived ? inventoryEvents.assetArchived : inventoryEvents.assetRestored
113
+ await announce(input.workspaceId, written, event, 'updated', actorId)
114
+ return toAsset(written.row)
115
+ }),
116
+ },
117
+ })
118
+ }
@@ -7,7 +7,7 @@ import {
7
7
  index,
8
8
  integer,
9
9
  jsonb,
10
- pgEnum,
10
+ primaryKey,
11
11
  text,
12
12
  timestamp,
13
13
  uniqueIndex,
@@ -33,20 +33,23 @@ const ts = (name: string) => timestamp(name, { withTimezone: true, mode: 'date'
33
33
  const created = () => ts('created_at').notNull().defaultNow()
34
34
  const updated = () => ts('updated_at').notNull().defaultNow()
35
35
 
36
- /**
37
- * Asset lifecycle. Stored rather than derived because every list filter asks for it; every
38
- * transition is written inside the same transaction as the row it derives from.
39
- */
40
- export const assetStatus = pgEnum('asset_status', ['in_stock', 'assigned', 'under_repair', 'retired'])
41
-
42
36
  export const counters = schema.table(
43
37
  'counters',
44
38
  /** Per-workspace sequence sources (`asset_code`). Narrow on purpose: one row per key. */
45
39
  {
46
- workspaceId: ws().primaryKey(),
47
- key: text('key').primaryKey(),
40
+ workspaceId: ws(),
41
+ key: text('key').notNull(),
48
42
  value: integer('value').notNull(),
49
43
  },
44
+ /**
45
+ * Composite, declared here rather than as two `.primaryKey()` columns.
46
+ *
47
+ * Column-level `.primaryKey()` twice reads like a compound key and is not one: drizzle emits
48
+ * `PRIMARY KEY` on both columns and Postgres refuses the table outright — "multiple primary keys
49
+ * for table are not allowed", SQLSTATE 42P16. The module's migration is the first thing the
50
+ * kernel runs, so the failure is not a broken table but a service that will not boot.
51
+ */
52
+ (t) => [primaryKey({ columns: [t.workspaceId, t.key] })],
50
53
  )
51
54
 
52
55
  export const categories = schema.table(
@@ -74,7 +77,10 @@ export const assets = schema.table(
74
77
  name: text('name').notNull(),
75
78
  description: text('description').notNull().default(''),
76
79
  categoryId: uuid('category_id'),
77
- status: assetStatus('status').notNull().default('in_stock'),
80
+ /** `AssetStatus` in the contract. Text, not a pg enum: every other Kern module stores a
81
+ * status this way, and an enum named `asset_status` in `public` is a type this module
82
+ * leaves behind when it is removed. */
83
+ status: text('status').notNull().default('in_stock'),
78
84
  /** Denormalized from `custody_periods`, which stays authoritative for history. */
79
85
  custodianUserId: uuid('custodian_user_id'),
80
86
  custodySince: ts('custody_since'),
@@ -86,6 +92,8 @@ export const assets = schema.table(
86
92
  currency: char('currency', { length: 3 }),
87
93
  warrantyUntil: date('warranty_until'),
88
94
  photoFileId: uuid('photo_file_id'),
95
+ /** Values for this workspace's own `field_defs`, keyed by their `key`. */
96
+ custom: jsonb('custom').$type<Record<string, unknown>>().notNull().default({}),
89
97
  createdAt: created(),
90
98
  updatedAt: updated(),
91
99
  archivedAt: ts('archived_at'),
@@ -95,6 +103,10 @@ export const assets = schema.table(
95
103
  index('inventory_assets_ws_created_idx').on(t.workspaceId, t.createdAt),
96
104
  index('inventory_assets_ws_status_idx').on(t.workspaceId, t.status),
97
105
  index('inventory_assets_ws_category_idx').on(t.workspaceId, t.categoryId),
106
+ // "What is Ada holding?" — asked by the person, by the offboarding hook, and by a widget.
107
+ index('inventory_assets_ws_custodian_idx')
108
+ .on(t.workspaceId, t.custodianUserId)
109
+ .where(sql`custodian_user_id is not null`),
98
110
  // The "what leaves warranty this month" scan, before a job makes it a widget's cheap query.
99
111
  index('inventory_assets_ws_warranty_idx')
100
112
  .on(t.workspaceId, t.warrantyUntil)
@@ -218,6 +230,11 @@ export const attachments = schema.table(
218
230
 
219
231
  /** Every tenant table, so the RLS migration can be checked against one list rather than memory. */
220
232
  export const TENANT_TABLES = [
233
+ // `counters` is a tenant table like any other: it carries `workspace_id`, so one workspace's
234
+ // asset-code sequence is readable to another without a policy. It was left out of this list —
235
+ // and therefore out of `0001_rls.sql` — because it holds no asset data, which is not the rule
236
+ // the file states at the top. Tracker's structurally identical `issue_counters` is covered.
237
+ 'counters',
221
238
  'categories',
222
239
  'assets',
223
240
  'field_defs',