@@ -46,6 +46,8 @@ const USAGE_EVENT_INDEX_SQL =
4646 "CREATE INDEX usage_event_feature_time_idx ON usage_event(feature, recorded_at)" ;
4747const QUOTA_OPERATION_INDEX_SQL =
4848 "CREATE INDEX quota_operation_feature_time_idx ON quota_operation(feature, recorded_at)" ;
49+ const QUOTA_TABLES = [ "counter" , "limit_override" , "usage_event" , "quota_operation" ] as const ;
50+ const CURRENT_QUOTA_FEATURES = [ QUOTA_FEATURES . composioCalls , QUOTA_FEATURES . sandboxHours ] as const ;
4951
5052const QUOTA_STORAGE_SCHEMA : readonly ExpectedSqliteObject [ ] = [
5153 { name : "counter" , sql : COUNTER_SQL , tableName : "counter" , type : "table" } ,
@@ -96,19 +98,29 @@ export function ensureQuotaTrackerStorage(ctx: DurableObjectState): void {
9698 ) ;
9799}
98100
99- /** Force-normalizes all quota tables while preserving every valid source row. */
101+ /**
102+ * Force-normalizes quota storage while preserving every current product row.
103+ *
104+ * Quotas removed from the product are intentionally discarded before the
105+ * exact-schema rebuild. Feature values must still be valid text so corrupt
106+ * rows cannot be mistaken for safely retired data.
107+ */
100108export function reconcileQuotaTrackerStorage ( ctx : DurableObjectState ) : void {
101- ensureSourceTables ( ctx ) ;
102- const limitColumns = ctx . storage . sql . exec ( "PRAGMA table_info(limit_override)" ) . toArray ( ) ;
103- if ( ! hasColumn ( limitColumns , "feature" ) || ! hasColumn ( limitColumns , "limit_val" ) ) {
104- throw new Error ( "Unsupported quota limit schema; refusing lossy evolution." ) ;
105- }
106- const hasEntitlementVersion = hasColumn ( limitColumns , "entitlement_version" ) ;
107- assertQuotaSourceRows ( ctx , hasEntitlementVersion ) ;
108- const entitlementVersion = hasEntitlementVersion ? "entitlement_version" : "0" ;
109- ctx . storage . transactionSync ( ( ) => rebuildQuotaTables ( ctx , entitlementVersion ) ) ;
110- setCurrentSqliteStorageVersion ( ctx ) ;
111- assertQuotaTrackerStorage ( ctx ) ;
109+ ctx . storage . transactionSync ( ( ) => {
110+ ensureSourceTables ( ctx ) ;
111+ const limitColumns = ctx . storage . sql . exec ( "PRAGMA table_info(limit_override)" ) . toArray ( ) ;
112+ if ( ! hasColumn ( limitColumns , "feature" ) || ! hasColumn ( limitColumns , "limit_val" ) ) {
113+ throw new Error ( "Unsupported quota limit schema; refusing lossy evolution." ) ;
114+ }
115+ const hasEntitlementVersion = hasColumn ( limitColumns , "entitlement_version" ) ;
116+ assertQuotaFeatureTypes ( ctx ) ;
117+ deleteRetiredQuotaRows ( ctx ) ;
118+ assertQuotaSourceRows ( ctx , hasEntitlementVersion ) ;
119+ const entitlementVersion = hasEntitlementVersion ? "entitlement_version" : "0" ;
120+ rebuildQuotaTables ( ctx , entitlementVersion ) ;
121+ setCurrentSqliteStorageVersion ( ctx ) ;
122+ assertQuotaTrackerStorage ( ctx ) ;
123+ } ) ;
112124}
113125
114126export function assertQuotaTrackerStorage ( ctx : DurableObjectState ) : void {
@@ -144,14 +156,14 @@ function ensureSourceTables(ctx: DurableObjectState): void {
144156function rebuildQuotaTables ( ctx : DurableObjectState , entitlementVersion : string ) : void {
145157 ctx . storage . sql . exec ( "DROP INDEX IF EXISTS usage_event_feature_time_idx" ) ;
146158 ctx . storage . sql . exec ( "DROP INDEX IF EXISTS quota_operation_feature_time_idx" ) ;
147- for ( const table of [ "counter" , "limit_override" , "usage_event" , "quota_operation" ] as const ) {
159+ for ( const table of QUOTA_TABLES ) {
148160 ctx . storage . sql . exec ( `ALTER TABLE ${ table } RENAME TO ${ table } _reconcile_source` ) ;
149161 }
150162 for ( const sql of [ COUNTER_SQL , LIMIT_OVERRIDE_SQL , USAGE_EVENT_SQL , QUOTA_OPERATION_SQL ] ) {
151163 ctx . storage . sql . exec ( sql ) ;
152164 }
153165 copyQuotaRows ( ctx , entitlementVersion ) ;
154- for ( const table of [ "counter" , "limit_override" , "usage_event" , "quota_operation" ] as const ) {
166+ for ( const table of QUOTA_TABLES ) {
155167 assertSqliteRowCountPreserved ( ctx , `${ table } _reconcile_source` , table ) ;
156168 ctx . storage . sql . exec ( `DROP TABLE ${ table } _reconcile_source` ) ;
157169 }
@@ -182,40 +194,61 @@ function copyQuotaRows(ctx: DurableObjectState, entitlementVersion: string): voi
182194 ) ;
183195}
184196
197+ function assertQuotaFeatureTypes ( ctx : DurableObjectState ) : void {
198+ for ( const table of QUOTA_TABLES ) {
199+ if (
200+ ctx . storage . sql
201+ . exec ( `SELECT 1 FROM ${ table } WHERE typeof(feature) <> 'text' LIMIT 1` )
202+ . toArray ( ) . length > 0
203+ ) {
204+ throw new Error ( `Quota ${ table } contains a malformed feature; refusing lossy evolution.` ) ;
205+ }
206+ }
207+ }
208+
209+ function deleteRetiredQuotaRows ( ctx : DurableObjectState ) : void {
210+ for ( const table of QUOTA_TABLES ) {
211+ ctx . storage . sql . exec (
212+ `DELETE FROM ${ table } WHERE feature NOT IN (?, ?)` ,
213+ ...CURRENT_QUOTA_FEATURES ,
214+ ) ;
215+ }
216+ }
217+
185218function assertQuotaSourceRows ( ctx : DurableObjectState , hasEntitlementVersion : boolean ) : void {
186- const features = [ QUOTA_FEATURES . composioCalls , QUOTA_FEATURES . sandboxHours ] as const ;
187219 assertNoInvalidRows (
188220 ctx ,
221+ "counter" ,
189222 `SELECT 1 FROM counter WHERE
190223 typeof(feature) <> 'text' OR feature NOT IN (?, ?) OR
191224 typeof(period_key) <> 'text' OR length(period_key) <> 7 OR
192225 period_key NOT GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]' OR
193226 typeof(used) NOT IN ('integer', 'real') OR used < 0 OR abs(used) > ${ MAX_FINITE_REAL } OR
194227 typeof(updated_at) <> 'integer' OR updated_at < 0 LIMIT 1` ,
195- features ,
196228 ) ;
197229 const entitlementPredicate = hasEntitlementVersion
198230 ? " OR typeof(entitlement_version) <> 'integer' OR entitlement_version < 0"
199231 : "" ;
200232 assertNoInvalidRows (
201233 ctx ,
234+ "limit_override" ,
202235 `SELECT 1 FROM limit_override WHERE
203236 typeof(feature) <> 'text' OR feature NOT IN (?, ?) OR
204237 typeof(limit_val) NOT IN ('integer', 'real') OR limit_val < 0 OR
205238 abs(limit_val) > ${ MAX_FINITE_REAL } ${ entitlementPredicate } LIMIT 1` ,
206- features ,
207239 ) ;
208240 assertNoInvalidRows (
209241 ctx ,
242+ "usage_event" ,
210243 `SELECT 1 FROM usage_event WHERE
211244 typeof(id) <> 'integer' OR typeof(feature) <> 'text' OR feature NOT IN (?, ?) OR
212245 typeof(amount) NOT IN ('integer', 'real') OR amount <= 0 OR
213246 abs(amount) > ${ MAX_FINITE_REAL } OR typeof(recorded_at) <> 'integer' OR
214247 recorded_at < 0 LIMIT 1` ,
215- features ,
216248 ) ;
217249 assertNoInvalidRows (
218250 ctx ,
251+ "quota_operation" ,
219252 `SELECT 1 FROM quota_operation WHERE
220253 typeof(event_id) <> 'text' OR length(event_id) NOT BETWEEN 1 AND 200 OR
221254 typeof(operation) <> 'text' OR operation NOT IN ('record', 'try-consume') OR
@@ -230,17 +263,16 @@ function assertQuotaSourceRows(ctx: DurableObjectState, hasEntitlementVersion: b
230263 abs(remaining) > ${ MAX_FINITE_REAL } OR typeof(used) NOT IN ('integer', 'real') OR
231264 used < 0 OR abs(used) > ${ MAX_FINITE_REAL } OR
232265 typeof(recorded_at) <> 'integer' OR recorded_at < 0 LIMIT 1` ,
233- features ,
234266 ) ;
235267}
236268
237269function assertNoInvalidRows (
238270 ctx : DurableObjectState ,
271+ table : ( typeof QUOTA_TABLES ) [ number ] ,
239272 sql : string ,
240- features : readonly [ string , string ] ,
241273) : void {
242- if ( ctx . storage . sql . exec ( sql , ...features ) . toArray ( ) . length > 0 ) {
243- throw new Error ( " Quota storage contains invalid or retired data; refusing lossy evolution." ) ;
274+ if ( ctx . storage . sql . exec ( sql , ...CURRENT_QUOTA_FEATURES ) . toArray ( ) . length > 0 ) {
275+ throw new Error ( ` Quota ${ table } contains invalid current data; refusing lossy evolution.` ) ;
244276 }
245277}
246278
0 commit comments