Skip to content

Commit 2af2009

Browse files
authored
Merge pull request #198 from Demilade01/main
feat(jobs): add ownership snapshot cleanup job scaffold
2 parents ab6633c + 2db1472 commit 2af2009

5 files changed

Lines changed: 390 additions & 2 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Ownership Snapshot Cleanup Scaffold
2+
3+
This scaffold adds a scheduled cleanup job that targets expired ownership snapshot rows.
4+
5+
The implementation is intentionally conservative:
6+
7+
- it runs only when explicitly enabled
8+
- it supports dry-run mode by default
9+
- it skips cleanup if the target table does not exist yet
10+
11+
## Environment variables
12+
13+
Use the following variables to control behavior:
14+
15+
- `OWNERSHIP_SNAPSHOT_CLEANUP_ENABLED` (default: `false`)
16+
- `OWNERSHIP_SNAPSHOT_CLEANUP_INTERVAL_MINUTES` (default: `60`)
17+
- `OWNERSHIP_SNAPSHOT_RETENTION_DAYS` (default: `30`)
18+
- `OWNERSHIP_SNAPSHOT_CLEANUP_DRY_RUN` (default: `true`)
19+
- `OWNERSHIP_SNAPSHOT_TABLE_NAME` (default: `creator_ownership_snapshots`)
20+
21+
## How cleanup works
22+
23+
On each run, the job computes a cutoff timestamp as:
24+
25+
`now - OWNERSHIP_SNAPSHOT_RETENTION_DAYS`
26+
27+
Then it checks whether the configured table exists.
28+
29+
- In dry-run mode, it only counts rows where `expiresAt < cutoff`.
30+
- In non-dry-run mode, it deletes rows where `expiresAt < cutoff`.
31+
32+
## Local validation steps
33+
34+
1. Configure `.env`:
35+
36+
```bash
37+
OWNERSHIP_SNAPSHOT_CLEANUP_ENABLED=true
38+
OWNERSHIP_SNAPSHOT_CLEANUP_INTERVAL_MINUTES=1
39+
OWNERSHIP_SNAPSHOT_RETENTION_DAYS=30
40+
OWNERSHIP_SNAPSHOT_CLEANUP_DRY_RUN=true
41+
OWNERSHIP_SNAPSHOT_TABLE_NAME=creator_ownership_snapshots
42+
```
43+
44+
2. Start the server:
45+
46+
```bash
47+
pnpm dev
48+
```
49+
50+
3. Confirm logs show one of the following:
51+
52+
- `Ownership snapshot cleanup dry-run completed`
53+
- `Ownership snapshot cleanup skipped because target table was not found`
54+
55+
4. Run tests for the scaffold:
56+
57+
```bash
58+
pnpm exec jest src/jobs/ownership-snapshot-cleanup.job.test.ts
59+
```
60+
61+
5. Optional delete validation (only when safe): set `OWNERSHIP_SNAPSHOT_CLEANUP_DRY_RUN=false`, run locally against non-production data, and verify delete counts in logs.

