@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.
- package/README.md +134 -9
- package/dist/contract/capabilities.d.ts +49 -0
- package/dist/contract/capabilities.d.ts.map +1 -0
- package/dist/contract/capabilities.js +94 -0
- package/dist/contract/capabilities.js.map +1 -0
- package/dist/contract/events.d.ts +76 -0
- package/dist/contract/events.d.ts.map +1 -0
- package/dist/contract/events.js +62 -0
- package/dist/contract/events.js.map +1 -0
- package/dist/contract/index.d.ts +16 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +16 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/contract/models.d.ts +468 -0
- package/dist/contract/models.d.ts.map +1 -0
- package/dist/contract/models.js +320 -0
- package/dist/contract/models.js.map +1 -0
- package/dist/contract/notifications.d.ts +24 -0
- package/dist/contract/notifications.d.ts.map +1 -0
- package/dist/contract/notifications.js +72 -0
- package/dist/contract/notifications.js.map +1 -0
- package/dist/contract/permissions.d.ts +72 -0
- package/dist/contract/permissions.d.ts.map +1 -0
- package/dist/contract/permissions.js +79 -0
- package/dist/contract/permissions.js.map +1 -0
- package/dist/contract/router.d.ts +1378 -0
- package/dist/contract/router.d.ts.map +1 -0
- package/dist/contract/router.js +266 -0
- package/dist/contract/router.js.map +1 -0
- package/dist/contract/settings.d.ts +20 -0
- package/dist/contract/settings.d.ts.map +1 -0
- package/dist/contract/settings.js +49 -0
- package/dist/contract/settings.js.map +1 -0
- package/dist/server/index.d.ts +8 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +288 -8
- package/dist/server/index.js.map +1 -1
- package/dist/server/jobs.d.ts +48 -0
- package/dist/server/jobs.d.ts.map +1 -0
- package/dist/server/jobs.js +358 -0
- package/dist/server/jobs.js.map +1 -0
- package/dist/server/router.d.ts +1789 -0
- package/dist/server/router.d.ts.map +1 -0
- package/dist/server/router.js +439 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/schema.d.ts +156 -9
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +183 -11
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +139 -0
- package/dist/server/services/assets.d.ts.map +1 -0
- package/dist/server/services/assets.js +429 -0
- package/dist/server/services/assets.js.map +1 -0
- package/dist/server/services/attachments.d.ts +80 -0
- package/dist/server/services/attachments.d.ts.map +1 -0
- package/dist/server/services/attachments.js +182 -0
- package/dist/server/services/attachments.js.map +1 -0
- package/dist/server/services/audience.d.ts +15 -0
- package/dist/server/services/audience.d.ts.map +1 -0
- package/dist/server/services/audience.js +64 -0
- package/dist/server/services/audience.js.map +1 -0
- package/dist/server/services/categories.d.ts +57 -0
- package/dist/server/services/categories.d.ts.map +1 -0
- package/dist/server/services/categories.js +124 -0
- package/dist/server/services/categories.js.map +1 -0
- package/dist/server/services/cursor.d.ts +68 -0
- package/dist/server/services/cursor.d.ts.map +1 -0
- package/dist/server/services/cursor.js +39 -0
- package/dist/server/services/cursor.js.map +1 -0
- package/dist/server/services/custody.d.ts +175 -0
- package/dist/server/services/custody.d.ts.map +1 -0
- package/dist/server/services/custody.js +367 -0
- package/dist/server/services/custody.js.map +1 -0
- package/dist/server/services/db-errors.d.ts +7 -0
- package/dist/server/services/db-errors.d.ts.map +1 -0
- package/dist/server/services/db-errors.js +32 -0
- package/dist/server/services/db-errors.js.map +1 -0
- package/dist/server/services/index.d.ts +26 -0
- package/dist/server/services/index.d.ts.map +1 -0
- package/dist/server/services/index.js +39 -0
- package/dist/server/services/index.js.map +1 -0
- package/dist/server/services/members.d.ts +27 -0
- package/dist/server/services/members.d.ts.map +1 -0
- package/dist/server/services/members.js +39 -0
- package/dist/server/services/members.js.map +1 -0
- package/dist/server/services/notify.d.ts +105 -0
- package/dist/server/services/notify.d.ts.map +1 -0
- package/dist/server/services/notify.js +147 -0
- package/dist/server/services/notify.js.map +1 -0
- package/dist/server/services/offboarding.d.ts +70 -0
- package/dist/server/services/offboarding.d.ts.map +1 -0
- package/dist/server/services/offboarding.js +116 -0
- package/dist/server/services/offboarding.js.map +1 -0
- package/dist/server/services/repairs.d.ts +204 -0
- package/dist/server/services/repairs.d.ts.map +1 -0
- package/dist/server/services/repairs.js +476 -0
- package/dist/server/services/repairs.js.map +1 -0
- package/dist/server/services/search.d.ts +85 -0
- package/dist/server/services/search.d.ts.map +1 -0
- package/dist/server/services/search.js +142 -0
- package/dist/server/services/search.js.map +1 -0
- package/dist/server/services/stats.d.ts +42 -0
- package/dist/server/services/stats.d.ts.map +1 -0
- package/dist/server/services/stats.js +80 -0
- package/dist/server/services/stats.js.map +1 -0
- package/dist/server/services/status.d.ts +102 -0
- package/dist/server/services/status.d.ts.map +1 -0
- package/dist/server/services/status.js +71 -0
- package/dist/server/services/status.js.map +1 -0
- package/migrations/0000_init.sql +12 -3
- package/migrations/0001_rls.sql +24 -0
- package/migrations/0002_custody_and_categories.sql +23 -0
- package/migrations/0003_repairs.sql +23 -0
- package/migrations/0004_platform_surfaces.sql +51 -0
- package/migrations/0005_repair_dates.sql +35 -0
- package/migrations/0006_workspace_registry_read.sql +50 -0
- package/migrations/0007_history_sequence.sql +83 -0
- package/migrations/meta/0000_snapshot.json +40 -13
- package/migrations/meta/0002_snapshot.json +1054 -0
- package/migrations/meta/0003_snapshot.json +1070 -0
- package/migrations/meta/0004_snapshot.json +1130 -0
- package/migrations/meta/0005_snapshot.json +1135 -0
- package/migrations/meta/_journal.json +44 -2
- package/package.json +5 -4
- package/src/client/api-instance.ts +27 -2
- package/src/client/api.ts +1 -1
- package/src/client/bidi.test.ts +148 -0
- package/src/client/bidi.ts +85 -0
- package/src/client/components/AssetDetailPanel.svelte +614 -0
- package/src/client/components/AssetFormDialog.svelte +191 -59
- package/src/client/components/AssetPhoto.svelte +178 -0
- package/src/client/components/AttachmentsSection.svelte +327 -0
- package/src/client/components/CustodyDialog.svelte +201 -0
- package/src/client/components/RepairDialog.svelte +271 -0
- package/src/client/components/RepairsSection.svelte +318 -0
- package/src/client/components/Timeline.svelte +347 -0
- package/src/client/components/TimelineText.svelte +124 -0
- package/src/client/core-api.ts +71 -0
- package/src/client/custody.test.ts +31 -0
- package/src/client/custody.ts +34 -0
- package/src/client/errors.test.ts +365 -0
- package/src/client/errors.ts +201 -0
- package/src/client/i18n.ts +11 -166
- package/src/client/index.ts +8 -1
- package/src/client/links.test.ts +74 -0
- package/src/client/links.ts +44 -0
- package/src/client/members.test.ts +132 -0
- package/src/client/members.ts +116 -0
- package/src/client/messages.test.ts +296 -0
- package/src/client/messages.ts +1424 -0
- package/src/client/mock.test.ts +555 -0
- package/src/client/mock.ts +1261 -52
- package/src/client/module.ts +76 -2
- package/src/client/pages/AssetsPage.svelte +638 -145
- package/src/client/permissions.ts +8 -1
- package/src/client/price.test.ts +254 -0
- package/src/client/price.ts +279 -0
- package/src/client/query.test.ts +58 -0
- package/src/client/query.ts +51 -2
- package/src/client/repairs.test.ts +38 -0
- package/src/client/repairs.ts +38 -0
- package/src/client/settings/CategoriesSettings.svelte +421 -0
- package/src/client/settings/GeneralSettings.svelte +403 -0
- package/src/client/status.ts +29 -0
- package/src/client/timeline.test.ts +175 -0
- package/src/client/timeline.ts +206 -0
- package/src/client/widgets/OverviewWidget.svelte +140 -26
- package/src/client/widgets/RepairsWidget.svelte +124 -0
- package/src/contract/capabilities.ts +99 -0
- package/src/contract/events.ts +83 -0
- package/src/contract/index.ts +16 -0
- package/src/contract/models.ts +360 -0
- package/src/contract/notifications.ts +73 -0
- package/src/contract/permissions.ts +79 -0
- package/src/contract/router.ts +300 -0
- package/src/contract/settings.ts +50 -0
- package/src/module.test.ts +330 -7
- package/src/server/index.ts +318 -8
- package/src/server/inventory.int.test.ts +4374 -0
- package/src/server/jobs.ts +444 -0
- package/src/server/migrations.test.ts +251 -0
- package/src/server/router.ts +574 -0
- package/src/server/schema.ts +184 -10
- package/src/server/services/assets.ts +528 -0
- package/src/server/services/attachments.ts +215 -0
- package/src/server/services/audience.ts +77 -0
- package/src/server/services/categories.ts +136 -0
- package/src/server/services/cursor.ts +104 -0
- package/src/server/services/custody.ts +471 -0
- package/src/server/services/db-errors.ts +42 -0
- package/src/server/services/index.ts +56 -0
- package/src/server/services/members.ts +54 -0
- package/src/server/services/notify.ts +196 -0
- package/src/server/services/offboarding.ts +150 -0
- package/src/server/services/repairs.ts +567 -0
- package/src/server/services/search.ts +166 -0
- package/src/server/services/stats.ts +88 -0
- package/src/server/services/status.test.ts +34 -0
- package/src/server/services/status.ts +143 -0
- package/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 +0 -387
- 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.d.ts +0 -427
- package/dist/server/_impl.d.ts.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,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
|
+
})
|