@kernhq/module-inventory 0.1.2 → 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.
Files changed (213) hide show
  1. package/README.md +134 -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 +94 -0
  5. package/dist/contract/capabilities.js.map +1 -0
  6. package/dist/contract/events.d.ts +76 -0
  7. package/dist/contract/events.d.ts.map +1 -0
  8. package/dist/contract/events.js +62 -0
  9. package/dist/contract/events.js.map +1 -0
  10. package/dist/contract/index.d.ts +16 -0
  11. package/dist/contract/index.d.ts.map +1 -0
  12. package/dist/contract/index.js +16 -0
  13. package/dist/contract/index.js.map +1 -0
  14. package/dist/contract/models.d.ts +468 -0
  15. package/dist/contract/models.d.ts.map +1 -0
  16. package/dist/contract/models.js +320 -0
  17. package/dist/contract/models.js.map +1 -0
  18. package/dist/contract/notifications.d.ts +24 -0
  19. package/dist/contract/notifications.d.ts.map +1 -0
  20. package/dist/contract/notifications.js +72 -0
  21. package/dist/contract/notifications.js.map +1 -0
  22. package/dist/contract/permissions.d.ts +72 -0
  23. package/dist/contract/permissions.d.ts.map +1 -0
  24. package/dist/contract/permissions.js +79 -0
  25. package/dist/contract/permissions.js.map +1 -0
  26. package/dist/contract/router.d.ts +1378 -0
  27. package/dist/contract/router.d.ts.map +1 -0
  28. package/dist/contract/router.js +266 -0
  29. package/dist/contract/router.js.map +1 -0
  30. package/dist/contract/settings.d.ts +20 -0
  31. package/dist/contract/settings.d.ts.map +1 -0
  32. package/dist/contract/settings.js +49 -0
  33. package/dist/contract/settings.js.map +1 -0
  34. package/dist/server/index.d.ts +8 -1
  35. package/dist/server/index.d.ts.map +1 -1
  36. package/dist/server/index.js +288 -8
  37. package/dist/server/index.js.map +1 -1
  38. package/dist/server/jobs.d.ts +48 -0
  39. package/dist/server/jobs.d.ts.map +1 -0
  40. package/dist/server/jobs.js +358 -0
  41. package/dist/server/jobs.js.map +1 -0
  42. package/dist/server/router.d.ts +1789 -0
  43. package/dist/server/router.d.ts.map +1 -0
  44. package/dist/server/router.js +439 -0
  45. package/dist/server/router.js.map +1 -0
  46. package/dist/server/schema.d.ts +156 -9
  47. package/dist/server/schema.d.ts.map +1 -1
  48. package/dist/server/schema.js +183 -11
  49. package/dist/server/schema.js.map +1 -1
  50. package/dist/server/services/assets.d.ts +139 -0
  51. package/dist/server/services/assets.d.ts.map +1 -0
  52. package/dist/server/services/assets.js +429 -0
  53. package/dist/server/services/assets.js.map +1 -0
  54. package/dist/server/services/attachments.d.ts +80 -0
  55. package/dist/server/services/attachments.d.ts.map +1 -0
  56. package/dist/server/services/attachments.js +182 -0
  57. package/dist/server/services/attachments.js.map +1 -0
  58. package/dist/server/services/audience.d.ts +15 -0
  59. package/dist/server/services/audience.d.ts.map +1 -0
  60. package/dist/server/services/audience.js +64 -0
  61. package/dist/server/services/audience.js.map +1 -0
  62. package/dist/server/services/categories.d.ts +57 -0
  63. package/dist/server/services/categories.d.ts.map +1 -0
  64. package/dist/server/services/categories.js +124 -0
  65. package/dist/server/services/categories.js.map +1 -0
  66. package/dist/server/services/cursor.d.ts +68 -0
  67. package/dist/server/services/cursor.d.ts.map +1 -0
  68. package/dist/server/services/cursor.js +39 -0
  69. package/dist/server/services/cursor.js.map +1 -0
  70. package/dist/server/services/custody.d.ts +175 -0
  71. package/dist/server/services/custody.d.ts.map +1 -0
  72. package/dist/server/services/custody.js +367 -0
  73. package/dist/server/services/custody.js.map +1 -0
  74. package/dist/server/services/db-errors.d.ts +7 -0
  75. package/dist/server/services/db-errors.d.ts.map +1 -0
  76. package/dist/server/services/db-errors.js +32 -0
  77. package/dist/server/services/db-errors.js.map +1 -0
  78. package/dist/server/services/index.d.ts +26 -0
  79. package/dist/server/services/index.d.ts.map +1 -0
  80. package/dist/server/services/index.js +39 -0
  81. package/dist/server/services/index.js.map +1 -0
  82. package/dist/server/services/members.d.ts +27 -0
  83. package/dist/server/services/members.d.ts.map +1 -0
  84. package/dist/server/services/members.js +39 -0
  85. package/dist/server/services/members.js.map +1 -0
  86. package/dist/server/services/notify.d.ts +105 -0
  87. package/dist/server/services/notify.d.ts.map +1 -0
  88. package/dist/server/services/notify.js +147 -0
  89. package/dist/server/services/notify.js.map +1 -0
  90. package/dist/server/services/offboarding.d.ts +70 -0
  91. package/dist/server/services/offboarding.d.ts.map +1 -0
  92. package/dist/server/services/offboarding.js +116 -0
  93. package/dist/server/services/offboarding.js.map +1 -0
  94. package/dist/server/services/repairs.d.ts +204 -0
  95. package/dist/server/services/repairs.d.ts.map +1 -0
  96. package/dist/server/services/repairs.js +476 -0
  97. package/dist/server/services/repairs.js.map +1 -0
  98. package/dist/server/services/search.d.ts +85 -0
  99. package/dist/server/services/search.d.ts.map +1 -0
  100. package/dist/server/services/search.js +142 -0
  101. package/dist/server/services/search.js.map +1 -0
  102. package/dist/server/services/stats.d.ts +42 -0
  103. package/dist/server/services/stats.d.ts.map +1 -0
  104. package/dist/server/services/stats.js +80 -0
  105. package/dist/server/services/stats.js.map +1 -0
  106. package/dist/server/services/status.d.ts +102 -0
  107. package/dist/server/services/status.d.ts.map +1 -0
  108. package/dist/server/services/status.js +71 -0
  109. package/dist/server/services/status.js.map +1 -0
  110. package/migrations/0000_init.sql +12 -3
  111. package/migrations/0001_rls.sql +24 -0
  112. package/migrations/0002_custody_and_categories.sql +23 -0
  113. package/migrations/0003_repairs.sql +23 -0
  114. package/migrations/0004_platform_surfaces.sql +51 -0
  115. package/migrations/0005_repair_dates.sql +35 -0
  116. package/migrations/0006_workspace_registry_read.sql +50 -0
  117. package/migrations/0007_history_sequence.sql +83 -0
  118. package/migrations/meta/0000_snapshot.json +40 -13
  119. package/migrations/meta/0002_snapshot.json +1054 -0
  120. package/migrations/meta/0003_snapshot.json +1070 -0
  121. package/migrations/meta/0004_snapshot.json +1130 -0
  122. package/migrations/meta/0005_snapshot.json +1135 -0
  123. package/migrations/meta/_journal.json +44 -2
  124. package/package.json +5 -4
  125. package/src/client/api-instance.ts +27 -2
  126. package/src/client/api.ts +1 -1
  127. package/src/client/bidi.test.ts +148 -0
  128. package/src/client/bidi.ts +85 -0
  129. package/src/client/components/AssetDetailPanel.svelte +614 -0
  130. package/src/client/components/AssetFormDialog.svelte +191 -59
  131. package/src/client/components/AssetPhoto.svelte +178 -0
  132. package/src/client/components/AttachmentsSection.svelte +327 -0
  133. package/src/client/components/CustodyDialog.svelte +201 -0
  134. package/src/client/components/RepairDialog.svelte +271 -0
  135. package/src/client/components/RepairsSection.svelte +318 -0
  136. package/src/client/components/Timeline.svelte +347 -0
  137. package/src/client/components/TimelineText.svelte +124 -0
  138. package/src/client/core-api.ts +71 -0
  139. package/src/client/custody.test.ts +31 -0
  140. package/src/client/custody.ts +34 -0
  141. package/src/client/errors.test.ts +365 -0
  142. package/src/client/errors.ts +201 -0
  143. package/src/client/i18n.ts +11 -166
  144. package/src/client/index.ts +8 -1
  145. package/src/client/links.test.ts +74 -0
  146. package/src/client/links.ts +44 -0
  147. package/src/client/members.test.ts +132 -0
  148. package/src/client/members.ts +116 -0
  149. package/src/client/messages.test.ts +296 -0
  150. package/src/client/messages.ts +1424 -0
  151. package/src/client/mock.test.ts +555 -0
  152. package/src/client/mock.ts +1261 -52
  153. package/src/client/module.ts +76 -2
  154. package/src/client/pages/AssetsPage.svelte +638 -145
  155. package/src/client/permissions.ts +8 -1
  156. package/src/client/price.test.ts +254 -0
  157. package/src/client/price.ts +279 -0
  158. package/src/client/query.test.ts +58 -0
  159. package/src/client/query.ts +51 -2
  160. package/src/client/repairs.test.ts +38 -0
  161. package/src/client/repairs.ts +38 -0
  162. package/src/client/settings/CategoriesSettings.svelte +421 -0
  163. package/src/client/settings/GeneralSettings.svelte +403 -0
  164. package/src/client/status.ts +29 -0
  165. package/src/client/timeline.test.ts +175 -0
  166. package/src/client/timeline.ts +206 -0
  167. package/src/client/widgets/OverviewWidget.svelte +140 -26
  168. package/src/client/widgets/RepairsWidget.svelte +124 -0
  169. package/src/contract/capabilities.ts +99 -0
  170. package/src/contract/events.ts +83 -0
  171. package/src/contract/index.ts +16 -0
  172. package/src/contract/models.ts +360 -0
  173. package/src/contract/notifications.ts +73 -0
  174. package/src/contract/permissions.ts +79 -0
  175. package/src/contract/router.ts +300 -0
  176. package/src/contract/settings.ts +50 -0
  177. package/src/module.test.ts +330 -7
  178. package/src/server/index.ts +318 -8
  179. package/src/server/inventory.int.test.ts +4374 -0
  180. package/src/server/jobs.ts +444 -0
  181. package/src/server/migrations.test.ts +251 -0
  182. package/src/server/router.ts +574 -0
  183. package/src/server/schema.ts +184 -10
  184. package/src/server/services/assets.ts +528 -0
  185. package/src/server/services/attachments.ts +215 -0
  186. package/src/server/services/audience.ts +77 -0
  187. package/src/server/services/categories.ts +136 -0
  188. package/src/server/services/cursor.ts +104 -0
  189. package/src/server/services/custody.ts +471 -0
  190. package/src/server/services/db-errors.ts +42 -0
  191. package/src/server/services/index.ts +56 -0
  192. package/src/server/services/members.ts +54 -0
  193. package/src/server/services/notify.ts +196 -0
  194. package/src/server/services/offboarding.ts +150 -0
  195. package/src/server/services/repairs.ts +567 -0
  196. package/src/server/services/search.ts +166 -0
  197. package/src/server/services/stats.ts +88 -0
  198. package/src/server/services/status.test.ts +34 -0
  199. package/src/server/services/status.ts +143 -0
  200. package/tsconfig.base.json +22 -0
  201. package/tsconfig.client.json +1 -1
  202. package/tsconfig.json +1 -1
  203. package/vitest.config.ts +18 -3
  204. package/dist/contract.d.ts +0 -387
  205. package/dist/contract.d.ts.map +0 -1
  206. package/dist/contract.js +0 -119
  207. package/dist/contract.js.map +0 -1
  208. package/dist/server/_impl.d.ts +0 -427
  209. package/dist/server/_impl.d.ts.map +0 -1
  210. package/dist/server/_impl.js +0 -204
  211. package/dist/server/_impl.js.map +0 -1
  212. package/src/contract.ts +0 -143
  213. package/src/server/_impl.ts +0 -275
