diff --git a/.env.example b/.env.example index 053cc61c4..cb9eefcbc 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,12 @@ REVIEW_SERVICE_PORT=4104 CALENDAR_SERVICE_PORT=4106 INTEGRATION_SERVICE_PORT=4107 DATABASE_URL=postgresql://lifeos:lifeos@postgres:5432/lifeos +NOTIFICATION_DATABASE_URL=postgresql://lifeos:lifeos@postgres:5432/lifeos +NOTIFICATION_DATABASE_POOL_MAX=10 +NOTIFICATION_DATABASE_CONNECT_TIMEOUT_MS=5000 +NOTIFICATION_DATABASE_IDLE_TIMEOUT_MS=30000 +NOTIFICATION_CLAIM_LEASE_SECONDS=300 +NOTIFICATION_REMINDER_BATCH_SIZE=50 NATS_URL=nats://nats:4222 CORS_ALLOWED_ORIGINS=http://localhost:3000 IDENTITY_SERVICE_ORIGIN=http://127.0.0.1:4101 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f822db9d..4ccaf379f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,7 @@ jobs: IDENTITY_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test PLANNING_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test HABIT_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test + NOTIFICATION_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test services: postgres: image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0777afe58..e4b2acc69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,17 @@ All notable changes to LifeOS are documented in this file. - An accessible quick-capture and search surface that keeps browser-local Today drafts visibly separate from durable workspace records. - Complete English and Korean message catalogs, a persisted keyboard-operable language selector, localized live-region announcements, and accessibility browser journeys for the Today action loop. - A bounded notification scheduler with IANA time-zone quiet hours, per-local-day fatigue limits, tenant-scoped atomic claims, idempotent delivery keys, and credential-free retry outcomes. +- Durable PostgreSQL reminder occurrences, expiring worker claims, immutable scheduler outcomes, and an idempotent in-app inbox in the independent `notification_service` schema. +- A bounded notification runtime that composes one PostgreSQL pool, the reminder repository, the in-app gateway, and the scheduler with exactly-once pool shutdown. ### Fixed - Planning search now normalizes browser query text and prevents stale or unmounted requests from replacing the latest visible result state. - Reminder fatigue deferral now crosses long IANA offset fallbacks and next-day quiet hours without abandoning the claimed occurrence. +- Notification workers now recover expired claims and exact delivery replays without creating duplicate inbox messages. +- Notification batches now isolate delivery-count persistence failures, issue a distinct token for each claim attempt, share concurrent shutdown work, and emit bounded credential-free PostgreSQL failure classifications. ### Security - Planning-search upstream responses are stopped at a fixed byte limit before they can be fully buffered by the web boundary. +- Notification persistence stores SHA-256 idempotency digests instead of raw delivery keys, validates every untrusted row, and keeps all SQL tenant-scoped and parameterized. diff --git a/apps/notification-service/migrations/0001_durable_reminder_inbox.sql b/apps/notification-service/migrations/0001_durable_reminder_inbox.sql new file mode 100644 index 000000000..a83856c5f --- /dev/null +++ b/apps/notification-service/migrations/0001_durable_reminder_inbox.sql @@ -0,0 +1,269 @@ +CREATE SCHEMA IF NOT EXISTS notification_service; + +CREATE TABLE IF NOT EXISTS notification_service.reminder_occurrences ( + reminder_id uuid NOT NULL, + workspace_id uuid NOT NULL, + reminder_title text NOT NULL, + due_instant timestamptz NOT NULL, + time_zone text NOT NULL, + quiet_start_minute smallint, + quiet_end_minute smallint, + daily_delivery_limit smallint NOT NULL, + delivery_attempt_count smallint NOT NULL DEFAULT 0, + occurrence_status text NOT NULL DEFAULT 'pending', + claim_key_hash bytea, + claim_expires_at timestamptz, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp(), + CONSTRAINT reminder_occurrences_primary_key PRIMARY KEY (reminder_id), + CONSTRAINT reminder_occurrences_workspace_reminder_unique + UNIQUE (workspace_id, reminder_id), + CONSTRAINT reminder_occurrences_id_uuid_v4 CHECK ( + get_byte(uuid_send(reminder_id), 6) >> 4 = 4 + AND get_byte(uuid_send(reminder_id), 8) >> 6 = 2 + ), + CONSTRAINT reminder_occurrences_workspace_uuid_v4 CHECK ( + get_byte(uuid_send(workspace_id), 6) >> 4 = 4 + AND get_byte(uuid_send(workspace_id), 8) >> 6 = 2 + ), + CONSTRAINT reminder_occurrences_title_bounds CHECK ( + char_length(reminder_title) BETWEEN 1 AND 160 + AND octet_length(reminder_title) <= 1024 + AND reminder_title = btrim(reminder_title) + AND reminder_title !~ '[[:cntrl:]]' + ), + CONSTRAINT reminder_occurrences_timezone_bounds CHECK ( + char_length(time_zone) BETWEEN 1 AND 64 + AND octet_length(time_zone) <= 256 + AND time_zone = btrim(time_zone) + AND time_zone !~ '[[:cntrl:]]' + ), + CONSTRAINT reminder_occurrences_quiet_pair CHECK ( + (quiet_start_minute IS NULL AND quiet_end_minute IS NULL) + OR ( + quiet_start_minute BETWEEN 0 AND 1439 + AND quiet_end_minute BETWEEN 0 AND 1439 + AND quiet_start_minute <> quiet_end_minute + ) + ), + CONSTRAINT reminder_occurrences_daily_limit CHECK ( + daily_delivery_limit BETWEEN 1 AND 20 + ), + CONSTRAINT reminder_occurrences_attempt_limit CHECK ( + delivery_attempt_count BETWEEN 0 AND 3 + ), + CONSTRAINT reminder_occurrences_status_values CHECK ( + occurrence_status IN ('pending', 'delivered', 'failed') + ), + CONSTRAINT reminder_occurrences_claim_pair CHECK ( + (claim_key_hash IS NULL AND claim_expires_at IS NULL) + OR ( + claim_key_hash IS NOT NULL + AND claim_expires_at IS NOT NULL + AND octet_length(claim_key_hash) = 32 + ) + ), + CONSTRAINT reminder_occurrences_terminal_claim CHECK ( + occurrence_status = 'pending' + OR (claim_key_hash IS NOT NULL AND claim_expires_at IS NOT NULL) + ), + CONSTRAINT reminder_occurrences_timestamp_order CHECK ( + updated_at >= created_at + ) +); + +CREATE INDEX IF NOT EXISTS reminder_occurrences_due_index + ON notification_service.reminder_occurrences ( + due_instant ASC, + reminder_id ASC + ) + WHERE occurrence_status = 'pending'; + +CREATE INDEX IF NOT EXISTS reminder_occurrences_claim_expiry_index + ON notification_service.reminder_occurrences ( + claim_expires_at ASC, + workspace_id ASC, + reminder_id ASC + ) + WHERE occurrence_status = 'pending' AND claim_key_hash IS NOT NULL; + +CREATE INDEX IF NOT EXISTS reminder_occurrences_workspace_index + ON notification_service.reminder_occurrences ( + workspace_id, + created_at DESC, + reminder_id ASC + ); + +CREATE TABLE IF NOT EXISTS notification_service.reminder_outcomes ( + outcome_id uuid NOT NULL, + workspace_id uuid NOT NULL, + reminder_id uuid NOT NULL, + outcome_kind text NOT NULL, + occurred_at timestamptz NOT NULL, + next_attempt_at timestamptz, + outcome_reason text, + idempotency_key_hash bytea NOT NULL, + delivery_local_date date, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + CONSTRAINT reminder_outcomes_primary_key PRIMARY KEY (outcome_id), + CONSTRAINT reminder_outcomes_id_uuid_v4 CHECK ( + get_byte(uuid_send(outcome_id), 6) >> 4 = 4 + AND get_byte(uuid_send(outcome_id), 8) >> 6 = 2 + ), + CONSTRAINT reminder_outcomes_workspace_uuid_v4 CHECK ( + get_byte(uuid_send(workspace_id), 6) >> 4 = 4 + AND get_byte(uuid_send(workspace_id), 8) >> 6 = 2 + ), + CONSTRAINT reminder_outcomes_reminder_uuid_v4 CHECK ( + get_byte(uuid_send(reminder_id), 6) >> 4 = 4 + AND get_byte(uuid_send(reminder_id), 8) >> 6 = 2 + ), + CONSTRAINT reminder_outcomes_kind_values CHECK ( + outcome_kind IN ('delivered', 'deferred', 'failed') + ), + CONSTRAINT reminder_outcomes_reason_values CHECK ( + outcome_reason IS NULL + OR outcome_reason IN ( + 'quiet_hours', + 'daily_limit', + 'delivery_failed', + 'attempt_limit' + ) + ), + CONSTRAINT reminder_outcomes_hash_length CHECK ( + octet_length(idempotency_key_hash) = 32 + ), + CONSTRAINT reminder_outcomes_state_consistency CHECK ( + ( + outcome_kind = 'delivered' + AND outcome_reason IS NULL + AND next_attempt_at IS NULL + AND delivery_local_date IS NOT NULL + ) + OR ( + outcome_kind = 'deferred' + AND outcome_reason IN ('quiet_hours', 'daily_limit') + AND next_attempt_at IS NOT NULL + AND delivery_local_date IS NULL + ) + OR ( + outcome_kind = 'failed' + AND delivery_local_date IS NULL + AND ( + ( + outcome_reason = 'delivery_failed' + AND next_attempt_at IS NOT NULL + ) + OR ( + outcome_reason = 'attempt_limit' + AND next_attempt_at IS NULL + ) + ) + ) + ), + CONSTRAINT reminder_outcomes_occurrence_foreign_key + FOREIGN KEY (workspace_id, reminder_id) + REFERENCES notification_service.reminder_occurrences (workspace_id, reminder_id) + ON DELETE RESTRICT, + CONSTRAINT reminder_outcomes_idempotency_unique + UNIQUE (workspace_id, idempotency_key_hash, outcome_kind) +); + +CREATE INDEX IF NOT EXISTS reminder_outcomes_workspace_index + ON notification_service.reminder_outcomes ( + workspace_id, + occurred_at DESC, + outcome_id ASC + ); + +CREATE INDEX IF NOT EXISTS reminder_outcomes_delivery_date_index + ON notification_service.reminder_outcomes ( + workspace_id, + delivery_local_date + ) + WHERE outcome_kind = 'delivered'; + +CREATE TABLE IF NOT EXISTS notification_service.inbox_messages ( + message_id uuid NOT NULL, + workspace_id uuid NOT NULL, + reminder_id uuid NOT NULL, + message_title text NOT NULL, + due_instant timestamptz NOT NULL, + time_zone text NOT NULL, + idempotency_key_hash bytea NOT NULL, + delivered_at timestamptz NOT NULL, + read_at timestamptz, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp(), + CONSTRAINT inbox_messages_primary_key PRIMARY KEY (message_id), + CONSTRAINT inbox_messages_id_uuid_v4 CHECK ( + get_byte(uuid_send(message_id), 6) >> 4 = 4 + AND get_byte(uuid_send(message_id), 8) >> 6 = 2 + ), + CONSTRAINT inbox_messages_workspace_uuid_v4 CHECK ( + get_byte(uuid_send(workspace_id), 6) >> 4 = 4 + AND get_byte(uuid_send(workspace_id), 8) >> 6 = 2 + ), + CONSTRAINT inbox_messages_reminder_uuid_v4 CHECK ( + get_byte(uuid_send(reminder_id), 6) >> 4 = 4 + AND get_byte(uuid_send(reminder_id), 8) >> 6 = 2 + ), + CONSTRAINT inbox_messages_title_bounds CHECK ( + char_length(message_title) BETWEEN 1 AND 160 + AND octet_length(message_title) <= 1024 + AND message_title = btrim(message_title) + AND message_title !~ '[[:cntrl:]]' + ), + CONSTRAINT inbox_messages_timezone_bounds CHECK ( + char_length(time_zone) BETWEEN 1 AND 64 + AND octet_length(time_zone) <= 256 + AND time_zone = btrim(time_zone) + AND time_zone !~ '[[:cntrl:]]' + ), + CONSTRAINT inbox_messages_hash_length CHECK ( + octet_length(idempotency_key_hash) = 32 + ), + CONSTRAINT inbox_messages_read_order CHECK ( + read_at IS NULL OR read_at >= delivered_at + ), + CONSTRAINT inbox_messages_timestamp_order CHECK ( + updated_at >= created_at + ), + CONSTRAINT inbox_messages_occurrence_foreign_key + FOREIGN KEY (workspace_id, reminder_id) + REFERENCES notification_service.reminder_occurrences (workspace_id, reminder_id) + ON DELETE RESTRICT, + CONSTRAINT inbox_messages_idempotency_unique + UNIQUE (workspace_id, idempotency_key_hash) +); + +CREATE INDEX IF NOT EXISTS inbox_messages_workspace_index + ON notification_service.inbox_messages ( + workspace_id, + delivered_at DESC, + message_id ASC + ); + +CREATE OR REPLACE FUNCTION notification_service.reject_reminder_outcome_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION 'reminder outcomes are immutable' + USING ERRCODE = '55000'; +END; +$$; + +DROP TRIGGER IF EXISTS reminder_outcomes_row_mutation_guard + ON notification_service.reminder_outcomes; +CREATE TRIGGER reminder_outcomes_row_mutation_guard +BEFORE UPDATE OR DELETE ON notification_service.reminder_outcomes +FOR EACH ROW +EXECUTE FUNCTION notification_service.reject_reminder_outcome_mutation(); + +DROP TRIGGER IF EXISTS reminder_outcomes_truncate_guard + ON notification_service.reminder_outcomes; +CREATE TRIGGER reminder_outcomes_truncate_guard +BEFORE TRUNCATE ON notification_service.reminder_outcomes +FOR EACH STATEMENT +EXECUTE FUNCTION notification_service.reject_reminder_outcome_mutation(); diff --git a/apps/notification-service/package.json b/apps/notification-service/package.json index 894c225c4..361bc6de9 100644 --- a/apps/notification-service/package.json +++ b/apps/notification-service/package.json @@ -6,11 +6,18 @@ "scripts": { "build": "tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch", - "lint": "tsc --noEmit && prettier --single-quote --check package.json tsconfig.json src/main.ts src/reminder-scheduler.ts src/reminder-scheduler.test.ts src/reminder-scheduler.integration.test.ts ../../docs/superpowers/plans/2026-08-04-bounded-reminder-scheduler-slice.md", - "test": "vitest run --no-file-parallelism", + "lint": "tsc --noEmit && prettier --single-quote --check package.json tsconfig.json vitest.config.ts \"src/**/*.ts\" ../../CHANGELOG.md ../../docs/operations/notification-persistence.md ../../docs/superpowers/specs/2026-08-04-notification-postgres-inbox-design.md \"../../docs/superpowers/plans/2026-08-04-*.md\"", + "test": "vitest run --no-file-parallelism --coverage", "typecheck": "tsc --noEmit" }, + "dependencies": { + "@nestjs/common": "^11.1.6", + "pg": "^8.22.0" + }, "devDependencies": { + "@types/node": "^24.3.0", + "@types/pg": "^8.20.0", + "@vitest/coverage-v8": "^3.2.4", "typescript": "^5.9.2", "vitest": "^3.2.4" } diff --git a/apps/notification-service/src/docstring-coverage.test.ts b/apps/notification-service/src/docstring-coverage.test.ts new file mode 100644 index 000000000..e3d9ec888 --- /dev/null +++ b/apps/notification-service/src/docstring-coverage.test.ts @@ -0,0 +1,153 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import * as ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +interface UndocumentedDeclaration { + readonly file: string; + readonly line: number; + readonly declaration: string; +} + +function hasJSDoc(node: ts.Node, sourceFile: ts.SourceFile): boolean { + const leadingTrivia = sourceFile.text.slice( + node.getFullStart(), + node.getStart(sourceFile), + ); + return /\/\*\*[\s\S]*?\*\/\s*$/u.test(leadingTrivia); +} + +function documentationOwner(node: ts.Node): ts.Node { + if ( + ts.isVariableDeclaration(node) && + ts.isVariableDeclarationList(node.parent) && + ts.isVariableStatement(node.parent.parent) + ) { + return node.parent.parent; + } + return node; +} + +function hasCallableInitializer( + node: ts.VariableDeclaration | ts.PropertyDeclaration, +): boolean { + return ( + node.initializer !== undefined && + (ts.isArrowFunction(node.initializer) || + ts.isFunctionExpression(node.initializer)) + ); +} + +function declarationName(node: ts.Node): string { + if (ts.isConstructorDeclaration(node)) { + const parent = node.parent; + return ts.isClassDeclaration(parent) && parent.name + ? `${parent.name.text}.constructor` + : 'constructor'; + } + if ( + ts.isVariableDeclaration(node) || + ts.isPropertyDeclaration(node) || + ts.isFunctionDeclaration(node) || + ts.isClassDeclaration(node) || + ts.isInterfaceDeclaration(node) || + ts.isTypeAliasDeclaration(node) || + ts.isMethodDeclaration(node) || + ts.isMethodSignature(node) + ) { + return node.name?.getText() ?? node.kind.toString(); + } + return node.kind.toString(); +} + +function requiresJSDoc(node: ts.Node): boolean { + return ( + ts.isFunctionDeclaration(node) || + ts.isClassDeclaration(node) || + ts.isInterfaceDeclaration(node) || + ts.isTypeAliasDeclaration(node) || + ts.isMethodDeclaration(node) || + ts.isMethodSignature(node) || + ts.isConstructorDeclaration(node) || + (ts.isVariableDeclaration(node) && hasCallableInitializer(node)) || + (ts.isPropertyDeclaration(node) && hasCallableInitializer(node)) + ); +} + +function isDocumentedScope(node: ts.Node, sourceFile: ts.SourceFile): boolean { + const owner = documentationOwner(node); + return ( + owner.parent === sourceFile || + ts.isClassDeclaration(node.parent) || + ts.isInterfaceDeclaration(node.parent) + ); +} + +function collectUndocumentedDeclarations( + file: string, + source: string, +): UndocumentedDeclaration[] { + const sourceFile = ts.createSourceFile( + file, + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const missing: UndocumentedDeclaration[] = []; + + function visit(node: ts.Node): void { + if ( + requiresJSDoc(node) && + isDocumentedScope(node, sourceFile) && + !hasJSDoc(documentationOwner(node), sourceFile) + ) { + const position = sourceFile.getLineAndCharacterOfPosition( + node.getStart(sourceFile), + ); + missing.push({ + file, + line: position.line + 1, + declaration: declarationName(node), + }); + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return missing; +} + +async function discoverSourceFiles(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + const files = await Promise.all( + entries.map(async (entry) => { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) { + return await discoverSourceFiles(path); + } + return entry.isFile() && + entry.name.endsWith('.ts') && + !entry.name.endsWith('.test.ts') + ? [path] + : []; + }), + ); + return files.flat().sort(); +} + +describe('notification-service source documentation', () => { + it('documents every production declaration with JSDoc', async () => { + const sourceFiles = await discoverSourceFiles(__dirname); + const missing = ( + await Promise.all( + sourceFiles.map(async (path) => { + const source = await readFile(path, 'utf8'); + return collectUndocumentedDeclarations(path, source); + }), + ) + ).flat(); + + expect(missing).toEqual([]); + }); +}); diff --git a/apps/notification-service/src/main.test.ts b/apps/notification-service/src/main.test.ts new file mode 100644 index 000000000..d1ade606b --- /dev/null +++ b/apps/notification-service/src/main.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import * as publicApi from './main'; + +/** Verifies that every runtime entry point remains available from the package. */ +describe('notification-service public API', () => { + it('exports the scheduler, PostgreSQL adapters, and runtime factories', () => { + expect(publicApi).toMatchObject({ + ReminderScheduler: expect.any(Function), + ReminderValidationError: expect.any(Function), + validateReminderOccurrence: expect.any(Function), + isWithinQuietHours: expect.any(Function), + PostgresReminderRepository: expect.any(Function), + PostgresInAppDeliveryGateway: expect.any(Function), + NotificationPersistenceError: expect.any(Function), + NotificationReplayConflictError: expect.any(Function), + hashNotificationIdempotencyKey: expect.any(Function), + NotificationRuntime: expect.any(Function), + createNotificationPoolConfiguration: expect.any(Function), + createNotificationRuntime: expect.any(Function), + }); + expect(publicApi.MAX_REMINDER_BATCH_SIZE).toBe(100); + expect(publicApi.MAX_REMINDER_TITLE_LENGTH).toBe(160); + expect(publicApi.MAX_DAILY_REMINDERS).toBe(20); + expect(publicApi.MAX_DELIVERY_ATTEMPTS).toBe(3); + }); +}); diff --git a/apps/notification-service/src/main.ts b/apps/notification-service/src/main.ts index 996ebd802..0ae380d3f 100644 --- a/apps/notification-service/src/main.ts +++ b/apps/notification-service/src/main.ts @@ -7,11 +7,46 @@ export { ReminderValidationError, isWithinQuietHours, validateReminderOccurrence, + /** Represents the bounded quiet hours values accepted by the notification service. */ type QuietHours, + /** Represents the bounded reminder delivery values accepted by the notification service. */ type ReminderDelivery, + /** Represents the bounded reminder delivery gateway values accepted by the notification service. */ type ReminderDeliveryGateway, + /** Represents the bounded reminder occurrence values accepted by the notification service. */ type ReminderOccurrence, + /** Represents the bounded reminder repository values accepted by the notification service. */ type ReminderRepository, + /** Represents the bounded reminder run report values accepted by the notification service. */ type ReminderRunReport, + /** Represents the bounded reminder validation code values accepted by the notification service. */ type ReminderValidationCode, } from './reminder-scheduler'; + +export { + NotificationPersistenceError, + NotificationReplayConflictError, + PostgresInAppDeliveryGateway, + PostgresReminderRepository, + hashNotificationIdempotencyKey, + /** Represents the bounded inbox message values accepted by the notification service. */ + type InboxMessage, + /** Represents the bounded notification sql client values accepted by the notification service. */ + type NotificationSqlClient, + /** Represents the bounded notification sql query result values accepted by the notification service. */ + type NotificationSqlQueryResult, + /** Represents the bounded persisted reminder occurrence values accepted by the notification service. */ + type PersistedReminderOccurrence, + /** Represents the bounded reminder outcome values accepted by the notification service. */ + type ReminderOutcome, +} from './postgres-reminder-repository'; + +export { + NotificationRuntime, + createNotificationPoolConfiguration, + createNotificationRuntime, + /** Represents the bounded notification pool values accepted by the notification service. */ + type NotificationPool, + /** Represents the bounded notification pool factory values accepted by the notification service. */ + type NotificationPoolFactory, +} from './notification-runtime'; diff --git a/apps/notification-service/src/notification-runtime.integration.test.ts b/apps/notification-service/src/notification-runtime.integration.test.ts new file mode 100644 index 000000000..671153299 --- /dev/null +++ b/apps/notification-service/src/notification-runtime.integration.test.ts @@ -0,0 +1,110 @@ +import { randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { Pool } from 'pg'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { + createNotificationRuntime, + type NotificationRuntime, +} from './notification-runtime'; +import { NotificationPersistenceError } from './postgres-reminder-repository'; + +const DATABASE_URL = process.env.NOTIFICATION_DATABASE_URL; +const describeWithPostgres = DATABASE_URL ? describe : describe.skip; +let administrativePool: Pool; +let runtime: NotificationRuntime | undefined; + +function requireDatabaseUrl(): string { + if (!DATABASE_URL) { + throw new Error( + 'NOTIFICATION_DATABASE_URL is required for integration tests', + ); + } + return DATABASE_URL; +} + +async function applyMigration(pool: Pool): Promise { + const sql = await readFile( + resolve(__dirname, '../migrations/0001_durable_reminder_inbox.sql'), + 'utf8', + ); + await pool.query(sql); +} + +async function resetSchema(): Promise { + await administrativePool.query( + 'DROP SCHEMA IF EXISTS notification_service CASCADE', + ); + await applyMigration(administrativePool); +} + +describeWithPostgres('production notification runtime integration', () => { + beforeAll(async () => { + administrativePool = new Pool({ + connectionString: requireDatabaseUrl(), + application_name: 'life-os-notification-runtime-integration-admin', + max: 2, + }); + }); + + beforeEach(async () => { + if (runtime) { + await runtime.close(); + runtime = undefined; + } + await resetSchema(); + }); + + afterAll(async () => { + if (runtime) { + await runtime.close(); + } + await administrativePool.query( + 'DROP SCHEMA IF EXISTS notification_service CASCADE', + ); + await administrativePool.end(); + }); + + it('composes, executes, and closes the default node-postgres runtime', async () => { + const now = '2026-08-04T12:00:00.000Z'; + const workspaceId = randomUUID(); + const reminderId = randomUUID(); + runtime = createNotificationRuntime({ + NOTIFICATION_DATABASE_URL: requireDatabaseUrl(), + NOTIFICATION_DATABASE_POOL_MAX: '2', + NOTIFICATION_CLAIM_LEASE_SECONDS: '30', + NOTIFICATION_REMINDER_BATCH_SIZE: '5', + }); + + await runtime.repository.schedule({ + id: reminderId, + workspaceId, + title: 'Exercise the production runtime', + dueAt: now, + timeZone: 'UTC', + quietHours: null, + maxPerLocalDay: 4, + deliveryAttempt: 0, + }); + + await expect(runtime.scheduler.run(new Date(now))).resolves.toMatchObject({ + scanned: 1, + delivered: 1, + failed: 0, + }); + await expect(runtime.repository.listInbox(workspaceId)).resolves.toEqual([ + expect.objectContaining({ + workspaceId, + reminderId, + title: 'Exercise the production runtime', + }), + ]); + + await runtime.close(); + await runtime.close(); + await expect(runtime.repository.listDue(now, 1)).rejects.toBeInstanceOf( + NotificationPersistenceError, + ); + runtime = undefined; + }); +}); diff --git a/apps/notification-service/src/notification-runtime.test.ts b/apps/notification-service/src/notification-runtime.test.ts new file mode 100644 index 000000000..73f86ca75 --- /dev/null +++ b/apps/notification-service/src/notification-runtime.test.ts @@ -0,0 +1,337 @@ +import { Logger } from '@nestjs/common'; +import type { PoolConfig } from 'pg'; +import { describe, expect, it, vi } from 'vitest'; +import { + createNotificationPoolConfiguration, + createNotificationRuntime, + registerNotificationPoolErrorHandler, + type NotificationPool, +} from './notification-runtime'; + +const DATABASE_URL = [ + 'postgresql:', + '', + 'database.example.test:5432', + 'life_os', +].join('/'); + +/** Implements the fake notification pool test double with observable deterministic behavior. */ +class FakeNotificationPool implements NotificationPool { + endCalls = 0; + endBehavior: () => Promise = async () => undefined; + readonly calls: Array<{ + readonly text: string; + readonly values: readonly unknown[]; + }> = []; + + /** Executes one parameterized query through the bounded SQL or test-double contract. */ + async query( + text: string, + values: readonly unknown[] = [], + ): Promise<{ rows: Row[] }> { + this.calls.push({ text, values: [...values] }); + return { rows: [] }; + } + + /** Closes the owned resource without exposing connection details. */ + async end(): Promise { + this.endCalls += 1; + await this.endBehavior(); + } +} + +describe('Notification runtime', () => { + it('emits bounded credential-free pool error classifications', () => { + let errorListener: ((error: Error) => void) | undefined; + const source = { + on(event: 'error', listener: (error: Error) => void): void { + expect(event).toBe('error'); + errorListener = listener; + }, + }; + const logged: unknown[][] = []; + registerNotificationPoolErrorHandler(source, (...values: unknown[]) => { + logged.push(values); + }); + + errorListener?.( + Object.assign( + new Error('postgresql://administrator:secret@database.example.test'), + { name: 'DatabaseError', code: '57P01' }, + ), + ); + errorListener?.( + Object.assign(new Error('secret'), { + name: 'bad name', + code: 'bad code', + }), + ); + errorListener?.(Object.assign(new Error('secret'), { code: 42 })); + + expect(logged).toEqual([ + [ + { + message: 'Notification PostgreSQL pool reported an idle client error', + context: 'NotificationRuntime', + errorName: 'DatabaseError', + postgresCode: '57P01', + }, + ], + [ + { + message: 'Notification PostgreSQL pool reported an idle client error', + context: 'NotificationRuntime', + errorName: 'Error', + postgresCode: null, + }, + ], + [ + { + message: 'Notification PostgreSQL pool reported an idle client error', + context: 'NotificationRuntime', + errorName: 'Error', + postgresCode: null, + }, + ], + ]); + expect(JSON.stringify(logged)).not.toContain('secret'); + }); + + it('uses the Nest logger without serializing the database error', () => { + let errorListener: ((error: Error) => void) | undefined; + const source = { + on(_event: 'error', listener: (error: Error) => void): void { + errorListener = listener; + }, + }; + const logger = vi + .spyOn(Logger, 'error') + .mockImplementation(() => undefined); + + registerNotificationPoolErrorHandler(source); + errorListener?.( + new Error('postgresql://administrator:secret@database.example.test'), + ); + + expect(logger).toHaveBeenCalledWith( + { + message: 'Notification PostgreSQL pool reported an idle client error', + context: 'NotificationRuntime', + errorName: 'Error', + postgresCode: null, + }, + 'NotificationRuntime', + ); + expect(JSON.stringify(logger.mock.calls)).not.toContain('secret'); + logger.mockRestore(); + }); + + it('builds a bounded PostgreSQL pool configuration', () => { + expect( + createNotificationPoolConfiguration({ + NOTIFICATION_DATABASE_URL: DATABASE_URL, + NOTIFICATION_DATABASE_POOL_MAX: '12', + NOTIFICATION_DATABASE_CONNECT_TIMEOUT_MS: '2500', + NOTIFICATION_DATABASE_IDLE_TIMEOUT_MS: '45000', + }), + ).toEqual({ + connectionString: DATABASE_URL, + application_name: 'life-os-notification-service', + max: 12, + connectionTimeoutMillis: 2500, + idleTimeoutMillis: 45000, + }); + }); + + it('fails closed on missing, malformed, non-PostgreSQL, or oversized database configuration', () => { + expect(() => createNotificationPoolConfiguration({})).toThrowError( + 'Required notification configuration is missing: NOTIFICATION_DATABASE_URL', + ); + expect(() => + createNotificationPoolConfiguration({ + NOTIFICATION_DATABASE_URL: 'not a URL', + }), + ).toThrowError('Notification database URL is invalid'); + expect(() => + createNotificationPoolConfiguration({ + NOTIFICATION_DATABASE_URL: 'https://database.example.test/life_os', + }), + ).toThrowError('Notification database URL must use PostgreSQL'); + expect(() => + createNotificationPoolConfiguration({ + NOTIFICATION_DATABASE_URL: `postgresql://${'a'.repeat(8 * 1024)}`, + }), + ).toThrowError( + 'Notification configuration exceeds maximum length: NOTIFICATION_DATABASE_URL', + ); + }); + + it('fails closed on non-integer or out-of-range pool configuration', () => { + expect(() => + createNotificationPoolConfiguration({ + NOTIFICATION_DATABASE_URL: DATABASE_URL, + NOTIFICATION_DATABASE_POOL_MAX: '1.5', + }), + ).toThrowError('Notification database pool size is invalid'); + expect(() => + createNotificationPoolConfiguration({ + NOTIFICATION_DATABASE_URL: DATABASE_URL, + NOTIFICATION_DATABASE_POOL_MAX: '33', + }), + ).toThrowError('Notification database pool size is invalid'); + expect(() => + createNotificationPoolConfiguration({ + NOTIFICATION_DATABASE_URL: DATABASE_URL, + NOTIFICATION_DATABASE_CONNECT_TIMEOUT_MS: '99', + }), + ).toThrowError('Notification database connection timeout is invalid'); + expect(() => + createNotificationPoolConfiguration({ + NOTIFICATION_DATABASE_URL: DATABASE_URL, + NOTIFICATION_DATABASE_IDLE_TIMEOUT_MS: '300001', + }), + ).toThrowError('Notification database idle timeout is invalid'); + }); + + it('uses defaults for absent and blank optional integer configuration', () => { + expect( + createNotificationPoolConfiguration({ + NOTIFICATION_DATABASE_URL: DATABASE_URL, + NOTIFICATION_DATABASE_POOL_MAX: ' ', + }), + ).toMatchObject({ + max: 10, + connectionTimeoutMillis: 5_000, + idleTimeoutMillis: 30_000, + }); + }); + + it('fails before allocating a pool for invalid scheduler bounds', () => { + let poolFactoryCalls = 0; + /** Creates a deterministic pool factory that records allocation attempts. */ + const poolFactory = (): NotificationPool => { + poolFactoryCalls += 1; + return new FakeNotificationPool(); + }; + + expect(() => + createNotificationRuntime( + { + NOTIFICATION_DATABASE_URL: DATABASE_URL, + NOTIFICATION_CLAIM_LEASE_SECONDS: '29', + }, + poolFactory, + ), + ).toThrowError('Notification claim lease is invalid'); + expect(() => + createNotificationRuntime( + { + NOTIFICATION_DATABASE_URL: DATABASE_URL, + NOTIFICATION_REMINDER_BATCH_SIZE: '101', + }, + poolFactory, + ), + ).toThrowError('Notification reminder batch size is invalid'); + expect(poolFactoryCalls).toBe(0); + }); + + it('constructs and closes the production pool without opening a connection', async () => { + const runtime = createNotificationRuntime({ + NOTIFICATION_DATABASE_URL: DATABASE_URL, + }); + + await runtime.close(); + await runtime.close(); + }); + + it('shares one in-flight close promise across concurrent callers', async () => { + const pool = new FakeNotificationPool(); + let releaseEnd: (() => void) | undefined; + pool.endBehavior = () => + new Promise((resolve) => { + releaseEnd = resolve; + }); + const runtime = createNotificationRuntime( + { NOTIFICATION_DATABASE_URL: DATABASE_URL }, + () => pool, + ); + + const first = runtime.close(); + const second = runtime.onApplicationShutdown(); + let secondSettled = false; + void second.then(() => { + secondSettled = true; + }); + await Promise.resolve(); + + expect(pool.endCalls).toBe(1); + expect(secondSettled).toBe(false); + releaseEnd?.(); + await Promise.all([first, second]); + expect(secondSettled).toBe(true); + }); + + it('allows a later close attempt to retry a rejected pool shutdown', async () => { + const pool = new FakeNotificationPool(); + let failureAvailable = true; + pool.endBehavior = async () => { + if (failureAvailable) { + failureAvailable = false; + throw new Error('shutdown unavailable'); + } + }; + const runtime = createNotificationRuntime( + { NOTIFICATION_DATABASE_URL: DATABASE_URL }, + () => pool, + ); + + await expect(runtime.close()).rejects.toThrowError('shutdown unavailable'); + await expect(runtime.close()).resolves.toBeUndefined(); + expect(pool.endCalls).toBe(2); + }); + + it('shares one pool across adapters and closes it exactly once', async () => { + const pool = new FakeNotificationPool(); + let capturedConfiguration: PoolConfig | undefined; + let factoryCalls = 0; + const runtime = createNotificationRuntime( + { + NOTIFICATION_DATABASE_URL: DATABASE_URL, + NOTIFICATION_CLAIM_LEASE_SECONDS: '600', + NOTIFICATION_REMINDER_BATCH_SIZE: '25', + }, + (configuration) => { + factoryCalls += 1; + capturedConfiguration = configuration; + return pool; + }, + ); + + expect(factoryCalls).toBe(1); + expect(capturedConfiguration).toMatchObject({ + connectionString: DATABASE_URL, + application_name: 'life-os-notification-service', + max: 10, + }); + expect(runtime.scheduler.batchSize).toBe(25); + await expect( + runtime.repository.claim( + '018f47a4-9976-4c57-8a8a-674630a873d1', + '91fe0f58-2035-49b7-a793-ac75939a433f', + '2026-08-04T12:00:00.000Z', + 0, + ), + ).resolves.toBeNull(); + expect(pool.calls).toHaveLength(1); + expect(pool.calls[0]?.text).toContain( + 'UPDATE notification_service.reminder_occurrences', + ); + expect(pool.calls[0]?.values?.[3]).toBe(600); + expect(pool.calls[0]?.values?.[4]).toBe('2026-08-04T12:00:00.000Z'); + expect(pool.calls[0]?.values?.[5]).toBe(0); + + await runtime.onApplicationShutdown(); + await runtime.close(); + expect(pool.endCalls).toBe(1); + }); +}); diff --git a/apps/notification-service/src/notification-runtime.ts b/apps/notification-service/src/notification-runtime.ts new file mode 100644 index 000000000..3040aac19 --- /dev/null +++ b/apps/notification-service/src/notification-runtime.ts @@ -0,0 +1,270 @@ +import { Logger, type OnApplicationShutdown } from '@nestjs/common'; +import { Pool, type PoolConfig } from 'pg'; +import { + PostgresInAppDeliveryGateway, + PostgresReminderRepository, + /** Represents the bounded notification sql client values accepted by the notification service. */ + type NotificationSqlClient, + /** Represents the bounded notification sql query result values accepted by the notification service. */ + type NotificationSqlQueryResult, +} from './postgres-reminder-repository'; +import { ReminderScheduler } from './reminder-scheduler'; + +const MAXIMUM_CONFIGURATION_LENGTH = 8 * 1024; +const DEFAULT_CLAIM_LEASE_SECONDS = 300; +const DEFAULT_REMINDER_BATCH_SIZE = 50; + +/** Represents the bounded runtime environment values used by the notification service. */ +type RuntimeEnvironment = Readonly>; + +/** Minimal event boundary needed to observe idle PostgreSQL client failures. */ +export interface NotificationPoolErrorSource { + /** Subscribes to unexpected idle-client failures emitted by the pool. */ + on(event: 'error', listener: (error: Error) => void): unknown; +} + +/** Structured credential-free record emitted for one pool failure. */ +export interface NotificationPoolErrorRecord { + readonly message: string; + readonly context: 'NotificationRuntime'; + readonly errorName: string; + readonly postgresCode: string | null; +} + +/** Credential-free error logger used by the pool error boundary. */ +export type NotificationPoolErrorLogger = ( + record: NotificationPoolErrorRecord, +) => void; + +const NOTIFICATION_POOL_ERROR_MESSAGE = + 'Notification PostgreSQL pool reported an idle client error'; +const POOL_ERROR_CLASSIFICATION_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/u; + +/** Retains only bounded non-secret error classification tokens. */ +function safePoolErrorClassification(value: unknown): string | null { + if (typeof value !== 'string') return null; + return POOL_ERROR_CLASSIFICATION_PATTERN.test(value) ? value : null; +} + +/** Emits one structured record without serializing the database error. */ +function defaultNotificationPoolErrorLogger( + record: NotificationPoolErrorRecord, +): void { + Logger.error(record, record.context); +} + +/** Registers a sanitized listener before the PostgreSQL pool can be used. */ +export function registerNotificationPoolErrorHandler( + pool: NotificationPoolErrorSource, + logError: NotificationPoolErrorLogger = defaultNotificationPoolErrorLogger, +): void { + pool.on('error', (error) => { + const code = (error as { code?: unknown }).code; + logError({ + message: NOTIFICATION_POOL_ERROR_MESSAGE, + context: 'NotificationRuntime', + errorName: safePoolErrorClassification(error.name) ?? 'Error', + postgresCode: safePoolErrorClassification(code), + }); + }); +} + +/** PostgreSQL pool boundary owned by the notification service runtime. */ +export interface NotificationPool { + /** Executes one parameterized PostgreSQL statement and maps transport failures to a credential-free service error. */ + query( + text: string, + values?: readonly unknown[], + ): Promise>; + /** Closes the owned node-postgres pool and releases its connections. */ + end(): Promise; +} + +/** Factory boundary used to construct one validated notification pool. */ +export type NotificationPoolFactory = ( + configuration: PoolConfig, +) => NotificationPool; + +/** Implements node postgres notification pool behavior behind an explicit notification-service boundary. */ +class NodePostgresNotificationPool implements NotificationPool { + /** Creates the component with validated dependencies and bounded configuration. */ + constructor(private readonly pool: Pool) {} + + /** Executes one parameterized PostgreSQL statement and maps transport failures to a credential-free service error. */ + async query( + text: string, + values: readonly unknown[] = [], + ): Promise> { + const result = await this.pool.query(text, [...values]); + return { rows: result.rows as Row[] }; + } + + /** Closes the owned node-postgres pool and releases its connections. */ + async end(): Promise { + await this.pool.end(); + } +} + +/** Implements node postgres notification sql client behavior behind an explicit notification-service boundary. */ +class NodePostgresNotificationSqlClient implements NotificationSqlClient { + /** Creates the component with validated dependencies and bounded configuration. */ + constructor(private readonly pool: NotificationPool) {} + + /** Executes one parameterized PostgreSQL statement and maps transport failures to a credential-free service error. */ + async query( + text: string, + values: readonly unknown[], + ): Promise> { + return await this.pool.query(text, values); + } +} + +/** Reads one required bounded runtime setting without exposing its value in errors. */ +function requireConfiguration( + environment: RuntimeEnvironment, + name: string, +): string { + const value = environment[name]?.trim(); + if (!value) { + throw new Error(`Required notification configuration is missing: ${name}`); + } + if (value.length > MAXIMUM_CONFIGURATION_LENGTH) { + throw new Error( + `Notification configuration exceeds maximum length: ${name}`, + ); + } + return value; +} + +/** Accepts only a syntactically valid PostgreSQL connection URL. */ +function requireDatabaseUrl(value: string): string { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error('Notification database URL is invalid'); + } + if (parsed.protocol !== 'postgres:' && parsed.protocol !== 'postgresql:') { + throw new Error('Notification database URL must use PostgreSQL'); + } + return value; +} + +/** Parses one optional integer setting and enforces its documented inclusive range. */ +function requireBoundedInteger( + value: string | undefined, + defaultValue: number, + minimum: number, + maximum: number, + message: string, +): number { + if (value === undefined || value.trim() === '') { + return defaultValue; + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) { + throw new Error(message); + } + return parsed; +} + +/** Builds bounded node-postgres configuration for the notification service. */ +export function createNotificationPoolConfiguration( + environment: RuntimeEnvironment, +): PoolConfig { + return { + connectionString: requireDatabaseUrl( + requireConfiguration(environment, 'NOTIFICATION_DATABASE_URL'), + ), + application_name: 'life-os-notification-service', + max: requireBoundedInteger( + environment.NOTIFICATION_DATABASE_POOL_MAX, + 10, + 1, + 32, + 'Notification database pool size is invalid', + ), + connectionTimeoutMillis: requireBoundedInteger( + environment.NOTIFICATION_DATABASE_CONNECT_TIMEOUT_MS, + 5_000, + 100, + 30_000, + 'Notification database connection timeout is invalid', + ), + idleTimeoutMillis: requireBoundedInteger( + environment.NOTIFICATION_DATABASE_IDLE_TIMEOUT_MS, + 30_000, + 1_000, + 300_000, + 'Notification database idle timeout is invalid', + ), + }; +} + +/** Creates the production node-postgres pool behind the runtime-owned pool boundary. */ +function defaultPoolFactory(configuration: PoolConfig): NotificationPool { + const pool = new Pool(configuration); + registerNotificationPoolErrorHandler(pool); + return new NodePostgresNotificationPool(pool); +} + +/** Owns one pool and the composed durable notification scheduler. */ +export class NotificationRuntime implements OnApplicationShutdown { + private closing: Promise | undefined; + + /** Creates the component with validated dependencies and bounded configuration. */ + constructor( + private readonly pool: NotificationPool, + readonly repository: PostgresReminderRepository, + readonly gateway: PostgresInAppDeliveryGateway, + readonly scheduler: ReminderScheduler, + ) {} + + /** Closes the owned PostgreSQL pool exactly once. */ + async close(): Promise { + if (this.closing === undefined) { + this.closing = this.pool.end().catch((error: unknown) => { + this.closing = undefined; + throw error; + }); + } + await this.closing; + } + + /** Delegates the NestJS shutdown lifecycle to the idempotent runtime close operation. */ + async onApplicationShutdown(): Promise { + await this.close(); + } +} + +/** Constructs the production notification runtime from validated environment data. */ +export function createNotificationRuntime( + environment: RuntimeEnvironment = process.env, + poolFactory: NotificationPoolFactory = defaultPoolFactory, +): NotificationRuntime { + const configuration = createNotificationPoolConfiguration(environment); + const claimLeaseSeconds = requireBoundedInteger( + environment.NOTIFICATION_CLAIM_LEASE_SECONDS, + DEFAULT_CLAIM_LEASE_SECONDS, + 30, + 3_600, + 'Notification claim lease is invalid', + ); + const reminderBatchSize = requireBoundedInteger( + environment.NOTIFICATION_REMINDER_BATCH_SIZE, + DEFAULT_REMINDER_BATCH_SIZE, + 1, + 100, + 'Notification reminder batch size is invalid', + ); + const pool = poolFactory(configuration); + const client = new NodePostgresNotificationSqlClient(pool); + const repository = new PostgresReminderRepository(client, claimLeaseSeconds); + const gateway = new PostgresInAppDeliveryGateway(client); + const scheduler = new ReminderScheduler( + repository, + gateway, + reminderBatchSize, + ); + return new NotificationRuntime(pool, repository, gateway, scheduler); +} diff --git a/apps/notification-service/src/postgres-reminder-migration.test.ts b/apps/notification-service/src/postgres-reminder-migration.test.ts new file mode 100644 index 000000000..280a7f382 --- /dev/null +++ b/apps/notification-service/src/postgres-reminder-migration.test.ts @@ -0,0 +1,113 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const migrationPath = resolve( + __dirname, + '../migrations/0001_durable_reminder_inbox.sql', +); + +async function migrationSql(): Promise { + return await readFile(migrationPath, 'utf8'); +} + +describe('durable notification database contract', () => { + it('uses only dedicated multi-word snake_case objects', async () => { + const sql = await migrationSql(); + + expect(sql).toContain('CREATE SCHEMA IF NOT EXISTS notification_service'); + for (const tableName of [ + 'notification_service.reminder_occurrences', + 'notification_service.reminder_outcomes', + 'notification_service.inbox_messages', + ]) { + expect(sql).toContain(`CREATE TABLE IF NOT EXISTS ${tableName}`); + } + + const objectNames = [ + ...sql.matchAll( + /(?:CONSTRAINT|INDEX(?: IF NOT EXISTS)?)\s+([a-z][a-z0-9_]*)/gu, + ), + ].map((match) => match[1]); + expect(objectNames.length).toBeGreaterThan(12); + expect( + objectNames.every((name) => name !== undefined && name.includes('_')), + ).toBe(true); + + const columnNames = [ + 'reminder_id', + 'workspace_id', + 'reminder_title', + 'due_instant', + 'time_zone', + 'quiet_start_minute', + 'quiet_end_minute', + 'daily_delivery_limit', + 'delivery_attempt_count', + 'occurrence_status', + 'claim_key_hash', + 'claim_expires_at', + 'outcome_id', + 'outcome_kind', + 'occurred_at', + 'next_attempt_at', + 'outcome_reason', + 'idempotency_key_hash', + 'delivery_local_date', + 'message_id', + 'message_title', + 'delivered_at', + 'read_at', + 'created_at', + 'updated_at', + ]; + for (const columnName of columnNames) { + expect(sql).toMatch(new RegExp(`\\b${columnName}\\b`, 'u')); + expect(columnName).toContain('_'); + } + }); + + it('enforces UUIDv4, policy, state, and digest invariants', async () => { + const sql = await migrationSql(); + + expect(sql).toContain('get_byte(uuid_send(reminder_id), 6) >> 4 = 4'); + expect(sql).toContain('get_byte(uuid_send(workspace_id), 6) >> 4 = 4'); + expect(sql).toContain('get_byte(uuid_send(outcome_id), 6) >> 4 = 4'); + expect(sql).toContain('get_byte(uuid_send(message_id), 6) >> 4 = 4'); + expect(sql).toContain('char_length(reminder_title) BETWEEN 1 AND 160'); + expect(sql).toContain('octet_length(reminder_title) <= 1024'); + expect(sql).toContain('quiet_start_minute BETWEEN 0 AND 1439'); + expect(sql).toContain('quiet_end_minute BETWEEN 0 AND 1439'); + expect(sql).toContain('daily_delivery_limit BETWEEN 1 AND 20'); + expect(sql).toContain('delivery_attempt_count BETWEEN 0 AND 3'); + expect(sql).toContain( + "occurrence_status IN ('pending', 'delivered', 'failed')", + ); + expect(sql).toContain( + "outcome_kind IN ('delivered', 'deferred', 'failed')", + ); + expect(sql).toMatch( + /outcome_reason\s+IN\s*\(\s*'quiet_hours'\s*,\s*'daily_limit'\s*,\s*'delivery_failed'\s*,\s*'attempt_limit'\s*\)/u, + ); + expect( + sql.match( + /octet_length\((?:claim_key_hash|idempotency_key_hash)\) = 32/gu, + ), + ).toHaveLength(3); + expect(sql).not.toMatch(/\b(?:serial|bigserial)\b/iu); + }); + + it('supports deterministic due work, tenant reads, and exact idempotency', async () => { + const sql = await migrationSql(); + + expect(sql).toContain('reminder_occurrences_due_index'); + expect(sql).toContain('reminder_outcomes_workspace_index'); + expect(sql).toContain('inbox_messages_workspace_index'); + expect(sql).toContain('reminder_outcomes_idempotency_unique'); + expect(sql).toContain('inbox_messages_idempotency_unique'); + expect(sql).toContain('FOREIGN KEY (workspace_id, reminder_id)'); + expect(sql).toContain( + 'REFERENCES notification_service.reminder_occurrences (workspace_id, reminder_id)', + ); + }); +}); diff --git a/apps/notification-service/src/postgres-reminder-repository.coverage.test.ts b/apps/notification-service/src/postgres-reminder-repository.coverage.test.ts new file mode 100644 index 000000000..65e147794 --- /dev/null +++ b/apps/notification-service/src/postgres-reminder-repository.coverage.test.ts @@ -0,0 +1,530 @@ +import { describe, expect, it } from 'vitest'; +import { + MAX_DELIVERY_ATTEMPTS, + type ReminderDelivery, + type ReminderOccurrence, +} from './reminder-scheduler'; +import { + NotificationPersistenceError, + NotificationReplayConflictError, + PostgresInAppDeliveryGateway, + PostgresReminderRepository, + type NotificationSqlClient, + type NotificationSqlQueryResult, +} from './postgres-reminder-repository'; + +const workspaceId = '018f47a4-9976-4c57-8a8a-674630a873d1'; +const otherWorkspaceId = '69b8f6fb-c65a-462e-b5e7-1b21808db998'; +const reminderId = '91fe0f58-2035-49b7-a793-ac75939a433f'; +const otherReminderId = 'ee09fe10-2602-4d6c-b52a-e58cbf55ea41'; +const outcomeId = 'fa6d0f3e-337c-4d94-b17d-4afcf6bf79c1'; +const messageId = 'ca035df4-0149-4b08-8f21-07bd758cfbaa'; +const claimKey = 'ebeb80f5-a077-45ee-9f39-f3e64af94cdb'; +const idempotencyKey = `${workspaceId}:${reminderId}:2026-08-04T12:00:00.000Z`; + +const baseReminder: ReminderOccurrence = { + id: reminderId, + workspaceId, + title: 'Prepare the weekly review', + dueAt: '2026-08-04T12:00:00.000Z', + timeZone: 'Asia/Seoul', + quietHours: { startMinute: 1320, endMinute: 420 }, + maxPerLocalDay: 4, + deliveryAttempt: 0, +}; + +const baseDelivery: ReminderDelivery = { + workspaceId, + reminderId, + title: baseReminder.title, + dueAt: baseReminder.dueAt, + timeZone: baseReminder.timeZone, + idempotencyKey, +}; + +/** One parameterized SQL call captured by the deterministic test client. */ +interface RecordedQuery { + readonly text: string; + readonly values: readonly unknown[]; +} + +/** A deterministic SQL client together with its observable calls. */ +interface SequencedSqlClient { + readonly client: NotificationSqlClient; + readonly calls: RecordedQuery[]; +} + +/** Builds a SQL client that returns or throws each supplied response in order. */ +function sequencedSqlClient( + responses: readonly (readonly unknown[] | Error)[], +): SequencedSqlClient { + let index = 0; + const calls: RecordedQuery[] = []; + const client: NotificationSqlClient = { + query: async ( + text: string, + values: readonly unknown[], + ): Promise> => { + calls.push({ text, values }); + const response = responses[index]; + if (response === undefined) { + throw new Error(`unexpected query #${index + 1}: no response prepared`); + } + index += 1; + if (response instanceof Error) { + throw response; + } + return { rows: [...response] as Row[] }; + }, + }; + return { client, calls }; +} + +/** Builds one valid or intentionally malformed PostgreSQL reminder row. */ +function reminderRow(overrides: Record = {}) { + return { + reminder_id: reminderId, + workspace_id: workspaceId, + reminder_title: baseReminder.title, + due_instant: new Date(baseReminder.dueAt), + time_zone: baseReminder.timeZone, + quiet_start_minute: 1320, + quiet_end_minute: 420, + daily_delivery_limit: 4, + delivery_attempt_count: 0, + occurrence_status: 'pending', + claim_expires_at: null, + created_at: new Date('2026-08-04T10:00:00.000Z'), + updated_at: new Date('2026-08-04T10:00:00.000Z'), + ...overrides, + }; +} + +/** Builds one valid or intentionally malformed immutable outcome row. */ +function outcomeRow(overrides: Record = {}) { + return { + outcome_id: outcomeId, + workspace_id: workspaceId, + reminder_id: reminderId, + outcome_kind: 'delivered', + occurred_at: new Date('2026-08-04T12:00:01.000Z'), + next_attempt_at: null, + outcome_reason: null, + delivery_local_date: '2026-08-04', + created_at: new Date('2026-08-04T12:00:01.000Z'), + ...overrides, + }; +} + +/** Builds one valid or intentionally malformed in-app inbox row. */ +function inboxRow(overrides: Record = {}) { + return { + message_id: messageId, + workspace_id: workspaceId, + reminder_id: reminderId, + message_title: baseReminder.title, + due_instant: new Date(baseReminder.dueAt), + time_zone: baseReminder.timeZone, + delivered_at: new Date('2026-08-04T12:00:01.000Z'), + read_at: null, + created_at: new Date('2026-08-04T12:00:01.000Z'), + ...overrides, + }; +} + +describe('PostgreSQL notification defensive coverage', () => { + it('covers aliases, nullable policies, status variants, and bounded result guards', async () => { + await expect( + new PostgresReminderRepository( + sequencedSqlClient([[reminderRow()]]).client, + ).createOccurrence(baseReminder), + ).resolves.toMatchObject({ id: reminderId, status: 'pending' }); + + await expect( + new PostgresReminderRepository( + sequencedSqlClient([[reminderRow()]]).client, + ).listOccurrences(workspaceId, 1), + ).resolves.toHaveLength(1); + + await expect( + new PostgresReminderRepository( + sequencedSqlClient([[inboxRow()]]).client, + ).listInboxMessages(workspaceId, 1), + ).resolves.toHaveLength(1); + + await expect( + new PostgresReminderRepository( + sequencedSqlClient([ + [ + reminderRow({ + quiet_start_minute: null, + quiet_end_minute: null, + }), + ], + ]).client, + ).listDue(baseReminder.dueAt, 1), + ).resolves.toEqual([{ ...baseReminder, quietHours: null }]); + + await expect( + new PostgresReminderRepository( + sequencedSqlClient([ + [ + reminderRow({ + occurrence_status: 'delivered', + claim_expires_at: new Date('2026-08-04T12:05:00.000Z'), + }), + reminderRow({ occurrence_status: 'failed' }), + ], + ]).client, + ).listReminders(workspaceId, 2), + ).resolves.toMatchObject([ + { status: 'delivered', claimExpiresAt: '2026-08-04T12:05:00.000Z' }, + { status: 'failed' }, + ]); + + for (const operation of [ + () => + new PostgresReminderRepository( + sequencedSqlClient([[reminderRow(), reminderRow()]]).client, + ).listDue(baseReminder.dueAt, 1), + () => + new PostgresReminderRepository( + sequencedSqlClient([[reminderRow(), reminderRow()]]).client, + ).listReminders(workspaceId, 1), + () => + new PostgresReminderRepository( + sequencedSqlClient([[outcomeRow(), outcomeRow()]]).client, + ).listOutcomes(workspaceId, 1), + () => + new PostgresReminderRepository( + sequencedSqlClient([[inboxRow(), inboxRow()]]).client, + ).listInbox(workspaceId, 1), + ]) { + await expect(operation()).rejects.toBeInstanceOf( + NotificationPersistenceError, + ); + } + }); + + it('rejects malformed temporal, cardinality, ownership, and row-state values', async () => { + const malformedDueRows = [ + reminderRow({ due_instant: new Date(Number.NaN) }), + reminderRow({ due_instant: '2026-13-01T00:00:00Z' }), + reminderRow({ due_instant: 42 }), + reminderRow({ quiet_start_minute: null, quiet_end_minute: 420 }), + reminderRow({ daily_delivery_limit: {} }), + ]; + for (const row of malformedDueRows) { + await expect( + new PostgresReminderRepository( + sequencedSqlClient([[row]]).client, + ).listDue(baseReminder.dueAt, 1), + ).rejects.toBeInstanceOf(NotificationPersistenceError); + } + + for (const localDate of ['2026-13-01', '2026-02-30']) { + await expect( + new PostgresReminderRepository( + sequencedSqlClient([]).client, + ).countDelivered(workspaceId, localDate), + ).rejects.toBeInstanceOf(NotificationPersistenceError); + } + + await expect( + new PostgresReminderRepository( + sequencedSqlClient([[]]).client, + ).countDelivered(workspaceId, '2026-08-04'), + ).rejects.toBeInstanceOf(NotificationPersistenceError); + + await expect( + new PostgresReminderRepository( + sequencedSqlClient([[{ delivery_count: {} }]]).client, + ).countDelivered(workspaceId, '2026-08-04'), + ).rejects.toBeInstanceOf(NotificationPersistenceError); + + await expect( + new PostgresReminderRepository( + sequencedSqlClient([[reminderRow(), reminderRow()]]).client, + ).schedule(baseReminder), + ).rejects.toBeInstanceOf(NotificationPersistenceError); + + for (const row of [ + reminderRow({ workspace_id: otherWorkspaceId }), + reminderRow({ occurrence_status: 'archived' }), + reminderRow({ + created_at: new Date('2026-08-04T11:00:00.000Z'), + updated_at: new Date('2026-08-04T10:00:00.000Z'), + }), + ]) { + await expect( + new PostgresReminderRepository( + sequencedSqlClient([[row]]).client, + ).listReminders(workspaceId, 1), + ).rejects.toBeInstanceOf(NotificationPersistenceError); + } + + await expect( + new PostgresReminderRepository( + sequencedSqlClient([[reminderRow({ reminder_id: otherReminderId })]]) + .client, + ).schedule(baseReminder), + ).rejects.toBeInstanceOf(NotificationPersistenceError); + }); + + it('covers every reachable schedule replay comparison', async () => { + const replayMismatches = [ + { occurrence_status: 'delivered' }, + { reminder_title: 'Different reminder' }, + { due_instant: new Date('2026-08-04T13:00:00.000Z') }, + { time_zone: 'UTC' }, + { daily_delivery_limit: 5 }, + { delivery_attempt_count: 1 }, + { quiet_start_minute: 1200 }, + { quiet_end_minute: 300 }, + { quiet_start_minute: null, quiet_end_minute: null }, + ]; + for (const overrides of replayMismatches) { + await expect( + new PostgresReminderRepository( + sequencedSqlClient([[], [reminderRow(overrides)]]).client, + ).schedule(baseReminder), + ).rejects.toBeInstanceOf(NotificationReplayConflictError); + } + + await expect( + new PostgresReminderRepository( + sequencedSqlClient([ + [], + [ + reminderRow({ + quiet_start_minute: null, + quiet_end_minute: null, + }), + ], + ]).client, + ).schedule({ ...baseReminder, quietHours: null }), + ).resolves.toMatchObject({ quietHours: null }); + }); + + it('accepts valid outcome variants and rejects every kind-specific invariant violation', async () => { + const validRows = [ + outcomeRow(), + outcomeRow({ + outcome_kind: 'deferred', + next_attempt_at: new Date('2026-08-04T13:00:00.000Z'), + outcome_reason: 'quiet_hours', + delivery_local_date: null, + }), + outcomeRow({ + outcome_kind: 'deferred', + next_attempt_at: new Date('2026-08-05T00:00:00.000Z'), + outcome_reason: 'daily_limit', + delivery_local_date: null, + }), + outcomeRow({ + outcome_kind: 'failed', + next_attempt_at: new Date('2026-08-04T12:05:00.000Z'), + outcome_reason: 'delivery_failed', + delivery_local_date: null, + }), + outcomeRow({ + outcome_kind: 'failed', + next_attempt_at: null, + outcome_reason: 'attempt_limit', + delivery_local_date: null, + }), + ]; + for (const row of validRows) { + await expect( + new PostgresReminderRepository( + sequencedSqlClient([[row]]).client, + ).listOutcomes(workspaceId, 1), + ).resolves.toHaveLength(1); + } + + const invalidRows = [ + outcomeRow({ outcome_kind: 'unknown' }), + outcomeRow({ outcome_reason: 'unknown' }), + outcomeRow({ workspace_id: otherWorkspaceId }), + outcomeRow({ outcome_reason: 'quiet_hours' }), + outcomeRow({ + next_attempt_at: new Date('2026-08-04T13:00:00.000Z'), + }), + outcomeRow({ delivery_local_date: null }), + outcomeRow({ + outcome_kind: 'deferred', + next_attempt_at: null, + outcome_reason: 'quiet_hours', + delivery_local_date: null, + }), + outcomeRow({ + outcome_kind: 'deferred', + next_attempt_at: new Date('2026-08-04T13:00:00.000Z'), + outcome_reason: 'delivery_failed', + delivery_local_date: null, + }), + outcomeRow({ + outcome_kind: 'deferred', + next_attempt_at: new Date('2026-08-04T13:00:00.000Z'), + outcome_reason: 'daily_limit', + delivery_local_date: '2026-08-04', + }), + outcomeRow({ + outcome_kind: 'failed', + next_attempt_at: null, + outcome_reason: 'attempt_limit', + delivery_local_date: '2026-08-04', + }), + outcomeRow({ + outcome_kind: 'failed', + next_attempt_at: null, + outcome_reason: 'delivery_failed', + delivery_local_date: null, + }), + outcomeRow({ + outcome_kind: 'failed', + next_attempt_at: new Date('2026-08-04T13:00:00.000Z'), + outcome_reason: 'attempt_limit', + delivery_local_date: null, + }), + outcomeRow({ + outcome_kind: 'failed', + next_attempt_at: null, + outcome_reason: 'quiet_hours', + delivery_local_date: null, + }), + outcomeRow({ delivery_local_date: '2026-13-01' }), + outcomeRow({ delivery_local_date: '2026-02-30' }), + outcomeRow({ delivery_local_date: new Date(Number.NaN) }), + ]; + for (const row of invalidRows) { + await expect( + new PostgresReminderRepository( + sequencedSqlClient([[row]]).client, + ).listOutcomes(workspaceId, 1), + ).rejects.toBeInstanceOf(NotificationPersistenceError); + } + }); + + it('covers inbox chronology, delivery comparisons, and gateway transport failures', async () => { + await expect( + new PostgresReminderRepository( + sequencedSqlClient([ + [inboxRow({ read_at: new Date('2026-08-04T12:00:02.000Z') })], + ]).client, + ).listInbox(workspaceId, 1), + ).resolves.toMatchObject([{ readAt: '2026-08-04T12:00:02.000Z' }]); + + for (const row of [ + inboxRow({ read_at: new Date('2026-08-04T12:00:00.000Z') }), + inboxRow({ workspace_id: otherWorkspaceId }), + ]) { + await expect( + new PostgresReminderRepository( + sequencedSqlClient([[row]]).client, + ).listInbox(workspaceId, 1), + ).rejects.toBeInstanceOf(NotificationPersistenceError); + } + + const insertedMismatches = [ + { reminder_id: otherReminderId }, + { message_title: 'Different reminder' }, + { due_instant: new Date('2026-08-04T13:00:00.000Z') }, + { time_zone: 'UTC' }, + ]; + for (const overrides of insertedMismatches) { + await expect( + new PostgresInAppDeliveryGateway( + sequencedSqlClient([[inboxRow(overrides)]]).client, + () => messageId, + ).deliver(baseDelivery), + ).rejects.toBeInstanceOf(NotificationReplayConflictError); + } + + await expect( + new PostgresInAppDeliveryGateway( + sequencedSqlClient([new Error('database unavailable')]).client, + () => messageId, + ).deliver(baseDelivery), + ).rejects.toEqual(new NotificationPersistenceError()); + }); + + it('covers transition outcome failures and every invalid terminal-state combination', async () => { + await expect( + new PostgresReminderRepository( + sequencedSqlClient([[{ transitioned: true, outcome_inserted: false }]]) + .client, + 300, + () => outcomeId, + () => claimKey, + ).markDelivered( + baseReminder, + '2026-08-04T12:00:01.000Z', + claimKey, + idempotencyKey, + ), + ).rejects.toBeInstanceOf(NotificationPersistenceError); + + const invalidFailures = [ + () => + new PostgresReminderRepository( + sequencedSqlClient([]).client, + 300, + () => outcomeId, + () => claimKey, + ).fail(baseReminder, null, 'delivery_failed', claimKey, idempotencyKey), + () => + new PostgresReminderRepository( + sequencedSqlClient([]).client, + 300, + () => outcomeId, + () => claimKey, + ).fail( + { ...baseReminder, deliveryAttempt: MAX_DELIVERY_ATTEMPTS }, + '2026-08-04T12:05:00.000Z', + 'delivery_failed', + claimKey, + idempotencyKey, + ), + () => + new PostgresReminderRepository( + sequencedSqlClient([]).client, + 300, + () => outcomeId, + () => claimKey, + ).fail( + { ...baseReminder, deliveryAttempt: MAX_DELIVERY_ATTEMPTS }, + '2026-08-04T12:05:00.000Z', + 'attempt_limit', + claimKey, + idempotencyKey, + ), + () => + new PostgresReminderRepository( + sequencedSqlClient([]).client, + 300, + () => outcomeId, + () => claimKey, + ).fail(baseReminder, null, 'attempt_limit', claimKey, idempotencyKey), + ]; + for (const operation of invalidFailures) { + await expect(operation()).rejects.toBeInstanceOf( + NotificationPersistenceError, + ); + } + + await expect( + new PostgresReminderRepository( + sequencedSqlClient([]).client, + 300, + () => 'invalid-outcome-id', + () => claimKey, + ).markDelivered( + baseReminder, + '2026-08-04T12:00:01.000Z', + claimKey, + idempotencyKey, + ), + ).rejects.toBeInstanceOf(NotificationPersistenceError); + }); +}); diff --git a/apps/notification-service/src/postgres-reminder-repository.integration.test.ts b/apps/notification-service/src/postgres-reminder-repository.integration.test.ts new file mode 100644 index 000000000..f04efc917 --- /dev/null +++ b/apps/notification-service/src/postgres-reminder-repository.integration.test.ts @@ -0,0 +1,604 @@ +import { randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { Pool } from 'pg'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { createNotificationRuntime } from './notification-runtime'; +import { + NotificationPersistenceError, + NotificationReplayConflictError, + PostgresInAppDeliveryGateway, + PostgresReminderRepository, + type NotificationSqlClient, + type NotificationSqlQueryResult, +} from './postgres-reminder-repository'; +import { + ReminderScheduler, + idempotencyKey, + type ReminderOccurrence, +} from './reminder-scheduler'; + +const DATABASE_URL = process.env.NOTIFICATION_DATABASE_URL; +const describeWithPostgres = DATABASE_URL ? describe : describe.skip; +let administrativePool: Pool; + +/** Implements the pool sql client test double with observable deterministic behavior. */ +class PoolSqlClient implements NotificationSqlClient { + /** Creates the component with explicit dependencies and deterministic initial state. */ + constructor(private readonly pool: Pool) {} + + /** Executes one parameterized query through the bounded SQL or test-double contract. */ + async query( + text: string, + values: readonly unknown[], + ): Promise> { + const result = await this.pool.query(text, [...values]); + return { rows: result.rows as Row[] }; + } +} + +function requireDatabaseUrl(): string { + if (!DATABASE_URL) { + throw new Error( + 'NOTIFICATION_DATABASE_URL is required for integration tests', + ); + } + return DATABASE_URL; +} + +async function applyMigration(pool: Pool): Promise { + const sql = await readFile( + resolve(__dirname, '../migrations/0001_durable_reminder_inbox.sql'), + 'utf8', + ); + await pool.query(sql); +} + +async function resetSchema(): Promise { + await administrativePool.query( + 'DROP SCHEMA IF EXISTS notification_service CASCADE', + ); + await applyMigration(administrativePool); +} + +function repository( + pool: Pool, + claimLeaseSeconds = 30, +): PostgresReminderRepository { + return new PostgresReminderRepository( + new PoolSqlClient(pool), + claimLeaseSeconds, + ); +} + +function gateway(pool: Pool): PostgresInAppDeliveryGateway { + return new PostgresInAppDeliveryGateway(new PoolSqlClient(pool)); +} + +function occurrence( + workspaceId: string, + overrides: Partial = {}, +): ReminderOccurrence { + return { + id: randomUUID(), + workspaceId, + title: 'Persist durable reminder', + dueAt: '2026-08-04T12:00:00.000Z', + timeZone: 'Asia/Seoul', + quietHours: null, + maxPerLocalDay: 4, + deliveryAttempt: 0, + ...overrides, + }; +} + +describeWithPostgres('PostgreSQL notification repository integration', () => { + beforeAll(async () => { + administrativePool = new Pool({ + connectionString: requireDatabaseUrl(), + application_name: 'life-os-notification-integration-admin', + max: 12, + }); + }); + + beforeEach(async () => { + await resetSchema(); + }); + + afterAll(async () => { + await administrativePool.query( + 'DROP SCHEMA IF EXISTS notification_service CASCADE', + ); + await administrativePool.end(); + }); + + it('preserves tenant-isolated reminders across pool restarts', async () => { + const workspaceId = randomUUID(); + const otherWorkspaceId = randomUUID(); + const visible = occurrence(workspaceId); + const privateReminder = occurrence(otherWorkspaceId); + const firstPool = new Pool({ + connectionString: requireDatabaseUrl(), + application_name: 'life-os-notification-integration-first', + max: 2, + }); + const firstRepository = repository(firstPool); + await firstRepository.schedule(visible); + await firstRepository.schedule(privateReminder); + await firstPool.end(); + + const restartedPool = new Pool({ + connectionString: requireDatabaseUrl(), + application_name: 'life-os-notification-integration-restarted', + max: 2, + }); + const restartedRepository = repository(restartedPool); + const reminders = await restartedRepository.listReminders(workspaceId, 10); + + expect(reminders).toHaveLength(1); + expect(reminders[0]).toMatchObject(visible); + expect(reminders[0]?.status).toBe('pending'); + await expect( + restartedRepository.listReminders(otherWorkspaceId, 10), + ).resolves.toHaveLength(1); + await restartedPool.end(); + }); + + it('returns due reminders by instant and identifier deterministically', async () => { + const workspaceId = randomUUID(); + const durableRepository = repository(administrativePool); + const firstId = '00000000-0000-4000-8000-000000000001'; + const secondId = '00000000-0000-4000-8000-000000000002'; + const laterId = '00000000-0000-4000-8000-000000000003'; + await durableRepository.schedule( + occurrence(workspaceId, { + id: secondId, + dueAt: '2026-08-04T10:00:00.000Z', + }), + ); + await durableRepository.schedule( + occurrence(workspaceId, { + id: laterId, + dueAt: '2026-08-04T11:00:00.000Z', + }), + ); + await durableRepository.schedule( + occurrence(workspaceId, { + id: firstId, + dueAt: '2026-08-04T10:00:00.000Z', + }), + ); + + const due = await durableRepository.listDue('2026-08-04T12:00:00.000Z', 10); + + expect(due.map((reminder) => reminder.id)).toEqual([ + firstId, + secondId, + laterId, + ]); + }); + + it('serializes concurrent workers into one active claim', async () => { + const workspaceId = randomUUID(); + const reminder = occurrence(workspaceId); + const durableRepository = repository(administrativePool, 300); + await durableRepository.schedule(reminder); + + const claims = await Promise.all( + Array.from({ length: 16 }, () => + durableRepository.claim( + workspaceId, + reminder.id, + reminder.dueAt, + reminder.deliveryAttempt, + ), + ), + ); + + expect(claims.filter((claimKey) => claimKey !== null)).toHaveLength(1); + expect(claims.filter((claimKey) => claimKey === null)).toHaveLength(15); + }); + + it('rejects a claim when the observed row version has changed', async () => { + const workspaceId = randomUUID(); + const reminder = occurrence(workspaceId); + const durableRepository = repository(administrativePool, 300); + await durableRepository.schedule(reminder); + const [observed] = await durableRepository.listDue( + '2026-08-04T12:01:00.000Z', + 10, + ); + if (observed === undefined) { + throw new Error('expected one due reminder'); + } + await administrativePool.query( + `UPDATE notification_service.reminder_occurrences + SET due_instant = due_instant + interval '1 minute', + delivery_attempt_count = delivery_attempt_count + 1 + WHERE workspace_id = $1 AND reminder_id = $2`, + [workspaceId, reminder.id], + ); + + await expect( + durableRepository.claim( + observed.workspaceId, + observed.id, + observed.dueAt, + observed.deliveryAttempt, + ), + ).resolves.toBeNull(); + }); + + it('fences an expired owner after a replacement claim is acquired', async () => { + const workspaceId = randomUUID(); + const reminder = occurrence(workspaceId); + const durableRepository = repository(administrativePool, 30); + await durableRepository.schedule(reminder); + const deliveryKey = idempotencyKey(reminder); + const firstClaim = await durableRepository.claim( + workspaceId, + reminder.id, + reminder.dueAt, + reminder.deliveryAttempt, + ); + expect(firstClaim).not.toBeNull(); + await administrativePool.query( + `UPDATE notification_service.reminder_occurrences + SET claim_expires_at = clock_timestamp() - interval '1 second' + WHERE workspace_id = $1 AND reminder_id = $2`, + [workspaceId, reminder.id], + ); + const secondClaim = await durableRepository.claim( + workspaceId, + reminder.id, + reminder.dueAt, + reminder.deliveryAttempt, + ); + expect(secondClaim).not.toBeNull(); + expect(secondClaim).not.toBe(firstClaim); + + await expect( + durableRepository.markDelivered( + reminder, + '2026-08-04T12:00:01.000Z', + firstClaim as string, + deliveryKey, + ), + ).rejects.toBeInstanceOf(NotificationPersistenceError); + await expect( + durableRepository.markDelivered( + reminder, + '2026-08-04T12:00:01.000Z', + secondClaim as string, + deliveryKey, + ), + ).resolves.toBeUndefined(); + await expect( + durableRepository.listOutcomes(workspaceId, 10), + ).resolves.toHaveLength(1); + }); + + it('recovers an expired lease and completes an exact inbox replay once', async () => { + const workspaceId = randomUUID(); + const reminder = occurrence(workspaceId); + const durableRepository = repository(administrativePool, 30); + const inAppGateway = gateway(administrativePool); + await durableRepository.schedule(reminder); + const key = idempotencyKey(reminder); + await expect( + durableRepository.claim( + workspaceId, + reminder.id, + reminder.dueAt, + reminder.deliveryAttempt, + ), + ).resolves.not.toBeNull(); + await inAppGateway.deliver({ + workspaceId, + reminderId: reminder.id, + title: reminder.title, + dueAt: reminder.dueAt, + timeZone: reminder.timeZone, + idempotencyKey: key, + }); + await administrativePool.query( + `UPDATE notification_service.reminder_occurrences + SET claim_expires_at = clock_timestamp() - interval '1 second' + WHERE workspace_id = $1 AND reminder_id = $2`, + [workspaceId, reminder.id], + ); + + const scheduler = new ReminderScheduler( + durableRepository, + inAppGateway, + 10, + ); + await expect( + scheduler.run(new Date('2026-08-04T12:01:00.000Z')), + ).resolves.toEqual({ + scanned: 1, + delivered: 1, + deferred: 0, + failed: 0, + persistenceFailures: 0, + duplicateClaims: 0, + invalid: 0, + }); + await expect( + scheduler.run(new Date('2026-08-04T12:02:00.000Z')), + ).resolves.toMatchObject({ scanned: 0, delivered: 0 }); + await expect( + durableRepository.listInbox(workspaceId, 10), + ).resolves.toHaveLength(1); + await expect( + durableRepository.listOutcomes(workspaceId, 10), + ).resolves.toMatchObject([{ kind: 'delivered', reminderId: reminder.id }]); + await expect( + inAppGateway.deliver({ + workspaceId, + reminderId: reminder.id, + title: 'Conflicting reminder title', + dueAt: reminder.dueAt, + timeZone: reminder.timeZone, + idempotencyKey: key, + }), + ).rejects.toBeInstanceOf(NotificationReplayConflictError); + }); + + it('counts delivered evidence by tenant and local calendar date', async () => { + const workspaceId = randomUUID(); + const otherWorkspaceId = randomUUID(); + const durableRepository = repository(administrativePool); + const scheduler = new ReminderScheduler( + durableRepository, + gateway(administrativePool), + 10, + ); + await durableRepository.schedule( + occurrence(workspaceId, { + dueAt: '2026-08-04T08:00:00.000Z', + }), + ); + await durableRepository.schedule( + occurrence(workspaceId, { + dueAt: '2026-08-04T08:01:00.000Z', + }), + ); + await durableRepository.schedule( + occurrence(otherWorkspaceId, { + dueAt: '2026-08-04T08:02:00.000Z', + }), + ); + + await scheduler.run(new Date('2026-08-04T08:03:00.000Z')); + + await expect( + durableRepository.countDelivered(workspaceId, '2026-08-04'), + ).resolves.toBe(2); + await expect( + durableRepository.countDelivered(otherWorkspaceId, '2026-08-04'), + ).resolves.toBe(1); + await expect( + durableRepository.countDelivered(workspaceId, '2026-08-05'), + ).resolves.toBe(0); + }); + + it('persists quiet-hour and daily-limit deferrals with the next due instant', async () => { + const workspaceId = randomUUID(); + const durableRepository = repository(administrativePool); + const scheduler = new ReminderScheduler( + durableRepository, + gateway(administrativePool), + 10, + ); + const quietReminder = occurrence(workspaceId, { + dueAt: '2026-08-04T12:00:00.000Z', + quietHours: { startMinute: 1_200, endMinute: 1_320 }, + }); + await durableRepository.schedule(quietReminder); + + await expect( + scheduler.run(new Date('2026-08-04T12:01:00.000Z')), + ).resolves.toMatchObject({ deferred: 1, delivered: 0 }); + await expect( + durableRepository.listOutcomes(workspaceId, 10), + ).resolves.toMatchObject([ + { + reminderId: quietReminder.id, + kind: 'deferred', + reason: 'quiet_hours', + nextAttemptAt: '2026-08-04T13:00:00.000Z', + }, + ]); + await expect( + durableRepository.listReminders(workspaceId, 10), + ).resolves.toMatchObject([ + { + id: quietReminder.id, + dueAt: '2026-08-04T13:00:00.000Z', + status: 'pending', + }, + ]); + + await resetSchema(); + const fatigueRepository = repository(administrativePool); + const fatigueScheduler = new ReminderScheduler( + fatigueRepository, + gateway(administrativePool), + 10, + ); + const deliveredSeed = occurrence(workspaceId, { + dueAt: '2026-08-04T09:00:00.000Z', + maxPerLocalDay: 1, + }); + await fatigueRepository.schedule(deliveredSeed); + await fatigueScheduler.run(new Date('2026-08-04T09:01:00.000Z')); + const fatigueReminder = occurrence(workspaceId, { + dueAt: '2026-08-04T10:00:00.000Z', + maxPerLocalDay: 1, + }); + await fatigueRepository.schedule(fatigueReminder); + + await expect( + fatigueScheduler.run(new Date('2026-08-04T10:01:00.000Z')), + ).resolves.toMatchObject({ deferred: 1, delivered: 0 }); + await expect( + fatigueRepository.listOutcomes(workspaceId, 10), + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + reminderId: fatigueReminder.id, + kind: 'deferred', + reason: 'daily_limit', + nextAttemptAt: '2026-08-04T15:00:00.000Z', + }), + expect.objectContaining({ + reminderId: deliveredSeed.id, + kind: 'delivered', + }), + ]), + ); + }); + + it('persists retryable and terminal failures without provider exception text', async () => { + const workspaceId = randomUUID(); + const durableRepository = repository(administrativePool); + const failingGateway = { + /** Persists or verifies one idempotent in-app reminder delivery. */ + async deliver(): Promise { + throw new Error('provider token and sensitive exception text'); + }, + }; + const failingScheduler = new ReminderScheduler( + durableRepository, + failingGateway, + 10, + ); + const retryable = occurrence(workspaceId, { + dueAt: '2026-08-04T12:00:00.000Z', + }); + await durableRepository.schedule(retryable); + + await expect( + failingScheduler.run(new Date('2026-08-04T12:01:00.000Z')), + ).resolves.toMatchObject({ failed: 1 }); + await expect( + durableRepository.listReminders(workspaceId, 10), + ).resolves.toMatchObject([ + { + id: retryable.id, + dueAt: '2026-08-04T12:06:00.000Z', + deliveryAttempt: 1, + status: 'pending', + }, + ]); + const [retryOutcome] = await durableRepository.listOutcomes( + workspaceId, + 10, + ); + expect(retryOutcome).toMatchObject({ + reminderId: retryable.id, + kind: 'failed', + reason: 'delivery_failed', + nextAttemptAt: '2026-08-04T12:06:00.000Z', + }); + expect(JSON.stringify(retryOutcome)).not.toContain('provider token'); + + await resetSchema(); + const terminalRepository = repository(administrativePool); + const terminalScheduler = new ReminderScheduler( + terminalRepository, + failingGateway, + 10, + ); + const terminal = occurrence(workspaceId, { + dueAt: '2026-08-04T13:00:00.000Z', + deliveryAttempt: 3, + }); + await terminalRepository.schedule(terminal); + await expect( + terminalScheduler.run(new Date('2026-08-04T13:01:00.000Z')), + ).resolves.toMatchObject({ failed: 1 }); + await expect( + terminalRepository.listReminders(workspaceId, 10), + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: terminal.id, + deliveryAttempt: 3, + status: 'failed', + }), + ]), + ); + await expect( + terminalRepository.listOutcomes(workspaceId, 10), + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + reminderId: terminal.id, + kind: 'failed', + reason: 'attempt_limit', + nextAttemptAt: null, + }), + ]), + ); + }); + + it('composes the production runtime through one owned pool', async () => { + const workspaceId = randomUUID(); + const runtime = createNotificationRuntime({ + NOTIFICATION_DATABASE_URL: requireDatabaseUrl(), + NOTIFICATION_DATABASE_POOL_MAX: '2', + NOTIFICATION_CLAIM_LEASE_SECONDS: '30', + NOTIFICATION_REMINDER_BATCH_SIZE: '10', + }); + try { + await runtime.repository.schedule(occurrence(workspaceId)); + await expect( + runtime.repository.listReminders(workspaceId, 10), + ).resolves.toHaveLength(1); + } finally { + await runtime.close(); + await runtime.close(); + } + }); + + it('enforces immutable outcome history for update, delete, and truncate', async () => { + const workspaceId = randomUUID(); + const reminder = occurrence(workspaceId); + const durableRepository = repository(administrativePool); + const scheduler = new ReminderScheduler( + durableRepository, + gateway(administrativePool), + 10, + ); + await durableRepository.schedule(reminder); + await scheduler.run(new Date('2026-08-04T12:01:00.000Z')); + const [outcome] = await durableRepository.listOutcomes(workspaceId, 10); + expect(outcome).toBeDefined(); + + await expect( + administrativePool.query( + `UPDATE notification_service.reminder_outcomes + SET occurred_at = occurred_at + interval '1 second' + WHERE outcome_id = $1`, + [outcome?.id], + ), + ).rejects.toMatchObject({ code: '55000' }); + await expect( + administrativePool.query( + `DELETE FROM notification_service.reminder_outcomes + WHERE outcome_id = $1`, + [outcome?.id], + ), + ).rejects.toMatchObject({ code: '55000' }); + await expect( + administrativePool.query( + 'TRUNCATE notification_service.reminder_outcomes', + ), + ).rejects.toMatchObject({ code: '55000' }); + await expect( + durableRepository.listOutcomes(workspaceId, 10), + ).resolves.toHaveLength(1); + }); +}); diff --git a/apps/notification-service/src/postgres-reminder-repository.test.ts b/apps/notification-service/src/postgres-reminder-repository.test.ts new file mode 100644 index 000000000..8dd1c9aeb --- /dev/null +++ b/apps/notification-service/src/postgres-reminder-repository.test.ts @@ -0,0 +1,542 @@ +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import type { + ReminderDelivery, + ReminderOccurrence, +} from './reminder-scheduler'; +import { + NotificationPersistenceError, + NotificationReplayConflictError, + PostgresInAppDeliveryGateway, + PostgresReminderRepository, + hashNotificationIdempotencyKey, + type NotificationSqlClient, + type NotificationSqlQueryResult, +} from './postgres-reminder-repository'; + +const workspaceId = '018f47a4-9976-4c57-8a8a-674630a873d1'; +const otherWorkspaceId = '69b8f6fb-c65a-462e-b5e7-1b21808db998'; +const reminderId = '91fe0f58-2035-49b7-a793-ac75939a433f'; +const outcomeId = 'fa6d0f3e-337c-4d94-b17d-4afcf6bf79c1'; +const messageId = 'ca035df4-0149-4b08-8f21-07bd758cfbaa'; +const claimKey = 'ebeb80f5-a077-45ee-9f39-f3e64af94cdb'; +const idempotencyKey = `${workspaceId}:${reminderId}:2026-08-04T12:00:00.000Z`; + +/** Defines the query call shape used to make the test evidence explicit. */ +interface QueryCall { + readonly text: string; + readonly values: readonly unknown[]; +} + +/** Implements the recording sql client test double with observable deterministic behavior. */ +class RecordingSqlClient implements NotificationSqlClient { + readonly calls: QueryCall[] = []; + + /** Creates the component with explicit dependencies and deterministic initial state. */ + constructor(private readonly responses: readonly unknown[][]) {} + + /** Executes one parameterized query through the bounded SQL or test-double contract. */ + async query( + text: string, + values: readonly unknown[], + ): Promise> { + this.calls.push({ text, values }); + const response = this.responses[this.calls.length - 1] ?? []; + return { rows: response as Row[] }; + } +} + +function reminder( + overrides: Partial = {}, +): ReminderOccurrence { + return { + id: reminderId, + workspaceId, + title: 'Prepare the weekly review', + dueAt: '2026-08-04T12:00:00.000Z', + timeZone: 'Asia/Seoul', + quietHours: { startMinute: 1320, endMinute: 420 }, + maxPerLocalDay: 4, + deliveryAttempt: 0, + ...overrides, + }; +} + +function reminderRow(overrides: Record = {}) { + return { + reminder_id: reminderId, + workspace_id: workspaceId, + reminder_title: 'Prepare the weekly review', + due_instant: new Date('2026-08-04T12:00:00.000Z'), + time_zone: 'Asia/Seoul', + quiet_start_minute: 1320, + quiet_end_minute: 420, + daily_delivery_limit: 4, + delivery_attempt_count: 0, + occurrence_status: 'pending', + claim_expires_at: null, + created_at: new Date('2026-08-04T10:00:00.000Z'), + updated_at: new Date('2026-08-04T10:00:00.000Z'), + ...overrides, + }; +} + +function inboxRow(overrides: Record = {}) { + return { + message_id: messageId, + workspace_id: workspaceId, + reminder_id: reminderId, + message_title: 'Prepare the weekly review', + due_instant: new Date('2026-08-04T12:00:00.000Z'), + time_zone: 'Asia/Seoul', + delivered_at: new Date('2026-08-04T12:00:01.000Z'), + read_at: null, + created_at: new Date('2026-08-04T12:00:01.000Z'), + ...overrides, + }; +} + +function outcomeRow(overrides: Record = {}) { + return { + outcome_id: outcomeId, + workspace_id: workspaceId, + reminder_id: reminderId, + outcome_kind: 'delivered', + occurred_at: new Date('2026-08-04T12:00:01.000Z'), + next_attempt_at: null, + outcome_reason: null, + delivery_local_date: '2026-08-04', + created_at: new Date('2026-08-04T12:00:01.000Z'), + ...overrides, + }; +} + +describe('notification idempotency digest', () => { + it('returns the exact SHA-256 bytes without retaining the raw key', () => { + const digest = hashNotificationIdempotencyKey(idempotencyKey); + + expect(Buffer.isBuffer(digest)).toBe(true); + expect(digest).toHaveLength(32); + expect(digest).toEqual( + createHash('sha256').update(idempotencyKey, 'utf8').digest(), + ); + expect(digest.toString('utf8')).not.toContain(idempotencyKey); + }); + + it('rejects empty, control-bearing, non-string, and oversized keys', () => { + for (const value of [ + '', + 'key\nvalue', + 42, + 'x'.repeat(1025), + 'é'.repeat(600), + ]) { + expect(() => hashNotificationIdempotencyKey(value)).toThrowError( + NotificationPersistenceError, + ); + } + }); +}); + +describe('PostgresReminderRepository', () => { + it('inserts and returns one validated reminder with static parameters', async () => { + const client = new RecordingSqlClient([[reminderRow()]]); + const repository = new PostgresReminderRepository(client, 300); + + await expect(repository.schedule(reminder())).resolves.toEqual({ + ...reminder(), + status: 'pending', + claimExpiresAt: null, + createdAt: '2026-08-04T10:00:00.000Z', + updatedAt: '2026-08-04T10:00:00.000Z', + }); + + expect(client.calls).toHaveLength(1); + expect(client.calls[0]?.text).toContain( + 'INSERT INTO notification_service.reminder_occurrences', + ); + expect(client.calls[0]?.text).toContain('ON CONFLICT DO NOTHING'); + expect(client.calls[0]?.text).not.toContain(reminder().title); + expect(client.calls[0]?.values).toEqual([ + reminderId, + workspaceId, + reminder().title, + reminder().dueAt, + reminder().timeZone, + 1320, + 420, + 4, + 0, + ]); + }); + + it('returns an exact schedule replay and rejects conflicting identifier reuse', async () => { + const exactClient = new RecordingSqlClient([[], [reminderRow()]]); + const exactRepository = new PostgresReminderRepository(exactClient); + await expect(exactRepository.schedule(reminder())).resolves.toMatchObject({ + id: reminderId, + workspaceId, + title: reminder().title, + }); + expect(exactClient.calls[1]?.text).toContain( + 'WHERE workspace_id = $1 AND reminder_id = $2', + ); + + const conflictClient = new RecordingSqlClient([ + [], + [reminderRow({ reminder_title: 'Different reminder' })], + ]); + await expect( + new PostgresReminderRepository(conflictClient).schedule(reminder()), + ).rejects.toBeInstanceOf(NotificationReplayConflictError); + }); + + it('lists due rows in bounded deterministic order and validates every row', async () => { + const client = new RecordingSqlClient([[reminderRow()]]); + const repository = new PostgresReminderRepository(client); + + await expect( + repository.listDue('2026-08-04T12:00:00.000Z', 20), + ).resolves.toEqual([reminder()]); + expect(client.calls[0]?.text).toContain("occurrence_status = 'pending'"); + expect(client.calls[0]?.text).toContain( + 'claim_expires_at IS NULL OR claim_expires_at <= $1', + ); + expect(client.calls[0]?.text).toContain( + 'ORDER BY due_instant ASC, reminder_id ASC', + ); + expect(client.calls[0]?.text).toContain('LIMIT $2'); + expect(client.calls[0]?.values).toEqual(['2026-08-04T12:00:00.000Z', 20]); + + const invalidClient = new RecordingSqlClient([ + [reminderRow({ workspace_id: 'numeric-1' })], + ]); + await expect( + new PostgresReminderRepository(invalidClient).listDue( + '2026-08-04T12:00:00.000Z', + 20, + ), + ).rejects.toBeInstanceOf(NotificationPersistenceError); + }); + + it('returns one opaque claim key and null when the lease is unavailable', async () => { + const claimedClient = new RecordingSqlClient([ + [{ reminder_id: reminderId }], + ]); + const repository = new PostgresReminderRepository( + claimedClient, + 600, + () => outcomeId, + () => claimKey, + ); + + await expect( + repository.claim( + workspaceId, + reminderId, + reminder().dueAt, + reminder().deliveryAttempt, + ), + ).resolves.toBe(claimKey); + const call = claimedClient.calls[0]; + expect(call?.text).toContain( + 'UPDATE notification_service.reminder_occurrences', + ); + expect(call?.text).toContain('claim_expires_at <= clock_timestamp()'); + expect(call?.text).toContain('make_interval(secs => $4)'); + expect(call?.text).toContain('due_instant = $5'); + expect(call?.text).toContain('delivery_attempt_count = $6'); + expect(call?.values?.[0]).toBe(workspaceId); + expect(call?.values?.[1]).toBe(reminderId); + expect(call?.values?.[2]).toEqual(hashNotificationIdempotencyKey(claimKey)); + expect(call?.values?.[3]).toBe(600); + expect(call?.values?.[4]).toBe(reminder().dueAt); + expect(call?.values?.[5]).toBe(reminder().deliveryAttempt); + + const missedClient = new RecordingSqlClient([[]]); + await expect( + new PostgresReminderRepository( + missedClient, + 300, + () => outcomeId, + () => claimKey, + ).claim( + workspaceId, + reminderId, + reminder().dueAt, + reminder().deliveryAttempt, + ), + ).resolves.toBeNull(); + await expect( + new PostgresReminderRepository( + new RecordingSqlClient([]), + 300, + () => outcomeId, + () => 'numeric-claim-key', + ).claim( + workspaceId, + reminderId, + reminder().dueAt, + reminder().deliveryAttempt, + ), + ).rejects.toBeInstanceOf(NotificationPersistenceError); + }); + + it('counts delivered outcomes by tenant and validated local date', async () => { + const client = new RecordingSqlClient([[{ delivery_count: '7' }]]); + const repository = new PostgresReminderRepository(client); + + await expect( + repository.countDelivered(workspaceId, '2026-08-04'), + ).resolves.toBe(7); + expect(client.calls[0]?.text).toContain('WHERE workspace_id = $1'); + expect(client.calls[0]?.text).toContain("outcome_kind = 'delivered'"); + expect(client.calls[0]?.values).toEqual([workspaceId, '2026-08-04']); + }); + + it('persists delivered, deferred, retryable, and terminal transitions atomically', async () => { + const response = [{ transitioned: true, outcome_inserted: true }]; + const client = new RecordingSqlClient([ + response, + response, + response, + response, + ]); + const repository = new PostgresReminderRepository(client); + + await repository.markDelivered( + reminder(), + '2026-08-04T12:00:01.000Z', + claimKey, + idempotencyKey, + ); + await repository.defer( + reminder(), + '2026-08-04T22:00:00.000Z', + 'quiet_hours', + claimKey, + idempotencyKey, + ); + await repository.fail( + reminder(), + '2026-08-04T12:05:00.000Z', + 'delivery_failed', + claimKey, + idempotencyKey, + ); + await repository.fail( + reminder({ deliveryAttempt: 3 }), + null, + 'attempt_limit', + claimKey, + idempotencyKey, + ); + + for (const call of client.calls) { + expect(call.text).toContain('WITH transitioned_occurrence AS'); + expect(call.text).toContain('claim_expires_at > clock_timestamp()'); + expect(call.text).toContain( + 'INSERT INTO notification_service.reminder_outcomes', + ); + const claimDigest = hashNotificationIdempotencyKey(claimKey); + const deliveryDigest = hashNotificationIdempotencyKey(idempotencyKey); + expect(call.values).toContainEqual(deliveryDigest); + expect(call.values).toContainEqual(claimDigest); + expect(claimDigest).not.toEqual(deliveryDigest); + } + expect(client.calls[0]?.text).toContain("occurrence_status = 'delivered'"); + expect(client.calls[1]?.text).toContain("'deferred'"); + expect(client.calls[2]?.text).toContain('delivery_attempt_count + 1'); + expect(client.calls[3]?.text).toContain("occurrence_status = 'failed'"); + }); + + it('fails closed when a transition does not own the exact claim', async () => { + const repository = new PostgresReminderRepository( + new RecordingSqlClient([ + [{ transitioned: false, outcome_inserted: false }], + ]), + ); + + await expect( + repository.markDelivered( + reminder(), + '2026-08-04T12:00:01.000Z', + claimKey, + idempotencyKey, + ), + ).rejects.toBeInstanceOf(NotificationPersistenceError); + }); + + it('lists bounded tenant reminders, outcomes, and inbox rows', async () => { + const client = new RecordingSqlClient([ + [reminderRow()], + [outcomeRow()], + [inboxRow()], + ]); + const repository = new PostgresReminderRepository(client); + + await expect( + repository.listReminders(workspaceId, 10), + ).resolves.toHaveLength(1); + await expect(repository.listOutcomes(workspaceId, 10)).resolves.toEqual([ + { + id: outcomeId, + workspaceId, + reminderId, + kind: 'delivered', + occurredAt: '2026-08-04T12:00:01.000Z', + nextAttemptAt: null, + reason: null, + deliveryLocalDate: '2026-08-04', + createdAt: '2026-08-04T12:00:01.000Z', + }, + ]); + await expect(repository.listInbox(workspaceId, 10)).resolves.toEqual([ + { + id: messageId, + workspaceId, + reminderId, + title: reminder().title, + dueAt: reminder().dueAt, + timeZone: reminder().timeZone, + deliveredAt: '2026-08-04T12:00:01.000Z', + readAt: null, + createdAt: '2026-08-04T12:00:01.000Z', + }, + ]); + for (const call of client.calls) { + expect(call.text).toContain('WHERE workspace_id = $1'); + expect(call.text).toContain('LIMIT $2'); + expect(call.values).toEqual([workspaceId, 10]); + } + }); + + it('preserves PostgreSQL date values at a positive-offset boundary', async () => { + const previousTimeZone = process.env.TZ; + process.env.TZ = 'Asia/Seoul'; + try { + const client = new RecordingSqlClient([ + [outcomeRow({ delivery_local_date: new Date(2026, 7, 4) })], + ]); + + await expect( + new PostgresReminderRepository(client).listOutcomes(workspaceId, 10), + ).resolves.toMatchObject([{ deliveryLocalDate: '2026-08-04' }]); + } finally { + if (previousTimeZone === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = previousTimeZone; + } + } + }); + + it('rejects invalid limits, dates, identifiers, lease values, and SQL failures', async () => { + expect( + () => new PostgresReminderRepository(new RecordingSqlClient([]), 29), + ).toThrowError(NotificationPersistenceError); + expect( + () => new PostgresReminderRepository(new RecordingSqlClient([]), 3601), + ).toThrowError(NotificationPersistenceError); + + const repository = new PostgresReminderRepository( + new RecordingSqlClient([]), + ); + for (const operation of [ + () => repository.listDue('invalid', 10), + () => repository.listDue('2026-08-04', 10), + () => repository.listDue('2026-08-04T12:00:00.000Z', 0), + () => repository.countDelivered('123', '2026-08-04'), + () => repository.countDelivered(workspaceId, '08/04/2026'), + () => repository.listReminders(workspaceId, 101), + () => repository.listOutcomes('123', 10), + () => repository.listInbox(workspaceId, 1.5), + ]) { + await expect(operation()).rejects.toBeInstanceOf( + NotificationPersistenceError, + ); + } + + const failingClient: NotificationSqlClient = { + /** Executes one parameterized query through the bounded SQL or test-double contract. */ + async query() { + throw new Error('database secret must not escape'); + }, + }; + await expect( + new PostgresReminderRepository(failingClient).listReminders( + workspaceId, + 10, + ), + ).rejects.toEqual(new NotificationPersistenceError()); + }); +}); + +describe('PostgresInAppDeliveryGateway', () => { + const delivery: ReminderDelivery = { + workspaceId, + reminderId, + title: reminder().title, + dueAt: reminder().dueAt, + timeZone: reminder().timeZone, + idempotencyKey, + }; + + it('inserts one credential-free message with an opaque ID and digest', async () => { + const client = new RecordingSqlClient([[inboxRow()]]); + const gateway = new PostgresInAppDeliveryGateway(client, () => messageId); + + await expect(gateway.deliver(delivery)).resolves.toBeUndefined(); + const call = client.calls[0]; + expect(call?.text).toContain( + 'INSERT INTO notification_service.inbox_messages', + ); + expect(call?.text).toContain('ON CONFLICT DO NOTHING'); + expect(call?.text).not.toContain(delivery.title); + expect(call?.values).toEqual([ + messageId, + workspaceId, + reminderId, + delivery.title, + delivery.dueAt, + delivery.timeZone, + hashNotificationIdempotencyKey(idempotencyKey), + ]); + }); + + it('accepts exact replay and rejects an idempotency collision', async () => { + const exactClient = new RecordingSqlClient([[], [inboxRow()]]); + await expect( + new PostgresInAppDeliveryGateway(exactClient, () => messageId).deliver( + delivery, + ), + ).resolves.toBeUndefined(); + + const conflictClient = new RecordingSqlClient([ + [], + [inboxRow({ reminder_id: 'ee09fe10-2602-4d6c-b52a-e58cbf55ea41' })], + ]); + await expect( + new PostgresInAppDeliveryGateway(conflictClient, () => messageId).deliver( + delivery, + ), + ).rejects.toBeInstanceOf(NotificationReplayConflictError); + }); + + it('rejects malformed delivery envelopes and UUID factories', async () => { + await expect( + new PostgresInAppDeliveryGateway(new RecordingSqlClient([])).deliver({ + ...delivery, + workspaceId: otherWorkspaceId, + reminderId: 'numeric-2', + }), + ).rejects.toBeInstanceOf(NotificationPersistenceError); + + await expect( + new PostgresInAppDeliveryGateway( + new RecordingSqlClient([]), + () => 'numeric-3', + ).deliver(delivery), + ).rejects.toBeInstanceOf(NotificationPersistenceError); + }); +}); diff --git a/apps/notification-service/src/postgres-reminder-repository.ts b/apps/notification-service/src/postgres-reminder-repository.ts new file mode 100644 index 000000000..04e7a55ae --- /dev/null +++ b/apps/notification-service/src/postgres-reminder-repository.ts @@ -0,0 +1,1063 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { + MAX_DAILY_REMINDERS, + MAX_DELIVERY_ATTEMPTS, + MAX_REMINDER_BATCH_SIZE, + /** Represents the bounded reminder delivery values accepted by the notification service. */ + type ReminderDelivery, + /** Represents the bounded reminder delivery gateway values accepted by the notification service. */ + type ReminderDeliveryGateway, + /** Represents the bounded reminder occurrence values accepted by the notification service. */ + type ReminderOccurrence, + /** Represents the bounded reminder repository values accepted by the notification service. */ + type ReminderRepository, + validateReminderOccurrence, +} from './reminder-scheduler'; + +const UUID_V4_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; +const RFC_3339_TIMESTAMP_PATTERN = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/u; +const LOCAL_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u; +const MAXIMUM_IDEMPOTENCY_KEY_BYTES = 1_024; +const MAXIMUM_QUERY_LIMIT = 100; +const MINIMUM_CLAIM_LEASE_SECONDS = 30; +const MAXIMUM_CLAIM_LEASE_SECONDS = 3_600; + +/** Minimal query result returned by a notification SQL client. */ +export interface NotificationSqlQueryResult { + readonly rows: Row[]; +} + +/** Parameterized SQL boundary used by the PostgreSQL notification adapters. */ +export interface NotificationSqlClient { + /** Executes one parameterized PostgreSQL statement and maps transport failures to a credential-free service error. */ + query( + text: string, + values: readonly unknown[], + ): Promise>; +} + +/** Durable reminder record returned by tenant-scoped service reads. */ +export interface PersistedReminderOccurrence extends ReminderOccurrence { + readonly status: 'pending' | 'delivered' | 'failed'; + readonly claimExpiresAt: string | null; + readonly createdAt: string; + readonly updatedAt: string; +} + +/** Immutable scheduler outcome returned by tenant-scoped service reads. */ +export interface ReminderOutcome { + readonly id: string; + readonly workspaceId: string; + readonly reminderId: string; + readonly kind: 'delivered' | 'deferred' | 'failed'; + readonly occurredAt: string; + readonly nextAttemptAt: string | null; + readonly reason: + 'quiet_hours' | 'daily_limit' | 'delivery_failed' | 'attempt_limit' | null; + readonly deliveryLocalDate: string | null; + readonly createdAt: string; +} + +/** Durable in-app notification returned by tenant-scoped service reads. */ +export interface InboxMessage { + readonly id: string; + readonly workspaceId: string; + readonly reminderId: string; + readonly title: string; + readonly dueAt: string; + readonly timeZone: string; + readonly deliveredAt: string; + readonly readAt: string | null; + readonly createdAt: string; +} + +/** Describes the untrusted PostgreSQL reminder row validated before domain use. */ +interface ReminderRow { + reminder_id: unknown; + workspace_id: unknown; + reminder_title: unknown; + due_instant: unknown; + time_zone: unknown; + quiet_start_minute: unknown; + quiet_end_minute: unknown; + daily_delivery_limit: unknown; + delivery_attempt_count: unknown; + occurrence_status: unknown; + claim_expires_at: unknown; + created_at: unknown; + updated_at: unknown; +} + +/** Describes the untrusted PostgreSQL outcome row validated before domain use. */ +interface OutcomeRow { + outcome_id: unknown; + workspace_id: unknown; + reminder_id: unknown; + outcome_kind: unknown; + occurred_at: unknown; + next_attempt_at: unknown; + outcome_reason: unknown; + delivery_local_date: unknown; + created_at: unknown; +} + +/** Describes the untrusted PostgreSQL inbox row validated before domain use. */ +interface InboxRow { + message_id: unknown; + workspace_id: unknown; + reminder_id: unknown; + message_title: unknown; + due_instant: unknown; + time_zone: unknown; + delivered_at: unknown; + read_at: unknown; + created_at: unknown; +} + +/** Describes the untrusted PostgreSQL count row validated before domain use. */ +interface CountRow { + delivery_count: unknown; +} + +/** Describes the untrusted PostgreSQL identifier row validated before domain use. */ +interface IdentifierRow { + reminder_id: unknown; +} + +/** Describes the untrusted PostgreSQL transition row validated before domain use. */ +interface TransitionRow { + transitioned: unknown; + outcome_inserted: unknown; +} + +/** Safe public failure for invalid input, malformed rows, and SQL failures. */ +export class NotificationPersistenceError extends Error { + /** Creates the component with validated dependencies and bounded configuration. */ + constructor() { + super('Notification persistence operation failed'); + this.name = 'NotificationPersistenceError'; + } +} + +/** Signals that an idempotent identifier was reused with another payload. */ +export class NotificationReplayConflictError extends Error { + /** Creates the component with validated dependencies and bounded configuration. */ + constructor() { + super('Notification replay conflicts with the persisted payload'); + this.name = 'NotificationReplayConflictError'; + } +} + +/** Raises the stable credential-free persistence error used at every fail-closed boundary. */ +function persistenceFailure(): never { + throw new NotificationPersistenceError(); +} + +/** Validates and canonicalizes an untrusted UUIDv4 identifier before it reaches SQL. */ +function requireUuid(value: unknown): string { + if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) { + return persistenceFailure(); + } + return value.toLowerCase(); +} + +/** Rejects a returned identifier when it does not match the tenant-scoped value requested by the caller. */ +function requireExpectedUuid(actual: string, expected: string): void { + if (actual !== requireUuid(expected)) { + /** Performs the persistence failure operation while preserving tenant-safe bounded behavior. */ + persistenceFailure(); + } +} + +/** Validates an RFC 3339 timestamp or PostgreSQL Date value and returns canonical UTC text. */ +function requireTimestamp(value: unknown): string { + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) { + return persistenceFailure(); + } + return value.toISOString(); + } + if (typeof value !== 'string' || !RFC_3339_TIMESTAMP_PATTERN.test(value)) { + return persistenceFailure(); + } + const candidate = new Date(value); + if (Number.isNaN(candidate.getTime())) { + return persistenceFailure(); + } + return candidate.toISOString(); +} + +/** Validates an optional timestamp while preserving an explicit null value. */ +function requireNullableTimestamp(value: unknown): string | null { + return value === null ? null : requireTimestamp(value); +} + +/** Validates a real Gregorian calendar date in YYYY-MM-DD form. */ +function requireLocalDate(value: unknown): string { + const candidate = + value instanceof Date + ? [ + /** Performs the string operation while preserving tenant-safe bounded behavior. */ + String(value.getFullYear()).padStart(4, '0'), + /** Performs the string operation while preserving tenant-safe bounded behavior. */ + String(value.getMonth() + 1).padStart(2, '0'), + /** Performs the string operation while preserving tenant-safe bounded behavior. */ + String(value.getDate()).padStart(2, '0'), + ].join('-') + : value; + if (typeof candidate !== 'string' || !LOCAL_DATE_PATTERN.test(candidate)) { + return persistenceFailure(); + } + const parsed = new Date(`${candidate}T00:00:00.000Z`); + if ( + Number.isNaN(parsed.getTime()) || + parsed.toISOString().slice(0, 10) !== candidate + ) { + return persistenceFailure(); + } + return candidate; +} + +/** Validates an optional local calendar date while preserving null. */ +function requireNullableLocalDate(value: unknown): string | null { + return value === null ? null : requireLocalDate(value); +} + +/** Validates an integer against an explicit inclusive safety range. */ +function requireInteger( + value: unknown, + minimum: number, + maximum: number, +): number { + const candidate = typeof value === 'string' ? Number(value) : value; + if ( + typeof candidate !== 'number' || + !Number.isSafeInteger(candidate) || + candidate < minimum || + candidate > maximum + ) { + return persistenceFailure(); + } + return candidate; +} + +/** Validates a caller-supplied result limit against the repository-wide maximum. */ +function requireLimit(value: number): number { + return requireInteger(value, 1, MAXIMUM_QUERY_LIMIT); +} + +/** Converts untrusted reminder data into the validated scheduler domain shape or fails closed. */ +function safeReminderOccurrence(value: unknown): ReminderOccurrence { + try { + return validateReminderOccurrence(value); + } catch { + return persistenceFailure(); + } +} + +/** Returns the exact SHA-256 bytes for a bounded opaque idempotency key. */ +export function hashNotificationIdempotencyKey(value: unknown): Buffer { + if ( + typeof value !== 'string' || + value.length === 0 || + Buffer.byteLength(value, 'utf8') > MAXIMUM_IDEMPOTENCY_KEY_BYTES || + /[\u0000-\u001f\u007f]/u.test(value) + ) { + return persistenceFailure(); + } + return createHash('sha256').update(value, 'utf8').digest(); +} + +/** Requires a SQL operation to return exactly one row before any row is trusted. */ +function exactlyOne(rows: readonly Row[]): Row { + if (rows.length !== 1) { + return persistenceFailure(); + } + return rows[0] as Row; +} + +/** Requires a conditional SQL operation to return at most one row. */ +function zeroOrOne(rows: readonly Row[]): Row | undefined { + if (rows.length > 1) { + return persistenceFailure(); + } + return rows[0]; +} + +/** Validates the scheduler fields of an untrusted PostgreSQL reminder row. */ +function baseReminderFromRow(row: ReminderRow): ReminderOccurrence { + const quietStart = + row.quiet_start_minute === null + ? null + : requireInteger(row.quiet_start_minute, 0, 1_439); + const quietEnd = + row.quiet_end_minute === null + ? null + : requireInteger(row.quiet_end_minute, 0, 1_439); + if ((quietStart === null) !== (quietEnd === null)) { + return persistenceFailure(); + } + return safeReminderOccurrence({ + id: requireUuid(row.reminder_id), + workspaceId: requireUuid(row.workspace_id), + title: row.reminder_title, + dueAt: requireTimestamp(row.due_instant), + timeZone: row.time_zone, + quietHours: + quietStart === null + ? null + : { startMinute: quietStart, endMinute: quietEnd as number }, + maxPerLocalDay: requireInteger( + row.daily_delivery_limit, + 1, + MAX_DAILY_REMINDERS, + ), + deliveryAttempt: requireInteger( + row.delivery_attempt_count, + 0, + MAX_DELIVERY_ATTEMPTS, + ), + }); +} + +/** Validates a complete durable reminder row and enforces optional tenant and reminder expectations. */ +function parsePersistedReminder( + row: ReminderRow, + expectedWorkspaceId: string, + expectedReminderId?: string, +): PersistedReminderOccurrence { + const reminder = baseReminderFromRow(row); + /** Performs the require expected uuid operation while preserving tenant-safe bounded behavior. */ + requireExpectedUuid(reminder.workspaceId, expectedWorkspaceId); + if (expectedReminderId !== undefined) { + /** Performs the require expected uuid operation while preserving tenant-safe bounded behavior. */ + requireExpectedUuid(reminder.id, expectedReminderId); + } + const status = row.occurrence_status; + if (status !== 'pending' && status !== 'delivered' && status !== 'failed') { + return persistenceFailure(); + } + const createdAt = requireTimestamp(row.created_at); + const updatedAt = requireTimestamp(row.updated_at); + if (Date.parse(updatedAt) < Date.parse(createdAt)) { + return persistenceFailure(); + } + return { + ...reminder, + status, + claimExpiresAt: requireNullableTimestamp(row.claim_expires_at), + createdAt, + updatedAt, + }; +} + +/** Validates an immutable outcome row and its kind-specific invariants. */ +function parseOutcome( + row: OutcomeRow, + expectedWorkspaceId: string, +): ReminderOutcome { + const kind = row.outcome_kind; + if (kind !== 'delivered' && kind !== 'deferred' && kind !== 'failed') { + return persistenceFailure(); + } + const reason = row.outcome_reason; + if ( + reason !== null && + reason !== 'quiet_hours' && + reason !== 'daily_limit' && + reason !== 'delivery_failed' && + reason !== 'attempt_limit' + ) { + return persistenceFailure(); + } + const outcome: ReminderOutcome = { + id: requireUuid(row.outcome_id), + workspaceId: requireUuid(row.workspace_id), + reminderId: requireUuid(row.reminder_id), + kind, + occurredAt: requireTimestamp(row.occurred_at), + nextAttemptAt: requireNullableTimestamp(row.next_attempt_at), + reason, + deliveryLocalDate: requireNullableLocalDate(row.delivery_local_date), + createdAt: requireTimestamp(row.created_at), + }; + /** Rejects a returned identifier when it does not match the tenant-scoped value requested by the caller. */ + requireExpectedUuid(outcome.workspaceId, expectedWorkspaceId); + if ( + (outcome.kind === 'delivered' && + (outcome.reason !== null || + outcome.nextAttemptAt !== null || + outcome.deliveryLocalDate === null)) || + (outcome.kind === 'deferred' && + (outcome.nextAttemptAt === null || + (outcome.reason !== 'quiet_hours' && + outcome.reason !== 'daily_limit') || + outcome.deliveryLocalDate !== null)) || + (outcome.kind === 'failed' && + (outcome.deliveryLocalDate !== null || + (outcome.reason === 'delivery_failed' + ? outcome.nextAttemptAt === null + : outcome.reason !== 'attempt_limit' || + outcome.nextAttemptAt !== null))) + ) { + return persistenceFailure(); + } + return outcome; +} + +/** Validates an inbox row, tenant ownership, and monotonic delivery/read timestamps. */ +function parseInbox(row: InboxRow, expectedWorkspaceId: string): InboxMessage { + const reminder = safeReminderOccurrence({ + id: requireUuid(row.reminder_id), + workspaceId: requireUuid(row.workspace_id), + title: row.message_title, + dueAt: requireTimestamp(row.due_instant), + timeZone: row.time_zone, + quietHours: null, + maxPerLocalDay: 1, + deliveryAttempt: 0, + }); + /** Rejects a returned identifier when it does not match the tenant-scoped value requested by the caller. */ + requireExpectedUuid(reminder.workspaceId, expectedWorkspaceId); + const deliveredAt = requireTimestamp(row.delivered_at); + const readAt = requireNullableTimestamp(row.read_at); + if (readAt !== null && Date.parse(readAt) < Date.parse(deliveredAt)) { + return persistenceFailure(); + } + return { + id: requireUuid(row.message_id), + workspaceId: reminder.workspaceId, + reminderId: reminder.id, + title: reminder.title, + dueAt: reminder.dueAt, + timeZone: reminder.timeZone, + deliveredAt, + readAt, + createdAt: requireTimestamp(row.created_at), + }; +} + +/** Validates a delivery envelope without retaining its raw idempotency key. */ +function validateDelivery(message: ReminderDelivery): ReminderDelivery { + const reminder = safeReminderOccurrence({ + id: message.reminderId, + workspaceId: message.workspaceId, + title: message.title, + dueAt: message.dueAt, + timeZone: message.timeZone, + quietHours: null, + maxPerLocalDay: 1, + deliveryAttempt: 0, + }); + /** Returns SHA-256 bytes for a bounded opaque key without persisting the raw value. */ + hashNotificationIdempotencyKey(message.idempotencyKey); + return { + workspaceId: reminder.workspaceId, + reminderId: reminder.id, + title: reminder.title, + dueAt: reminder.dueAt, + timeZone: reminder.timeZone, + idempotencyKey: message.idempotencyKey, + }; +} + +/** Compares an attempted schedule with its persisted immutable replay fields. */ +function scheduleMatches( + persisted: PersistedReminderOccurrence, + attempted: ReminderOccurrence, +): boolean { + return ( + persisted.status === 'pending' && + persisted.title === attempted.title && + persisted.dueAt === attempted.dueAt && + persisted.timeZone === attempted.timeZone && + persisted.maxPerLocalDay === attempted.maxPerLocalDay && + persisted.deliveryAttempt === attempted.deliveryAttempt && + persisted.quietHours?.startMinute === attempted.quietHours?.startMinute && + persisted.quietHours?.endMinute === attempted.quietHours?.endMinute + ); +} + +/** Compares an attempted delivery with the persisted inbox replay fields. */ +function inboxMatches( + persisted: InboxMessage, + attempted: ReminderDelivery, +): boolean { + return ( + persisted.reminderId === attempted.reminderId && + persisted.title === attempted.title && + persisted.dueAt === attempted.dueAt && + persisted.timeZone === attempted.timeZone + ); +} + +/** Requires both the reminder state transition and immutable outcome insert to succeed atomically. */ +function requireSuccessfulTransition(row: TransitionRow): void { + if (row.transitioned !== true || row.outcome_inserted !== true) { + /** Performs the persistence failure operation while preserving tenant-safe bounded behavior. */ + persistenceFailure(); + } +} + +/** Parameterized, tenant-scoped PostgreSQL reminder repository. */ +export class PostgresReminderRepository implements ReminderRepository { + /** Creates the component with validated dependencies and bounded configuration. */ + constructor( + private readonly client: NotificationSqlClient, + private readonly claimLeaseSeconds = 300, + private readonly uuidFactory: () => string = randomUUID, + private readonly claimKeyFactory: () => string = randomUUID, + ) { + /** Performs the require integer operation while preserving tenant-safe bounded behavior. */ + requireInteger( + claimLeaseSeconds, + MINIMUM_CLAIM_LEASE_SECONDS, + MAXIMUM_CLAIM_LEASE_SECONDS, + ); + } + + /** Executes one parameterized PostgreSQL statement and maps transport failures to a credential-free service error. */ + private async query( + text: string, + values: readonly unknown[], + ): Promise> { + try { + return await this.client.query(text, values); + } catch { + throw new NotificationPersistenceError(); + } + } + + /** Inserts one occurrence or returns an exact idempotent replay. */ + async schedule( + occurrence: ReminderOccurrence, + ): Promise { + const safe = safeReminderOccurrence(occurrence); + const inserted = await this.query( + `INSERT INTO notification_service.reminder_occurrences + (reminder_id, workspace_id, reminder_title, due_instant, time_zone, + quiet_start_minute, quiet_end_minute, daily_delivery_limit, + delivery_attempt_count) + /** Performs the values operation while preserving tenant-safe bounded behavior. */ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT DO NOTHING + RETURNING reminder_id, workspace_id, reminder_title, due_instant, + time_zone, quiet_start_minute, quiet_end_minute, + daily_delivery_limit, delivery_attempt_count, + occurrence_status, claim_expires_at, created_at, updated_at`, + [ + safe.id, + safe.workspaceId, + safe.title, + safe.dueAt, + safe.timeZone, + safe.quietHours?.startMinute ?? null, + safe.quietHours?.endMinute ?? null, + safe.maxPerLocalDay, + safe.deliveryAttempt, + ], + ); + const insertedRow = zeroOrOne(inserted.rows); + if (insertedRow !== undefined) { + return parsePersistedReminder(insertedRow, safe.workspaceId, safe.id); + } + + const replay = await this.query( + `SELECT reminder_id, workspace_id, reminder_title, due_instant, + time_zone, quiet_start_minute, quiet_end_minute, + daily_delivery_limit, delivery_attempt_count, + occurrence_status, claim_expires_at, created_at, updated_at + FROM notification_service.reminder_occurrences + WHERE workspace_id = $1 AND reminder_id = $2 + LIMIT 2`, + [safe.workspaceId, safe.id], + ); + const persisted = parsePersistedReminder( + /** Performs the exactly one operation while preserving tenant-safe bounded behavior. */ + exactlyOne(replay.rows), + safe.workspaceId, + safe.id, + ); + if (!scheduleMatches(persisted, safe)) { + throw new NotificationReplayConflictError(); + } + return persisted; + } + + /** Compatibility alias for service code that names the write explicitly. */ + async createOccurrence( + occurrence: ReminderOccurrence, + ): Promise { + return await this.schedule(occurrence); + } + + /** Returns a bounded deterministic set of due, unclaimed reminder occurrences. */ + async listDue(now: string, limit: number): Promise { + const safeNow = requireTimestamp(now); + const safeLimit = requireInteger(limit, 1, MAX_REMINDER_BATCH_SIZE); + const result = await this.query( + `SELECT reminder_id, workspace_id, reminder_title, due_instant, + time_zone, quiet_start_minute, quiet_end_minute, + daily_delivery_limit, delivery_attempt_count, + occurrence_status, claim_expires_at, created_at, updated_at + FROM notification_service.reminder_occurrences + WHERE occurrence_status = 'pending' + AND due_instant <= $1 + /** Performs the and operation while preserving tenant-safe bounded behavior. */ + AND (claim_expires_at IS NULL OR claim_expires_at <= $1) + ORDER BY due_instant ASC, reminder_id ASC + LIMIT $2`, + [safeNow, safeLimit], + ); + if (result.rows.length > safeLimit) { + return persistenceFailure(); + } + return result.rows.map((row) => baseReminderFromRow(row)); + } + + /** Acquires a fenced expiring claim and returns its opaque per-attempt token. */ + async claim( + workspaceId: string, + reminderId: string, + dueAt: string, + deliveryAttempt: number, + ): Promise { + const safeWorkspaceId = requireUuid(workspaceId); + const safeReminderId = requireUuid(reminderId); + const safeDueAt = requireTimestamp(dueAt); + const safeDeliveryAttempt = requireInteger( + deliveryAttempt, + 0, + MAX_DELIVERY_ATTEMPTS, + ); + const claimKey = requireUuid(this.claimKeyFactory()); + const result = await this.query( + `UPDATE notification_service.reminder_occurrences + SET claim_key_hash = $3, + claim_expires_at = clock_timestamp() + + make_interval(secs => $4), + updated_at = clock_timestamp() + WHERE workspace_id = $1 + AND reminder_id = $2 + AND occurrence_status = 'pending' + AND due_instant = $5 + AND delivery_attempt_count = $6 + /** Performs the and operation while preserving tenant-safe bounded behavior. */ + AND (claim_expires_at IS NULL OR claim_expires_at <= clock_timestamp()) + RETURNING reminder_id`, + [ + safeWorkspaceId, + safeReminderId, + /** Performs the hash notification idempotency key operation while preserving tenant-safe bounded behavior. */ + hashNotificationIdempotencyKey(claimKey), + this.claimLeaseSeconds, + safeDueAt, + safeDeliveryAttempt, + ], + ); + const row = zeroOrOne(result.rows); + if (row === undefined) { + return null; + } + /** Performs the require expected uuid operation while preserving tenant-safe bounded behavior. */ + requireExpectedUuid(requireUuid(row.reminder_id), safeReminderId); + return claimKey; + } + + /** Counts delivered outcomes for one workspace and one local calendar date. */ + async countDelivered( + workspaceId: string, + localDate: string, + ): Promise { + const safeWorkspaceId = requireUuid(workspaceId); + const safeLocalDate = requireLocalDate(localDate); + const result = await this.query( + `SELECT count(*) AS delivery_count + FROM notification_service.reminder_outcomes + WHERE workspace_id = $1 + AND outcome_kind = 'delivered' + AND delivery_local_date = $2`, + [safeWorkspaceId, safeLocalDate], + ); + return requireInteger( + /** Performs the exactly one operation while preserving tenant-safe bounded behavior. */ + exactlyOne(result.rows).delivery_count, + 0, + Number.MAX_SAFE_INTEGER, + ); + } + + /** Atomically completes a fenced claim and appends its immutable delivered outcome. */ + async markDelivered( + reminder: ReminderOccurrence, + deliveredAt: string, + claimKey: string, + idempotencyKey: string, + ): Promise { + const safe = safeReminderOccurrence(reminder); + const safeDeliveredAt = requireTimestamp(deliveredAt); + const claimDigest = hashNotificationIdempotencyKey(claimKey); + const idempotencyDigest = hashNotificationIdempotencyKey(idempotencyKey); + const outcomeId = requireUuid(this.uuidFactory()); + const result = await this.query( + `WITH transitioned_occurrence AS ( + UPDATE notification_service.reminder_occurrences + SET occurrence_status = 'delivered', + updated_at = clock_timestamp() + WHERE workspace_id = $1 + AND reminder_id = $2 + AND due_instant = $3 + AND delivery_attempt_count = $4 + AND occurrence_status = 'pending' + AND claim_key_hash = $5 + AND claim_expires_at > clock_timestamp() + RETURNING workspace_id, reminder_id + ), inserted_outcome AS ( + INSERT INTO notification_service.reminder_outcomes + (outcome_id, workspace_id, reminder_id, outcome_kind, occurred_at, + next_attempt_at, outcome_reason, idempotency_key_hash, + delivery_local_date) + SELECT $6, workspace_id, reminder_id, 'delivered', $7, NULL, NULL, + $9, ($7::timestamptz AT TIME ZONE $8)::date + FROM transitioned_occurrence + RETURNING outcome_id + ) + SELECT EXISTS (SELECT 1 FROM transitioned_occurrence) AS transitioned, + /** Performs the exists operation while preserving tenant-safe bounded behavior. */ + EXISTS (SELECT 1 FROM inserted_outcome) AS outcome_inserted`, + [ + safe.workspaceId, + safe.id, + safe.dueAt, + safe.deliveryAttempt, + claimDigest, + outcomeId, + safeDeliveredAt, + safe.timeZone, + idempotencyDigest, + ], + ); + /** Performs the require successful transition operation while preserving tenant-safe bounded behavior. */ + requireSuccessfulTransition(exactlyOne(result.rows)); + } + + /** Atomically releases a fenced claim, reschedules the occurrence, and appends a deferral outcome. */ + async defer( + reminder: ReminderOccurrence, + nextAttemptAt: string, + reason: 'quiet_hours' | 'daily_limit', + claimKey: string, + idempotencyKey: string, + ): Promise { + const safe = safeReminderOccurrence(reminder); + const safeNextAttemptAt = requireTimestamp(nextAttemptAt); + const claimDigest = hashNotificationIdempotencyKey(claimKey); + const idempotencyDigest = hashNotificationIdempotencyKey(idempotencyKey); + const outcomeId = requireUuid(this.uuidFactory()); + const result = await this.query( + `WITH transitioned_occurrence AS ( + UPDATE notification_service.reminder_occurrences + SET due_instant = $5, + claim_key_hash = NULL, + claim_expires_at = NULL, + updated_at = clock_timestamp() + WHERE workspace_id = $1 + AND reminder_id = $2 + AND due_instant = $3 + AND delivery_attempt_count = $4 + AND occurrence_status = 'pending' + AND claim_key_hash = $6 + AND claim_expires_at > clock_timestamp() + RETURNING workspace_id, reminder_id + ), inserted_outcome AS ( + INSERT INTO notification_service.reminder_outcomes + (outcome_id, workspace_id, reminder_id, outcome_kind, occurred_at, + next_attempt_at, outcome_reason, idempotency_key_hash, + delivery_local_date) + SELECT $7, workspace_id, reminder_id, 'deferred', clock_timestamp(), + $5, $8, $9, NULL + FROM transitioned_occurrence + RETURNING outcome_id + ) + SELECT EXISTS (SELECT 1 FROM transitioned_occurrence) AS transitioned, + /** Performs the exists operation while preserving tenant-safe bounded behavior. */ + EXISTS (SELECT 1 FROM inserted_outcome) AS outcome_inserted`, + [ + safe.workspaceId, + safe.id, + safe.dueAt, + safe.deliveryAttempt, + safeNextAttemptAt, + claimDigest, + outcomeId, + reason, + idempotencyDigest, + ], + ); + /** Performs the require successful transition operation while preserving tenant-safe bounded behavior. */ + requireSuccessfulTransition(exactlyOne(result.rows)); + } + + /** Atomically records either a bounded retry or a terminal attempt-limit failure. */ + async fail( + reminder: ReminderOccurrence, + retryAt: string | null, + reason: 'delivery_failed' | 'attempt_limit', + claimKey: string, + idempotencyKey: string, + ): Promise { + const safe = safeReminderOccurrence(reminder); + const claimDigest = hashNotificationIdempotencyKey(claimKey); + const idempotencyDigest = hashNotificationIdempotencyKey(idempotencyKey); + const outcomeId = requireUuid(this.uuidFactory()); + if (reason === 'delivery_failed') { + if (retryAt === null || safe.deliveryAttempt >= MAX_DELIVERY_ATTEMPTS) { + return persistenceFailure(); + } + const safeRetryAt = requireTimestamp(retryAt); + const result = await this.query( + `WITH transitioned_occurrence AS ( + UPDATE notification_service.reminder_occurrences + SET due_instant = $5, + delivery_attempt_count = delivery_attempt_count + 1, + claim_key_hash = NULL, + claim_expires_at = NULL, + updated_at = clock_timestamp() + WHERE workspace_id = $1 + AND reminder_id = $2 + AND due_instant = $3 + AND delivery_attempt_count = $4 + AND occurrence_status = 'pending' + AND claim_key_hash = $6 + AND claim_expires_at > clock_timestamp() + RETURNING workspace_id, reminder_id + ), inserted_outcome AS ( + INSERT INTO notification_service.reminder_outcomes + (outcome_id, workspace_id, reminder_id, outcome_kind, occurred_at, + next_attempt_at, outcome_reason, idempotency_key_hash, + delivery_local_date) + SELECT $7, workspace_id, reminder_id, 'failed', clock_timestamp(), + $5, 'delivery_failed', $8, NULL + FROM transitioned_occurrence + RETURNING outcome_id + ) + SELECT EXISTS (SELECT 1 FROM transitioned_occurrence) AS transitioned, + /** Performs the exists operation while preserving tenant-safe bounded behavior. */ + EXISTS (SELECT 1 FROM inserted_outcome) AS outcome_inserted`, + [ + safe.workspaceId, + safe.id, + safe.dueAt, + safe.deliveryAttempt, + safeRetryAt, + claimDigest, + outcomeId, + idempotencyDigest, + ], + ); + /** Performs the require successful transition operation while preserving tenant-safe bounded behavior. */ + requireSuccessfulTransition(exactlyOne(result.rows)); + return; + } + + if (retryAt !== null || safe.deliveryAttempt !== MAX_DELIVERY_ATTEMPTS) { + return persistenceFailure(); + } + const result = await this.query( + `WITH transitioned_occurrence AS ( + UPDATE notification_service.reminder_occurrences + SET occurrence_status = 'failed', + updated_at = clock_timestamp() + WHERE workspace_id = $1 + AND reminder_id = $2 + AND due_instant = $3 + AND delivery_attempt_count = $4 + AND occurrence_status = 'pending' + AND claim_key_hash = $5 + AND claim_expires_at > clock_timestamp() + RETURNING workspace_id, reminder_id + ), inserted_outcome AS ( + INSERT INTO notification_service.reminder_outcomes + (outcome_id, workspace_id, reminder_id, outcome_kind, occurred_at, + next_attempt_at, outcome_reason, idempotency_key_hash, + delivery_local_date) + SELECT $6, workspace_id, reminder_id, 'failed', clock_timestamp(), + NULL, 'attempt_limit', $7, NULL + FROM transitioned_occurrence + RETURNING outcome_id + ) + SELECT EXISTS (SELECT 1 FROM transitioned_occurrence) AS transitioned, + /** Performs the exists operation while preserving tenant-safe bounded behavior. */ + EXISTS (SELECT 1 FROM inserted_outcome) AS outcome_inserted`, + [ + safe.workspaceId, + safe.id, + safe.dueAt, + safe.deliveryAttempt, + claimDigest, + outcomeId, + idempotencyDigest, + ], + ); + /** Performs the require successful transition operation while preserving tenant-safe bounded behavior. */ + requireSuccessfulTransition(exactlyOne(result.rows)); + } + + /** Returns a bounded newest-first tenant reminder view. */ + async listReminders( + workspaceId: string, + limit = MAXIMUM_QUERY_LIMIT, + ): Promise { + const safeWorkspaceId = requireUuid(workspaceId); + const safeLimit = requireLimit(limit); + const result = await this.query( + `SELECT reminder_id, workspace_id, reminder_title, due_instant, + time_zone, quiet_start_minute, quiet_end_minute, + daily_delivery_limit, delivery_attempt_count, + occurrence_status, claim_expires_at, created_at, updated_at + FROM notification_service.reminder_occurrences + WHERE workspace_id = $1 + ORDER BY created_at DESC, reminder_id ASC + LIMIT $2`, + [safeWorkspaceId, safeLimit], + ); + if (result.rows.length > safeLimit) { + return persistenceFailure(); + } + return result.rows.map((row) => + /** Performs the parse persisted reminder operation while preserving tenant-safe bounded behavior. */ + parsePersistedReminder(row, safeWorkspaceId), + ); + } + + /** Compatibility alias retained for internal composition. */ + async listOccurrences( + workspaceId: string, + limit = MAXIMUM_QUERY_LIMIT, + ): Promise { + return await this.listReminders(workspaceId, limit); + } + + /** Returns a bounded newest-first tenant outcome view. */ + async listOutcomes( + workspaceId: string, + limit = MAXIMUM_QUERY_LIMIT, + ): Promise { + const safeWorkspaceId = requireUuid(workspaceId); + const safeLimit = requireLimit(limit); + const result = await this.query( + `SELECT outcome_id, workspace_id, reminder_id, outcome_kind, + occurred_at, next_attempt_at, outcome_reason, + delivery_local_date, created_at + FROM notification_service.reminder_outcomes + WHERE workspace_id = $1 + ORDER BY occurred_at DESC, outcome_id ASC + LIMIT $2`, + [safeWorkspaceId, safeLimit], + ); + if (result.rows.length > safeLimit) { + return persistenceFailure(); + } + return result.rows.map((row) => parseOutcome(row, safeWorkspaceId)); + } + + /** Returns a bounded newest-first tenant inbox view. */ + async listInbox( + workspaceId: string, + limit = MAXIMUM_QUERY_LIMIT, + ): Promise { + const safeWorkspaceId = requireUuid(workspaceId); + const safeLimit = requireLimit(limit); + const result = await this.query( + `SELECT message_id, workspace_id, reminder_id, message_title, + due_instant, time_zone, delivered_at, read_at, created_at + FROM notification_service.inbox_messages + WHERE workspace_id = $1 + ORDER BY delivered_at DESC, message_id ASC + LIMIT $2`, + [safeWorkspaceId, safeLimit], + ); + if (result.rows.length > safeLimit) { + return persistenceFailure(); + } + return result.rows.map((row) => parseInbox(row, safeWorkspaceId)); + } + + /** Compatibility alias retained for internal composition. */ + async listInboxMessages( + workspaceId: string, + limit = MAXIMUM_QUERY_LIMIT, + ): Promise { + return await this.listInbox(workspaceId, limit); + } +} + +/** Idempotent in-app delivery adapter backed by the notification inbox table. */ +export class PostgresInAppDeliveryGateway implements ReminderDeliveryGateway { + /** Creates the component with validated dependencies and bounded configuration. */ + constructor( + private readonly client: NotificationSqlClient, + private readonly uuidFactory: () => string = randomUUID, + ) {} + + /** Executes one parameterized PostgreSQL statement and maps transport failures to a credential-free service error. */ + private async query( + text: string, + values: readonly unknown[], + ): Promise> { + try { + return await this.client.query(text, values); + } catch { + throw new NotificationPersistenceError(); + } + } + + /** Inserts one idempotent in-app message or verifies the exact persisted replay. */ + async deliver(message: ReminderDelivery): Promise { + const safe = validateDelivery(message); + const messageId = requireUuid(this.uuidFactory()); + const digest = hashNotificationIdempotencyKey(safe.idempotencyKey); + const inserted = await this.query( + `INSERT INTO notification_service.inbox_messages + (message_id, workspace_id, reminder_id, message_title, due_instant, + time_zone, idempotency_key_hash, delivered_at) + /** Performs the values operation while preserving tenant-safe bounded behavior. */ + VALUES ($1, $2, $3, $4, $5, $6, $7, clock_timestamp()) + ON CONFLICT DO NOTHING + RETURNING message_id, workspace_id, reminder_id, message_title, + due_instant, time_zone, delivered_at, read_at, created_at`, + [ + messageId, + safe.workspaceId, + safe.reminderId, + safe.title, + safe.dueAt, + safe.timeZone, + digest, + ], + ); + const insertedRow = zeroOrOne(inserted.rows); + if (insertedRow !== undefined) { + const persisted = parseInbox(insertedRow, safe.workspaceId); + if (!inboxMatches(persisted, safe)) { + throw new NotificationReplayConflictError(); + } + return; + } + + const replay = await this.query( + `SELECT message_id, workspace_id, reminder_id, message_title, + due_instant, time_zone, delivered_at, read_at, created_at + FROM notification_service.inbox_messages + WHERE workspace_id = $1 AND idempotency_key_hash = $2 + LIMIT 2`, + [safe.workspaceId, digest], + ); + const persisted = parseInbox(exactlyOne(replay.rows), safe.workspaceId); + if (!inboxMatches(persisted, safe)) { + throw new NotificationReplayConflictError(); + } + } +} diff --git a/apps/notification-service/src/reminder-scheduler.integration.test.ts b/apps/notification-service/src/reminder-scheduler.integration.test.ts index 4f7388f2b..f7cec8355 100644 --- a/apps/notification-service/src/reminder-scheduler.integration.test.ts +++ b/apps/notification-service/src/reminder-scheduler.integration.test.ts @@ -28,6 +28,7 @@ function reminder( }; } +/** Defines the outcome shape used to make the test evidence explicit. */ interface Outcome { readonly kind: 'delivered' | 'deferred' | 'failed'; readonly workspaceId: string; @@ -48,37 +49,49 @@ function localDate(instant: string, timeZone: string): string { return `${values.get('year')}-${values.get('month')}-${values.get('day')}`; } +/** Implements the in memory reminder repository test double with observable deterministic behavior. */ class InMemoryReminderRepository implements ReminderRepository { - readonly claims = new Set(); + readonly claims = new Map(); readonly outcomes: Outcome[] = []; readonly deliveredByWorkspaceDate = new Map(); + private claimSequence = 0; + /** Creates the component with explicit dependencies and deterministic initial state. */ constructor(readonly records: readonly unknown[]) {} + /** Returns a bounded deterministic set of currently due reminder occurrences. */ async listDue(_now: string, limit: number): Promise { return this.records.slice(0, limit); } + /** Attempts to acquire the exact observed reminder occurrence using a fenced expiring claim. */ async claim( workspaceId: string, reminderId: string, - idempotencyKey: string, - ): Promise { - const key = `${workspaceId}:${reminderId}:${idempotencyKey}`; - if (this.claims.has(key)) return false; - this.claims.add(key); - return true; + _dueAt: string, + _deliveryAttempt: number, + ): Promise { + const occurrenceKey = `${workspaceId}:${reminderId}`; + if (this.claims.has(occurrenceKey)) return null; + this.claimSequence += 1; + const claimKey = `${occurrenceKey}:claim:${this.claimSequence}`; + this.claims.set(occurrenceKey, claimKey); + return claimKey; } + /** Counts delivered outcomes for one workspace and one local calendar date. */ async countDelivered(workspaceId: string, date: string): Promise { return this.deliveredByWorkspaceDate.get(`${workspaceId}:${date}`) ?? 0; } + /** Atomically completes a fenced claim and records an immutable delivered outcome. */ async markDelivered( value: ReminderOccurrence, deliveredAt: string, + claimKey: string, idempotencyKey: string, ): Promise { + this.requireClaim(value, claimKey); const date = localDate(deliveredAt, value.timeZone); const key = `${value.workspaceId}:${date}`; this.deliveredByWorkspaceDate.set( @@ -95,13 +108,15 @@ class InMemoryReminderRepository implements ReminderRepository { }); } + /** Atomically reschedules a fenced occurrence and records its immutable deferral outcome. */ async defer( value: ReminderOccurrence, nextAttemptAt: string, reason: 'quiet_hours' | 'daily_limit', + claimKey: string, idempotencyKey: string, ): Promise { - this.release(value, idempotencyKey); + this.release(value, claimKey); this.outcomes.push({ kind: 'deferred', workspaceId: value.workspaceId, @@ -112,13 +127,16 @@ class InMemoryReminderRepository implements ReminderRepository { }); } + /** Atomically records a bounded retry or terminal reminder failure. */ async fail( value: ReminderOccurrence, retryAt: string | null, reason: 'delivery_failed' | 'attempt_limit', + claimKey: string, idempotencyKey: string, ): Promise { - if (retryAt !== null) this.release(value, idempotencyKey); + this.requireClaim(value, claimKey); + if (retryAt !== null) this.release(value, claimKey); this.outcomes.push({ kind: 'failed', workspaceId: value.workspaceId, @@ -129,15 +147,104 @@ class InMemoryReminderRepository implements ReminderRepository { }); } - private release(value: ReminderOccurrence, idempotencyKey: string): void { - this.claims.delete(`${value.workspaceId}:${value.id}:${idempotencyKey}`); + private occurrenceKey(value: ReminderOccurrence): string { + return `${value.workspaceId}:${value.id}`; + } + + private requireClaim(value: ReminderOccurrence, claimKey: string): void { + if (this.claims.get(this.occurrenceKey(value)) !== claimKey) { + throw new Error('claim is not owned'); + } + } + + private release(value: ReminderOccurrence, claimKey: string): void { + this.requireClaim(value, claimKey); + this.claims.delete(this.occurrenceKey(value)); + } +} + +type PersistenceOperation = + 'countDelivered' | 'defer' | 'fail' | 'markDelivered'; + +/** Implements the fail once reminder repository test double with observable deterministic behavior. */ +class FailOnceReminderRepository extends InMemoryReminderRepository { + private failureAvailable = true; + + /** Creates the component with explicit dependencies and deterministic initial state. */ + constructor( + records: readonly unknown[], + private readonly operation: PersistenceOperation, + ) { + super(records); + } + + private consumeFailure(operation: PersistenceOperation): boolean { + if (this.failureAvailable && this.operation === operation) { + this.failureAvailable = false; + return true; + } + return false; + } + + /** Counts delivered outcomes or simulates one transient persistence failure. */ + override async countDelivered( + workspaceId: string, + date: string, + ): Promise { + if (this.consumeFailure('countDelivered')) { + throw new Error('persistence unavailable'); + } + return await super.countDelivered(workspaceId, date); + } + + /** Atomically completes a fenced claim and records an immutable delivered outcome. */ + override async markDelivered( + value: ReminderOccurrence, + deliveredAt: string, + claimKey: string, + idempotencyKey: string, + ): Promise { + if (this.consumeFailure('markDelivered')) { + throw new Error('persistence unavailable'); + } + await super.markDelivered(value, deliveredAt, claimKey, idempotencyKey); + } + + /** Atomically reschedules a fenced occurrence and records its immutable deferral outcome. */ + override async defer( + value: ReminderOccurrence, + nextAttemptAt: string, + reason: 'quiet_hours' | 'daily_limit', + claimKey: string, + idempotencyKey: string, + ): Promise { + if (this.consumeFailure('defer')) { + throw new Error('persistence unavailable'); + } + await super.defer(value, nextAttemptAt, reason, claimKey, idempotencyKey); + } + + /** Atomically records a bounded retry or terminal reminder failure. */ + override async fail( + value: ReminderOccurrence, + retryAt: string | null, + reason: 'delivery_failed' | 'attempt_limit', + claimKey: string, + idempotencyKey: string, + ): Promise { + if (this.consumeFailure('fail')) { + throw new Error('persistence unavailable'); + } + await super.fail(value, retryAt, reason, claimKey, idempotencyKey); } } +/** Implements the recording gateway test double with observable deterministic behavior. */ class RecordingGateway implements ReminderDeliveryGateway { readonly messages: ReminderDelivery[] = []; shouldFail = false; + /** Persists or verifies one idempotent in-app reminder delivery. */ async deliver(message: ReminderDelivery): Promise { this.messages.push(message); if (this.shouldFail) throw new Error('provider secret must not escape'); @@ -167,6 +274,34 @@ describe('bounded reminder scheduling integration', () => { expect(gateway.messages[0]?.idempotencyKey).toContain(workspaceAlpha); }); + it('creates a distinct opaque token for each released claim attempt', async () => { + const value = reminder({ quietHours: null }); + const repository = new InMemoryReminderRepository([value]); + const firstClaim = await repository.claim( + value.workspaceId, + value.id, + value.dueAt, + value.deliveryAttempt, + ); + if (firstClaim === null) throw new Error('expected the first claim'); + await repository.defer( + value, + '2026-08-04T12:05:00.000Z', + 'quiet_hours', + firstClaim, + 'first-delivery-key', + ); + const secondClaim = await repository.claim( + value.workspaceId, + value.id, + value.dueAt, + value.deliveryAttempt, + ); + + expect(secondClaim).not.toBeNull(); + expect(secondClaim).not.toBe(firstClaim); + }); + it('defers through a daylight-saving fallback until local quiet hours end', async () => { const repository = new InMemoryReminderRepository([ reminder({ @@ -301,6 +436,96 @@ describe('bounded reminder scheduling integration', () => { }); }); + it('isolates transition persistence failures and continues the batch', async () => { + const secondReminderId = 'ee09fe10-2602-4d6c-b52a-e58cbf55ea41'; + const deliveredRepository = new FailOnceReminderRepository( + [ + reminder({ quietHours: null }), + reminder({ id: secondReminderId, quietHours: null }), + ], + 'markDelivered', + ); + const deliveredReport = await new ReminderScheduler( + deliveredRepository, + new RecordingGateway(), + ).run(new Date('2026-08-04T12:00:00.000Z')); + expect(deliveredReport).toMatchObject({ + scanned: 2, + delivered: 1, + persistenceFailures: 1, + }); + + const deferredRepository = new FailOnceReminderRepository( + [ + reminder({ + quietHours: { startMinute: 20 * 60, endMinute: 22 * 60 }, + }), + ], + 'defer', + ); + await expect( + new ReminderScheduler(deferredRepository, new RecordingGateway()).run( + new Date('2026-08-04T12:00:00.000Z'), + ), + ).resolves.toMatchObject({ deferred: 0, persistenceFailures: 1 }); + + const countRepository = new FailOnceReminderRepository( + [reminder({ quietHours: null })], + 'countDelivered', + ); + await expect( + new ReminderScheduler(countRepository, new RecordingGateway()).run( + new Date('2026-08-04T12:00:00.000Z'), + ), + ).resolves.toMatchObject({ delivered: 0, persistenceFailures: 1 }); + + const dailyLimitRepository = new FailOnceReminderRepository( + [ + reminder({ + quietHours: null, + maxPerLocalDay: 1, + }), + ], + 'defer', + ); + dailyLimitRepository.deliveredByWorkspaceDate.set( + `${workspaceAlpha}:2026-08-04`, + 1, + ); + await expect( + new ReminderScheduler(dailyLimitRepository, new RecordingGateway()).run( + new Date('2026-08-04T12:00:00.000Z'), + ), + ).resolves.toMatchObject({ deferred: 0, persistenceFailures: 1 }); + + const terminalRepository = new FailOnceReminderRepository( + [ + reminder({ + quietHours: null, + deliveryAttempt: MAX_DELIVERY_ATTEMPTS, + }), + ], + 'fail', + ); + await expect( + new ReminderScheduler(terminalRepository, new RecordingGateway()).run( + new Date('2026-08-04T12:00:00.000Z'), + ), + ).resolves.toMatchObject({ failed: 0, persistenceFailures: 1 }); + + const retryRepository = new FailOnceReminderRepository( + [reminder({ quietHours: null })], + 'fail', + ); + const failingGateway = new RecordingGateway(); + failingGateway.shouldFail = true; + await expect( + new ReminderScheduler(retryRepository, failingGateway).run( + new Date('2026-08-04T12:00:00.000Z'), + ), + ).resolves.toMatchObject({ failed: 0, persistenceFailures: 1 }); + }); + it('bounds untrusted repository output and reports invalid future records', async () => { const valid = reminder({ quietHours: null }); const repository = new InMemoryReminderRepository([ diff --git a/apps/notification-service/src/reminder-scheduler.test.ts b/apps/notification-service/src/reminder-scheduler.test.ts index 4e9225720..f05a95624 100644 --- a/apps/notification-service/src/reminder-scheduler.test.ts +++ b/apps/notification-service/src/reminder-scheduler.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { MAX_DAILY_REMINDERS, MAX_DELIVERY_ATTEMPTS, @@ -31,22 +31,49 @@ function reminder( }; } +/** Implements the noop repository test double with observable deterministic behavior. */ class NoopRepository implements ReminderRepository { + /** Returns a bounded deterministic set of currently due reminder occurrences. */ async listDue(): Promise { return []; } - async claim(): Promise { - return true; + /** Attempts to acquire the exact observed reminder occurrence using a fenced expiring claim. */ + async claim( + _workspaceId: string, + _reminderId: string, + _dueAt: string, + _deliveryAttempt: number, + ): Promise { + return 'noop-claim-key'; } + /** Counts delivered outcomes for one workspace and one local calendar date. */ async countDelivered(): Promise { return 0; } + /** Atomically completes a fenced claim and records an immutable delivered outcome. */ async markDelivered(): Promise {} + /** Atomically reschedules a fenced occurrence and records its immutable deferral outcome. */ async defer(): Promise {} + /** Atomically records a bounded retry or terminal reminder failure. */ async fail(): Promise {} } +/** Implements the static repository test double with observable deterministic behavior. */ +class StaticRepository extends NoopRepository { + /** Creates the component with explicit dependencies and deterministic initial state. */ + constructor(private readonly records: readonly unknown[]) { + super(); + } + + /** Returns a bounded deterministic set of currently due reminder occurrences. */ + override async listDue(): Promise { + return this.records; + } +} + +/** Implements the noop gateway test double with observable deterministic behavior. */ class NoopGateway implements ReminderDeliveryGateway { + /** Persists or verifies one idempotent in-app reminder delivery. */ async deliver(_message: ReminderDelivery): Promise {} } @@ -60,11 +87,53 @@ describe('reminder boundary validation', () => { expect(value.title).toBe('Prepare the weekly review'); }); + it('treats an omitted quiet-hours policy as disabled', () => { + const value = { ...reminder() } as Record; + delete value.quietHours; + + expect(validateReminderOccurrence(value).quietHours).toBeNull(); + }); + it.each([ + [null, 'invalid_record'], + [[], 'invalid_record'], [{ ...reminder(), id: '1' }, 'invalid_identifier'], + [{ ...reminder(), id: null }, 'invalid_identifier'], + [{ ...reminder(), title: '' }, 'invalid_title'], [{ ...reminder(), title: ' trailing ' }, 'invalid_title'], + [{ ...reminder(), title: 'x'.repeat(161) }, 'invalid_title'], + [{ ...reminder(), title: 'unsafe\u0000title' }, 'invalid_title'], + [{ ...reminder(), title: 7 }, 'invalid_title'], [{ ...reminder(), dueAt: 'tomorrow' }, 'invalid_due_at'], + [{ ...reminder(), dueAt: 7 }, 'invalid_due_at'], + [{ ...reminder(), dueAt: 'x'.repeat(41) }, 'invalid_due_at'], + [{ ...reminder(), dueAt: '2026-13-01T00:00:00Z' }, 'invalid_due_at'], [{ ...reminder(), timeZone: 'Mars/Olympus' }, 'invalid_time_zone'], + [{ ...reminder(), timeZone: '' }, 'invalid_time_zone'], + [{ ...reminder(), timeZone: 7 }, 'invalid_time_zone'], + [{ ...reminder(), timeZone: 'x'.repeat(65) }, 'invalid_time_zone'], + [{ ...reminder(), quietHours: 'night' }, 'invalid_quiet_hours'], + [ + { + ...reminder(), + quietHours: { startMinute: 1.5, endMinute: 60 }, + }, + 'invalid_quiet_hours', + ], + [ + { + ...reminder(), + quietHours: { startMinute: -1, endMinute: 60 }, + }, + 'invalid_quiet_hours', + ], + [ + { + ...reminder(), + quietHours: { startMinute: 60, endMinute: 1_440 }, + }, + 'invalid_quiet_hours', + ], [ { ...reminder(), @@ -72,15 +141,19 @@ describe('reminder boundary validation', () => { }, 'invalid_quiet_hours', ], + [{ ...reminder(), maxPerLocalDay: 0 }, 'invalid_daily_limit'], + [{ ...reminder(), maxPerLocalDay: 1.5 }, 'invalid_daily_limit'], [ { ...reminder(), maxPerLocalDay: MAX_DAILY_REMINDERS + 1 }, 'invalid_daily_limit', ], + [{ ...reminder(), deliveryAttempt: -1 }, 'invalid_delivery_attempt'], + [{ ...reminder(), deliveryAttempt: 1.5 }, 'invalid_delivery_attempt'], [ { ...reminder(), deliveryAttempt: MAX_DELIVERY_ATTEMPTS + 1 }, 'invalid_delivery_attempt', ], - ])('rejects malformed records with stable code %s', (value, code) => { + ])('rejects malformed records with stable code %#', (value, code) => { expect(() => validateReminderOccurrence(value)).toThrowError( new ReminderValidationError(code as never), ); @@ -110,16 +183,136 @@ describe('quiet-hours evaluation', () => { endMinute: 7 * 60, }), ).toBe(true); + expect( + isWithinQuietHours(12 * 60, { + startMinute: 22 * 60, + endMinute: 7 * 60, + }), + ).toBe(false); }); }); -describe('scheduler options', () => { - it('rejects unbounded batch sizes', () => { +describe('scheduler options and defensive failures', () => { + it('rejects non-integer and out-of-range batch sizes', () => { expect( () => new ReminderScheduler(new NoopRepository(), new NoopGateway(), 0), ).toThrow(RangeError); + expect( + () => new ReminderScheduler(new NoopRepository(), new NoopGateway(), 1.5), + ).toThrow(RangeError); expect( () => new ReminderScheduler(new NoopRepository(), new NoopGateway(), 101), ).toThrow(RangeError); }); + + it('rejects an invalid scheduler instant before repository access', async () => { + await expect( + new ReminderScheduler(new NoopRepository(), new NoopGateway()).run( + new Date(Number.NaN), + ), + ).rejects.toThrow('now must be a valid instant'); + }); + + it('rethrows an unexpected repository-record accessor failure', async () => { + const malformed = Object.defineProperty({}, 'id', { + get(): never { + throw new Error('unexpected accessor failure'); + }, + }); + + await expect( + new ReminderScheduler( + new StaticRepository([malformed]), + new NoopGateway(), + ).run(new Date('2026-08-04T12:00:00.000Z')), + ).rejects.toThrow('unexpected accessor failure'); + }); + + it('fails closed when the platform omits required zoned-clock parts', async () => { + const formatter = vi.spyOn(Intl, 'DateTimeFormat').mockImplementation( + () => + ({ + format: () => 'valid', + formatToParts: () => [ + { type: 'year', value: '2026' }, + { type: 'month', value: '08' }, + { type: 'day', value: '04' }, + ], + }) as Intl.DateTimeFormat, + ); + try { + await expect( + new ReminderScheduler( + new StaticRepository([reminder({ quietHours: null })]), + new NoopGateway(), + ).run(new Date('2026-08-04T12:00:00.000Z')), + ).rejects.toThrowError(new ReminderValidationError('invalid_time_zone')); + } finally { + formatter.mockRestore(); + } + }); + + it('keeps policy search bounded when local time never exits quiet hours', async () => { + const formatter = vi.spyOn(Intl, 'DateTimeFormat').mockImplementation( + () => + ({ + format: () => 'valid', + formatToParts: () => [ + { type: 'year', value: '2026' }, + { type: 'month', value: '08' }, + { type: 'day', value: '04' }, + { type: 'hour', value: '23' }, + { type: 'minute', value: '00' }, + ], + }) as Intl.DateTimeFormat, + ); + try { + await expect( + new ReminderScheduler( + new StaticRepository([reminder()]), + new NoopGateway(), + ).run(new Date('2026-08-04T12:00:00.000Z')), + ).rejects.toThrowError(new ReminderValidationError('invalid_time_zone')); + } finally { + formatter.mockRestore(); + } + }); + + it('isolates delivered-count persistence failures and continues the batch', async () => { + const secondReminderId = '2f3d9a62-7169-4d5e-9b0e-8d2a4b62ccef'; + const repository = new StaticRepository([ + reminder({ quietHours: null }), + reminder({ id: secondReminderId, quietHours: null }), + ]); + const countDelivered = vi + .spyOn(repository, 'countDelivered') + .mockRejectedValueOnce(new Error('count unavailable')) + .mockResolvedValue(0); + const markDelivered = vi.spyOn(repository, 'markDelivered'); + const gateway = new NoopGateway(); + const deliver = vi.spyOn(gateway, 'deliver'); + + const report = await new ReminderScheduler(repository, gateway).run( + new Date('2026-08-04T12:00:00.000Z'), + ); + + expect(report).toEqual({ + scanned: 2, + delivered: 1, + deferred: 0, + failed: 0, + persistenceFailures: 1, + duplicateClaims: 0, + invalid: 0, + }); + expect(countDelivered).toHaveBeenCalledTimes(2); + expect(deliver).toHaveBeenCalledTimes(1); + expect(markDelivered).toHaveBeenCalledTimes(1); + expect(markDelivered).toHaveBeenCalledWith( + expect.objectContaining({ id: secondReminderId }), + '2026-08-04T12:00:00.000Z', + 'noop-claim-key', + `${workspaceId}:${secondReminderId}:2026-08-04T12:00:00.000Z`, + ); + }); }); diff --git a/apps/notification-service/src/reminder-scheduler.ts b/apps/notification-service/src/reminder-scheduler.ts index e93ca7819..154df2f13 100644 --- a/apps/notification-service/src/reminder-scheduler.ts +++ b/apps/notification-service/src/reminder-scheduler.ts @@ -48,6 +48,7 @@ export interface ReminderDelivery { /** Provider boundary. Implementations must honor the supplied idempotency key. */ export interface ReminderDeliveryGateway { + /** Inserts one idempotent in-app message or verifies the exact persisted replay. */ deliver(message: ReminderDelivery): Promise; } @@ -56,28 +57,38 @@ export interface ReminderDeliveryGateway { * reminder occurrence so repositories can implement atomic claims safely. */ export interface ReminderRepository { + /** Returns a bounded deterministic set of due, unclaimed reminder occurrences. */ listDue(now: string, limit: number): Promise; + /** Acquires a fenced expiring claim and returns its opaque per-attempt token. */ claim( workspaceId: string, reminderId: string, - idempotencyKey: string, - ): Promise; + dueAt: string, + deliveryAttempt: number, + ): Promise; + /** Counts delivered outcomes for one workspace and one local calendar date. */ countDelivered(workspaceId: string, localDate: string): Promise; + /** Atomically completes a fenced claim and appends its immutable delivered outcome. */ markDelivered( reminder: ReminderOccurrence, deliveredAt: string, + claimKey: string, idempotencyKey: string, ): Promise; + /** Atomically releases a fenced claim, reschedules the occurrence, and appends a deferral outcome. */ defer( reminder: ReminderOccurrence, nextAttemptAt: string, reason: 'quiet_hours' | 'daily_limit', + claimKey: string, idempotencyKey: string, ): Promise; + /** Atomically records either a bounded retry or a terminal attempt-limit failure. */ fail( reminder: ReminderOccurrence, retryAt: string | null, reason: 'delivery_failed' | 'attempt_limit', + claimKey: string, idempotencyKey: string, ): Promise; } @@ -95,6 +106,7 @@ export type ReminderValidationCode = /** Error raised when an untrusted reminder record violates the boundary. */ export class ReminderValidationError extends Error { + /** Creates the component with validated dependencies and bounded configuration. */ constructor(readonly code: ReminderValidationCode) { super(code); this.name = 'ReminderValidationError'; @@ -107,19 +119,23 @@ export interface ReminderRunReport { readonly delivered: number; readonly deferred: number; readonly failed: number; + readonly persistenceFailures: number; readonly duplicateClaims: number; readonly invalid: number; } +/** Defines the zoned clock contract used across notification-service boundaries. */ interface ZonedClock { readonly localDate: string; readonly minuteOfDay: number; } +/** Narrows an untrusted value to a non-array object before field validation. */ function isRecord(value: unknown): value is Readonly> { return typeof value === 'object' && value !== null && !Array.isArray(value); } +/** Validates and canonicalizes an untrusted UUIDv4 identifier before it reaches SQL. */ function requireUuid(value: unknown): string { if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) { throw new ReminderValidationError('invalid_identifier'); @@ -127,6 +143,7 @@ function requireUuid(value: unknown): string { return value; } +/** Validates bounded user-authored reminder text without silently normalizing it. */ function requireTitle(value: unknown): string { if ( typeof value !== 'string' || @@ -140,6 +157,7 @@ function requireTitle(value: unknown): string { return value; } +/** Validates and canonicalizes an absolute RFC 3339 reminder instant. */ function requireInstant(value: unknown): string { if ( typeof value !== 'string' || @@ -152,6 +170,7 @@ function requireInstant(value: unknown): string { return new Date(value).toISOString(); } +/** Validates an IANA time-zone identifier through the platform time-zone database. */ function requireTimeZone(value: unknown): string { if (typeof value !== 'string' || value.length === 0 || value.length > 64) { throw new ReminderValidationError('invalid_time_zone'); @@ -164,6 +183,7 @@ function requireTimeZone(value: unknown): string { return value; } +/** Parses one optional integer setting and enforces its documented inclusive range. */ function requireBoundedInteger( value: unknown, minimum: number, @@ -181,6 +201,7 @@ function requireBoundedInteger( return value; } +/** Validates an optional non-empty local quiet-hours interval. */ function requireQuietHours(value: unknown): QuietHours | null { if (value === null || value === undefined) return null; if (!isRecord(value)) { @@ -231,6 +252,7 @@ export function validateReminderOccurrence(value: unknown): ReminderOccurrence { }; } +/** Projects an absolute instant into a validated local date and minute for one IANA time zone. */ function zonedClock(instant: Date, timeZone: string): ZonedClock { const parts = new Intl.DateTimeFormat('en-CA', { timeZone, @@ -278,6 +300,7 @@ export function isWithinQuietHours( ); } +/** Finds the first bounded absolute instant allowed by next-day and quiet-hours policy. */ function nextAllowedInstant( now: Date, timeZone: string, @@ -303,12 +326,14 @@ function nextAllowedInstant( throw new ReminderValidationError('invalid_time_zone'); } +/** Computes the bounded linear retry instant for the next delivery attempt. */ function retryInstant(now: Date, deliveryAttempt: number): string { const boundedAttempt = Math.min(deliveryAttempt + 1, MAX_DELIVERY_ATTEMPTS); return new Date(now.getTime() + boundedAttempt * 5 * 60_000).toISOString(); } -function idempotencyKey(reminder: ReminderOccurrence): string { +/** Builds the stable tenant-scoped occurrence key supplied to idempotent delivery adapters. */ +export function idempotencyKey(reminder: ReminderOccurrence): string { return `${reminder.workspaceId}:${reminder.id}:${reminder.dueAt}`; } @@ -319,6 +344,7 @@ function idempotencyKey(reminder: ReminderOccurrence): string { export class ReminderScheduler { readonly batchSize: number; + /** Creates the component with validated dependencies and bounded configuration. */ constructor( private readonly repository: ReminderRepository, private readonly gateway: ReminderDeliveryGateway, @@ -334,6 +360,7 @@ export class ReminderScheduler { this.batchSize = batchSize; } + /** Processes one bounded scheduler iteration with fenced claims and deterministic outcome accounting. */ async run(now = new Date()): Promise { if (Number.isNaN(now.getTime())) { throw new RangeError('now must be a valid instant'); @@ -345,6 +372,7 @@ export class ReminderScheduler { let delivered = 0; let deferred = 0; let failed = 0; + let persistenceFailures = 0; let duplicateClaims = 0; let invalid = 0; @@ -364,12 +392,13 @@ export class ReminderScheduler { continue; } const deliveryKey = idempotencyKey(reminder); - const claimed = await this.repository.claim( + const claimKey = await this.repository.claim( reminder.workspaceId, reminder.id, - deliveryKey, + reminder.dueAt, + reminder.deliveryAttempt, ); - if (!claimed) { + if (claimKey === null) { duplicateClaims += 1; continue; } @@ -380,39 +409,72 @@ export class ReminderScheduler { quietHours !== null && isWithinQuietHours(clock.minuteOfDay, quietHours) ) { - await this.repository.defer( - reminder, - nextAllowedInstant(now, reminder.timeZone, quietHours, false), - 'quiet_hours', - deliveryKey, + const nextAttemptAt = nextAllowedInstant( + now, + reminder.timeZone, + quietHours, + false, ); - deferred += 1; + try { + await this.repository.defer( + reminder, + nextAttemptAt, + 'quiet_hours', + claimKey, + deliveryKey, + ); + deferred += 1; + } catch { + persistenceFailures += 1; + } continue; } - const deliveredToday = await this.repository.countDelivered( - reminder.workspaceId, - clock.localDate, - ); + let deliveredToday: number; + try { + deliveredToday = await this.repository.countDelivered( + reminder.workspaceId, + clock.localDate, + ); + } catch { + persistenceFailures += 1; + continue; + } if (deliveredToday >= reminder.maxPerLocalDay) { - await this.repository.defer( - reminder, - nextAllowedInstant(now, reminder.timeZone, quietHours, true), - 'daily_limit', - deliveryKey, + const nextAttemptAt = nextAllowedInstant( + now, + reminder.timeZone, + quietHours, + true, ); - deferred += 1; + try { + await this.repository.defer( + reminder, + nextAttemptAt, + 'daily_limit', + claimKey, + deliveryKey, + ); + deferred += 1; + } catch { + persistenceFailures += 1; + } continue; } if (reminder.deliveryAttempt >= MAX_DELIVERY_ATTEMPTS) { - await this.repository.fail( - reminder, - null, - 'attempt_limit', - deliveryKey, - ); - failed += 1; + try { + await this.repository.fail( + reminder, + null, + 'attempt_limit', + claimKey, + deliveryKey, + ); + failed += 1; + } catch { + persistenceFailures += 1; + } continue; } @@ -425,20 +487,31 @@ export class ReminderScheduler { timeZone: reminder.timeZone, idempotencyKey: deliveryKey, }); + } catch { + try { + await this.repository.fail( + reminder, + retryInstant(now, reminder.deliveryAttempt), + 'delivery_failed', + claimKey, + deliveryKey, + ); + failed += 1; + } catch { + persistenceFailures += 1; + } + continue; + } + try { await this.repository.markDelivered( reminder, now.toISOString(), + claimKey, deliveryKey, ); delivered += 1; } catch { - await this.repository.fail( - reminder, - retryInstant(now, reminder.deliveryAttempt), - 'delivery_failed', - deliveryKey, - ); - failed += 1; + persistenceFailures += 1; } } @@ -447,6 +520,7 @@ export class ReminderScheduler { delivered, deferred, failed, + persistenceFailures, duplicateClaims, invalid, }; diff --git a/apps/notification-service/vitest.config.ts b/apps/notification-service/vitest.config.ts new file mode 100644 index 000000000..e63d4df48 --- /dev/null +++ b/apps/notification-service/vitest.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vitest/config'; + +const integrationDatabaseUrl = + process.env.NOTIFICATION_DATABASE_URL ?? process.env.PLANNING_DATABASE_URL; +if (integrationDatabaseUrl !== undefined) { + process.env.NOTIFICATION_DATABASE_URL = integrationDatabaseUrl; +} + +/** Complete notification-service coverage gate. */ +export default defineConfig({ + test: { + coverage: { + enabled: true, + provider: 'v8', + reporter: [['text', { maxCols: 1_000 }], 'json-summary'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts'], + thresholds: { + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }, + }, + }, +}); diff --git a/docs/operations/notification-persistence.md b/docs/operations/notification-persistence.md new file mode 100644 index 000000000..b8944cca8 --- /dev/null +++ b/docs/operations/notification-persistence.md @@ -0,0 +1,130 @@ +# Notification persistence operations + +## Purpose + +The notification service owns the `notification_service` PostgreSQL schema. The schema persists reminder occurrences, expiring worker claims, immutable scheduler outcomes, and credential-free in-app inbox messages. It is an independent bounded context and must not read or mutate another service's tables. + +This design provides at-least-once scheduler execution with atomic claims and idempotent delivery evidence. It does not claim distributed exactly-once execution. Safe replay depends on the repository transition checks and the in-app gateway's persisted SHA-256 idempotency digest. + +## Migration + +Apply `apps/notification-service/migrations/0001_durable_reminder_inbox.sql` before starting a runtime that uses `PostgresReminderRepository`. + +The migration creates: + +- `notification_service.reminder_occurrences` for policy, attempts, and lease state; +- `notification_service.reminder_outcomes` for immutable delivery, deferral, and failure evidence; +- `notification_service.inbox_messages` for durable in-app messages; +- bounded indexes for due work, expired claims, tenant reads, delivered-date counts, and idempotency; +- mutation guards that reject update, delete, and truncate operations against outcome history with SQLSTATE `55000`. + +Run the migration through the normal release migration job using a role with schema DDL rights. The application role should receive only the table and sequence privileges required by the repository. Do not grant the application role ownership of the schema or the mutation-guard function. + +Before rollout, verify that the target database is PostgreSQL 16 or a compatibility-tested later release and that the connection uses TLS outside a private development environment. + +## Runtime configuration + +The service validates all configuration before allocating a pool. + +| Variable | Default | Accepted boundary | +| ------------------------------------------ | ------: | ----------------------------------------- | +| `NOTIFICATION_DATABASE_URL` | none | required `postgres:` or `postgresql:` URL | +| `NOTIFICATION_DATABASE_POOL_MAX` | `10` | integer `1`–`32` | +| `NOTIFICATION_DATABASE_CONNECT_TIMEOUT_MS` | `5000` | integer `100`–`30000` | +| `NOTIFICATION_DATABASE_IDLE_TIMEOUT_MS` | `30000` | integer `1000`–`300000` | +| `NOTIFICATION_CLAIM_LEASE_SECONDS` | `300` | integer `30`–`3600` | +| `NOTIFICATION_REMINDER_BATCH_SIZE` | `50` | integer `1`–`100` | + +The pool sets `application_name` to `life-os-notification-service`. Use this value to distinguish service connections in PostgreSQL activity and connection metrics. + +## Claim and recovery model + +Due-row selection is advisory. The authoritative ownership boundary is one tenant-scoped conditional update that writes a SHA-256 digest of a unique per-attempt claim token and a bounded expiration time. + +A worker may process an occurrence only when its claim update returns a unique opaque claim token. Concurrent workers can observe the same due row, but only one unexpired claim succeeds. + +If a worker exits after claiming but before a terminal transition, another worker can claim the occurrence after `claim_expires_at`. Operators should not clear active claims manually during normal operation. For urgent recovery, first confirm that the original worker is no longer running and that no delivery provider request remains in flight. Prefer waiting for the bounded lease to expire. + +Repository failures are isolated per occurrence. A delivered-count read or transition failure increments the scheduler's `persistenceFailures` aggregate and processing continues with the remaining bounded batch. Alert on any non-zero value and investigate PostgreSQL health; do not classify it as a provider delivery failure or manually release the active claim. + +Runtime shutdown shares one in-flight close operation across concurrent callers. If pool closure rejects, the runtime preserves the error and permits a later shutdown attempt instead of reporting a false closed state. + +To inspect overdue pending work without exposing message text, use an aggregate query such as: + +```sql +SELECT + count(*) AS overdue_reminder_count, + min(due_instant) AS oldest_due_instant +FROM notification_service.reminder_occurrences +WHERE occurrence_status = 'pending' + AND due_instant <= clock_timestamp(); +``` + +To inspect expired claims: + +```sql +SELECT count(*) AS expired_claim_count +FROM notification_service.reminder_occurrences +WHERE occurrence_status = 'pending' + AND claim_key_hash IS NOT NULL + AND claim_expires_at <= clock_timestamp(); +``` + +Do not log `reminder_title`, raw idempotency keys, database URLs, or provider credentials while investigating claims. + +## Delivery replay + +The in-app gateway stores only a 32-byte SHA-256 digest of the composite idempotency key. A repeated insert is accepted only when the persisted workspace, reminder, title, due instant, and time zone match the attempted message. + +Claim tokens are separate from stable delivery idempotency keys, and every transition requires both the exact token digest and an unexpired lease. A mismatched replay raises `NotificationReplayConflictError` and must be treated as an integrity incident. Do not delete the existing inbox row to force the retry through. Preserve the row and the corresponding occurrence for investigation. + +A provider success followed by a repository failure can therefore be retried safely: the inbox insert resolves as an exact replay, and the repository can complete the terminal occurrence transition after reacquiring an expired lease. + +## Outcome integrity + +Every delivered, deferred, retryable-failed, or terminal-failed transition is written in the same PostgreSQL statement as the corresponding occurrence mutation. The statement fails closed unless the worker owns the exact claim digest and the occurrence still has the expected due instant and attempt count. + +Outcome history is append-only. Direct update, delete, and truncate operations are rejected. Administrative corrections must be represented as a new, separately reviewed migration or compensating evidence record; never disable the mutation guard in place. + +## Privacy and security boundaries + +The persistence layer stores reminder titles and scheduling metadata because they are required to render the in-app inbox. It does not store cookies, bearer tokens, provider authorization values, arbitrary callback URLs, raw idempotency keys, or exception text. + +Operational controls should include: + +- encrypted database transport and encrypted storage; +- least-privilege application and migration roles; +- tenant-scoped repository methods with fixed parameterized SQL; +- database backups and restore tests that include the `notification_service` schema; +- restricted access to inbox content and query logs; +- retention and deletion policy approval before exposing user-facing history controls. + +Database statement logging can capture bound reminder titles depending on PostgreSQL and proxy configuration. Keep production statement logging at a privacy-reviewed level and prohibit query logging in application error payloads. + +## Rollback boundary + +Application rollback is safe only while the prior version can ignore the new schema. Do not roll back the schema destructively while any runtime may still use it. + +The forward migration has no automatic down migration because reminder outcomes and inbox messages are durable user evidence. A rollback should: + +1. stop new notification scheduling and delivery; +2. drain or terminate notification workers; +3. deploy the prior application version; +4. retain the `notification_service` schema intact; +5. verify no prior process attempts incompatible writes; +6. prepare a separately reviewed forward repair migration. + +Dropping the schema is destructive and is permitted only in disposable development or test databases. Production removal requires an approved retention/export plan, verified backups, and an explicit maintenance change. + +## Verification after deployment + +Verify all of the following on the deployed release: + +- the migration completed once without partial objects; +- the application pool is bounded and identified by `application_name`; +- one due occurrence produces one successful claim; +- an expired test claim can be recovered; +- one exact replay produces one inbox message; +- tenant-scoped reads never return another workspace's records; +- outcome mutation attempts fail with SQLSTATE `55000`; +- shutdown closes the pool without leaving persistent idle connections. diff --git a/docs/superpowers/plans/2026-08-04-bounded-reminder-scheduler-slice.md b/docs/superpowers/plans/2026-08-04-bounded-reminder-scheduler-slice.md index c05780e1d..1597c5a82 100644 --- a/docs/superpowers/plans/2026-08-04-bounded-reminder-scheduler-slice.md +++ b/docs/superpowers/plans/2026-08-04-bounded-reminder-scheduler-slice.md @@ -12,7 +12,7 @@ Before delivery, the scheduler obtains an atomic tenant-scoped claim keyed by wo ## Time-zone and fatigue policy -Quiet hours are evaluated from the current absolute instant using `Intl.DateTimeFormat` and the reminder's IANA time zone. Deferral searches absolute minutes until it reaches the first permitted local minute, which preserves correctness across offset transitions, missing wall-clock times, and repeated wall-clock times. A 26-hour upper bound covers contemporary civil-time transitions without an unbounded search. +Quiet hours are evaluated from the current absolute instant using `Intl.DateTimeFormat` and the reminder's IANA time zone. Deferral searches absolute minutes until it reaches the first permitted local minute, which preserves correctness across offset transitions, missing wall-clock times, and repeated wall-clock times. A 72-hour hard limit covers the next local date, a nearly full-day quiet interval, and large IANA offset discontinuities while keeping every scheduler evaluation bounded. Per-day limits are keyed by workspace and local calendar date. Once the configured limit is reached, the occurrence is deferred to the first permitted minute of the next local date. Limits are bounded from one to twenty deliveries per local day. diff --git a/docs/superpowers/plans/2026-08-04-notification-postgres-inbox.md b/docs/superpowers/plans/2026-08-04-notification-postgres-inbox.md new file mode 100644 index 000000000..fd18073a8 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-notification-postgres-inbox.md @@ -0,0 +1,69 @@ +# Durable Notification Claims and Inbox Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:test-driven-development for each behavior and superpowers:verification-before-completion before merge. + +**Goal:** Persist reminder occurrences, claims, outcomes, and in-app delivery evidence in a dedicated PostgreSQL bounded context without changing the existing scheduler port. + +**Architecture:** A forward-only migration defines only multi-word snake_case database objects. `PostgresReminderRepository` implements the scheduler repository plus bounded tenant reads/writes, `PostgresInAppDeliveryGateway` provides idempotent credential-free delivery, and `NotificationRuntime` owns the pool and composes both with `ReminderScheduler`. + +**Tech Stack:** TypeScript 5.9, PostgreSQL, `pg`, Vitest, Node crypto, pnpm, Turbo, GitHub Actions. + +## Task 1: Migration contract first + +- [ ] Add `apps/notification-service/migrations/0001_durable_reminder_inbox.sql`. +- [ ] Add a migration contract test that rejects any one-word schema/table/column/index/constraint name. +- [ ] Verify UUIDv4, bounds, enum, paired quiet-hours, hash-length, and retry/terminal consistency constraints. +- [ ] Commit `test(notification): define durable reminder schema contract`. + +## Task 2: Repository tests before implementation + +- [ ] Add `postgres-reminder-repository.test.ts` with a recording SQL client. +- [ ] Define tests for fixed parameterized statements, SHA-256 claim hashes, deterministic row mapping, response caps, tenant predicates, exact replay, and malformed/cross-tenant failure. +- [ ] Verify tests fail because repository exports do not exist. +- [ ] Commit `test(notification): define PostgreSQL repository contract`. + +## Task 3: Minimal repository and in-app gateway + +- [ ] Implement `postgres-reminder-repository.ts`. +- [ ] Keep claim acquisition one conditional update with a bounded lease. +- [ ] Keep each state transition and outcome insertion atomic in one statement. +- [ ] Implement exact idempotent inbox insertion and conflict verification. +- [ ] Run focused tests to green. +- [ ] Commit `feat(notification): add durable reminder repository`. + +## Task 4: Runtime tests and implementation + +- [ ] Add `notification-runtime.test.ts` for URL validation, pool bounds, exact application name, shared pool composition, and exactly-once close. +- [ ] Implement `notification-runtime.ts` and export production symbols from `main.ts`. +- [ ] Add `pg`, Nest shutdown typing, and `@types/pg` dependencies consistently with other services. +- [ ] Commit `feat(notification): wire PostgreSQL reminder runtime`. + +## Task 5: Real PostgreSQL evidence + +- [ ] Add `postgres-reminder-repository.integration.test.ts`. +- [ ] Apply the migration to a clean schema. +- [ ] Prove restart durability and deterministic due ordering. +- [ ] Prove two workers cannot claim one occurrence and that an expired lease is recoverable. +- [ ] Prove tenant-isolated reads/counts and malformed ownership rejection. +- [ ] Prove in-app replay creates one message and conflicting replay fails closed. +- [ ] Prove delivered, quiet/fatigue-deferred, retryable-failed, and terminal-failed transitions persist correct immutable outcomes. +- [ ] Commit `test(notification): verify durable reminder persistence`. + +## Task 6: Product and operational evidence + +- [ ] Correct the obsolete 26-hour statement in the prior scheduler design. +- [ ] Add runtime variables to `.env.example`. +- [ ] Add `docs/operations/notification-persistence.md` with migration, claim recovery, replay, privacy, and rollback boundaries. +- [ ] Register implementation/test evidence and tracking issue #103 in `product/capabilities.json` without weakening target maturity. +- [ ] Update `CHANGELOG.md`. +- [ ] Add every source, test, migration, and document to formatting/lint inventories. +- [ ] Commit `docs(notification): register durable inbox evidence`. + +## Task 7: Exact-head verification and merge + +- [ ] Run formatting, lint, type checking, tests, build, Compose, and real PostgreSQL integration validation. +- [ ] Inspect CI, AppGuardrail, Semgrep, Security Scan, Commercial Readiness, CodeRabbit, human reviews, and every unresolved thread on the exact head. +- [ ] Apply only evidence-backed fixes and rerun all affected checks. +- [ ] Remove Draft only when reviewable. +- [ ] Merge only the exact successful head with no requested changes, unresolved actionable thread, or base drift. +- [ ] Confirm issue #103 closes and select the next buyer-visible notification gap. diff --git a/docs/superpowers/specs/2026-08-04-notification-postgres-inbox-design.md b/docs/superpowers/specs/2026-08-04-notification-postgres-inbox-design.md new file mode 100644 index 000000000..0907218fe --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-notification-postgres-inbox-design.md @@ -0,0 +1,76 @@ +# Durable notification claims and in-app inbox design + +**Date:** 2026-08-04 +**Status:** Approved for the `notifications.reminders` durability slice +**Tracking issue:** #103 + +## Product objective + +Reminder occurrences survive process restarts, multiple notification workers share one atomic claim boundary, and accepted in-app deliveries remain durable and idempotent. This slice strengthens the existing scheduler without adding external channels, user credentials, or a browser-facing API. + +## Bounded-context architecture + +`apps/notification-service` remains independently deployable and depends on no other LifeOS database. It owns a dedicated `notification_service` PostgreSQL schema and the following multi-word snake_case objects: + +- `reminder_occurrences`: one UUIDv4 occurrence, policy, retry state, and expiring claim; +- `reminder_outcomes`: immutable delivered, deferred, and failed scheduler evidence; +- `inbox_messages`: credential-free in-app delivery evidence keyed by an idempotency digest. + +Every table, column, index, constraint, and schema identifier contains at least two snake_case words. The service neither discovers tables dynamically nor reads another service's schema. + +## Persistence contract + +The concrete `PostgresReminderRepository` implements the existing `ReminderRepository` port. SQL structure and identifiers are static; all values are bound parameters. Repository rows are treated as untrusted and are normalized through the scheduler's existing UUIDv4, title, instant, time-zone, quiet-hours, limit, and attempt validators before being returned. + +The repository also exposes bounded service-facing methods to create an occurrence and list tenant reminders, outcomes, and inbox messages. Returned arrays are capped, deterministically ordered, cloned, and tenant-scoped. + +## Atomic claims and recovery + +`claim` creates a unique per-attempt opaque token and hashes it with SHA-256 and performs one conditional `UPDATE ... RETURNING`. A claim succeeds only when the occurrence is pending and its prior lease is absent or expired. The lease duration is fixed and bounded in the repository constructor. Concurrent workers may observe the same due row, but only one conditional update succeeds. + +The stable delivery idempotency key remains separate from the claim token. Every completion statement requires the exact claim digest and an unexpired lease, then uses data-modifying common table expressions so the occurrence transition and immutable outcome insertion succeed or fail as one PostgreSQL statement. Retryable deferrals and failures clear the claim; terminal delivered and attempt-limit outcomes retain terminal state. An expired claim is recoverable by another worker. + +PostgreSQL documents `SKIP LOCKED` as suitable for queue-like consumers but as an intentionally inconsistent view. This design does not depend on a long-lived selection lock: listing is advisory and the conditional claim update is authoritative. + +## In-app delivery idempotency + +`PostgresInAppDeliveryGateway` hashes the raw composite idempotency key before storage. It inserts one UUIDv4 `inbox_messages` row with `ON CONFLICT DO NOTHING`, then verifies an existing conflict represents the same workspace, reminder, title, due instant, and time zone. A provider-success/persistence-failure retry therefore cannot create a duplicate or silently alias a different message. + +No cookie, bearer value, provider token, arbitrary URL, exception text, or raw composite idempotency key is stored or returned. + +## Data model invariants + +- all internal IDs are UUIDv4 values; +- reminder titles are non-empty, trimmed, bounded, and control-character free; +- timestamps are `timestamptz` and normalized to RFC 3339 UTC strings at the TypeScript boundary; +- quiet-hour endpoints are both null or both bounded minute-of-day integers and must differ; +- daily limits are 1–20 and attempts are 0–3; +- status and outcome values are constrained enums; +- claim and idempotency hashes are exactly 32 bytes; +- retryable transitions require a next instant, terminal transitions prohibit one; +- tenant reads always include `workspace_id` in the predicate. + +## Runtime boundary + +`NotificationRuntime` owns one bounded PostgreSQL pool, the concrete repository, the in-app gateway, and the existing scheduler. Configuration accepts only PostgreSQL URLs and bounded pool/timeout values. Shutdown closes the pool exactly once. Scheduler leadership and process-level intervals remain a separate operational slice; database claims make concurrent invocations safe. + +## Test evidence + +- unit tests inspect fixed parameterized SQL, hash behavior, row validation, response bounds, and runtime configuration; +- real PostgreSQL tests apply the migration and prove restart durability, deterministic due ordering, concurrent claim exclusion, lease recovery, tenant isolation, exact in-app replay, conflict rejection, delivered counts, deferral, retry, and terminal outcomes; +- existing scheduler integration evidence remains unchanged and includes the verified 72-hour policy horizon through a three-hour IANA fallback. + +## Deferred scope + +- browser/gateway reminder commands and inbox routes; +- automatic process scheduling and distributed leadership; +- external email, SMS, push, or webhook adapters; +- encrypted channel credentials and per-user preferences; +- planning/habit outbox ingestion; +- read/unread mutations and notification retention policy. + +## Primary references + +- PostgreSQL current documentation: transactions, row locks, queue-like `SKIP LOCKED`, data-modifying common table expressions, and `INSERT ... ON CONFLICT`; +- RFC 3339, _Date and Time on the Internet: Timestamps_; +- IANA Time Zone Database and ECMA-402 named-time-zone projection. diff --git a/product/capabilities.json b/product/capabilities.json index 071c040a9..289ea1e7a 100644 --- a/product/capabilities.json +++ b/product/capabilities.json @@ -386,7 +386,7 @@ "acquisition_impact": 4, "effort": 4, "dependencies": ["planning.durable-data", "habit.recurring-core"], - "tracking_issue": null, + "tracking_issue": 103, "evidence": [ { "maturity": "prototype", @@ -400,11 +400,29 @@ "mode": "exists", "path": "apps/notification-service/src/reminder-scheduler.ts" }, + { + "maturity": "usable", + "kind": "implementation", + "mode": "exists", + "path": "apps/notification-service/src/postgres-reminder-repository.ts" + }, + { + "maturity": "usable", + "kind": "implementation", + "mode": "exists", + "path": "apps/notification-service/src/notification-runtime.ts" + }, { "maturity": "production", "kind": "test", "mode": "exists", - "path": "apps/notification-service/src/reminder-scheduler.integration.test.ts" + "path": "apps/notification-service/src/postgres-reminder-repository.integration.test.ts" + }, + { + "maturity": "production", + "kind": "documentation", + "mode": "exists", + "path": "docs/operations/notification-persistence.md" } ] }, diff --git a/turbo.json b/turbo.json index 587fdb27b..2d3e4d95e 100644 --- a/turbo.json +++ b/turbo.json @@ -4,6 +4,7 @@ "AI_DATABASE_URL", "HABIT_DATABASE_URL", "IDENTITY_DATABASE_URL", + "NOTIFICATION_DATABASE_URL", "PLANNING_DATABASE_URL" ], "tasks": {