poll-manager.integration.test.ts121 lines · main
| 1 | /** |
| 2 | * Per-table change scoping — Phase 4.1 (the whole point of the phase). |
| 3 | * |
| 4 | * Proves the REAL PollManager, against a live DoltGres, fires ONLY the |
| 5 | * channels whose table actually changed — a write to table_a must NOT wake |
| 6 | * a subscriber watching table_b. Before Phase 4 every commit re-invoked |
| 7 | * every subscription in the project; this test locks in the cure so a |
| 8 | * regression fails CI. |
| 9 | * |
| 10 | * How to run (needs a DoltGres container; e.g. the throwaway probe on :5456): |
| 11 | * BRIVEN_DATA_PLANE_URL=postgres://postgres:password@127.0.0.1:5456/postgres \ |
| 12 | * bun test src/poll-manager.integration.test.ts |
| 13 | * |
| 14 | * Skips entirely when BRIVEN_DATA_PLANE_URL is unset (keeps the no-DB unit run green). |
| 15 | */ |
| 16 | import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; |
| 17 | import pg from 'pg'; |
| 18 | |
| 19 | import { PollManager } from './poll-manager.js'; |
| 20 | import { SubscriptionRegistry } from './subscription-registry.js'; |
| 21 | |
| 22 | const DSN = process.env.BRIVEN_DATA_PLANE_URL; |
| 23 | const HAS_DB = Boolean(DSN); |
| 24 | |
| 25 | // Throwaway project id, unique per run. Sanitised form must match dbNameFor. |
| 26 | const PROJECT_ID = `p4s${Date.now().toString(36)}`; |
| 27 | function dbNameFor(id: string): string { |
| 28 | return `proj_${id.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase()}`; |
| 29 | } |
| 30 | function channelFor(id: string, table: string): string { |
| 31 | return `briven_${dbNameFor(id)}_${table}`; |
| 32 | } |
| 33 | const DB_NAME = dbNameFor(PROJECT_ID); |
| 34 | const CH_A = channelFor(PROJECT_ID, 'table_a'); |
| 35 | const CH_B = channelFor(PROJECT_ID, 'table_b'); |
| 36 | |
| 37 | const AUTHOR = "--author=probe <p@p.io>"; |
| 38 | const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); |
| 39 | |
| 40 | function basePool(): pg.Pool { |
| 41 | const u = new URL(DSN as string); |
| 42 | return new pg.Pool({ |
| 43 | host: u.hostname, |
| 44 | port: Number(u.port || 5432), |
| 45 | user: decodeURIComponent(u.username), |
| 46 | password: decodeURIComponent(u.password), |
| 47 | database: u.pathname.replace(/^\//, '') || 'postgres', |
| 48 | max: 1, |
| 49 | }); |
| 50 | } |
| 51 | function projPool(): pg.Pool { |
| 52 | const u = new URL(DSN as string); |
| 53 | return new pg.Pool({ |
| 54 | host: u.hostname, |
| 55 | port: Number(u.port || 5432), |
| 56 | user: decodeURIComponent(u.username), |
| 57 | password: decodeURIComponent(u.password), |
| 58 | database: DB_NAME, |
| 59 | max: 1, |
| 60 | }); |
| 61 | } |
| 62 | |
| 63 | describe.skipIf(!HAS_DB)('PollManager per-table scoping', () => { |
| 64 | let manager: PollManager | null = null; |
| 65 | |
| 66 | beforeAll(async () => { |
| 67 | // Provision a throwaway DoltGres database with two tables + a baseline commit. |
| 68 | const base = basePool(); |
| 69 | await base.query(`DROP DATABASE IF EXISTS ${DB_NAME}`).catch(() => {}); |
| 70 | await base.query(`CREATE DATABASE ${DB_NAME}`); |
| 71 | await base.end(); |
| 72 | |
| 73 | const proj = projPool(); |
| 74 | await proj.query('CREATE TABLE IF NOT EXISTS table_a (id int primary key, v text)'); |
| 75 | await proj.query('CREATE TABLE IF NOT EXISTS table_b (id int primary key, v text)'); |
| 76 | await proj.query(`SELECT DOLT_COMMIT('-A', '-m', 'baseline', '${AUTHOR}')`); |
| 77 | await proj.end(); |
| 78 | }); |
| 79 | |
| 80 | afterAll(async () => { |
| 81 | if (manager) await manager.close().catch(() => {}); |
| 82 | const base = basePool(); |
| 83 | await base.query(`DROP DATABASE IF EXISTS ${DB_NAME}`).catch(() => {}); |
| 84 | await base.end(); |
| 85 | }); |
| 86 | |
| 87 | test('a commit touching only table_a fires CH_A and NOT CH_B', async () => { |
| 88 | const fired: string[] = []; |
| 89 | const registry = new SubscriptionRegistry(); |
| 90 | // Two subscribers: one on table_a, one on table_b. |
| 91 | registry.attach('sub-a', CH_A); |
| 92 | registry.attach('sub-b', CH_B); |
| 93 | |
| 94 | manager = new PollManager( |
| 95 | registry, |
| 96 | async (channel) => { |
| 97 | fired.push(channel); |
| 98 | }, |
| 99 | 150, // fast poll for the test (clamped floor is 100ms) |
| 100 | ); |
| 101 | await manager.init(DSN as string); |
| 102 | manager.addProject(PROJECT_ID); |
| 103 | |
| 104 | // Let the first poll seed the baseline HEAD hash (no fire — no baseline yet). |
| 105 | await sleep(600); |
| 106 | expect(fired).toEqual([]); // nothing fired before any change |
| 107 | |
| 108 | // Commit a change to table_a ONLY. |
| 109 | const proj = projPool(); |
| 110 | await proj.query("INSERT INTO table_a (id, v) VALUES (1, 'x')"); |
| 111 | await proj.query(`SELECT DOLT_COMMIT('-A', '-m', 'touch a', '${AUTHOR}')`); |
| 112 | await proj.end(); |
| 113 | |
| 114 | // Let the next poll detect + scope + fire. |
| 115 | await sleep(800); |
| 116 | |
| 117 | // CH_A must have fired; CH_B must NOT — that is per-table scoping. |
| 118 | expect(fired).toContain(CH_A); |
| 119 | expect(fired).not.toContain(CH_B); |
| 120 | }, 15_000); |
| 121 | }); |