@@ -0,0 +1,251 @@
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
+
139
+ it('keeps the check that stops a repair coming back before it was sent', async () => {
140
+ const { rows } = await db.query<{ conname: string; convalidated: boolean }>(
141
+ `select conname, convalidated from pg_constraint
142
+ where conrelid = 'mod_inventory.repairs'::regclass
143
+ and conname = 'inventory_repairs_returned_after_sent'`,
144
+ )
145
+ // Exactly one: `add constraint` is not idempotent on its own, so a replay without the preceding
146
+ // `drop constraint if exists` would either throw or leave two.
147
+ expect(rows).toHaveLength(1)
148
+ /**
149
+ * `not valid`, and asserted rather than assumed.
150
+ *
151
+ * The constraint is enforced on every insert and update from the moment it exists; what
152
+ * `not valid` skips is the scan of rows already in the table — which are exactly the rows this
153
+ * defect may have written. A validating constraint that met one of them would throw *during
154
+ * migration*, and a module's migrations are the first thing the kernel runs, so that is not a
155
+ * broken feature but a host service that never binds its port.
156
+ */
157
+ expect(rows[0]?.convalidated, 'a validating check would fail the upgrade on a row it found').toBe(false)
158
+ })
159
+
160
+ it('lets a session with no workspace read the registry the sweeps enumerate', async () => {
161
+ /**
162
+ * The one table in this schema that a scheduler reads unbound.
163
+ *
164
+ * Every table here carries `force row level security`, which subjects the schema's **owner** to
165
+ * its policies as well — so the per-workspace policy alone left `select … from workspaces` with
166
+ * no `app.workspace_id` answering zero rows for everybody but a superuser. That is silent: both
167
+ * nightly sweeps simply found nothing to do. The second policy is what admits the scheduler, and
168
+ * `inventory.int.test.ts` proves it against a plain login role; this proves it survives a replay.
169
+ */
170
+ const { rows } = await db.query<{ policyname: string; cmd: string }>(
171
+ `select policyname, cmd from pg_policies
172
+ where schemaname = 'mod_inventory' and tablename = 'workspaces'
173
+ order by policyname`,
174
+ )
175
+ expect(rows.map((r) => `${r.policyname}:${r.cmd}`)).toEqual([
176
+ 'workspaces_unbound_read:SELECT',
177
+ 'workspaces_ws_isolation:ALL',
178
+ ])
179
+ })
180
+
181
+ /**
182
+ * The timeline's ordering key, which a replay must neither duplicate nor rewind.
183
+ *
184
+ * `asset_history` was read newest-first by its uuidv7 primary key, and a uuidv7 is only ordered to
185
+ * the millisecond — so two entries written inside one sorted by ten random bytes and a timeline
186
+ * could show a file being removed before it was added. `0007` adds a sequence, and the three things
187
+ * that make it work are all things a hand-written migration can get wrong twice: the column has to
188
+ * carry the sequence as its **default** (or the previous image's inserts write nothing into it), the
189
+ * replay must not create a second sequence, and the `setval` must never move the sequence
190
+ * *backwards* onto a value some open transaction has already taken.
191
+ */
192
+ it('gives the timeline a sequence to order by, once', async () => {
193
+ const { rows: columns } = await db.query<{ is_nullable: string; column_default: string | null }>(
194
+ `select is_nullable, column_default from information_schema.columns
195
+ where table_schema = 'mod_inventory' and table_name = 'asset_history' and column_name = 'seq'`,
196
+ )
197
+ expect(columns, 'the column exists after the replay').toHaveLength(1)
198
+ // `not null` **with a default** is what keeps the previous image writing: it never names `seq`,
199
+ // so the default fills it, and it never selects it, so it reads the table exactly as before.
200
+ expect(columns[0]?.is_nullable).toBe('NO')
201
+ expect(columns[0]?.column_default).toContain('asset_history_seq')
202
+
203
+ const { rows: sequences } = await db.query<{ n: string }>(
204
+ `select count(*) as n from pg_sequences
205
+ where schemaname = 'mod_inventory' and sequencename = 'asset_history_seq'`,
206
+ )
207
+ expect(Number(sequences[0]?.n), 'a replay must not leave two').toBe(1)
208
+
209
+ const { rows: index } = await db.query<{ indexdef: string }>(
210
+ `select indexdef from pg_indexes
211
+ where schemaname = 'mod_inventory' and indexname = 'inventory_asset_history_ws_asset_seq_uq'`,
212
+ )
213
+ expect(index).toHaveLength(1)
214
+ // What the timeline pages on: one asset's entries, bounded by `seq`.
215
+ expect(index[0]?.indexdef).toContain('UNIQUE')
216
+ expect(index[0]?.indexdef).toMatch(/workspace_id.*asset_id.*seq/s)
217
+ })
218
+
219
+ it('hands out increasing sequence values to rows written in the same instant', async () => {
220
+ // The property the whole migration exists for, asserted against the schema it produced rather
221
+ // than against the service: three rows in one statement share `created_at` exactly, because it
222
+ // defaults to the transaction's `now()`, and they still come back in an order.
223
+ const workspaceId = '00000000-0000-4000-8000-0000000000aa'
224
+ const assetId = '00000000-0000-4000-8000-0000000000bb'
225
+ await db.query(
226
+ `insert into mod_inventory.asset_history (workspace_id, asset_id, action)
227
+ values ($1,$2,'one'), ($1,$2,'two'), ($1,$2,'three')`,
228
+ [workspaceId, assetId],
229
+ )
230
+ const { rows } = await db.query<{ action: string; seq: string; created_at: Date }>(
231
+ `select action, seq, created_at from mod_inventory.asset_history
232
+ where asset_id = $1 order by seq`,
233
+ [assetId],
234
+ )
235
+ expect(rows.map((r) => r.action)).toEqual(['one', 'two', 'three'])
236
+ expect(new Set(rows.map((r) => r.created_at.toISOString())).size, 'one now()').toBe(1)
237
+ expect(new Set(rows.map((r) => r.seq)).size, 'three distinct sequence values').toBe(3)
238
+ })
239
+
240
+ it('keeps the partial index that makes two open repairs impossible', async () => {
241
+ // drizzle emits a bare `CREATE UNIQUE INDEX` for this one; the `if not exists` is added by
242
+ // hand in `0003_repairs.sql`, and a replay is the only thing that proves it is really there.
243
+ const { rows } = await db.query<{ indexdef: string }>(
244
+ `select indexdef from pg_indexes
245
+ where schemaname = 'mod_inventory' and indexname = 'inventory_repairs_one_open_uq'`,
246
+ )
247
+ expect(rows).toHaveLength(1)
248
+ // Partial, not total: an asset may be repaired many times, just not twice at once.
249
+ expect(rows[0]?.indexdef).toContain('WHERE (returned_on IS NULL)')
250
+ })
251
+ })