@@ -28,11 +28,7 @@ vi.mock('@/lib/uploads/core/storage-service', () => ({
2828 deleteFile : mockDeleteFile ,
2929} ) )
3030
31- import {
32- getOrCreateTableSnapshot ,
33- SNAPSHOT_MAX_BYTES ,
34- TableSnapshotTooLargeError ,
35- } from '@/lib/table/snapshot-cache'
31+ import { getOrCreateTableSnapshot , TableSnapshotTooLargeError } from '@/lib/table/snapshot-cache'
3632
3733const table = {
3834 id : 'tbl_1' ,
@@ -86,7 +82,11 @@ describe('getOrCreateTableSnapshot', () => {
8682
8783 const ref = await getOrCreateTableSnapshot ( table , 'req' )
8884
89- expect ( ref ) . toEqual ( { key : 'table-snapshots/ws_1/tbl_1/v3.csv' , size : 42 , version : 3 } )
85+ expect ( ref ) . toEqual ( {
86+ key : expect . stringMatching ( / ^ t a b l e - s n a p s h o t s \/ w s _ 1 \/ t b l _ 1 \/ v 3 - [ 0 - 9 a - f ] { 12 } \. c s v $ / ) ,
87+ size : 42 ,
88+ version : 3 ,
89+ } )
9090 expect ( mockCreateMultipartUpload ) . not . toHaveBeenCalled ( )
9191 expect ( mockSelectExportRowPage ) . not . toHaveBeenCalled ( )
9292 } )
@@ -98,25 +98,50 @@ describe('getOrCreateTableSnapshot', () => {
9898 const ref = await getOrCreateTableSnapshot ( table , 'req' )
9999
100100 expect ( mockCreateMultipartUpload ) . toHaveBeenCalledWith (
101- expect . objectContaining ( { key : 'table-snapshots/ws_1/tbl_1/v3.csv' , context : 'execution' } )
101+ expect . objectContaining ( {
102+ key : expect . stringMatching ( / ^ t a b l e - s n a p s h o t s \/ w s _ 1 \/ t b l _ 1 \/ v 3 - [ 0 - 9 a - f ] { 12 } \. c s v $ / ) ,
103+ context : 'execution' ,
104+ } )
102105 )
103106 expect ( lastHandle ?. content ) . toBe ( 'name\nAda\n' )
104107 expect ( ref ) . toEqual ( {
105- key : ' table-snapshots/ws_1/tbl_1/v3.csv' ,
108+ key : expect . stringMatching ( / ^ t a b l e - s n a p s h o t s \ /w s _ 1 \ /t b l _ 1 \ /v 3 - [ 0 - 9 a - f ] { 12 } \ .c s v $ / ) ,
106109 size : Buffer . byteLength ( 'name\nAda\n' ) ,
107110 version : 3 ,
108111 } )
109112 // Best-effort prune of v2.
110113 expect ( mockDeleteFile ) . toHaveBeenCalledWith (
111- expect . objectContaining ( { key : 'table-snapshots/ws_1/tbl_1/v2.csv' , context : 'execution' } )
114+ expect . objectContaining ( {
115+ key : expect . stringMatching ( / ^ t a b l e - s n a p s h o t s \/ w s _ 1 \/ t b l _ 1 \/ v 2 - [ 0 - 9 a - f ] { 12 } \. c s v $ / ) ,
116+ context : 'execution' ,
117+ } )
112118 )
113119 } )
114120
115121 it ( 'keys the snapshot by tenant — the same table id in another workspace gets a different key' , async ( ) => {
116122 versions ( 1 )
117123 mockHeadObject . mockResolvedValue ( { size : 1 } )
118124 const ref = await getOrCreateTableSnapshot ( { ...table , workspaceId : 'ws_2' } , 'req' )
119- expect ( ref . key ) . toBe ( 'table-snapshots/ws_2/tbl_1/v1.csv' )
125+ expect ( ref . key ) . toMatch ( / ^ t a b l e - s n a p s h o t s \/ w s _ 2 \/ t b l _ 1 \/ v 1 - [ 0 - 9 a - f ] { 12 } \. c s v $ / )
126+ } )
127+
128+ it ( 'changes the key when the column shape changes (schema edits invalidate the cache)' , async ( ) => {
129+ versions ( 7 , 7 )
130+ mockHeadObject . mockResolvedValue ( { size : 1 } )
131+
132+ const a = await getOrCreateTableSnapshot ( table , 'req' )
133+ const b = await getOrCreateTableSnapshot (
134+ {
135+ ...table ,
136+ schema : { columns : [ { id : 'col_name' , name : 'renamed' , type : 'string' } ] } ,
137+ } as never ,
138+ 'req'
139+ )
140+
141+ // Same workspace/table/row-version, but a renamed column flips the shape hash → different key.
142+ expect ( a . key ) . not . toBe ( b . key )
143+ expect ( a . key ) . toMatch ( / \/ v 7 - [ 0 - 9 a - f ] { 12 } \. c s v $ / )
144+ expect ( b . key ) . toMatch ( / \/ v 7 - [ 0 - 9 a - f ] { 12 } \. c s v $ / )
120145 } )
121146
122147 it ( 're-keys and rebuilds when rows_version advances mid-scan' , async ( ) => {
@@ -134,21 +159,25 @@ describe('getOrCreateTableSnapshot', () => {
134159 const ref = await getOrCreateTableSnapshot ( table , 'req' )
135160
136161 expect ( ref . version ) . toBe ( 4 )
137- expect ( ref . key ) . toBe ( ' table-snapshots/ws_1/tbl_1/v4.csv' )
162+ expect ( ref . key ) . toMatch ( / ^ t a b l e - s n a p s h o t s \ /w s _ 1 \ /t b l _ 1 \ /v 4 - [ 0 - 9 a - f ] { 12 } \ .c s v $ / )
138163 expect ( mockCreateMultipartUpload ) . toHaveBeenCalledTimes ( 2 )
139164 // the stale v3 object is dropped
140165 expect ( mockDeleteFile ) . toHaveBeenCalledWith (
141- expect . objectContaining ( { key : 'table-snapshots/ws_1/tbl_1/v3.csv' } )
166+ expect . objectContaining ( {
167+ key : expect . stringMatching ( / ^ t a b l e - s n a p s h o t s \/ w s _ 1 \/ t b l _ 1 \/ v 3 - [ 0 - 9 a - f ] { 12 } \. c s v $ / ) ,
168+ } )
142169 )
143170 } )
144171
145172 it ( 'aborts and throws when the CSV exceeds the size cap' , async ( ) => {
146173 versions ( 1 )
147174 mockHeadObject . mockResolvedValue ( null )
148175 mockSelectExportRowPage . mockReset ( )
149- mockSelectExportRowPage . mockResolvedValueOnce ( [
150- { id : 'r1' , data : { col_name : 'x' . repeat ( SNAPSHOT_MAX_BYTES + 10 ) } , position : 0 } ,
151- ] )
176+ // A full batch of wide rows on every page → the materialize loop keeps paging until the running
177+ // byte count crosses the cap, then aborts. Peak memory stays at one page (~MBs), not the cap.
178+ const wideRow = { id : 'r' , data : { col_name : 'x' . repeat ( 1000 ) } , position : 0 }
179+ const fullPage = Array . from ( { length : 10000 } , ( ) => wideRow )
180+ mockSelectExportRowPage . mockResolvedValue ( fullPage )
152181
153182 await expect ( getOrCreateTableSnapshot ( table , 'req' ) ) . rejects . toBeInstanceOf (
154183 TableSnapshotTooLargeError
0 commit comments