@kernhq/module-inventory 0.2.0 → 0.4.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 +106 -25
- package/dist/contract/capabilities.d.ts +4 -4
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +51 -9
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/events.d.ts +43 -0
- package/dist/contract/events.d.ts.map +1 -1
- package/dist/contract/events.js +40 -0
- package/dist/contract/events.js.map +1 -1
- package/dist/contract/index.d.ts +1 -0
- package/dist/contract/index.d.ts.map +1 -1
- package/dist/contract/index.js +1 -0
- package/dist/contract/index.js.map +1 -1
- package/dist/contract/models.d.ts +345 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +245 -0
- package/dist/contract/models.js.map +1 -1
- 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 +52 -2
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +55 -2
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/router.d.ts +1108 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +253 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/contract/settings.d.ts +2 -0
- package/dist/contract/settings.d.ts.map +1 -1
- package/dist/contract/settings.js +24 -4
- package/dist/contract/settings.js.map +1 -1
- package/dist/server/index.d.ts +8 -4
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +289 -5
- 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 +1392 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +385 -7
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +134 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +169 -4
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +69 -3
- package/dist/server/services/assets.d.ts.map +1 -1
- package/dist/server/services/assets.js +199 -31
- package/dist/server/services/assets.js.map +1 -1
- 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 +157 -0
- package/dist/server/services/categories.d.ts.map +1 -0
- package/dist/server/services/categories.js +309 -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 +16 -0
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +25 -1
- package/dist/server/services/index.js.map +1 -1
- 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 +43 -1
- package/dist/server/services/notify.d.ts.map +1 -1
- package/dist/server/services/notify.js +47 -5
- package/dist/server/services/notify.js.map +1 -1
- 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/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/0008_category_order_unique.sql +71 -0
- 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 +49 -0
- package/package.json +2 -1
- package/src/client/api-instance.ts +27 -2
- 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 +71 -22
- 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 +395 -0
- package/src/client/errors.ts +229 -0
- 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 +127 -0
- package/src/client/messages.ts +1106 -18
- package/src/client/mock.test.ts +465 -1
- package/src/client/mock.ts +1105 -79
- package/src/client/module.ts +73 -1
- package/src/client/pages/AssetsPage.svelte +317 -45
- package/src/client/permissions.ts +7 -0
- package/src/client/price.test.ts +151 -3
- package/src/client/price.ts +155 -11
- package/src/client/query.ts +36 -0
- package/src/client/reorder.test.ts +100 -0
- package/src/client/reorder.ts +79 -0
- package/src/client/repairs.test.ts +38 -0
- package/src/client/repairs.ts +38 -0
- package/src/client/sequence.test.ts +248 -0
- package/src/client/sequence.ts +185 -0
- package/src/client/settings/CategoriesSettings.svelte +746 -0
- package/src/client/settings/GeneralSettings.svelte +0 -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 +127 -26
- package/src/client/widgets/RepairsWidget.svelte +124 -0
- package/src/contract/capabilities.ts +53 -9
- package/src/contract/events.ts +49 -0
- package/src/contract/index.ts +1 -0
- package/src/contract/models.ts +270 -0
- package/src/contract/notifications.ts +73 -0
- package/src/contract/permissions.ts +55 -2
- package/src/contract/router.ts +284 -1
- package/src/contract/settings.ts +24 -4
- package/src/module.test.ts +217 -4
- package/src/server/index.ts +311 -4
- package/src/server/inventory.int.test.ts +4109 -19
- package/src/server/jobs.ts +444 -0
- package/src/server/migrations.test.ts +253 -2
- package/src/server/router.ts +486 -6
- package/src/server/schema.ts +171 -3
- package/src/server/services/assets.ts +214 -54
- package/src/server/services/attachments.ts +215 -0
- package/src/server/services/audience.ts +77 -0
- package/src/server/services/categories.ts +337 -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 +34 -1
- package/src/server/services/members.ts +54 -0
- package/src/server/services/notify.ts +52 -7
- 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/src/client/settings/core-api.ts +0 -32
|
@@ -53,14 +53,17 @@ function migrationFiles(): string[] {
|
|
|
53
53
|
* folder may use a dollar-quoted body: a breakpoint inside `do $$ … end $$` cuts the function in
|
|
54
54
|
* half, and the error is `unterminated dollar-quoted string`, which does not sound like what it is.
|
|
55
55
|
*/
|
|
56
|
-
async function apply(
|
|
56
|
+
async function apply(
|
|
57
|
+
file: string,
|
|
58
|
+
client: pg.Client = db,
|
|
59
|
+
): Promise<Array<{ statement: string; error: string }>> {
|
|
57
60
|
const sql = readFileSync(join(MIGRATIONS, file), 'utf8')
|
|
58
61
|
const failures: Array<{ statement: string; error: string }> = []
|
|
59
62
|
for (const raw of sql.split('--> statement-breakpoint')) {
|
|
60
63
|
const statement = raw.trim()
|
|
61
64
|
if (!statement || statement.split('\n').every((l) => l.trim().startsWith('--'))) continue
|
|
62
65
|
try {
|
|
63
|
-
await
|
|
66
|
+
await client.query(statement)
|
|
64
67
|
} catch (err) {
|
|
65
68
|
failures.push({
|
|
66
69
|
statement: statement.slice(0, 120).replace(/\s+/g, ' '),
|
|
@@ -135,4 +138,252 @@ describe('the migration folder', () => {
|
|
|
135
138
|
// preceding `drop constraint if exists` would either throw or leave two.
|
|
136
139
|
expect(Number(rows[0]?.n)).toBe(1)
|
|
137
140
|
})
|
|
141
|
+
|
|
142
|
+
it('keeps the check that stops a repair coming back before it was sent', async () => {
|
|
143
|
+
const { rows } = await db.query<{ conname: string; convalidated: boolean }>(
|
|
144
|
+
`select conname, convalidated from pg_constraint
|
|
145
|
+
where conrelid = 'mod_inventory.repairs'::regclass
|
|
146
|
+
and conname = 'inventory_repairs_returned_after_sent'`,
|
|
147
|
+
)
|
|
148
|
+
// Exactly one: `add constraint` is not idempotent on its own, so a replay without the preceding
|
|
149
|
+
// `drop constraint if exists` would either throw or leave two.
|
|
150
|
+
expect(rows).toHaveLength(1)
|
|
151
|
+
/**
|
|
152
|
+
* `not valid`, and asserted rather than assumed.
|
|
153
|
+
*
|
|
154
|
+
* The constraint is enforced on every insert and update from the moment it exists; what
|
|
155
|
+
* `not valid` skips is the scan of rows already in the table — which are exactly the rows this
|
|
156
|
+
* defect may have written. A validating constraint that met one of them would throw *during
|
|
157
|
+
* migration*, and a module's migrations are the first thing the kernel runs, so that is not a
|
|
158
|
+
* broken feature but a host service that never binds its port.
|
|
159
|
+
*/
|
|
160
|
+
expect(rows[0]?.convalidated, 'a validating check would fail the upgrade on a row it found').toBe(false)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('lets a session with no workspace read the registry the sweeps enumerate', async () => {
|
|
164
|
+
/**
|
|
165
|
+
* The one table in this schema that a scheduler reads unbound.
|
|
166
|
+
*
|
|
167
|
+
* Every table here carries `force row level security`, which subjects the schema's **owner** to
|
|
168
|
+
* its policies as well — so the per-workspace policy alone left `select … from workspaces` with
|
|
169
|
+
* no `app.workspace_id` answering zero rows for everybody but a superuser. That is silent: both
|
|
170
|
+
* nightly sweeps simply found nothing to do. The second policy is what admits the scheduler, and
|
|
171
|
+
* `inventory.int.test.ts` proves it against a plain login role; this proves it survives a replay.
|
|
172
|
+
*/
|
|
173
|
+
const { rows } = await db.query<{ policyname: string; cmd: string }>(
|
|
174
|
+
`select policyname, cmd from pg_policies
|
|
175
|
+
where schemaname = 'mod_inventory' and tablename = 'workspaces'
|
|
176
|
+
order by policyname`,
|
|
177
|
+
)
|
|
178
|
+
expect(rows.map((r) => `${r.policyname}:${r.cmd}`)).toEqual([
|
|
179
|
+
'workspaces_unbound_read:SELECT',
|
|
180
|
+
'workspaces_ws_isolation:ALL',
|
|
181
|
+
])
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The timeline's ordering key, which a replay must neither duplicate nor rewind.
|
|
186
|
+
*
|
|
187
|
+
* `asset_history` was read newest-first by its uuidv7 primary key, and a uuidv7 is only ordered to
|
|
188
|
+
* the millisecond — so two entries written inside one sorted by ten random bytes and a timeline
|
|
189
|
+
* could show a file being removed before it was added. `0007` adds a sequence, and the three things
|
|
190
|
+
* that make it work are all things a hand-written migration can get wrong twice: the column has to
|
|
191
|
+
* carry the sequence as its **default** (or the previous image's inserts write nothing into it), the
|
|
192
|
+
* replay must not create a second sequence, and the `setval` must never move the sequence
|
|
193
|
+
* *backwards* onto a value some open transaction has already taken.
|
|
194
|
+
*/
|
|
195
|
+
it('gives the timeline a sequence to order by, once', async () => {
|
|
196
|
+
const { rows: columns } = await db.query<{ is_nullable: string; column_default: string | null }>(
|
|
197
|
+
`select is_nullable, column_default from information_schema.columns
|
|
198
|
+
where table_schema = 'mod_inventory' and table_name = 'asset_history' and column_name = 'seq'`,
|
|
199
|
+
)
|
|
200
|
+
expect(columns, 'the column exists after the replay').toHaveLength(1)
|
|
201
|
+
// `not null` **with a default** is what keeps the previous image writing: it never names `seq`,
|
|
202
|
+
// so the default fills it, and it never selects it, so it reads the table exactly as before.
|
|
203
|
+
expect(columns[0]?.is_nullable).toBe('NO')
|
|
204
|
+
expect(columns[0]?.column_default).toContain('asset_history_seq')
|
|
205
|
+
|
|
206
|
+
const { rows: sequences } = await db.query<{ n: string }>(
|
|
207
|
+
`select count(*) as n from pg_sequences
|
|
208
|
+
where schemaname = 'mod_inventory' and sequencename = 'asset_history_seq'`,
|
|
209
|
+
)
|
|
210
|
+
expect(Number(sequences[0]?.n), 'a replay must not leave two').toBe(1)
|
|
211
|
+
|
|
212
|
+
const { rows: index } = await db.query<{ indexdef: string }>(
|
|
213
|
+
`select indexdef from pg_indexes
|
|
214
|
+
where schemaname = 'mod_inventory' and indexname = 'inventory_asset_history_ws_asset_seq_uq'`,
|
|
215
|
+
)
|
|
216
|
+
expect(index).toHaveLength(1)
|
|
217
|
+
// What the timeline pages on: one asset's entries, bounded by `seq`.
|
|
218
|
+
expect(index[0]?.indexdef).toContain('UNIQUE')
|
|
219
|
+
expect(index[0]?.indexdef).toMatch(/workspace_id.*asset_id.*seq/s)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('hands out increasing sequence values to rows written in the same instant', async () => {
|
|
223
|
+
// The property the whole migration exists for, asserted against the schema it produced rather
|
|
224
|
+
// than against the service: three rows in one statement share `created_at` exactly, because it
|
|
225
|
+
// defaults to the transaction's `now()`, and they still come back in an order.
|
|
226
|
+
const workspaceId = '00000000-0000-4000-8000-0000000000aa'
|
|
227
|
+
const assetId = '00000000-0000-4000-8000-0000000000bb'
|
|
228
|
+
await db.query(
|
|
229
|
+
`insert into mod_inventory.asset_history (workspace_id, asset_id, action)
|
|
230
|
+
values ($1,$2,'one'), ($1,$2,'two'), ($1,$2,'three')`,
|
|
231
|
+
[workspaceId, assetId],
|
|
232
|
+
)
|
|
233
|
+
const { rows } = await db.query<{ action: string; seq: string; created_at: Date }>(
|
|
234
|
+
`select action, seq, created_at from mod_inventory.asset_history
|
|
235
|
+
where asset_id = $1 order by seq`,
|
|
236
|
+
[assetId],
|
|
237
|
+
)
|
|
238
|
+
expect(rows.map((r) => r.action)).toEqual(['one', 'two', 'three'])
|
|
239
|
+
expect(new Set(rows.map((r) => r.created_at.toISOString())).size, 'one now()').toBe(1)
|
|
240
|
+
expect(new Set(rows.map((r) => r.seq)).size, 'three distinct sequence values').toBe(3)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The index that makes "no two live categories share a place" a fact rather than an intention.
|
|
245
|
+
*
|
|
246
|
+
* Partial on purpose, and the `WHERE` is the half worth asserting: an archived category keeps the
|
|
247
|
+
* number it had when it left, and the next reorder renumbers a live row straight onto it. A total
|
|
248
|
+
* unique index would refuse that entirely correct pair, during a migration, which is a host service
|
|
249
|
+
* that does not boot rather than a settings screen that misbehaves.
|
|
250
|
+
*/
|
|
251
|
+
it('keeps the partial index that makes two live categories on one place impossible', async () => {
|
|
252
|
+
const { rows } = await db.query<{ indexdef: string }>(
|
|
253
|
+
`select indexdef from pg_indexes
|
|
254
|
+
where schemaname = 'mod_inventory' and indexname = 'inventory_categories_ws_order_live_uq'`,
|
|
255
|
+
)
|
|
256
|
+
// Exactly one after the replay: the `if not exists` is added by hand in `0008`.
|
|
257
|
+
expect(rows).toHaveLength(1)
|
|
258
|
+
expect(rows[0]?.indexdef).toContain('UNIQUE')
|
|
259
|
+
expect(rows[0]?.indexdef).toMatch(/workspace_id.*order/s)
|
|
260
|
+
expect(rows[0]?.indexdef, 'live rows only').toContain('WHERE (archived_at IS NULL)')
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('keeps the partial index that makes two open repairs impossible', async () => {
|
|
264
|
+
// drizzle emits a bare `CREATE UNIQUE INDEX` for this one; the `if not exists` is added by
|
|
265
|
+
// hand in `0003_repairs.sql`, and a replay is the only thing that proves it is really there.
|
|
266
|
+
const { rows } = await db.query<{ indexdef: string }>(
|
|
267
|
+
`select indexdef from pg_indexes
|
|
268
|
+
where schemaname = 'mod_inventory' and indexname = 'inventory_repairs_one_open_uq'`,
|
|
269
|
+
)
|
|
270
|
+
expect(rows).toHaveLength(1)
|
|
271
|
+
// Partial, not total: an asset may be repaired many times, just not twice at once.
|
|
272
|
+
expect(rows[0]?.indexdef).toContain('WHERE (returned_on IS NULL)')
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The upgrade that has rows the new index would refuse — which is every instance that ran 0.2.0.
|
|
278
|
+
*
|
|
279
|
+
* A fresh database can never exercise this, and a fresh database is what every test above uses: the
|
|
280
|
+
* folder is applied in order, so by the time `0008` runs nothing has had a chance to write a
|
|
281
|
+
* duplicate. That is the shape of test that passes while the release it is guarding takes production
|
|
282
|
+
* down. `CREATE UNIQUE INDEX` meeting two live rows on one number throws, a module's migrations are
|
|
283
|
+
* the first thing the kernel runs, and `core` hosts five modules and never binds its port.
|
|
284
|
+
*
|
|
285
|
+
* So this builds the database an existing instance actually has — the folder up to `0007`, with
|
|
286
|
+
* duplicates written into it the way the append race wrote them — and then applies `0008` alone.
|
|
287
|
+
*/
|
|
288
|
+
describe('upgrading a database that already has two live categories on one place', () => {
|
|
289
|
+
const DUPES_DB = `${DB_NAME}_dupes`
|
|
290
|
+
const WS = '00000000-0000-4000-8000-00000000d0d0'
|
|
291
|
+
const OTHER = '00000000-0000-4000-8000-00000000d0d1'
|
|
292
|
+
let dupes: pg.Client
|
|
293
|
+
|
|
294
|
+
beforeAll(async () => {
|
|
295
|
+
await admin.query(`create database "${DUPES_DB}"`)
|
|
296
|
+
const url = new URL(BASE_URL)
|
|
297
|
+
url.pathname = `/${DUPES_DB}`
|
|
298
|
+
dupes = new pg.Client({ connectionString: url.toString() })
|
|
299
|
+
await dupes.connect()
|
|
300
|
+
|
|
301
|
+
// Everything an instance on 0.2.0 has, and nothing this migration adds.
|
|
302
|
+
for (const file of migrationFiles().filter((f) => f < '0008')) {
|
|
303
|
+
expect(await apply(file, dupes), `${file}, on the pre-upgrade database`).toEqual([])
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Three live categories, two of them on place 1 — exactly what two appends at the same instant
|
|
307
|
+
// used to leave behind — plus an archived row sitting on a number a live row also holds, which
|
|
308
|
+
// the repair must not touch and the index must not refuse.
|
|
309
|
+
await dupes.query(
|
|
310
|
+
`insert into mod_inventory.categories (workspace_id, name, "order", archived_at) values
|
|
311
|
+
($1,'Desks',0,null), ($1,'Chairs',1,null), ($1,'Lamps',1,null),
|
|
312
|
+
($1,'Retired',0,now()),
|
|
313
|
+
($2,'Untouched',7,null)`,
|
|
314
|
+
[WS, OTHER],
|
|
315
|
+
)
|
|
316
|
+
}, 120_000)
|
|
317
|
+
|
|
318
|
+
afterAll(async () => {
|
|
319
|
+
await dupes?.end().catch(() => undefined)
|
|
320
|
+
await admin?.query(`drop database if exists "${DUPES_DB}" with (force)`).catch(() => undefined)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('renumbers the duplicates instead of failing the boot', async () => {
|
|
324
|
+
expect(await apply('0008_category_order_unique.sql', dupes), 'the upgrade itself').toEqual([])
|
|
325
|
+
|
|
326
|
+
const { rows } = await dupes.query<{ name: string; order: number }>(
|
|
327
|
+
`select name, "order" from mod_inventory.categories
|
|
328
|
+
where workspace_id = $1 and archived_at is null order by "order"`,
|
|
329
|
+
[WS],
|
|
330
|
+
)
|
|
331
|
+
expect(
|
|
332
|
+
rows.map((r) => [r.name, r.order]),
|
|
333
|
+
'walked in the order the screen already showed them — ("order", "name") — so nothing visibly moved',
|
|
334
|
+
).toEqual([
|
|
335
|
+
['Desks', 0],
|
|
336
|
+
['Chairs', 1],
|
|
337
|
+
['Lamps', 2],
|
|
338
|
+
])
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('leaves a workspace that had no duplicate exactly as it was', async () => {
|
|
342
|
+
const { rows } = await dupes.query<{ order: number }>(
|
|
343
|
+
`select "order" from mod_inventory.categories where workspace_id = $1`,
|
|
344
|
+
[OTHER],
|
|
345
|
+
)
|
|
346
|
+
expect(
|
|
347
|
+
rows.map((r) => r.order),
|
|
348
|
+
'a sparse but valid sequence is not a defect to tidy up',
|
|
349
|
+
).toEqual([7])
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('leaves the archived row on the number a live row now holds', async () => {
|
|
353
|
+
const { rows } = await dupes.query<{ order: number }>(
|
|
354
|
+
`select "order" from mod_inventory.categories
|
|
355
|
+
where workspace_id = $1 and archived_at is not null`,
|
|
356
|
+
[WS],
|
|
357
|
+
)
|
|
358
|
+
expect(
|
|
359
|
+
rows.map((r) => r.order),
|
|
360
|
+
'outside the partial index, so outside the repair',
|
|
361
|
+
).toEqual([0])
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('applies a second time, because a replay must not take down the host service', async () => {
|
|
365
|
+
expect(await apply('0008_category_order_unique.sql', dupes), 'replayed').toEqual([])
|
|
366
|
+
const { rows } = await dupes.query<{ name: string; order: number }>(
|
|
367
|
+
`select name, "order" from mod_inventory.categories
|
|
368
|
+
where workspace_id = $1 and archived_at is null order by "order"`,
|
|
369
|
+
[WS],
|
|
370
|
+
)
|
|
371
|
+
expect(
|
|
372
|
+
rows.map((r) => [r.name, r.order]),
|
|
373
|
+
'and it changed nothing the second time',
|
|
374
|
+
).toEqual([
|
|
375
|
+
['Desks', 0],
|
|
376
|
+
['Chairs', 1],
|
|
377
|
+
['Lamps', 2],
|
|
378
|
+
])
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it('refuses a duplicate from then on', async () => {
|
|
382
|
+
await expect(
|
|
383
|
+
dupes.query(
|
|
384
|
+
`update mod_inventory.categories set "order" = 0 where workspace_id = $1 and name = 'Chairs'`,
|
|
385
|
+
[WS],
|
|
386
|
+
),
|
|
387
|
+
).rejects.toThrow(/inventory_categories_ws_order_live_uq/)
|
|
388
|
+
})
|
|
138
389
|
})
|