@@ -4,9 +4,11 @@ import { describe, expect, it } from "vitest"
44import {
55 currentSeq ,
66 getDrainCursor ,
7+ hydrateRows ,
78 initSchema ,
89 installTriggers ,
910 readChangesSince ,
11+ readChangesSinceFor ,
1012 setDrainCursor ,
1113} from "../src/server/changes.ts"
1214
@@ -113,4 +115,74 @@ describe("CDC: AFTER triggers -> _sync_changes (D12)", () => {
113115 expect ( new Set ( seqs ) . size ) . toBe ( 10 )
114116 } )
115117 } )
118+
119+ // WHY: chunking must not drop or duplicate keys — the batched IN query must
120+ // return exactly the rows that exist, regardless of how many chunks are needed
121+ // (ADR-0007 D9: TEXT pk, String(row[pk]) keying must match the changelog key).
122+ it ( "hydrateRows returns exactly the existing keys across multiple chunks (no drops, no dups)" , async ( ) => {
123+ await runInDurableObject ( freshStub ( ) , ( _i , state ) => {
124+ const sql = state . storage . sql
125+ setup ( sql )
126+ // Seed 150 rows — spans 3 chunks of 64 (64+64+22).
127+ const ids : string [ ] = [ ]
128+ for ( let i = 0 ; i < 150 ; i ++ ) {
129+ const id = `row-${ String ( i ) . padStart ( 3 , "0" ) } `
130+ ids . push ( id )
131+ sql . exec ( "INSERT INTO items(id,name,n) VALUES(?,?,?)" , id , `name-${ i } ` , i )
132+ }
133+ // Request all existing keys plus a handful of nonexistent ones.
134+ const nonexistent = [ "missing-1" , "missing-2" , "missing-3" ]
135+ const result = hydrateRows ( sql , "items" , "id" , [ ...ids , ...nonexistent ] )
136+ // Exactly the 150 seeded rows — nonexistent keys absent, no dups.
137+ expect ( result . size ) . toBe ( 150 )
138+ for ( const id of ids ) {
139+ const row = result . get ( id )
140+ expect ( row ) . toBeDefined ( )
141+ expect ( row ! [ "id" ] ) . toBe ( id )
142+ }
143+ for ( const id of nonexistent ) {
144+ expect ( result . has ( id ) ) . toBe ( false )
145+ }
146+ } )
147+ } )
148+
149+ // WHY: readChangesSinceFor must use the (tbl,seq) index path and return only
150+ // the requested table's rows — interleaved writes to other tables must not
151+ // appear. A mid-stream cursor must window correctly, matching the per-table
152+ // contract that emitCatchUp relies on for exactly-once catch-up delivery.
153+ it ( "readChangesSinceFor isolates by table and windows by cursor" , async ( ) => {
154+ await runInDurableObject ( freshStub ( ) , ( _i , state ) => {
155+ const sql = state . storage . sql
156+ initSchema ( sql )
157+ // Two tables with interleaved writes.
158+ sql . exec ( `CREATE TABLE IF NOT EXISTS messages (id TEXT PRIMARY KEY, body TEXT)` )
159+ sql . exec ( `CREATE TABLE IF NOT EXISTS files (id TEXT PRIMARY KEY, name TEXT)` )
160+ installTriggers ( sql , "messages" , "id" )
161+ installTriggers ( sql , "files" , "id" )
162+
163+ sql . exec ( "INSERT INTO messages(id,body) VALUES('m1','hello')" )
164+ sql . exec ( "INSERT INTO files(id,name) VALUES('f1','doc.pdf')" )
165+ sql . exec ( "INSERT INTO messages(id,body) VALUES('m2','world')" )
166+ sql . exec ( "INSERT INTO files(id,name) VALUES('f2','img.png')" )
167+ sql . exec ( "INSERT INTO messages(id,body) VALUES('m3','!')" )
168+
169+ // readChangesSinceFor("messages", 0) returns only messages rows, ascending.
170+ const msgRows = readChangesSinceFor ( sql , "messages" , 0 )
171+ expect ( msgRows . map ( ( r ) => r . key ) ) . toEqual ( [ "m1" , "m2" , "m3" ] )
172+ expect ( msgRows . every ( ( r ) => r . tbl === "messages" ) ) . toBe ( true )
173+ // seq values are ascending.
174+ const seqs = msgRows . map ( ( r ) => r . seq )
175+ expect ( seqs ) . toEqual ( [ ...seqs ] . sort ( ( a , b ) => a - b ) )
176+
177+ // readChangesSinceFor("files", 0) returns only files rows.
178+ const fileRows = readChangesSinceFor ( sql , "files" , 0 )
179+ expect ( fileRows . map ( ( r ) => r . key ) ) . toEqual ( [ "f1" , "f2" ] )
180+ expect ( fileRows . every ( ( r ) => r . tbl === "files" ) ) . toBe ( true )
181+
182+ // Mid-stream cursor: only rows after the seq of m2 (the 3rd change overall).
183+ // seq of m2 is msgRows[1].seq; we want only m3 after that.
184+ const afterM2 = readChangesSinceFor ( sql , "messages" , msgRows [ 1 ] ! . seq )
185+ expect ( afterM2 . map ( ( r ) => r . key ) ) . toEqual ( [ "m3" ] )
186+ } )
187+ } )
116188} )
0 commit comments