Skip to content

Commit 192535f

Browse files
authored
fix(api): enforce grid occupancy, foreign keys, and atomic derivation writes (#74)
* refactor(api): take ContainerWriteInput only at container creation. Callers pass quantity as an option instead of flattening to ContainerData before persist. * refactor(api): assemble container reads through one view. List, collection detail, GET /containers/:id, and export project from the same load. ADR 0009 records the decision. * refactor(web): split derivations bulk-import into reducer, effects, and gateway. The page and hook become a thin shell over a testable core. * fix(api): enforce grid occupancy, foreign keys, and atomic derivation writes Reconnects were leaving SQLite foreign keys off, and two containers could share a grid cell. Open every connection with FK on, unique-index occupied positions, and wrap derivation writes in a transaction that survives await.
1 parent 4230422 commit 192535f

29 files changed

Lines changed: 732 additions & 61 deletions

CONTEXT.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,13 @@ A place in the storage hierarchy where collections are kept (e.g. freezer, shelf
124124
_Avoid_: Site (unless referring to an external facility), storage (too vague)
125125

126126
**Container placement**:
127-
Where a **Container** sits in the physical storage hierarchy — its position within a **Collection**, the collection it belongs to, and the **Location** path where that collection is stored. For tubes and wells, the immediate **Collection** is a plate or box and may include a grid position. For **Paper** containers, the immediate **Collection** is a **Sheet** (within a box or bag). Distinct from specimen provenance (**Source**, study/control context) and from container identity metadata (barcodes, **Tags**).
127+
Where a **Container** sits in the physical storage hierarchy — its **grid position** within a **Collection**, the collection it belongs to, and the **Location** path where that collection is stored. For tubes and wells, the immediate **Collection** is a plate or box. For **Paper** containers, the immediate **Collection** is a **Sheet** (within a box or bag). Distinct from specimen provenance (**Source**, study/control context) and from container identity metadata (barcodes, **Tags**).
128128
_Avoid_: Enrichment (implementation term); conflating placement with specimen or source lookups; placing paper directly on a box or bag without a sheet
129129

130+
**Grid position**:
131+
The well or slot of a tube or well **Container** in its plate or box (e.g. A01). Optional: a tube or well may belong to a **Collection** with no grid position (legacy rows). At most one Container occupies a given grid position in a Collection; a missing position occupies no cell. Not applicable to **Paper**.
132+
_Avoid_: Well (as the domain term); treating a missing position as occupying a slot; requiring every tube to have a position before it can exist in a Collection
133+
130134
### Export
131135

132136
**Container export**:

packages/api/initial_schema.sql

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ CREATE TABLE IF NOT EXISTS micronix_tube (
206206
barcode TEXT NOT NULL UNIQUE,
207207
position TEXT
208208
);--> statement-breakpoint
209+
CREATE UNIQUE INDEX IF NOT EXISTS micronix_tube_collection_position_idx ON micronix_tube(collection_id, position) WHERE position IS NOT NULL;--> statement-breakpoint
209210
CREATE TABLE IF NOT EXISTS cryovial_box (
210211
id INTEGER PRIMARY KEY,
211212
location_id INTEGER NOT NULL REFERENCES location(id),
@@ -223,6 +224,7 @@ CREATE TABLE IF NOT EXISTS cryovial_tube (
223224
barcode TEXT,
224225
position TEXT
225226
);--> statement-breakpoint
227+
CREATE UNIQUE INDEX IF NOT EXISTS cryovial_tube_collection_position_idx ON cryovial_tube(collection_id, position) WHERE position IS NOT NULL;--> statement-breakpoint
226228
CREATE TABLE IF NOT EXISTS box (
227229
id INTEGER PRIMARY KEY,
228230
location_id INTEGER NOT NULL REFERENCES location(id),
@@ -266,6 +268,7 @@ CREATE TABLE IF NOT EXISTS static_well (
266268
collection_id INTEGER NOT NULL REFERENCES micronix_plate(id),
267269
position TEXT
268270
);--> statement-breakpoint
271+
CREATE UNIQUE INDEX IF NOT EXISTS static_well_collection_position_idx ON static_well(collection_id, position) WHERE position IS NOT NULL;--> statement-breakpoint
269272
CREATE TABLE IF NOT EXISTS strain (
270273
id INTEGER PRIMARY KEY,
271274
name TEXT NOT NULL UNIQUE,
@@ -279,7 +282,7 @@ CREATE TABLE IF NOT EXISTS storage_type (
279282
CREATE TABLE IF NOT EXISTS schema_version (
280283
version INTEGER NOT NULL
281284
);--> statement-breakpoint
282-
INSERT INTO schema_version (version) VALUES (3);--> statement-breakpoint
285+
INSERT INTO schema_version (version) VALUES (4);--> statement-breakpoint
283286
CREATE TABLE IF NOT EXISTS settings (
284287
key TEXT NOT NULL,
285288
user_id INTEGER REFERENCES users(id),

packages/api/src/db/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ See [ADR-0003: SQLite schema evolution](../../../docs/adr/0003-sqlite-schema-evo
66

77
## Opening a database
88

9-
Use `openOperationalDatabase(optionalPath?)` from the client module. It resolves the file path, enables WAL, runs schema evolution, and returns Drizzle + raw `bun:sqlite` handles. Importing the client module does **not** open a connection.
9+
Use `openOperationalDatabase(optionalPath?)` from the client module. It resolves the file path, enables WAL, turns on foreign keys, runs schema evolution, and returns Drizzle + raw `bun:sqlite` handles. Importing the client module does **not** open a connection.
10+
11+
Multi-statement writes that `await` must use `withWriteTransaction` from `write-transaction.ts`. Drizzle's bun-sqlite `db.transaction(async () => …)` commits when the callback first yields; it does not await the returned Promise.
1012

1113
Tests should use the same entry point (e.g. `openOperationalDatabase(':memory:')` via `setupTestDatabase()`).
1214

packages/api/src/db/__tests__/client.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ describe('Database Client', () => {
2929
const db = new Database(testDbPath)
3030
db.exec('CREATE TABLE study (id INTEGER PRIMARY KEY)')
3131
db.exec('CREATE TABLE settings (key TEXT, user_id INTEGER, value TEXT, PRIMARY KEY (key, user_id))')
32+
db.exec(`CREATE TABLE micronix_tube (id INTEGER PRIMARY KEY, collection_id INTEGER NOT NULL, barcode TEXT, position TEXT)`)
33+
db.exec(`CREATE TABLE cryovial_tube (id INTEGER PRIMARY KEY, collection_id INTEGER NOT NULL, barcode TEXT, position TEXT)`)
34+
db.exec(`CREATE TABLE static_well (id INTEGER PRIMARY KEY, collection_id INTEGER NOT NULL, position TEXT)`)
3235
// Intentionally omit paper — migration 003 preflight creates legacy stub
3336
// Intentionally do NOT create error_logs
3437
const before = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='error_logs'").get()

packages/api/src/db/__tests__/schema-evolution.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,26 @@ import {
1212
import { openOperationalDatabase } from '../open'
1313
import { SchemaMigrationError, runSqlMigrationStatements } from '../migration-runner'
1414

15+
function createGridPositionTables(sqlite: Database): void {
16+
sqlite.exec(`CREATE TABLE IF NOT EXISTS micronix_tube (
17+
id INTEGER PRIMARY KEY,
18+
collection_id INTEGER NOT NULL,
19+
barcode TEXT,
20+
position TEXT
21+
)`)
22+
sqlite.exec(`CREATE TABLE IF NOT EXISTS cryovial_tube (
23+
id INTEGER PRIMARY KEY,
24+
collection_id INTEGER NOT NULL,
25+
barcode TEXT,
26+
position TEXT
27+
)`)
28+
sqlite.exec(`CREATE TABLE IF NOT EXISTS static_well (
29+
id INTEGER PRIMARY KEY,
30+
collection_id INTEGER NOT NULL,
31+
position TEXT
32+
)`)
33+
}
34+
1535
describe('schema evolution', () => {
1636
let testDbPath: string
1737

@@ -53,6 +73,18 @@ describe('schema evolution', () => {
5373
sqlite.close()
5474
})
5575

76+
it('openOperationalDatabase enables foreign keys on empty and reconnect', () => {
77+
const first = openOperationalDatabase(testDbPath)
78+
const emptyFk = first.sqlite.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number }
79+
expect(emptyFk.foreign_keys).toBe(1)
80+
first.sqlite.close()
81+
82+
const second = openOperationalDatabase(testDbPath)
83+
const reconnectFk = second.sqlite.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number }
84+
expect(reconnectFk.foreign_keys).toBe(1)
85+
second.sqlite.close()
86+
})
87+
5688
it('legacy unversioned database gains error_logs and schema_version 1 then upgrades to current', () => {
5789
const sqlite = new Database(testDbPath)
5890
sqlite.exec('CREATE TABLE study (id INTEGER PRIMARY KEY)')
@@ -67,6 +99,7 @@ describe('schema evolution', () => {
6799
barcode TEXT,
68100
position TEXT
69101
)`)
102+
createGridPositionTables(sqlite)
70103
sqlite.close()
71104

72105
const { sqlite: opened } = openOperationalDatabase(testDbPath)
@@ -91,6 +124,7 @@ describe('schema evolution', () => {
91124
barcode TEXT,
92125
position TEXT
93126
)`)
127+
createGridPositionTables(sqlite)
94128
evolveOperationalSchema(sqlite)
95129
expect(getRecordedSchemaVersion(sqlite)).toBe(CURRENT_SCHEMA_VERSION)
96130
sqlite.close()
@@ -108,6 +142,7 @@ describe('schema evolution', () => {
108142
barcode TEXT,
109143
position TEXT
110144
)`)
145+
createGridPositionTables(sqlite)
111146
evolveOperationalSchema(sqlite)
112147
expect(getRecordedSchemaVersion(sqlite)).toBe(CURRENT_SCHEMA_VERSION)
113148
const columns = sqlite
@@ -121,6 +156,7 @@ describe('schema evolution', () => {
121156
const sqlite = new Database(testDbPath)
122157
sqlite.exec('CREATE TABLE schema_version (version INTEGER NOT NULL)')
123158
sqlite.exec('INSERT INTO schema_version (version) VALUES (2)')
159+
createGridPositionTables(sqlite)
124160
evolveOperationalSchema(sqlite)
125161
expect(getRecordedSchemaVersion(sqlite)).toBe(CURRENT_SCHEMA_VERSION)
126162
const paperTable = sqlite
@@ -155,6 +191,39 @@ describe('schema evolution', () => {
155191
sqlite.close()
156192
})
157193

194+
it('applies migration 004 unique indexes when database is at version 3', () => {
195+
const sqlite = new Database(testDbPath)
196+
sqlite.exec('CREATE TABLE schema_version (version INTEGER NOT NULL)')
197+
sqlite.exec('INSERT INTO schema_version (version) VALUES (3)')
198+
createGridPositionTables(sqlite)
199+
evolveOperationalSchema(sqlite)
200+
expect(getRecordedSchemaVersion(sqlite)).toBe(CURRENT_SCHEMA_VERSION)
201+
const names = sqlite
202+
.prepare(
203+
`SELECT name FROM sqlite_master WHERE type='index' AND name LIKE '%collection_position%'`,
204+
)
205+
.all() as Array<{ name: string }>
206+
expect(names.map((row) => row.name).sort()).toEqual([
207+
'cryovial_tube_collection_position_idx',
208+
'micronix_tube_collection_position_idx',
209+
'static_well_collection_position_idx',
210+
])
211+
sqlite.close()
212+
})
213+
214+
it('migration 004 aborts when duplicate occupied grid positions exist', () => {
215+
const sqlite = new Database(testDbPath)
216+
sqlite.exec('CREATE TABLE schema_version (version INTEGER NOT NULL)')
217+
sqlite.exec('INSERT INTO schema_version (version) VALUES (3)')
218+
createGridPositionTables(sqlite)
219+
sqlite.exec(`INSERT INTO micronix_tube (id, collection_id, barcode, position) VALUES (1, 1, 'A', 'A01')`)
220+
sqlite.exec(`INSERT INTO micronix_tube (id, collection_id, barcode, position) VALUES (2, 1, 'B', 'A01')`)
221+
222+
expect(() => evolveOperationalSchema(sqlite)).toThrow(SchemaMigrationError)
223+
expect(getRecordedSchemaVersion(sqlite)).toBe(3)
224+
sqlite.close()
225+
})
226+
158227
it('fail-hard: invalid migration leaves schema_version unchanged', () => {
159228
const sqlite = new Database(testDbPath)
160229
sqlite.exec('CREATE TABLE schema_version (version INTEGER NOT NULL)')
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2+
import { setupTestDatabase, cleanupTestDatabase } from '../../__tests__/helpers/db-setup'
3+
import { createTestStudy } from '../../__tests__/helpers/factories'
4+
import { study } from '../schema'
5+
import { withWriteTransaction } from '../write-transaction'
6+
import type { Database } from '../client'
7+
8+
describe('withWriteTransaction', () => {
9+
let testDb: Database
10+
let sqlite: Awaited<ReturnType<typeof setupTestDatabase>>['sqlite']
11+
12+
beforeEach(async () => {
13+
const setup = await setupTestDatabase()
14+
testDb = setup.db
15+
sqlite = setup.sqlite
16+
})
17+
18+
afterEach(() => {
19+
if (sqlite) cleanupTestDatabase(sqlite)
20+
})
21+
22+
it('rolls back awaited writes when fn throws', async () => {
23+
await expect(
24+
withWriteTransaction(testDb, async (db) => {
25+
await createTestStudy(db, { title: 'Rollback Study', shortCode: 'RB1' })
26+
throw new Error('boom')
27+
}),
28+
).rejects.toThrow('boom')
29+
30+
const rows = await testDb.select().from(study)
31+
expect(rows.find((row) => row.shortCode === 'RB1')).toBeUndefined()
32+
})
33+
34+
it('commits awaited writes when fn returns', async () => {
35+
await withWriteTransaction(testDb, async (db) => {
36+
await createTestStudy(db, { title: 'Commit Study', shortCode: 'CM1' })
37+
})
38+
39+
const rows = await testDb.select().from(study)
40+
expect(rows.find((row) => row.shortCode === 'CM1')).toBeDefined()
41+
})
42+
43+
it('does not nest when already in a transaction', async () => {
44+
await expect(
45+
withWriteTransaction(testDb, async (outer) => {
46+
await withWriteTransaction(outer, async (inner) => {
47+
await createTestStudy(inner, { title: 'Nested Study', shortCode: 'N1' })
48+
})
49+
throw new Error('outer fail')
50+
}),
51+
).rejects.toThrow('outer fail')
52+
53+
const rows = await testDb.select().from(study)
54+
expect(rows.find((row) => row.shortCode === 'N1')).toBeUndefined()
55+
})
56+
})
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
-- Migration 004: at most one Container per grid position in a Collection.
2+
-- NULL positions are excluded (legacy unpositioned tubes occupy no cell).
3+
CREATE TEMP TABLE IF NOT EXISTS __migration_004_guard (id INTEGER PRIMARY KEY);--> statement-breakpoint
4+
CREATE TRIGGER __migration_004_guard_tr BEFORE INSERT ON __migration_004_guard
5+
BEGIN
6+
SELECT RAISE(ABORT, 'Cannot add unique grid position index: duplicate (collection_id, position) rows exist.')
7+
WHERE EXISTS (
8+
SELECT 1 FROM micronix_tube WHERE position IS NOT NULL GROUP BY collection_id, position HAVING COUNT(*) > 1
9+
)
10+
OR EXISTS (
11+
SELECT 1 FROM cryovial_tube WHERE position IS NOT NULL GROUP BY collection_id, position HAVING COUNT(*) > 1
12+
)
13+
OR EXISTS (
14+
SELECT 1 FROM static_well WHERE position IS NOT NULL GROUP BY collection_id, position HAVING COUNT(*) > 1
15+
);
16+
END;--> statement-breakpoint
17+
INSERT INTO __migration_004_guard DEFAULT VALUES;--> statement-breakpoint
18+
DROP TRIGGER __migration_004_guard_tr;--> statement-breakpoint
19+
DROP TABLE __migration_004_guard;--> statement-breakpoint
20+
CREATE UNIQUE INDEX IF NOT EXISTS micronix_tube_collection_position_idx ON micronix_tube(collection_id, position) WHERE position IS NOT NULL;--> statement-breakpoint
21+
CREATE UNIQUE INDEX IF NOT EXISTS cryovial_tube_collection_position_idx ON cryovial_tube(collection_id, position) WHERE position IS NOT NULL;--> statement-breakpoint
22+
CREATE UNIQUE INDEX IF NOT EXISTS static_well_collection_position_idx ON static_well(collection_id, position) WHERE position IS NOT NULL;

packages/api/src/db/open.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export function openOperationalDatabase(dbPath?: string): {
109109

110110
const sqlite = new SQLiteDatabase(resolvedPath)
111111
sqlite.exec('PRAGMA journal_mode = WAL')
112+
sqlite.exec('PRAGMA foreign_keys = ON')
112113

113114
try {
114115
evolveOperationalSchema(sqlite)

packages/api/src/db/schema-evolution.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { listNumberedMigrations, runSqlMigration } from './migration-runner'
77
const PAPER_SUBLABEL_MIGRATION_VERSION = 3
88

99
/** Canonical schema level; bump when adding numbered deltas under migrations/. */
10-
export const CURRENT_SCHEMA_VERSION = 3
10+
export const CURRENT_SCHEMA_VERSION = 4
1111

1212
const SCHEMA_VERSION_TABLE = 'schema_version'
1313
const LEGACY_BASELINE_VERSION = 1

packages/api/src/db/schema.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { sqliteTable, text, integer, real, check, primaryKey, unique, index } from 'drizzle-orm/sqlite-core'
1+
import { sqliteTable, text, integer, real, check, primaryKey, unique, uniqueIndex, index } from 'drizzle-orm/sqlite-core'
22
import { sql, type InferSelectModel } from 'drizzle-orm'
33

44
// Type exports for all database tables
@@ -288,7 +288,11 @@ export const micronixTube = sqliteTable('micronix_tube', {
288288
collectionId: integer('collection_id').notNull().references(() => micronixPlate.id),
289289
barcode: text('barcode').notNull().unique(),
290290
position: text('position'),
291-
})
291+
}, (table) => ({
292+
collectionPositionUniq: uniqueIndex('micronix_tube_collection_position_idx')
293+
.on(table.collectionId, table.position)
294+
.where(sql`${table.position} IS NOT NULL`),
295+
}))
292296

293297
export const cryovialBox = sqliteTable('cryovial_box', {
294298
id: integer('id').primaryKey(),
@@ -308,7 +312,11 @@ export const cryovialTube = sqliteTable('cryovial_tube', {
308312
collectionId: integer('collection_id').notNull().references(() => cryovialBox.id),
309313
barcode: text('barcode'),
310314
position: text('position'),
311-
})
315+
}, (table) => ({
316+
collectionPositionUniq: uniqueIndex('cryovial_tube_collection_position_idx')
317+
.on(table.collectionId, table.position)
318+
.where(sql`${table.position} IS NOT NULL`),
319+
}))
312320

313321
export const box = sqliteTable('box', {
314322
id: integer('id').primaryKey(),
@@ -360,7 +368,11 @@ export const staticWell = sqliteTable('static_well', {
360368
id: integer('id').primaryKey().references(() => storageContainer.id),
361369
collectionId: integer('collection_id').notNull().references(() => micronixPlate.id),
362370
position: text('position'),
363-
})
371+
}, (table) => ({
372+
collectionPositionUniq: uniqueIndex('static_well_collection_position_idx')
373+
.on(table.collectionId, table.position)
374+
.where(sql`${table.position} IS NOT NULL`),
375+
}))
364376

365377
// Additional reference tables
366378
export const strain = sqliteTable('strain', {

0 commit comments

Comments
 (0)