diff --git a/README.md b/README.md index 7afbd998..6bbf789c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ NestJS PostgreSQL Apache 2.0 License - 1591 Tests Passing 12 AI Agents + 1598 Tests Passing 12 AI Agents

@@ -510,7 +510,7 @@ Operator notes for activating existing adapters, metasearch landings on the dire | OTA Channels | Booking.com + Expedia (EQC) + SiteMinder + DerbySoft | Direct + aggregated OTA connectivity (ARI + content) | | XML Processing | fast-xml-parser | Booking.com OTA XML protocol | | Package Manager | pnpm workspaces | Monorepo management | -| Testing | Vitest (1591 tests across 220 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds | +| Testing | Vitest (1598 tests across 221 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds | | Containers | Docker + docker-compose | Local dev and production deployment | | CI/CD | GitHub Actions | Automated testing, builds, and releases | @@ -642,7 +642,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment. ### Run tests ```bash -# All tests (1591 tests across 220 test files) +# All tests (1598 tests across 221 test files) # API tests only pnpm --filter @telivityhaip/api test @@ -1190,7 +1190,7 @@ HAIP is built in public and contributions are welcome. pnpm install # Install dependencies pnpm build # Build all workspace packages pnpm dev # Start API in dev mode (hot reload) -pnpm test # Run all tests (1591 tests, 220 files) +pnpm test # Run all tests (1598 tests, 221 files) pnpm lint # ESLint ``` diff --git a/apps/api/src/modules/connect/connect-events.service.spec.ts b/apps/api/src/modules/connect/connect-events.service.spec.ts index db70fc46..bb7d6760 100644 --- a/apps/api/src/modules/connect/connect-events.service.spec.ts +++ b/apps/api/src/modules/connect/connect-events.service.spec.ts @@ -187,6 +187,37 @@ describe('ConnectEventsService', () => { ); }); + it('forwards logicalEventId to WebhookDeliveryService for persisted dedup', async () => { + mockDb.select.mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([mockSubscription]), + }), + })); + + const logicalEventId = 'bbbbbbbb-0000-4000-a000-000000000002'; + const deliveryService = { enqueue: vi.fn().mockResolvedValue({ id: 'del-1' }) }; + const svc = new ConnectEventsService(mockDb, deliveryService as any); + + await svc.handleEvent({ + event: 'reservation.created', + entityType: 'reservation', + entityId: 'res-1', + propertyId: 'prop-1', + data: { foo: 'bar' }, + timestamp: new Date().toISOString(), + logicalEventId, + }); + + expect(deliveryService.enqueue).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'reservation.created', + propertyId: 'prop-1', + }), + 'sub-1', + logicalEventId, + ); + }); + it('does nothing when no subscriptions match', async () => { mockDb.select.mockImplementation(() => ({ from: vi.fn().mockReturnValue({ diff --git a/apps/api/src/modules/connect/connect-events.service.ts b/apps/api/src/modules/connect/connect-events.service.ts index c840f19d..9643ef6e 100644 --- a/apps/api/src/modules/connect/connect-events.service.ts +++ b/apps/api/src/modules/connect/connect-events.service.ts @@ -5,6 +5,7 @@ import { agentWebhookSubscriptions, auditLogs } from '@telivityhaip/database'; import { DRIZZLE } from '../../database/database.module'; import type { CreateSubscriptionDto } from './dto/agent-event-subscription.dto'; import { WebhookDeliveryService } from '../webhook/webhook-delivery.service'; +import type { WebhookPayload } from '../webhook/webhook.service'; @Injectable() export class ConnectEventsService { @@ -173,7 +174,7 @@ export class ConnectEventsService { * Listens to all events via wildcard. */ @OnEvent('**') - async handleEvent(payload: any) { + async handleEvent(payload: WebhookPayload) { if (!payload?.propertyId || !payload?.event) return; // Find matching subscriptions @@ -191,18 +192,23 @@ export class ConnectEventsService { const events = (sub.events ?? []) as string[]; if (events.some((pattern: string) => this.matchesEventPattern(payload.event, pattern))) { if (this.deliveryService) { - // Enqueue a real HTTP delivery (HMAC-signed, retried). - await this.deliveryService.enqueue( - { - eventType: payload.event, - propertyId: payload.propertyId, - entityType: payload.entityType, - entityId: payload.entityId, - data: payload.data ?? {}, - timestamp: payload.timestamp ?? new Date().toISOString(), - }, - sub.id, - ); + const deliveryPayload = { + eventType: payload.event, + propertyId: payload.propertyId, + entityType: payload.entityType, + entityId: payload.entityId, + data: payload.data ?? {}, + timestamp: payload.timestamp ?? new Date().toISOString(), + }; + if (payload.logicalEventId) { + await this.deliveryService.enqueue( + deliveryPayload, + sub.id, + payload.logicalEventId, + ); + } else { + await this.deliveryService.enqueue(deliveryPayload, sub.id); + } } else { // Fallback — just log the match (for tests / environments without delivery service). await this.db diff --git a/apps/api/src/modules/webhook/webhook-delivery.service.spec.ts b/apps/api/src/modules/webhook/webhook-delivery.service.spec.ts index f7016dfe..83213d21 100644 --- a/apps/api/src/modules/webhook/webhook-delivery.service.spec.ts +++ b/apps/api/src/modules/webhook/webhook-delivery.service.spec.ts @@ -51,14 +51,27 @@ function createStatefulMockDb(subscription: any) { }; }), insert: vi.fn((_tbl: any) => ({ - values: vi.fn((vals: any) => ({ - returning: vi.fn(() => { + values: vi.fn((vals: any) => { + const insertOnce = () => { + const existing = vals.logicalEventId + ? Array.from(deliveries.values()).find((row) => + row.propertyId === vals.propertyId + && row.subscriptionId === vals.subscriptionId + && row.logicalEventId === vals.logicalEventId) + : undefined; + if (existing) return Promise.resolve([]); const id = `del-${idCounter++}`; const row = { id, ...vals }; deliveries.set(id, row); return Promise.resolve([row]); - }), - })), + }; + return { + returning: vi.fn(insertOnce), + onConflictDoNothing: vi.fn(() => ({ + returning: vi.fn(insertOnce), + })), + }; + }), })), update: vi.fn((_tbl: any) => ({ set: vi.fn((vals: any) => ({ @@ -136,7 +149,7 @@ describe('WebhookDeliveryService', () => { expect(stored.attempts).toBe(0); }); - it('worker POSTs with HMAC signature + event headers', async () => { + it('uses the delivery row ID as the event header for legacy events', async () => { fetchMock.mockResolvedValue({ ok: true, status: 200 }); const db = createStatefulMockDb(subscription); const queue = createMockQueue(); @@ -166,6 +179,124 @@ describe('WebhookDeliveryService', () => { expect(stored.attempts).toBe(1); }); + it('reuses one persisted delivery and stable header across event replay', async () => { + fetchMock + .mockResolvedValueOnce({ ok: false, status: 500 }) + .mockResolvedValueOnce({ ok: true, status: 200 }); + const db = createStatefulMockDb(subscription); + const queue = createMockQueue(); + const service = new WebhookDeliveryService( + db as unknown as ConstructorParameters[0], + undefined, + queue as unknown as ConstructorParameters[2], + ); + const logicalEventId = 'bbbbbbbb-0000-4000-a000-000000000002'; + + const first = await service.enqueue(payload, subscription.id, logicalEventId); + const replay = await service.enqueue(payload, subscription.id, logicalEventId); + + expect(replay.id).toBe(first.id); + expect(db._deliveries.size).toBe(1); + expect(db._deliveries.get(first.id).logicalEventId).toBe(logicalEventId); + expect(queue.add).toHaveBeenCalledTimes(2); + expect(queue.add.mock.calls.map((call) => call[2]?.jobId)).toEqual([ + first.id, + first.id, + ]); + + await expect( + service.processDeliveryJob({ deliveryId: first.id, propertyId: 'prop-1' }), + ).rejects.toThrow('scheduled for retry'); + await service.processDeliveryJob({ deliveryId: first.id, propertyId: 'prop-1' }); + await service.processDeliveryJob({ deliveryId: replay.id, propertyId: 'prop-1' }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls.map((call) => + call[1].headers['X-HAIP-Event-Id'])).toEqual([ + logicalEventId, + logicalEventId, + ]); + }); + + it('creates separate deliveries for different persisted logical events', async () => { + const db = createStatefulMockDb(subscription); + const queue = createMockQueue(); + const service = new WebhookDeliveryService( + db as unknown as ConstructorParameters[0], + undefined, + queue as unknown as ConstructorParameters[2], + ); + + const first = await service.enqueue( + payload, + subscription.id, + 'bbbbbbbb-0000-4000-a000-000000000002', + ); + const second = await service.enqueue( + payload, + subscription.id, + 'bbbbbbbb-0000-4000-a000-000000000003', + ); + + expect(second.id).not.toBe(first.id); + expect(db._deliveries.size).toBe(2); + expect(Array.from(db._deliveries.values()).map((row) => row.logicalEventId)).toEqual([ + 'bbbbbbbb-0000-4000-a000-000000000002', + 'bbbbbbbb-0000-4000-a000-000000000003', + ]); + }); + + it('returns the same delivery from concurrent persisted event creation', async () => { + const db = createStatefulMockDb(subscription); + const queue = createMockQueue(); + const service = new WebhookDeliveryService( + db as unknown as ConstructorParameters[0], + undefined, + queue as unknown as ConstructorParameters[2], + ); + const logicalEventId = 'bbbbbbbb-0000-4000-a000-000000000002'; + + const [first, replay] = await Promise.all([ + service.enqueue(payload, subscription.id, logicalEventId), + service.enqueue(payload, subscription.id, logicalEventId), + ]); + + expect(replay.id).toBe(first.id); + expect(db._deliveries.size).toBe(1); + expect(queue.add.mock.calls.map((call) => call[2]?.jobId)).toEqual([ + first.id, + first.id, + ]); + }); + + it('requeues the existing delivery when the first queue write is lost', async () => { + const db = createStatefulMockDb(subscription); + const queue = createMockQueue(); + queue.add + .mockRejectedValueOnce(new Error('Redis unavailable')) + .mockResolvedValueOnce({ id: 'job-recovered' }); + const service = new WebhookDeliveryService( + db as unknown as ConstructorParameters[0], + undefined, + queue as unknown as ConstructorParameters[2], + ); + const logicalEventId = 'bbbbbbbb-0000-4000-a000-000000000002'; + + await expect( + service.enqueue(payload, subscription.id, logicalEventId), + ).rejects.toThrow('Redis unavailable'); + const [stored] = Array.from(db._deliveries.values()); + + const recovered = await service.enqueue(payload, subscription.id, logicalEventId); + + expect(recovered.id).toBe(stored.id); + expect(db._deliveries.size).toBe(1); + expect(queue.add.mock.calls.map((call) => call[2]?.jobId)).toEqual([ + stored.id, + stored.id, + ]); + }); + it('updates the row and throws so BullMQ retries on non-2xx response', async () => { fetchMock.mockResolvedValue({ ok: false, status: 500 }); const db = createStatefulMockDb(subscription); diff --git a/apps/api/src/modules/webhook/webhook-delivery.service.ts b/apps/api/src/modules/webhook/webhook-delivery.service.ts index 6c93eeb9..b0be8612 100644 --- a/apps/api/src/modules/webhook/webhook-delivery.service.ts +++ b/apps/api/src/modules/webhook/webhook-delivery.service.ts @@ -56,6 +56,7 @@ interface WebhookDeliveryJob { } type DeliveryAttemptOutcome = 'delivered' | 'retry' | 'failed' | 'skipped'; +type WebhookDeliveryRow = typeof webhookDeliveries.$inferSelect; interface WebhookDeliveryQueue { add(name: string, data: WebhookDeliveryJob, options?: JobsOptions): Promise; @@ -111,22 +112,49 @@ export class WebhookDeliveryService implements OnModuleInit, OnModuleDestroy { } /** - * Enqueue deliveries for an event — one row per matching subscription, - * then add a durable BullMQ job for the worker. + * Enqueue one delivery per subscription and persisted logical event, then + * add its durable BullMQ job. Re-adding an existing row recovers a crash + * between the database insert and queue write; BullMQ deduplicates the UUID + * delivery job ID while an existing job remains present. */ - async enqueue(payload: DeliveryPayload, subscriptionId: string) { - const [delivery] = await this.db + async enqueue( + payload: DeliveryPayload, + subscriptionId: string, + logicalEventId?: string, + ) { + const [inserted] = await this.db .insert(webhookDeliveries) .values({ propertyId: payload.propertyId, subscriptionId, + logicalEventId: logicalEventId ?? null, eventType: payload.eventType, payload, status: 'pending', attempts: 0, }) + .onConflictDoNothing() .returning(); + let delivery = inserted as WebhookDeliveryRow | undefined; + if (!delivery && logicalEventId) { + const candidates = await this.db + .select() + .from(webhookDeliveries) + .where(and( + eq(webhookDeliveries.propertyId, payload.propertyId), + eq(webhookDeliveries.subscriptionId, subscriptionId), + eq(webhookDeliveries.logicalEventId, logicalEventId), + )); + delivery = candidates.find((candidate: WebhookDeliveryRow) => + candidate.propertyId === payload.propertyId + && candidate.subscriptionId === subscriptionId + && candidate.logicalEventId === logicalEventId); + } + if (!delivery) { + throw new Error('Webhook delivery could not be created or recovered'); + } + await this.enqueueDeliveryJob(delivery.id, payload.propertyId); return delivery; @@ -209,7 +237,7 @@ export class WebhookDeliveryService implements OnModuleInit, OnModuleDestroy { headers: { 'Content-Type': 'application/json', 'X-HAIP-Signature': signature, - 'X-HAIP-Event-Id': delivery.id, + 'X-HAIP-Event-Id': delivery.logicalEventId ?? delivery.id, 'X-HAIP-Event-Type': delivery.eventType, }, body, diff --git a/apps/api/src/modules/webhook/webhook.service.spec.ts b/apps/api/src/modules/webhook/webhook.service.spec.ts new file mode 100644 index 00000000..3874c5ef --- /dev/null +++ b/apps/api/src/modules/webhook/webhook.service.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest'; +import { WebhookService, type WebhookPayload } from './webhook.service'; + +describe('WebhookService persisted dispatch', () => { + it('adds the stable logical event ID only to the internal dispatch envelope', async () => { + const db = { insert: vi.fn() }; + const eventEmitter = { emitAsync: vi.fn().mockResolvedValue([]) }; + const service = new WebhookService( + db as unknown as ConstructorParameters[0], + eventEmitter as unknown as ConstructorParameters[1], + ); + const payload: WebhookPayload = { + event: 'reservation.created', + entityType: 'reservation', + entityId: 'bbbbbbbb-0000-4000-a000-000000000001', + propertyId: 'aaaaaaaa-0000-4000-a000-000000000001', + data: { + reservationId: 'bbbbbbbb-0000-4000-a000-000000000001', + status: 'pending', + }, + timestamp: '2026-08-24T17:15:00.000Z', + }; + + await service.dispatchPersisted( + payload, + 'bbbbbbbb-0000-4000-a000-000000000002', + ); + + expect(eventEmitter.emitAsync).toHaveBeenCalledWith( + 'reservation.created', + { + ...payload, + logicalEventId: 'bbbbbbbb-0000-4000-a000-000000000002', + }, + ); + expect(payload).not.toHaveProperty('logicalEventId'); + expect(db.insert).not.toHaveBeenCalled(); + }); + + it('rejects a persisted dispatch name outside the shared WebhookEvent catalog', async () => { + const eventEmitter = { emitAsync: vi.fn().mockResolvedValue([]) }; + const service = new WebhookService( + { insert: vi.fn() } as unknown as ConstructorParameters[0], + eventEmitter as unknown as ConstructorParameters[1], + ); + const payload = { + event: 'payment.retained', + entityType: 'booking_request_payment_resolution', + entityId: 'bbbbbbbb-0000-4000-a000-000000000001', + data: {}, + timestamp: '2026-08-25T00:00:00.000Z', + } as unknown as WebhookPayload; + + await expect(service.dispatchPersisted(payload, 'logical-event-1')) + .rejects.toThrow(/unknown persisted webhook event/i); + expect(eventEmitter.emitAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/webhook/webhook.service.ts b/apps/api/src/modules/webhook/webhook.service.ts index 07c18d35..cfda8f32 100644 --- a/apps/api/src/modules/webhook/webhook.service.ts +++ b/apps/api/src/modules/webhook/webhook.service.ts @@ -1,16 +1,17 @@ import { Injectable, Inject } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { auditLogs } from '@telivityhaip/database'; -import { type WebhookEvent } from '@telivityhaip/shared'; +import { WEBHOOK_EVENTS, type WebhookEvent } from '@telivityhaip/shared'; import { DRIZZLE } from '../../database/database.module'; export interface WebhookPayload { - event: string; + event: WebhookEvent; entityType: string; entityId: string; propertyId?: string; data: Record; timestamp: string; + logicalEventId?: string; } @Injectable() @@ -20,6 +21,24 @@ export class WebhookService { private readonly eventEmitter: EventEmitter2, ) {} + /** + * Dispatch a payload whose audit/outbox records were already committed by + * the domain transaction. Async listeners are awaited so a durable caller + * can retain and retry its pending consequence on delivery failure. + */ + async dispatchPersisted( + payload: WebhookPayload, + logicalEventId: string, + ): Promise { + if (!Object.hasOwn(WEBHOOK_EVENTS, payload.event)) { + throw new Error(`Unknown persisted webhook event: ${String(payload.event)}`); + } + await this.eventEmitter.emitAsync(payload.event, { + ...payload, + logicalEventId, + } satisfies WebhookPayload); + } + /** * Emit a webhook event and log it to the audit trail. */ diff --git a/apps/api/src/release-smoke.integration.spec.ts b/apps/api/src/release-smoke.integration.spec.ts index 9fe9ed59..aa6d4014 100644 --- a/apps/api/src/release-smoke.integration.spec.ts +++ b/apps/api/src/release-smoke.integration.spec.ts @@ -43,7 +43,7 @@ describe.runIf(runSmoke)('release smoke (migrate → reservation lifecycle)', () process.env.REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379'; const root = join(__dirname, '..', '..', '..'); - execSync('node packages/database/dist/push-schema.js', { + execSync('node packages/database/dist/run-migrations.js', { cwd: root, env: { ...process.env, DATABASE_URL: dbUrl }, stdio: 'inherit', diff --git a/docker-compose.yml b/docker-compose.yml index d770f6f9..7a0022f7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -73,7 +73,7 @@ services: dockerfile: apps/api/Dockerfile image: haip-api container_name: haip-init - command: sh -c "node packages/database/dist/push-schema.js && node packages/database/dist/seed.js" + command: sh -c "node packages/database/dist/run-migrations.js && node packages/database/dist/seed.js" environment: DATABASE_URL: postgresql://haip:haip@postgres:5432/haip depends_on: diff --git a/docs/test-stats.json b/docs/test-stats.json index 8f0b7de6..13c71e8a 100644 --- a/docs/test-stats.json +++ b/docs/test-stats.json @@ -1,5 +1,5 @@ { - "tests": 1591, - "files": 220, - "updatedAt": "2026-08-27T08:48:14.423Z" + "tests": 1598, + "files": 221, + "updatedAt": "2026-08-27T09:12:56.919Z" } diff --git a/docs/webhooks.md b/docs/webhooks.md index dd1f85f3..011c231f 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -52,7 +52,7 @@ Each matching event is POSTed to your `callbackUrl`: POST Content-Type: application/json X-HAIP-Signature: sha256= -X-HAIP-Event-Id: +X-HAIP-Event-Id: X-HAIP-Event-Type: reservation.checked_in ``` diff --git a/packages/database/package.json b/packages/database/package.json index 3399c590..be130b82 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -42,9 +42,9 @@ "test:coverage": "vitest run --coverage", "seed": "tsx src/seed.ts", "link-integration": "tsx src/link-integration-principal.ts", - "migrate": "tsx src/push-schema.ts", + "migrate": "tsx src/run-migrations.ts", "db:generate": "drizzle-kit generate", - "db:migrate": "tsx src/push-schema.ts", + "db:migrate": "tsx src/run-migrations.ts", "db:studio": "drizzle-kit studio" }, "dependencies": { diff --git a/packages/database/src/migration-runner.spec.ts b/packages/database/src/migration-runner.spec.ts new file mode 100644 index 00000000..4c3196bc --- /dev/null +++ b/packages/database/src/migration-runner.spec.ts @@ -0,0 +1,236 @@ +/** + * PostgreSQL integration tests for the numbered SQL migration runner (0022+). + * + * Uses ephemeral databases when Postgres is reachable (haip_test pattern). + */ +import { randomBytes } from 'node:crypto'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import postgres from 'postgres'; +import { postgresOptionsFromEnv } from './postgres-options.js'; +import { pushSchema } from './push-schema.js'; +import { + FIRST_TRACKED_MIGRATION_VERSION, + getAppliedMigrationVersions, + listPendingMigrations, + resolveMigrationsDirectory, + runAllMigrations, + runPendingSqlMigrations, +} from './migration-runner.js'; + +const DEFAULT_DATABASE_URL = + process.env['DATABASE_URL'] ?? 'postgresql://haip:haip@localhost:5432/haip_test'; + +const ADMIN_DATABASE_URL = DEFAULT_DATABASE_URL.replace(/\/[^/]+$/, '/postgres'); + +const MIGRATIONS_DIR = resolveMigrationsDirectory( + dirname(fileURLToPath(import.meta.url)), +); + +async function postgresReachable(url: string): Promise { + const client = postgres(url, { ...postgresOptionsFromEnv(), max: 1 }); + try { + await client`SELECT 1`; + return true; + } catch { + return false; + } finally { + await client.end(); + } +} + +async function withEphemeralDatabase(run: (databaseUrl: string) => Promise): Promise { + const dbName = `haip_mig_${randomBytes(6).toString('hex')}`; + const admin = postgres(ADMIN_DATABASE_URL, { ...postgresOptionsFromEnv(), max: 1 }); + const databaseUrl = DEFAULT_DATABASE_URL.replace(/\/[^/]+$/, `/${dbName}`); + + await admin.unsafe(`CREATE DATABASE "${dbName}"`); + try { + await run(databaseUrl); + } finally { + await admin.unsafe(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`); + await admin.end(); + } +} + +async function columnExists(sql: postgres.Sql, column: string): Promise { + const rows = await sql<{ exists: boolean }[]>` + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'webhook_deliveries' + AND column_name = ${column} + ) AS exists + `; + return rows[0]?.exists ?? false; +} + +async function uniqueIndexExists(sql: postgres.Sql, indexName: string): Promise { + const rows = await sql<{ exists: boolean }[]>` + SELECT EXISTS ( + SELECT 1 + FROM pg_indexes + WHERE schemaname = 'public' + AND indexname = ${indexName} + ) AS exists + `; + return rows[0]?.exists ?? false; +} + +async function seedWebhookDedupFixtures(sql: postgres.Sql) { + const propertyId = 'f3500001-0000-4000-a000-000000000001'; + const subscriptionId = 'f3500002-0000-4000-a000-000000000002'; + const logicalEventId = 'f3500003-0000-4000-a000-000000000003'; + + await sql` + INSERT INTO properties ( + id, name, code, country_code, timezone, currency_code, total_rooms + ) VALUES ( + ${propertyId}, + 'Migration test property', + ${`M350${randomBytes(2).toString('hex').toUpperCase()}`}, + 'US', + 'America/New_York', + 'USD', + 10 + ) + `; + + await sql` + INSERT INTO agent_webhook_subscriptions ( + id, property_id, subscriber_id, callback_url, events + ) VALUES ( + ${subscriptionId}, + ${propertyId}, + 'migration-test-subscriber', + 'https://example.com/webhooks', + ${JSON.stringify(['reservation.created'])} + ) + `; + + return { propertyId, subscriptionId, logicalEventId }; +} + +const postgresReady = await postgresReachable(DEFAULT_DATABASE_URL); + +describe.skipIf(!postgresReady)('migration runner (PostgreSQL)', () => { + beforeAll(async () => { + if (!postgresReady) return; + const canCreateDb = await postgresReachable(ADMIN_DATABASE_URL); + if (!canCreateDb) { + throw new Error('Postgres admin connection required for ephemeral migration tests'); + } + }); + + it('applies 0022 on a fresh database', async () => { + await withEphemeralDatabase(async (databaseUrl) => { + await runAllMigrations(databaseUrl, { migrationsDir: MIGRATIONS_DIR }); + + const sql = postgres(databaseUrl, postgresOptionsFromEnv()); + try { + expect(await columnExists(sql, 'logical_event_id')).toBe(true); + expect(await uniqueIndexExists( + sql, + 'webhook_deliveries_property_subscription_logical_event_unique', + )).toBe(true); + + const applied = await getAppliedMigrationVersions(sql); + expect(applied.has(FIRST_TRACKED_MIGRATION_VERSION)).toBe(true); + } finally { + await sql.end(); + } + }); + }); + + it('upgrades an existing pre-0022 database (push-schema baseline only)', async () => { + await withEphemeralDatabase(async (databaseUrl) => { + await pushSchema(databaseUrl); + + const sql = postgres(databaseUrl, postgresOptionsFromEnv()); + try { + expect(await columnExists(sql, 'logical_event_id')).toBe(false); + expect(await getAppliedMigrationVersions(sql).then((v) => v.size)).toBe(0); + + const pendingBefore = await listPendingMigrations(sql, MIGRATIONS_DIR); + expect(pendingBefore.some((m) => m.version === FIRST_TRACKED_MIGRATION_VERSION)).toBe(true); + + await runPendingSqlMigrations(sql, MIGRATIONS_DIR); + + expect(await columnExists(sql, 'logical_event_id')).toBe(true); + expect(await uniqueIndexExists( + sql, + 'webhook_deliveries_property_subscription_logical_event_unique', + )).toBe(true); + } finally { + await sql.end(); + } + }); + }); + + it('skips already-applied migrations when run twice (ledger idempotency)', async () => { + await withEphemeralDatabase(async (databaseUrl) => { + await runAllMigrations(databaseUrl, { migrationsDir: MIGRATIONS_DIR }); + + const sql = postgres(databaseUrl, postgresOptionsFromEnv()); + try { + const firstApplied = await getAppliedMigrationVersions(sql); + expect(firstApplied.has(FIRST_TRACKED_MIGRATION_VERSION)).toBe(true); + + const secondPass = await runPendingSqlMigrations(sql, MIGRATIONS_DIR); + expect(secondPass).toEqual([]); + + const appliedAfter = await getAppliedMigrationVersions(sql); + expect(appliedAfter.size).toBe(firstApplied.size); + } finally { + await sql.end(); + } + }); + }); + + it('rejects duplicate (property_id, subscription_id, logical_event_id) rows', async () => { + await withEphemeralDatabase(async (databaseUrl) => { + await runAllMigrations(databaseUrl, { migrationsDir: MIGRATIONS_DIR }); + + const sql = postgres(databaseUrl, postgresOptionsFromEnv()); + try { + const { propertyId, subscriptionId, logicalEventId } = await seedWebhookDedupFixtures(sql); + const payload = JSON.stringify({ eventType: 'reservation.created' }); + + await sql` + INSERT INTO webhook_deliveries ( + property_id, subscription_id, logical_event_id, event_type, payload + ) VALUES ( + ${propertyId}, + ${subscriptionId}, + ${logicalEventId}, + 'reservation.created', + ${payload}::jsonb + ) + `; + + await expect(sql` + INSERT INTO webhook_deliveries ( + property_id, subscription_id, logical_event_id, event_type, payload + ) VALUES ( + ${propertyId}, + ${subscriptionId}, + ${logicalEventId}, + 'reservation.created', + ${payload}::jsonb + ) + `).rejects.toMatchObject({ code: '23505' }); + } finally { + await sql.end(); + } + }); + }); +}); + +describe('migration runner (unit)', () => { + it('resolves migrations adjacent to the compiled entry directory', () => { + const dir = resolveMigrationsDirectory(join(dirname(fileURLToPath(import.meta.url)))); + expect(dir.endsWith(`${join('src', 'migrations')}`) || dir.endsWith(`${join('dist', 'migrations')}`)).toBe(true); + }); +}); diff --git a/packages/database/src/migration-runner.ts b/packages/database/src/migration-runner.ts new file mode 100644 index 00000000..3e5f9d41 --- /dev/null +++ b/packages/database/src/migration-runner.ts @@ -0,0 +1,139 @@ +/** + * Numbered SQL migration runner for schema changes from 0022 onward. + * + * Migrations 0001–0021 are incorporated idempotently by push-schema.ts (legacy + * baseline). This runner records applied versions in schema_migrations and + * executes each new .sql file once, inside a transaction. + */ +import { readdir, readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type postgres from 'postgres'; +import { pushSchema } from './push-schema.js'; + +/** Last migration version baked into push-schema.ts (baseline, not re-run). */ +export const BASELINE_MIGRATION_VERSION = 21; + +/** First version executed by this runner. */ +export const FIRST_TRACKED_MIGRATION_VERSION = BASELINE_MIGRATION_VERSION + 1; + +const MIGRATION_FILENAME_RE = /^(\d{4})_.+\.sql$/; + +export type MigrationFile = { + version: number; + filename: string; + path: string; +}; + +export function parseMigrationFilename(filename: string): number | null { + const match = MIGRATION_FILENAME_RE.exec(filename); + if (!match) return null; + return Number.parseInt(match[1]!, 10); +} + +export function resolveMigrationsDirectory(entryDir = dirname(fileURLToPath(import.meta.url))): string { + return join(entryDir, 'migrations'); +} + +export async function listTrackedMigrationFiles(migrationsDir: string): Promise { + const entries = await readdir(migrationsDir); + const files: MigrationFile[] = []; + + for (const filename of entries) { + const version = parseMigrationFilename(filename); + if (version === null || version < FIRST_TRACKED_MIGRATION_VERSION) continue; + files.push({ + version, + filename, + path: join(migrationsDir, filename), + }); + } + + files.sort((a, b) => a.version - b.version); + return files; +} + +export async function ensureMigrationLedger(sql: postgres.Sql): Promise { + await sql` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version integer PRIMARY KEY, + filename varchar(255) NOT NULL, + applied_at timestamptz NOT NULL DEFAULT now() + ) + `; +} + +export async function getAppliedMigrationVersions(sql: postgres.Sql): Promise> { + await ensureMigrationLedger(sql); + const rows = await sql<{ version: number }[]>` + SELECT version FROM schema_migrations ORDER BY version + `; + return new Set(rows.map((row) => row.version)); +} + +export async function runPendingSqlMigrations( + sql: postgres.Sql, + migrationsDir: string, +): Promise { + const pending = await listPendingMigrations(sql, migrationsDir); + const applied: string[] = []; + + for (const migration of pending) { + const body = await readFile(migration.path, 'utf8'); + await sql.begin(async (tx) => { + await tx.unsafe(body); + await tx` + INSERT INTO schema_migrations (version, filename) + VALUES (${migration.version}, ${migration.filename}) + `; + }); + applied.push(migration.filename); + console.log(`Applied migration ${migration.filename}`); + } + + return applied; +} + +export async function listPendingMigrations( + sql: postgres.Sql, + migrationsDir: string, +): Promise { + const applied = await getAppliedMigrationVersions(sql); + const files = await listTrackedMigrationFiles(migrationsDir); + return files.filter((file) => !applied.has(file.version)); +} + +/** + * Push legacy baseline schema, then apply tracked SQL migrations (0022+). + */ +export async function runAllMigrations( + databaseUrl: string, + options: { + migrationsDir?: string; + skipPushSchema?: boolean; + } = {}, +): Promise { + const postgresModule = await import('postgres'); + const defaultPostgres = postgresModule.default; + const { postgresOptionsFromEnv } = await import('./postgres-options.js'); + + const migrationsDir = options.migrationsDir ?? resolveMigrationsDirectory(); + + if (!options.skipPushSchema) { + console.log('Pushing baseline schema (migrations 0001–0021 via push-schema)...'); + await pushSchema(databaseUrl); + } + + const sql = defaultPostgres(databaseUrl, postgresOptionsFromEnv()); + + try { + const applied = await runPendingSqlMigrations(sql, migrationsDir); + if (applied.length === 0) { + console.log('No pending SQL migrations.'); + } else { + console.log(`SQL migrations applied: ${applied.join(', ')}`); + } + } finally { + await sql.end(); + } +} diff --git a/packages/database/src/migrations/0022_webhook_logical_event_id.sql b/packages/database/src/migrations/0022_webhook_logical_event_id.sql new file mode 100644 index 00000000..3a9900fe --- /dev/null +++ b/packages/database/src/migrations/0022_webhook_logical_event_id.sql @@ -0,0 +1,6 @@ +-- Webhook delivery deduplication by logical event id (crash-safe re-enqueue). +ALTER TABLE webhook_deliveries + ADD COLUMN IF NOT EXISTS logical_event_id uuid; + +CREATE UNIQUE INDEX IF NOT EXISTS webhook_deliveries_property_subscription_logical_event_unique + ON webhook_deliveries (property_id, subscription_id, logical_event_id); diff --git a/packages/database/src/push-schema.ts b/packages/database/src/push-schema.ts index 3fcf7950..5be679c2 100644 --- a/packages/database/src/push-schema.ts +++ b/packages/database/src/push-schema.ts @@ -2,6 +2,7 @@ * Push schema to database using drizzle-orm's migrate API. * Workaround for drizzle-kit CJS/.js extension issue. */ +import { fileURLToPath } from 'node:url'; import postgres from 'postgres'; import { drizzle } from 'drizzle-orm/postgres-js'; import { sql } from 'drizzle-orm'; @@ -12,8 +13,8 @@ import { postgresOptionsFromEnv } from './postgres-options.js'; const DATABASE_URL = process.env['DATABASE_URL'] ?? 'postgresql://haip:haip@localhost:5432/haip'; -async function main() { - const client = postgres(DATABASE_URL, postgresOptionsFromEnv()); +export async function pushSchema(databaseUrl: string = DATABASE_URL) { + const client = postgres(databaseUrl, postgresOptionsFromEnv()); const db = drizzle(client, { schema }); // Create enums @@ -1481,7 +1482,13 @@ async function main() { await client.end(); } -main().catch((err) => { - console.error('Push failed:', err); - process.exit(1); -}); +async function main() { + await pushSchema(); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((err) => { + console.error('Push failed:', err); + process.exit(1); + }); +} diff --git a/packages/database/src/run-migrations.ts b/packages/database/src/run-migrations.ts new file mode 100644 index 00000000..71884144 --- /dev/null +++ b/packages/database/src/run-migrations.ts @@ -0,0 +1,12 @@ +/** + * HAIP database migrate entrypoint — baseline push-schema + tracked SQL (0022+). + */ +import { runAllMigrations } from './migration-runner.js'; + +const DATABASE_URL = + process.env['DATABASE_URL'] ?? 'postgresql://haip:haip@localhost:5432/haip'; + +runAllMigrations(DATABASE_URL).catch((err) => { + console.error('Migration failed:', err); + process.exit(1); +}); diff --git a/packages/database/src/schema/connect.ts b/packages/database/src/schema/connect.ts index 180cfc72..5b8f5520 100644 --- a/packages/database/src/schema/connect.ts +++ b/packages/database/src/schema/connect.ts @@ -1,4 +1,15 @@ -import { pgTable, uuid, varchar, boolean, timestamp, jsonb, integer, text, pgEnum } from 'drizzle-orm/pg-core'; +import { + pgTable, + uuid, + varchar, + boolean, + timestamp, + jsonb, + integer, + text, + pgEnum, + uniqueIndex, +} from 'drizzle-orm/pg-core'; import { properties } from './property.js'; /** @@ -46,6 +57,7 @@ export const webhookDeliveries = pgTable('webhook_deliveries', { id: uuid('id').primaryKey().defaultRandom(), propertyId: uuid('property_id').notNull().references(() => properties.id), subscriptionId: uuid('subscription_id').notNull().references(() => agentWebhookSubscriptions.id), + logicalEventId: uuid('logical_event_id'), eventType: varchar('event_type', { length: 100 }).notNull(), payload: jsonb('payload').notNull(), @@ -59,7 +71,11 @@ export const webhookDeliveries = pgTable('webhook_deliveries', { createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), deliveredAt: timestamp('delivered_at', { withTimezone: true }), -}); +}, (table) => ({ + propertySubscriptionLogicalEventUnique: + uniqueIndex('webhook_deliveries_property_subscription_logical_event_unique') + .on(table.propertyId, table.subscriptionId, table.logicalEventId), +})); /** * Connect API credentials — tenant-bound API keys for the /api/v1/connect/* surface. diff --git a/packages/database/tsup.config.ts b/packages/database/tsup.config.ts index 58332f86..d4adcb64 100644 --- a/packages/database/tsup.config.ts +++ b/packages/database/tsup.config.ts @@ -1,11 +1,21 @@ import { defineConfig } from 'tsup'; +import { cpSync } from 'node:fs'; export default defineConfig({ - // push-schema and seed are emitted as runnable scripts so the production - // Docker image (which ships only dist/, no tsx) can migrate+seed via `node`. - entry: ['src/index.ts', 'src/schema/index.ts', 'src/push-schema.ts', 'src/seed.ts'], + // push-schema, run-migrations, and seed are emitted as runnable scripts so the + // production Docker image (which ships only dist/, no tsx) can migrate+seed via `node`. + entry: [ + 'src/index.ts', + 'src/schema/index.ts', + 'src/push-schema.ts', + 'src/run-migrations.ts', + 'src/seed.ts', + ], format: ['esm', 'cjs'], dts: true, clean: true, sourcemap: true, + onSuccess: async () => { + cpSync('src/migrations', 'dist/migrations', { recursive: true }); + }, }); diff --git a/render.yaml b/render.yaml index cbeb6f63..7f454972 100644 --- a/render.yaml +++ b/render.yaml @@ -31,7 +31,7 @@ services: # Push schema + seed the demo hotel before each deploy goes live. # Idempotent (schema IF NOT EXISTS; seed skips if property 'TGH' exists), # so redeploys are safe. Runs in the built image (compiled dist, no tsx). - preDeployCommand: 'node packages/database/dist/push-schema.js && node packages/database/dist/seed.js' + preDeployCommand: 'node packages/database/dist/run-migrations.js && node packages/database/dist/seed.js' envVars: - key: NODE_ENV value: production