@@ -33,6 +33,81 @@ const schedulerStub = {
3333const passthroughInternalMutation = ( ( definition : unknown ) =>
3434 definition ) as never ;
3535const METRIC_STATE_KIND = 'metric' as const ;
36+ const RANK_STATE_KIND = 'rank' as const ;
37+
38+ const createReadCountingDb = ( db : unknown ) => {
39+ const reads = new Map < string , number > ( ) ;
40+ const recordReads = ( table : string , count : number ) => {
41+ reads . set ( table , ( reads . get ( table ) ?? 0 ) + count ) ;
42+ } ;
43+ const wrapQuery = ( query : object , table : string ) : object =>
44+ new Proxy ( query , {
45+ get ( target , prop ) {
46+ const value = Reflect . get ( target , prop ) ;
47+ if ( typeof value !== 'function' ) {
48+ return value ;
49+ }
50+ if ( prop === Symbol . asyncIterator ) {
51+ return ( ) => {
52+ const iterator = value . call ( target ) as AsyncIterator < unknown > ;
53+ return {
54+ next : async ( ) => {
55+ const step = await iterator . next ( ) ;
56+ if ( ! step . done ) {
57+ recordReads ( table , 1 ) ;
58+ }
59+ return step ;
60+ } ,
61+ return : iterator . return ?. bind ( iterator ) ,
62+ throw : iterator . throw ?. bind ( iterator ) ,
63+ } ;
64+ } ;
65+ }
66+ return ( ...args : unknown [ ] ) => {
67+ const result = value . apply ( target , args ) ;
68+ if ( prop === 'collect' || prop === 'take' ) {
69+ return ( result as Promise < unknown [ ] > ) . then ( ( rows ) => {
70+ recordReads ( table , rows . length ) ;
71+ return rows ;
72+ } ) ;
73+ }
74+ if ( prop === 'first' || prop === 'unique' ) {
75+ return ( result as Promise < unknown > ) . then ( ( row ) => {
76+ recordReads ( table , row === null ? 0 : 1 ) ;
77+ return row ;
78+ } ) ;
79+ }
80+ if ( prop === 'paginate' ) {
81+ return ( result as Promise < { page : unknown [ ] } > ) . then ( ( page ) => {
82+ recordReads ( table , page . page . length ) ;
83+ return page ;
84+ } ) ;
85+ }
86+ return typeof result === 'object' && result !== null
87+ ? wrapQuery ( result , table )
88+ : result ;
89+ } ;
90+ } ,
91+ } ) ;
92+ const dbProxy = new Proxy ( db as object , {
93+ get ( target , prop ) {
94+ const value = Reflect . get ( target , prop ) ;
95+ if ( prop === 'query' ) {
96+ return ( table : string ) =>
97+ wrapQuery ( ( value as Function ) . call ( target , table ) as object , table ) ;
98+ }
99+ if ( prop === 'get' ) {
100+ return async ( ...args : unknown [ ] ) => {
101+ const doc = await ( value as Function ) . apply ( target , args ) ;
102+ recordReads ( '#db.get' , doc === null ? 0 : 1 ) ;
103+ return doc ;
104+ } ;
105+ }
106+ return typeof value === 'function' ? value . bind ( target ) : value ;
107+ } ,
108+ } ) ;
109+ return { db : dbProxy , reads } ;
110+ } ;
36111
37112const buildCountIndexedFixtures = ( options ?: {
38113 includeOrgStatusIndex ?: boolean ;
@@ -1336,6 +1411,295 @@ describe('ORM count() with aggregateIndex', () => {
13361411 } ) ;
13371412 } ) ;
13381413
1414+ it ( 'resume kickoff prunes orphans across kinds, tables, and indexes while keeping active data' , async ( ) => {
1415+ const { schema } = buildCountIndexedFixtures ( {
1416+ includeOrgStatusIndex : true ,
1417+ } ) ;
1418+ const { relations : relationsWithoutOrgStatus } = buildCountIndexedFixtures ( {
1419+ includeOrgStatusIndex : false ,
1420+ } ) ;
1421+ const t = convexTest ( schema ) ;
1422+
1423+ await t . run ( async ( baseCtx ) => {
1424+ const insertMember = async (
1425+ kind : string ,
1426+ tableKey : string ,
1427+ indexName : string ,
1428+ docId : string
1429+ ) => {
1430+ await baseCtx . db . insert ( 'aggregate_member' , {
1431+ kind,
1432+ tableKey,
1433+ indexName,
1434+ docId,
1435+ keyHash : '["org-1"]' ,
1436+ keyParts : [ 'org-1' ] ,
1437+ sumValues : { } ,
1438+ nonNullCountValues : { } ,
1439+ extremaValues : { } ,
1440+ updatedAt : 0 ,
1441+ } ) ;
1442+ } ;
1443+
1444+ await insertMember (
1445+ METRIC_STATE_KIND ,
1446+ 'countUsers' ,
1447+ 'by_org_status' ,
1448+ 'doc_orphan_1'
1449+ ) ;
1450+ await insertMember (
1451+ METRIC_STATE_KIND ,
1452+ 'countUsers' ,
1453+ 'by_org_status' ,
1454+ 'doc_orphan_2'
1455+ ) ;
1456+ await insertMember (
1457+ METRIC_STATE_KIND ,
1458+ 'countUsers' ,
1459+ 'by_org_tier' ,
1460+ 'doc_active_1'
1461+ ) ;
1462+ await insertMember (
1463+ METRIC_STATE_KIND ,
1464+ 'countPosts' ,
1465+ 'by_org' ,
1466+ 'doc_active_2'
1467+ ) ;
1468+ await insertMember (
1469+ RANK_STATE_KIND ,
1470+ 'countUsers' ,
1471+ 'by_rank_removed_a' ,
1472+ 'doc_rank_orphan_a'
1473+ ) ;
1474+ await insertMember (
1475+ RANK_STATE_KIND ,
1476+ 'countUsers' ,
1477+ 'by_rank_removed_b' ,
1478+ 'doc_rank_orphan_b'
1479+ ) ;
1480+
1481+ await baseCtx . db . insert ( 'aggregate_bucket' , {
1482+ tableKey : 'countUsers' ,
1483+ indexName : 'by_org_status' ,
1484+ keyHash : '["org-1"]' ,
1485+ keyParts : [ 'org-1' ] ,
1486+ count : 2 ,
1487+ sumValues : { } ,
1488+ nonNullCountValues : { } ,
1489+ updatedAt : 0 ,
1490+ } ) ;
1491+ for ( const orphanBucketIndex of [ 'by_removed_x' , 'by_removed_y' ] ) {
1492+ await baseCtx . db . insert ( 'aggregate_bucket' , {
1493+ tableKey : 'countPosts' ,
1494+ indexName : orphanBucketIndex ,
1495+ keyHash : '["org-1"]' ,
1496+ keyParts : [ 'org-1' ] ,
1497+ count : 1 ,
1498+ sumValues : { } ,
1499+ nonNullCountValues : { } ,
1500+ updatedAt : 0 ,
1501+ } ) ;
1502+ }
1503+ await baseCtx . db . insert ( 'aggregate_bucket' , {
1504+ tableKey : 'countUsers' ,
1505+ indexName : 'by_org_tier' ,
1506+ keyHash : '["org-1"]' ,
1507+ keyParts : [ 'org-1' ] ,
1508+ count : 1 ,
1509+ sumValues : { } ,
1510+ nonNullCountValues : { } ,
1511+ updatedAt : 0 ,
1512+ } ) ;
1513+ await baseCtx . db . insert ( 'aggregate_extrema' , {
1514+ tableKey : 'countUsers' ,
1515+ indexName : 'by_org_status' ,
1516+ keyHash : '["org-1"]' ,
1517+ fieldName : 'score' ,
1518+ valueHash : 'value_hash' ,
1519+ value : 1 ,
1520+ sortKey : 'n:1' ,
1521+ count : 1 ,
1522+ updatedAt : 0 ,
1523+ } ) ;
1524+
1525+ const prunedOrmClient = createOrm ( {
1526+ schema : relationsWithoutOrgStatus ,
1527+ ormFunctions : {
1528+ scheduledDelete : { } as any ,
1529+ scheduledMutationBatch : { } as any ,
1530+ } ,
1531+ internalMutation : passthroughInternalMutation ,
1532+ } ) ;
1533+ const prunedApi = prunedOrmClient . api ( ) ;
1534+
1535+ const result = await ( prunedApi as any ) . aggregateBackfill . handler (
1536+ { db : baseCtx . db , scheduler : schedulerStub } ,
1537+ { }
1538+ ) ;
1539+ expect ( result ) . toMatchObject ( {
1540+ mode : 'resume' ,
1541+ pruned : 5 ,
1542+ } ) ;
1543+
1544+ const membersByIndex = async (
1545+ kind : string ,
1546+ tableKey : string ,
1547+ indexName : string
1548+ ) =>
1549+ baseCtx . db
1550+ . query ( 'aggregate_member' )
1551+ . withIndex ( 'by_kind_table_index' , ( q : any ) =>
1552+ q
1553+ . eq ( 'kind' , kind )
1554+ . eq ( 'tableKey' , tableKey )
1555+ . eq ( 'indexName' , indexName )
1556+ )
1557+ . collect ( ) ;
1558+
1559+ await expect (
1560+ membersByIndex ( METRIC_STATE_KIND , 'countUsers' , 'by_org_status' )
1561+ ) . resolves . toHaveLength ( 0 ) ;
1562+ await expect (
1563+ membersByIndex ( RANK_STATE_KIND , 'countUsers' , 'by_rank_removed_a' )
1564+ ) . resolves . toHaveLength ( 0 ) ;
1565+ await expect (
1566+ membersByIndex ( RANK_STATE_KIND , 'countUsers' , 'by_rank_removed_b' )
1567+ ) . resolves . toHaveLength ( 0 ) ;
1568+ await expect (
1569+ membersByIndex ( METRIC_STATE_KIND , 'countUsers' , 'by_org_tier' )
1570+ ) . resolves . toHaveLength ( 1 ) ;
1571+ await expect (
1572+ membersByIndex ( METRIC_STATE_KIND , 'countPosts' , 'by_org' )
1573+ ) . resolves . toHaveLength ( 1 ) ;
1574+
1575+ const orphanBuckets = await baseCtx . db
1576+ . query ( 'aggregate_bucket' )
1577+ . withIndex ( 'by_table_index' , ( q : any ) =>
1578+ q . eq ( 'tableKey' , 'countUsers' ) . eq ( 'indexName' , 'by_org_status' )
1579+ )
1580+ . collect ( ) ;
1581+ expect ( orphanBuckets ) . toHaveLength ( 0 ) ;
1582+
1583+ const activeBuckets = await baseCtx . db
1584+ . query ( 'aggregate_bucket' )
1585+ . withIndex ( 'by_table_index' , ( q : any ) =>
1586+ q . eq ( 'tableKey' , 'countUsers' ) . eq ( 'indexName' , 'by_org_tier' )
1587+ )
1588+ . collect ( ) ;
1589+ expect ( activeBuckets ) . toHaveLength ( 1 ) ;
1590+
1591+ for ( const orphanBucketIndex of [ 'by_removed_x' , 'by_removed_y' ] ) {
1592+ const orphanOnlyBuckets = await baseCtx . db
1593+ . query ( 'aggregate_bucket' )
1594+ . withIndex ( 'by_table_index' , ( q : any ) =>
1595+ q . eq ( 'tableKey' , 'countPosts' ) . eq ( 'indexName' , orphanBucketIndex )
1596+ )
1597+ . collect ( ) ;
1598+ expect ( orphanOnlyBuckets ) . toHaveLength ( 0 ) ;
1599+ }
1600+
1601+ const orphanExtrema = await baseCtx . db
1602+ . query ( 'aggregate_extrema' )
1603+ . withIndex ( 'by_table_index' , ( q : any ) =>
1604+ q . eq ( 'tableKey' , 'countUsers' ) . eq ( 'indexName' , 'by_org_status' )
1605+ )
1606+ . collect ( ) ;
1607+ expect ( orphanExtrema ) . toHaveLength ( 0 ) ;
1608+ } ) ;
1609+ } ) ;
1610+
1611+ it ( 'resume kickoff reads a bounded number of aggregate rows regardless of member table size' , async ( ) => {
1612+ const { schema, relations } = buildCountIndexedFixtures ( ) ;
1613+ const t = convexTest ( schema ) ;
1614+
1615+ await t . run ( async ( baseCtx ) => {
1616+ const ormClient = createOrm ( {
1617+ schema : relations ,
1618+ ormFunctions : {
1619+ scheduledDelete : { } as any ,
1620+ scheduledMutationBatch : { } as any ,
1621+ } ,
1622+ internalMutation : passthroughInternalMutation ,
1623+ } ) ;
1624+ const api = ormClient . api ( ) ;
1625+
1626+ const memberTuples = [
1627+ {
1628+ kind : METRIC_STATE_KIND ,
1629+ tableKey : 'countUsers' ,
1630+ indexName : 'by_org_status' ,
1631+ } ,
1632+ {
1633+ kind : METRIC_STATE_KIND ,
1634+ tableKey : 'countUsers' ,
1635+ indexName : 'by_org_tier' ,
1636+ } ,
1637+ {
1638+ kind : METRIC_STATE_KIND ,
1639+ tableKey : 'countPosts' ,
1640+ indexName : 'by_org' ,
1641+ } ,
1642+ ] ;
1643+ for ( const [ tupleIndex , tuple ] of memberTuples . entries ( ) ) {
1644+ for ( let i = 0 ; i < 100 ; i += 1 ) {
1645+ await baseCtx . db . insert ( 'aggregate_member' , {
1646+ ...tuple ,
1647+ docId : `doc_${ tupleIndex } _${ i } ` ,
1648+ keyHash : '["org-1","active"]' ,
1649+ keyParts : [ 'org-1' , 'active' ] ,
1650+ sumValues : { } ,
1651+ nonNullCountValues : { } ,
1652+ extremaValues : { } ,
1653+ updatedAt : 0 ,
1654+ } ) ;
1655+ }
1656+ }
1657+ const bucketTuples = [
1658+ { tableKey : 'countUsers' , indexName : 'by_org_status' } ,
1659+ { tableKey : 'countUsers' , indexName : 'by_org_tier' } ,
1660+ ] ;
1661+ for ( const tuple of bucketTuples ) {
1662+ for ( let i = 0 ; i < 30 ; i += 1 ) {
1663+ await baseCtx . db . insert ( 'aggregate_bucket' , {
1664+ ...tuple ,
1665+ keyHash : `["org-${ i } ","active"]` ,
1666+ keyParts : [ `org-${ i } ` , 'active' ] ,
1667+ count : 1 ,
1668+ sumValues : { } ,
1669+ nonNullCountValues : { } ,
1670+ updatedAt : 0 ,
1671+ } ) ;
1672+ await baseCtx . db . insert ( 'aggregate_extrema' , {
1673+ ...tuple ,
1674+ keyHash : `["org-${ i } ","active"]` ,
1675+ fieldName : 'score' ,
1676+ valueHash : `value_hash_${ i } ` ,
1677+ value : i ,
1678+ sortKey : `n:${ i } ` ,
1679+ count : 1 ,
1680+ updatedAt : 0 ,
1681+ } ) ;
1682+ }
1683+ }
1684+
1685+ const { db : countingDb , reads } = createReadCountingDb ( baseCtx . db ) ;
1686+
1687+ const result = await ( api as any ) . aggregateBackfill . handler (
1688+ { db : countingDb , scheduler : schedulerStub } ,
1689+ { }
1690+ ) ;
1691+
1692+ expect ( result ) . toMatchObject ( { mode : 'resume' , status : 'ok' } ) ;
1693+ expect ( reads . get ( 'aggregate_member' ) ?? 0 ) . toBeGreaterThan ( 0 ) ;
1694+ expect ( reads . get ( 'aggregate_member' ) ?? 0 ) . toBeLessThan ( 20 ) ;
1695+ expect ( reads . get ( 'aggregate_bucket' ) ?? 0 ) . toBeGreaterThan ( 0 ) ;
1696+ expect ( reads . get ( 'aggregate_bucket' ) ?? 0 ) . toBeLessThan ( 20 ) ;
1697+ expect ( reads . get ( 'aggregate_extrema' ) ?? 0 ) . toBeGreaterThan ( 0 ) ;
1698+ expect ( reads . get ( 'aggregate_extrema' ) ?? 0 ) . toBeLessThan ( 20 ) ;
1699+ expect ( reads . get ( '#db.get' ) ?? 0 ) . toBeLessThan ( 20 ) ;
1700+ } ) ;
1701+ } ) ;
1702+
13391703 it ( 'processes at most one paginated target per chunk invocation' , async ( ) => {
13401704 const { schema, relations } = buildCountIndexedFixtures ( ) ;
13411705 const t = convexTest ( schema ) ;
0 commit comments