src/config.schema.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,16 @@ export const envSchema = z
116116
'STELLAR_SOROBAN_RPC_URL must be a valid URL (e.g. https://soroban-testnet.stellar.org)'
117117
)
118118
.default('https://soroban-testnet.stellar.org'),
119+
120+
// Ownership snapshot cleanup job
121+
OWNERSHIP_SNAPSHOT_TABLE_NAME: z
122+
.string()
123+
.min(1)
124+
.default('creator_ownership_snapshots'),
125+
OWNERSHIP_SNAPSHOT_CLEANUP_DRY_RUN: z.coerce.boolean().default(true),
126+
OWNERSHIP_SNAPSHOT_RETENTION_DAYS: z.coerce.number().int().positive().default(30),
127+
OWNERSHIP_SNAPSHOT_CLEANUP_ENABLED: z.coerce.boolean().default(false),
128+
OWNERSHIP_SNAPSHOT_CLEANUP_INTERVAL_MINUTES: z.coerce.number().int().positive().default(60),
119129
})
120130
.superRefine((data, ctx) => {
121131
if (
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
jest.mock('../config', () => ({
2+
envConfig: {
3+
OWNERSHIP_SNAPSHOT_CLEANUP_DRY_RUN: true,
4+
OWNERSHIP_SNAPSHOT_RETENTION_DAYS: 30,
5+
OWNERSHIP_SNAPSHOT_TABLE_NAME: 'creator_ownership_snapshots',
6+
OWNERSHIP_SNAPSHOT_CLEANUP_ENABLED: false,
7+
OWNERSHIP_SNAPSHOT_CLEANUP_INTERVAL_MINUTES: 60,
8+
},
9+
}));
10+
11+
jest.mock('../utils/prisma.utils', () => ({
12+
prisma: {
13+
$queryRawUnsafe: jest.fn(),
14+
$executeRawUnsafe: jest.fn(),
15+
},
16+
}));
17+
18+
jest.mock('../utils/logger.utils', () => ({
19+
logger: {
20+
info: jest.fn(),
21+
warn: jest.fn(),
22+
error: jest.fn(),
23+
},
24+
}));
25+
26+
import { envConfig } from '../config';
27+
import { cleanupExpiredOwnershipSnapshots } from './ownership-snapshot-cleanup.job';
28+
29+
describe('ownership-snapshot-cleanup.job', () => {
30+
beforeEach(() => {
31+
jest.clearAllMocks();
32+
envConfig.OWNERSHIP_SNAPSHOT_CLEANUP_DRY_RUN = true;
33+
envConfig.OWNERSHIP_SNAPSHOT_RETENTION_DAYS = 30;
34+
envConfig.OWNERSHIP_SNAPSHOT_TABLE_NAME = 'creator_ownership_snapshots';
35+
});
36+
37+
it('returns skipped when the snapshot table does not exist', async () => {
38+
const queryRawUnsafe = jest
39+
.fn()
40+
.mockResolvedValueOnce([{ regclass: null }]);
41+
42+
const result = await cleanupExpiredOwnershipSnapshots({
43+
queryRawUnsafe,
44+
now: () => new Date('2026-01-31T00:00:00.000Z'),
45+
});
46+
47+
expect(result).toMatchObject({
48+
skipped: true,
49+
reason: 'table_not_found',
50+
dryRun: true,
51+
affectedRows: 0,
52+
tableName: 'creator_ownership_snapshots',
53+
});
54+
expect(queryRawUnsafe).toHaveBeenCalledTimes(1);
55+
});
56+
57+
it('counts rows in dry-run mode', async () => {
58+
const queryRawUnsafe = jest
59+
.fn()
60+
.mockResolvedValueOnce([{ regclass: 'creator_ownership_snapshots' }])
61+
.mockResolvedValueOnce([{ count: 12 }]);
62+
const executeRawUnsafe = jest.fn();
63+
64+
const result = await cleanupExpiredOwnershipSnapshots({
65+
queryRawUnsafe,
66+
executeRawUnsafe,
67+
now: () => new Date('2026-01-31T00:00:00.000Z'),
68+
});
69+
70+
expect(result).toMatchObject({
71+
skipped: false,
72+
dryRun: true,
73+
affectedRows: 12,
74+
tableName: 'creator_ownership_snapshots',
75+
});
76+
expect(queryRawUnsafe).toHaveBeenCalledTimes(2);
77+
expect(executeRawUnsafe).not.toHaveBeenCalled();
78+
});
79+
80+
it('deletes rows when dry-run is disabled', async () => {
81+
envConfig.OWNERSHIP_SNAPSHOT_CLEANUP_DRY_RUN = false;
82+
83+
const queryRawUnsafe = jest
84+
.fn()
85+
.mockResolvedValueOnce([{ regclass: 'creator_ownership_snapshots' }]);
86+
const executeRawUnsafe = jest.fn().mockResolvedValueOnce(5);
87+
88+
const result = await cleanupExpiredOwnershipSnapshots({
89+
queryRawUnsafe,
90+
executeRawUnsafe,
91+
now: () => new Date('2026-01-31T00:00:00.000Z'),
92+
});
93+
94+
expect(result).toMatchObject({
95+
skipped: false,
96+
dryRun: false,
97+
affectedRows: 5,
98+
tableName: 'creator_ownership_snapshots',
99+
});
100+
expect(executeRawUnsafe).toHaveBeenCalledTimes(1);
101+
});
102+
103+
it('applies retention days when calculating cutoff timestamp', async () => {
104+
envConfig.OWNERSHIP_SNAPSHOT_RETENTION_DAYS = 10;
105+
106+
const queryRawUnsafe = jest
107+
.fn()
108+
.mockResolvedValueOnce([{ regclass: 'creator_ownership_snapshots' }])
109+
.mockResolvedValueOnce([{ count: 0 }]);
110+
111+
const now = new Date('2026-02-15T00:00:00.000Z');
112+
const expectedCutoff = new Date('2026-02-05T00:00:00.000Z');
113+
114+
const result = await cleanupExpiredOwnershipSnapshots({
115+
queryRawUnsafe,
116+
now: () => now,
117+
});
118+
119+
expect(result.cutoffTimestamp.toISOString()).toBe(
120+
expectedCutoff.toISOString()
121+
);
122+
expect(queryRawUnsafe).toHaveBeenLastCalledWith(
123+
expect.stringContaining('COUNT(*)::int'),
124+
expectedCutoff
125+
);
126+
});
127+
});
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { envConfig } from '../config';
2+
import { logger } from '../utils/logger.utils';
3+
import { prisma } from '../utils/prisma.utils';
4+
5+
const VALID_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
6+
7+
export type OwnershipSnapshotCleanupResult = {
8+
skipped: boolean;
9+
dryRun: boolean;
10+
cutoffTimestamp: Date;
11+
affectedRows: number;
12+
tableName: string;
13+
reason?: string;
14+
};
15+
16+
type OwnershipSnapshotCleanupDeps = {
17+
queryRawUnsafe: typeof prisma.$queryRawUnsafe;
18+
executeRawUnsafe: typeof prisma.$executeRawUnsafe;
19+
now: () => Date;
20+
};
21+
22+
function getCutoffTimestamp(now: Date, retentionDays: number): Date {
23+
const cutoffMs = retentionDays * 24 * 60 * 60 * 1000;
24+
return new Date(now.getTime() - cutoffMs);
25+
}
26+
27+
function assertSafeTableName(tableName: string) {
28+
if (!VALID_IDENTIFIER.test(tableName)) {
29+
throw new Error(
30+
`Invalid OWNERSHIP_SNAPSHOT_TABLE_NAME: ${tableName}. Expected a simple SQL identifier.`
31+
);
32+
}
33+
}
34+
35+
async function tableExists(
36+
queryRawUnsafe: typeof prisma.$queryRawUnsafe,
37+
tableName: string
38+
): Promise<boolean> {
39+
const rows = await queryRawUnsafe<Array<{ regclass: string | null }>>(
40+
'SELECT to_regclass($1) AS regclass',
41+
`public.${tableName}`
42+
);
43+
44+
return Boolean(rows[0]?.regclass);
45+
}
46+
47+
export async function cleanupExpiredOwnershipSnapshots(
48+
deps?: Partial<OwnershipSnapshotCleanupDeps>
49+
): Promise<OwnershipSnapshotCleanupResult> {
50+
const queryRawUnsafe = deps?.queryRawUnsafe ?? prisma.$queryRawUnsafe.bind(prisma);
51+
const executeRawUnsafe =
52+
deps?.executeRawUnsafe ?? prisma.$executeRawUnsafe.bind(prisma);
53+
const now = deps?.now ?? (() => new Date());
54+
55+
const tableName = envConfig.OWNERSHIP_SNAPSHOT_TABLE_NAME;
56+
const dryRun = envConfig.OWNERSHIP_SNAPSHOT_CLEANUP_DRY_RUN;
57+
const cutoffTimestamp = getCutoffTimestamp(
58+
now(),
59+
envConfig.OWNERSHIP_SNAPSHOT_RETENTION_DAYS
60+
);
61+
62+
assertSafeTableName(tableName);
63+
64+
const exists = await tableExists(queryRawUnsafe, tableName);
65+
if (!exists) {
66+
logger.warn(
67+
{
68+
tableName,
69+
cutoffTimestamp: cutoffTimestamp.toISOString(),
70+
dryRun,
71+
},
72+
'Ownership snapshot cleanup skipped because target table was not found'
73+
);
74+
75+
return {
76+
skipped: true,
77+
reason: 'table_not_found',
78+
dryRun,
79+
cutoffTimestamp,
80+
affectedRows: 0,
81+
tableName,
82+
};
83+
}
84+
85+
if (dryRun) {
86+
const rows = await queryRawUnsafe<Array<{ count: number }>>(
87+
`SELECT COUNT(*)::int AS count FROM "${tableName}" WHERE "expiresAt" < $1`,
88+
cutoffTimestamp
89+
);
90+
const count = rows[0]?.count ?? 0;
91+
92+
logger.info(
93+
{
94+
tableName,
95+
cutoffTimestamp: cutoffTimestamp.toISOString(),
96+
retainedDays: envConfig.OWNERSHIP_SNAPSHOT_RETENTION_DAYS,
97+
wouldDeleteCount: count,
98+
dryRun,
99+
},
100+
'Ownership snapshot cleanup dry-run completed'
101+
);
102+
103+
return {
104+
skipped: false,
105+
dryRun,
106+
cutoffTimestamp,
107+
affectedRows: count,
108+
tableName,
109+
};
110+
}
111+
112+
const deletedCount = await executeRawUnsafe(
113+
`DELETE FROM "${tableName}" WHERE "expiresAt" < $1`,
114+
cutoffTimestamp
115+
);
116+
117+
logger.info(
118+
{
119+
tableName,
120+
cutoffTimestamp: cutoffTimestamp.toISOString(),
121+
retainedDays: envConfig.OWNERSHIP_SNAPSHOT_RETENTION_DAYS,
122+
deletedCount,
123+
dryRun,
124+
},
125+
'Ownership snapshot cleanup completed'
126+
);
127+
128+
return {
129+
skipped: false,
130+
dryRun,
131+
cutoffTimestamp,
132+
affectedRows: deletedCount,
133+
tableName,
134+
};
135+
}
136+
137+
let cleanupTimer: NodeJS.Timeout | null = null;
138+
139+
export function startOwnershipSnapshotCleanupJob() {
140+
if (!envConfig.OWNERSHIP_SNAPSHOT_CLEANUP_ENABLED) {
141+
logger.info('Ownership snapshot cleanup job is disabled');
142+
return;
143+
}
144+
145+
const intervalMs =
146+
envConfig.OWNERSHIP_SNAPSHOT_CLEANUP_INTERVAL_MINUTES * 60 * 1000;
147+
148+
const run = async () => {
149+
try {
150+
await cleanupExpiredOwnershipSnapshots();
151+
} catch (error) {
152+
logger.error(
153+
{ err: error },
154+
'Ownership snapshot cleanup failed with an unexpected error'
155+
);
156+
}
157+
};
158+
159+
void run();
160+
cleanupTimer = setInterval(() => {
161+
void run();
162+
}, intervalMs);
163+
164+
if (typeof cleanupTimer.unref === 'function') {
165+
cleanupTimer.unref();
166+
}
167+
168+
logger.info(
169+
{
170+
intervalMinutes: envConfig.OWNERSHIP_SNAPSHOT_CLEANUP_INTERVAL_MINUTES,
171+
retentionDays: envConfig.OWNERSHIP_SNAPSHOT_RETENTION_DAYS,
172+
dryRun: envConfig.OWNERSHIP_SNAPSHOT_CLEANUP_DRY_RUN,
173+
tableName: envConfig.OWNERSHIP_SNAPSHOT_TABLE_NAME,
174+
},
175+
'Ownership snapshot cleanup job started'
176+
);
177+
}
178+
179+
export function stopOwnershipSnapshotCleanupJob() {
180+
if (!cleanupTimer) {
181+
return;
182+
}
183+
184+
clearInterval(cleanupTimer);
185+
cleanupTimer = null;
186+
logger.info('Ownership snapshot cleanup job stopped');
187+
}

0 commit comments

Comments
 (0)