@@ -226,3 +226,275 @@ async fn alter_collection_alter_column_type() {
226226 . unwrap ( ) ;
227227 assert_eq ! ( rows. len( ) , 1 ) ;
228228}
229+
230+ // ── Pre-ALTER row survival tests ──────────────────────────────────────
231+ //
232+ // Every test below verifies that rows written BEFORE a schema-altering DDL
233+ // remain readable with correct values AFTER the DDL. The bug class is:
234+ // catalog schema mutated without row migration or read-time compat shim.
235+
236+ #[ tokio:: test( flavor = "multi_thread" , worker_threads = 4 ) ]
237+ async fn add_column_preserves_pre_alter_row_existing_columns ( ) {
238+ let server = TestServer :: start ( ) . await ;
239+
240+ server
241+ . exec ( "CREATE COLLECTION ac_preserve TYPE DOCUMENT STRICT (id TEXT PRIMARY KEY, name TEXT)" )
242+ . await
243+ . unwrap ( ) ;
244+ server
245+ . exec ( "INSERT INTO ac_preserve (id, name) VALUES ('a', 'alice')" )
246+ . await
247+ . unwrap ( ) ;
248+
249+ server
250+ . exec ( "ALTER TABLE ac_preserve ADD COLUMN note TEXT DEFAULT 'n/a'" )
251+ . await
252+ . unwrap ( ) ;
253+
254+ // Pre-ALTER row must still return correct values for original columns.
255+ let rows = server
256+ . query_text ( "SELECT id, name FROM ac_preserve WHERE id = 'a'" )
257+ . await
258+ . unwrap ( ) ;
259+ assert_eq ! ( rows. len( ) , 1 , "pre-ALTER row must be visible" ) ;
260+ assert ! (
261+ rows[ 0 ] . contains( "\" name\" :\" alice\" " ) ,
262+ "original column 'name' must retain its value, got {:?}" ,
263+ rows[ 0 ]
264+ ) ;
265+ // Regression guard: must NOT return null-everywhere.
266+ assert ! (
267+ !rows[ 0 ] . contains( "\" name\" :null" ) ,
268+ "pre-ALTER row must not have null-everywhere corruption, got {:?}" ,
269+ rows[ 0 ]
270+ ) ;
271+ }
272+
273+ #[ tokio:: test( flavor = "multi_thread" , worker_threads = 4 ) ]
274+ async fn add_column_returns_default_for_pre_alter_row ( ) {
275+ let server = TestServer :: start ( ) . await ;
276+
277+ server
278+ . exec ( "CREATE COLLECTION ac_default TYPE DOCUMENT STRICT (id TEXT PRIMARY KEY, name TEXT)" )
279+ . await
280+ . unwrap ( ) ;
281+ server
282+ . exec ( "INSERT INTO ac_default (id, name) VALUES ('a', 'alice')" )
283+ . await
284+ . unwrap ( ) ;
285+
286+ server
287+ . exec ( "ALTER TABLE ac_default ADD COLUMN note TEXT DEFAULT 'n/a'" )
288+ . await
289+ . unwrap ( ) ;
290+
291+ // The new column should virtual-fill with its DEFAULT for pre-ALTER rows.
292+ let rows = server
293+ . query_text ( "SELECT id, name, note FROM ac_default WHERE id = 'a'" )
294+ . await
295+ . unwrap ( ) ;
296+ assert_eq ! ( rows. len( ) , 1 , "pre-ALTER row must be visible" ) ;
297+ assert ! (
298+ rows[ 0 ] . contains( "\" note\" :\" n/a\" " ) ,
299+ "new column must return DEFAULT value for pre-ALTER rows, got {:?}" ,
300+ rows[ 0 ]
301+ ) ;
302+ }
303+
304+ #[ tokio:: test( flavor = "multi_thread" , worker_threads = 4 ) ]
305+ async fn add_column_then_update_pre_alter_row ( ) {
306+ let server = TestServer :: start ( ) . await ;
307+
308+ server
309+ . exec ( "CREATE COLLECTION ac_update TYPE DOCUMENT STRICT (id TEXT PRIMARY KEY, name TEXT)" )
310+ . await
311+ . unwrap ( ) ;
312+ server
313+ . exec ( "INSERT INTO ac_update (id, name) VALUES ('a', 'alice')" )
314+ . await
315+ . unwrap ( ) ;
316+
317+ server
318+ . exec ( "ALTER TABLE ac_update ADD COLUMN note TEXT DEFAULT 'n/a'" )
319+ . await
320+ . unwrap ( ) ;
321+
322+ // Updating a pre-ALTER row must succeed and preserve all columns.
323+ server
324+ . exec ( "UPDATE ac_update SET note = 'updated' WHERE id = 'a'" )
325+ . await
326+ . unwrap ( ) ;
327+
328+ let rows = server
329+ . query_text ( "SELECT id, name, note FROM ac_update WHERE id = 'a'" )
330+ . await
331+ . unwrap ( ) ;
332+ assert_eq ! ( rows. len( ) , 1 ) ;
333+ assert ! (
334+ rows[ 0 ] . contains( "\" name\" :\" alice\" " ) ,
335+ "original column must survive update, got {:?}" ,
336+ rows[ 0 ]
337+ ) ;
338+ assert ! (
339+ rows[ 0 ] . contains( "\" note\" :\" updated\" " ) ,
340+ "updated column must reflect new value, got {:?}" ,
341+ rows[ 0 ]
342+ ) ;
343+ }
344+
345+ #[ tokio:: test( flavor = "multi_thread" , worker_threads = 4 ) ]
346+ async fn multiple_add_columns_preserves_pre_alter_row ( ) {
347+ let server = TestServer :: start ( ) . await ;
348+
349+ server
350+ . exec ( "CREATE COLLECTION ac_multi TYPE DOCUMENT STRICT (id TEXT PRIMARY KEY, name TEXT)" )
351+ . await
352+ . unwrap ( ) ;
353+ server
354+ . exec ( "INSERT INTO ac_multi (id, name) VALUES ('a', 'alice')" )
355+ . await
356+ . unwrap ( ) ;
357+
358+ server
359+ . exec ( "ALTER TABLE ac_multi ADD COLUMN col1 INT DEFAULT 0" )
360+ . await
361+ . unwrap ( ) ;
362+ server
363+ . exec ( "ALTER TABLE ac_multi ADD COLUMN col2 TEXT DEFAULT 'x'" )
364+ . await
365+ . unwrap ( ) ;
366+
367+ // Two sequential ADD COLUMNs compound the schema drift — pre-ALTER row
368+ // must still be readable with correct values and defaults.
369+ let rows = server
370+ . query_text ( "SELECT id, name, col1, col2 FROM ac_multi WHERE id = 'a'" )
371+ . await
372+ . unwrap ( ) ;
373+ assert_eq ! (
374+ rows. len( ) ,
375+ 1 ,
376+ "pre-ALTER row must be visible after two ADD COLUMNs"
377+ ) ;
378+ assert ! (
379+ rows[ 0 ] . contains( "\" name\" :\" alice\" " ) ,
380+ "original column must retain value, got {:?}" ,
381+ rows[ 0 ]
382+ ) ;
383+ // Regression guard: null-everywhere means total schema-data offset corruption.
384+ assert ! (
385+ !rows[ 0 ] . contains( "\" name\" :null" ) ,
386+ "must not exhibit null-everywhere corruption, got {:?}" ,
387+ rows[ 0 ]
388+ ) ;
389+ }
390+
391+ #[ tokio:: test( flavor = "multi_thread" , worker_threads = 4 ) ]
392+ async fn drop_column_preserves_pre_alter_row_remaining_columns ( ) {
393+ let server = TestServer :: start ( ) . await ;
394+
395+ server
396+ . exec (
397+ "CREATE COLLECTION dc_preserve TYPE DOCUMENT STRICT (\
398+ id TEXT PRIMARY KEY, \
399+ name TEXT NOT NULL, \
400+ scratch TEXT)",
401+ )
402+ . await
403+ . unwrap ( ) ;
404+ server
405+ . exec ( "INSERT INTO dc_preserve (id, name, scratch) VALUES ('a', 'alice', 'temp')" )
406+ . await
407+ . unwrap ( ) ;
408+
409+ server
410+ . exec ( "ALTER COLLECTION dc_preserve DROP COLUMN scratch" )
411+ . await
412+ . unwrap ( ) ;
413+
414+ // Remaining columns of the pre-ALTER row must read correctly.
415+ let rows = server
416+ . query_text ( "SELECT id, name FROM dc_preserve WHERE id = 'a'" )
417+ . await
418+ . unwrap ( ) ;
419+ assert_eq ! (
420+ rows. len( ) ,
421+ 1 ,
422+ "pre-ALTER row must be visible after DROP COLUMN"
423+ ) ;
424+ assert ! (
425+ rows[ 0 ] . contains( "\" name\" :\" alice\" " ) ,
426+ "remaining column 'name' must retain its value after DROP COLUMN, got {:?}" ,
427+ rows[ 0 ]
428+ ) ;
429+ }
430+
431+ #[ tokio:: test( flavor = "multi_thread" , worker_threads = 4 ) ]
432+ async fn rename_column_preserves_pre_alter_row_value ( ) {
433+ let server = TestServer :: start ( ) . await ;
434+
435+ server
436+ . exec (
437+ "CREATE COLLECTION rc_preserve TYPE DOCUMENT STRICT (\
438+ id TEXT PRIMARY KEY, \
439+ name TEXT NOT NULL, \
440+ score INT DEFAULT 0)",
441+ )
442+ . await
443+ . unwrap ( ) ;
444+ server
445+ . exec ( "INSERT INTO rc_preserve (id, name, score) VALUES ('a', 'alice', 42)" )
446+ . await
447+ . unwrap ( ) ;
448+
449+ server
450+ . exec ( "ALTER COLLECTION rc_preserve RENAME COLUMN score TO points" )
451+ . await
452+ . unwrap ( ) ;
453+
454+ // Pre-ALTER row must be readable under the new column name with correct value.
455+ let rows = server
456+ . query_text ( "SELECT id, name, points FROM rc_preserve WHERE id = 'a'" )
457+ . await
458+ . unwrap ( ) ;
459+ assert_eq ! ( rows. len( ) , 1 ) ;
460+ assert ! (
461+ rows[ 0 ] . contains( "\" points\" :42" ) || rows[ 0 ] . contains( "\" points\" : 42" ) ,
462+ "renamed column must retain pre-ALTER value, got {:?}" ,
463+ rows[ 0 ]
464+ ) ;
465+ }
466+
467+ #[ tokio:: test( flavor = "multi_thread" , worker_threads = 4 ) ]
468+ async fn alter_column_type_preserves_pre_alter_row_value ( ) {
469+ let server = TestServer :: start ( ) . await ;
470+
471+ server
472+ . exec (
473+ "CREATE COLLECTION at_preserve TYPE DOCUMENT STRICT (\
474+ id TEXT PRIMARY KEY, \
475+ value INT NOT NULL)",
476+ )
477+ . await
478+ . unwrap ( ) ;
479+ server
480+ . exec ( "INSERT INTO at_preserve (id, value) VALUES ('a', 42)" )
481+ . await
482+ . unwrap ( ) ;
483+
484+ server
485+ . exec ( "ALTER COLLECTION at_preserve ALTER COLUMN value TYPE BIGINT" )
486+ . await
487+ . unwrap ( ) ;
488+
489+ // Pre-ALTER row must still read correctly after type widening.
490+ let rows = server
491+ . query_text ( "SELECT id, value FROM at_preserve WHERE id = 'a'" )
492+ . await
493+ . unwrap ( ) ;
494+ assert_eq ! ( rows. len( ) , 1 ) ;
495+ assert ! (
496+ rows[ 0 ] . contains( "\" value\" :42" ) || rows[ 0 ] . contains( "\" value\" : 42" ) ,
497+ "value must survive ALTER COLUMN TYPE, got {:?}" ,
498+ rows[ 0 ]
499+ ) ;
500+ }
0 commit comments