@@ -5,76 +5,100 @@ import type { Sql } from 'postgres'
55import { describe , expect , it , vi } from 'vitest'
66import { repairUnknownTableRowProvenance } from './0005_repair_unknown_table_row_provenance'
77
8- interface PageResult {
9- candidates : number
10- repaired : number
11- lastRowId : string | null
12- }
13-
148function normalizeSql ( value : string ) : string {
159 return value . replace ( / \s + / g, ' ' ) . trim ( )
1610}
1711
18- /** Replays a scripted sequence of pages and records the `afterRowId` each pass asked for. */
19- function createSqlHarness ( pages : PageResult [ ] ) : {
12+ /**
13+ * Replays a scripted sequence of candidate pages and records every statement in the order it was
14+ * issued, so a test can assert on lock ordering rather than only on the final counts.
15+ */
16+ function createSqlHarness ( pages : string [ ] [ ] ) : {
2017 sql : Sql
21- cursors : unknown [ ]
2218 statements : string [ ]
19+ cursors : unknown [ ]
2320} {
24- const cursors : unknown [ ] = [ ]
2521 const statements : string [ ] = [ ]
26- let call = 0
27- const query = vi . fn ( ( strings : TemplateStringsArray , ...values : unknown [ ] ) => {
28- statements . push ( normalizeSql ( strings . join ( '?' ) ) )
29- /** `afterRowId` is interpolated before the page size, so it is the first bound value. */
30- cursors . push ( values [ 0 ] )
31- const page = pages [ call ] ?? { candidates : 0 , repaired : 0 , lastRowId : null }
32- call += 1
33- return Promise . resolve ( [ page ] )
34- } )
35- return { sql : query as unknown as Sql , cursors, statements }
22+ const cursors : unknown [ ] = [ ]
23+ let page = 0
24+
25+ const run = ( strings : TemplateStringsArray , ...values : unknown [ ] ) => {
26+ const text = normalizeSql ( strings . join ( '?' ) )
27+ statements . push ( text )
28+
29+ if ( text . startsWith ( 'SELECT row_id AS "rowId"' ) ) {
30+ cursors . push ( values [ 0 ] )
31+ const rows = ( pages [ page ] ?? [ ] ) . map ( ( rowId ) => ( { rowId } ) )
32+ page += 1
33+ return Promise . resolve ( rows )
34+ }
35+ if ( text . startsWith ( 'DELETE FROM user_table_row_secret_provenance' ) ) {
36+ const ids = ( values [ 0 ] as string [ ] ) ?? [ ]
37+ return Promise . resolve ( ids . map ( ( rowId ) => ( { rowId } ) ) )
38+ }
39+ if ( text . startsWith ( 'UPDATE user_table_rows' ) ) {
40+ const ids = ( values [ 0 ] as string [ ] ) ?? [ ]
41+ return Promise . resolve ( ids . map ( ( id ) => ( { id } ) ) )
42+ }
43+ return Promise . resolve ( [ ] )
44+ }
45+
46+ const sql = run as unknown as Sql
47+ sql . begin = vi . fn ( async ( callback ) => ( callback as ( tx : Sql ) => unknown ) ( sql ) ) as Sql [ 'begin' ]
48+ return { sql, statements, cursors }
3649}
3750
3851describe ( '0005 repair unknown table row provenance' , ( ) => {
3952 /**
40- * A provenance-aware write commits its exact sidecar between this statement's snapshot and its
41- * delete. Matching on the captured id alone would drop that fresh sidecar and clear the marker
42- * behind it, leaving a secret-bearing row reading as legacy — provenance destroyed by the repair
43- * meant to make provenance safe. The re-check is what makes the writer's row stop matching.
53+ * `mutateTableRowsWithSecretProvenance` locks `user_table_rows` up front and upserts the sidecar
54+ * inside the same transaction. Touching the sidecar first is the opposite order, and an
55+ * overlapping write would deadlock — Postgres resolving it by aborting either the deployment or
56+ * somebody's table write.
57+ */
58+ it ( 'locks the parent row before touching the sidecar, in the order writers take them' , async ( ) => {
59+ const { sql, statements } = createSqlHarness ( [ [ 'row-1' , 'row-2' ] , [ ] ] )
60+
61+ await repairUnknownTableRowProvenance . up ( sql )
62+
63+ const lockIndex = statements . findIndex ( ( s ) => s . includes ( 'FOR UPDATE' ) )
64+ const deleteIndex = statements . findIndex ( ( s ) =>
65+ s . startsWith ( 'DELETE FROM user_table_row_secret_provenance' )
66+ )
67+ expect ( lockIndex ) . toBeGreaterThanOrEqual ( 0 )
68+ expect ( deleteIndex ) . toBeGreaterThan ( lockIndex )
69+ expect ( statements [ lockIndex ] ) . toContain ( 'ORDER BY id' )
70+ } )
71+
72+ /**
73+ * A provenance-aware write commits its exact sidecar and its marker together. Matching on the
74+ * captured id alone would drop that fresh sidecar and clear the marker behind it, leaving a
75+ * secret-bearing row reading as legacy.
4476 */
4577 it ( 'only deletes sidecars still reading unknown' , async ( ) => {
46- const { sql, statements } = createSqlHarness ( [
47- { candidates : 1 , repaired : 1 , lastRowId : 'row-1' } ,
48- { candidates : 0 , repaired : 0 , lastRowId : null } ,
49- ] )
78+ const { sql, statements } = createSqlHarness ( [ [ 'row-1' ] , [ ] ] )
5079
5180 await repairUnknownTableRowProvenance . up ( sql )
5281
53- expect ( statements [ 0 ] ) . toContain ( 'DELETE FROM user_table_row_secret_provenance' )
54- expect ( statements [ 0 ] ) . toContain ( "AND status = 'unknown'" )
82+ const deleteStatement = statements . find ( ( s ) =>
83+ s . startsWith ( 'DELETE FROM user_table_row_secret_provenance' )
84+ )
85+ expect ( deleteStatement ) . toContain ( "AND status = 'unknown'" )
5586 } )
5687
5788 /**
5889 * A page whose rows were all repaired by a concurrent writer clears nothing. Stopping there would
5990 * have ended the walk and left the rest of the backlog untouched.
6091 */
6192 it ( 'keeps walking past a page a concurrent writer already repaired' , async ( ) => {
62- const { sql, cursors } = createSqlHarness ( [
63- { candidates : 2 , repaired : 0 , lastRowId : 'row-2' } ,
64- { candidates : 1 , repaired : 1 , lastRowId : 'row-9' } ,
65- { candidates : 0 , repaired : 0 , lastRowId : null } ,
66- ] )
93+ const { sql, cursors } = createSqlHarness ( [ [ 'row-1' , 'row-2' ] , [ 'row-9' ] , [ ] ] )
6794
6895 await repairUnknownTableRowProvenance . up ( sql )
6996
7097 expect ( cursors ) . toEqual ( [ '' , 'row-2' , 'row-9' ] )
7198 } )
7299
73100 it ( 'stops on the first page with no candidates left' , async ( ) => {
74- const { sql, cursors } = createSqlHarness ( [
75- { candidates : 1 , repaired : 1 , lastRowId : 'row-1' } ,
76- { candidates : 0 , repaired : 0 , lastRowId : null } ,
77- ] )
101+ const { sql, cursors } = createSqlHarness ( [ [ 'row-1' ] , [ ] ] )
78102
79103 await repairUnknownTableRowProvenance . up ( sql )
80104
0 commit comments