From 28e08fa31c5199fe34761cdda677a7bf4387ca91 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 01:40:04 +0900 Subject: [PATCH 01/12] feat(habit): add validated PostgreSQL runtime --- apps/habit-service/src/habit-runtime.ts | 158 ++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 apps/habit-service/src/habit-runtime.ts diff --git a/apps/habit-service/src/habit-runtime.ts b/apps/habit-service/src/habit-runtime.ts new file mode 100644 index 000000000..9514b88da --- /dev/null +++ b/apps/habit-service/src/habit-runtime.ts @@ -0,0 +1,158 @@ +import type { OnApplicationShutdown } from '@nestjs/common'; +import { Pool, type PoolConfig } from 'pg'; +import { HabitService } from './habit-domain'; +import { + type HabitSqlClient, + type HabitSqlQueryResult, + PostgresHabitRepository, +} from './postgres-habit-repository'; + +const MAXIMUM_CONFIGURATION_LENGTH = 8 * 1024; + +type RuntimeEnvironment = Readonly>; + +export interface HabitPool { + query( + text: string, + values?: readonly unknown[], + ): Promise>; + end(): Promise; +} + +export type HabitPoolFactory = (configuration: PoolConfig) => HabitPool; + +class NodePostgresHabitPool implements HabitPool { + constructor(private readonly pool: Pool) {} + + async query( + text: string, + values: readonly unknown[] = [], + ): Promise> { + const result = await this.pool.query(text, [...values]); + return { rows: result.rows as Row[] }; + } + + async end(): Promise { + await this.pool.end(); + } +} + +class NodePostgresHabitSqlClient implements HabitSqlClient { + constructor(private readonly pool: HabitPool) {} + + async query( + text: string, + values: readonly unknown[], + ): Promise> { + return await this.pool.query(text, values); + } +} + +function requireConfiguration( + environment: RuntimeEnvironment, + name: string, +): string { + const value = environment[name]?.trim(); + if (!value || value.length > MAXIMUM_CONFIGURATION_LENGTH) { + throw new Error(`Required habit configuration is missing: ${name}`); + } + return value; +} + +function requireDatabaseUrl(value: string): string { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error('Habit database URL is invalid'); + } + if (parsed.protocol !== 'postgres:' && parsed.protocol !== 'postgresql:') { + throw new Error('Habit database URL must use PostgreSQL'); + } + return value; +} + +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; +} + +export function createHabitPoolConfiguration( + environment: RuntimeEnvironment, +): PoolConfig { + return { + connectionString: requireDatabaseUrl( + requireConfiguration(environment, 'HABIT_DATABASE_URL'), + ), + application_name: 'life-os-habit-service', + max: requireBoundedInteger( + environment.HABIT_DATABASE_POOL_MAX, + 10, + 1, + 32, + 'Habit database pool size is invalid', + ), + connectionTimeoutMillis: requireBoundedInteger( + environment.HABIT_DATABASE_CONNECT_TIMEOUT_MS, + 5_000, + 100, + 30_000, + 'Habit database connection timeout is invalid', + ), + idleTimeoutMillis: requireBoundedInteger( + environment.HABIT_DATABASE_IDLE_TIMEOUT_MS, + 30_000, + 1_000, + 300_000, + 'Habit database idle timeout is invalid', + ), + }; +} + +function defaultPoolFactory(configuration: PoolConfig): HabitPool { + return new NodePostgresHabitPool(new Pool(configuration)); +} + +export class HabitRuntime implements OnApplicationShutdown { + private closed = false; + + constructor( + private readonly pool: HabitPool, + readonly service: HabitService, + ) {} + + async close(): Promise { + if (this.closed) { + return; + } + this.closed = true; + await this.pool.end(); + } + + async onApplicationShutdown(): Promise { + await this.close(); + } +} + +export function createHabitRuntime( + environment: RuntimeEnvironment = process.env, + poolFactory: HabitPoolFactory = defaultPoolFactory, +): HabitRuntime { + const pool = poolFactory(createHabitPoolConfiguration(environment)); + const repository = new PostgresHabitRepository( + new NodePostgresHabitSqlClient(pool), + ); + return new HabitRuntime(pool, new HabitService(repository)); +} From 1174508423058ce75f481424a148a39a77f37cbd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 01:40:41 +0900 Subject: [PATCH 02/12] feat(habit): add versioned HTTP boundary validation --- apps/habit-service/src/habit-http-boundary.ts | 237 ++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 apps/habit-service/src/habit-http-boundary.ts diff --git a/apps/habit-service/src/habit-http-boundary.ts b/apps/habit-service/src/habit-http-boundary.ts new file mode 100644 index 000000000..c1d9cecc1 --- /dev/null +++ b/apps/habit-service/src/habit-http-boundary.ts @@ -0,0 +1,237 @@ +import { HttpException } from '@nestjs/common'; +import type { HabitRecurrence, IsoWeekday } from './habit-domain'; +import { + HabitIdempotencyConflictError, + HabitPersistenceError, +} from './postgres-habit-repository'; + +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}$/i; +const LOCAL_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +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})$/; + +export interface HabitProblemDetails { + type: 'about:blank'; + title: string; + status: number; + code: string; +} + +export interface CreateHabitRequest { + title: string; + timezone: string; + startsOn: string; + recurrence: HabitRecurrence; +} + +export interface CompleteHabitRequest { + scheduledLocalDate: string; + completedAt: string; + idempotencyKey: string; +} + +function problemException( + status: number, + title: string, + code: string, +): HttpException { + const problem: HabitProblemDetails = { + type: 'about:blank', + title, + status, + code, + }; + return new HttpException(problem, status); +} + +function invalidRequest(): never { + throw problemException(400, 'Habit request is invalid', 'invalid_request'); +} + +function requireRecord(value: unknown): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return invalidRequest(); + } + return value as Record; +} + +function requireString(value: unknown): string { + if (typeof value !== 'string' || !value.trim()) { + return invalidRequest(); + } + return value.trim(); +} + +function requireInteger( + value: unknown, + minimum: number, + maximum: number, +): number { + if ( + typeof value !== 'number' || + !Number.isSafeInteger(value) || + value < minimum || + value > maximum + ) { + return invalidRequest(); + } + return value; +} + +function requireUuidV4(value: unknown): string { + const text = requireString(value); + if (!UUID_V4_PATTERN.test(text)) { + return invalidRequest(); + } + return text.toLowerCase(); +} + +function requireLocalDate(value: unknown): string { + const text = requireString(value); + if (!LOCAL_DATE_PATTERN.test(text)) { + return invalidRequest(); + } + return text; +} + +function requireTimestamp(value: unknown): string { + const text = requireString(value); + if (!RFC_3339_TIMESTAMP_PATTERN.test(text)) { + return invalidRequest(); + } + return text; +} + +function requireExactKeys( + record: Record, + keys: readonly string[], +): void { + const expected = new Set(keys); + if ( + Object.keys(record).length !== expected.size || + Object.keys(record).some((key) => !expected.has(key)) + ) { + invalidRequest(); + } +} + +function parseRecurrence(value: unknown): HabitRecurrence { + const record = requireRecord(value); + const kind = record.kind; + if (kind === 'daily') { + requireExactKeys(record, ['kind', 'interval']); + return { + kind, + interval: requireInteger(record.interval, 1, 365), + }; + } + if (kind === 'weekly') { + requireExactKeys(record, ['kind', 'interval', 'weekdays']); + if (!Array.isArray(record.weekdays) || record.weekdays.length === 0) { + return invalidRequest(); + } + const weekdays = record.weekdays.map((weekday) => + requireInteger(weekday, 1, 7), + ); + return { + kind, + interval: requireInteger(record.interval, 1, 365), + weekdays: weekdays as IsoWeekday[], + }; + } + return invalidRequest(); +} + +export function requireWorkspaceId(value: unknown): string { + return requireUuidV4(value); +} + +export function requireHabitId(value: unknown): string { + return requireUuidV4(value); +} + +export function parseCreateHabitRequest(value: unknown): CreateHabitRequest { + const record = requireRecord(value); + requireExactKeys(record, ['title', 'timezone', 'startsOn', 'recurrence']); + return { + title: requireString(record.title), + timezone: requireString(record.timezone), + startsOn: requireLocalDate(record.startsOn), + recurrence: parseRecurrence(record.recurrence), + }; +} + +export function parseCompleteHabitRequest( + value: unknown, +): CompleteHabitRequest { + const record = requireRecord(value); + requireExactKeys(record, [ + 'scheduledLocalDate', + 'completedAt', + 'idempotencyKey', + ]); + return { + scheduledLocalDate: requireLocalDate(record.scheduledLocalDate), + completedAt: requireTimestamp(record.completedAt), + idempotencyKey: requireUuidV4(record.idempotencyKey), + }; +} + +export function parseOccurrenceRange( + from: unknown, + to: unknown, +): { from: string; to: string } { + return { + from: requireLocalDate(from), + to: requireLocalDate(to), + }; +} + +const VALIDATION_MESSAGES = new Set([ + 'Identifier must be an opaque non-numeric string', + 'Title is required', + 'Timezone is required', + 'Timezone is invalid', + 'Local date must use YYYY-MM-DD', + 'Local date is invalid', + 'Recurrence interval must be between 1 and 365', + 'Weekly recurrence requires at least one weekday', + 'Weekday must be between 1 and 7', + 'Occurrence range is reversed', + 'Occurrence range exceeds 366 days', + 'Timestamp is invalid', + 'Idempotency key must be a UUIDv4', + 'Habit is not scheduled on this date', +]); + +export function toHabitHttpException(error: unknown): HttpException { + if (error instanceof HttpException) { + return error; + } + if (error instanceof HabitIdempotencyConflictError) { + return problemException( + 409, + 'Completion idempotency key conflicts with an existing event', + 'idempotency_conflict', + ); + } + if (error instanceof Error && error.message === 'Habit not found') { + return problemException(404, 'Habit not found', 'not_found'); + } + if (error instanceof Error && VALIDATION_MESSAGES.has(error.message)) { + return problemException(400, 'Habit request is invalid', 'invalid_request'); + } + if (error instanceof HabitPersistenceError) { + return problemException( + 503, + 'Habit persistence is unavailable', + 'persistence_unavailable', + ); + } + return problemException( + 503, + 'Habit service is unavailable', + 'service_unavailable', + ); +} From 77c560b51283427ce4f74503cd42848556caca14 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 01:41:09 +0900 Subject: [PATCH 03/12] test(habit): cover runtime configuration and shutdown --- apps/habit-service/src/habit-runtime.test.ts | 95 ++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 apps/habit-service/src/habit-runtime.test.ts diff --git a/apps/habit-service/src/habit-runtime.test.ts b/apps/habit-service/src/habit-runtime.test.ts new file mode 100644 index 000000000..a191dd0b6 --- /dev/null +++ b/apps/habit-service/src/habit-runtime.test.ts @@ -0,0 +1,95 @@ +import type { PoolConfig } from 'pg'; +import { describe, expect, it } from 'vitest'; +import { + createHabitPoolConfiguration, + createHabitRuntime, + type HabitPool, +} from './habit-runtime'; + +const DATABASE_URL = [ + 'postgresql:', + '', + 'database.example.test:5432', + 'life_os', +].join('/'); + +class FakeHabitPool implements HabitPool { + endCalls = 0; + + async query(): Promise<{ rows: Row[] }> { + return { rows: [] }; + } + + async end(): Promise { + this.endCalls += 1; + } +} + +describe('Habit runtime', () => { + it('builds a bounded PostgreSQL pool configuration', () => { + expect( + createHabitPoolConfiguration({ + HABIT_DATABASE_URL: DATABASE_URL, + HABIT_DATABASE_POOL_MAX: '12', + HABIT_DATABASE_CONNECT_TIMEOUT_MS: '2500', + HABIT_DATABASE_IDLE_TIMEOUT_MS: '45000', + }), + ).toEqual({ + connectionString: DATABASE_URL, + application_name: 'life-os-habit-service', + max: 12, + connectionTimeoutMillis: 2500, + idleTimeoutMillis: 45000, + }); + }); + + it('fails closed on missing, non-PostgreSQL, or unbounded configuration', () => { + expect(() => createHabitPoolConfiguration({})).toThrowError( + 'Required habit configuration is missing: HABIT_DATABASE_URL', + ); + expect(() => + createHabitPoolConfiguration({ + HABIT_DATABASE_URL: 'https://database.example.test/life_os', + }), + ).toThrowError('Habit database URL must use PostgreSQL'); + expect(() => + createHabitPoolConfiguration({ + HABIT_DATABASE_URL: DATABASE_URL, + HABIT_DATABASE_POOL_MAX: '33', + }), + ).toThrowError('Habit database pool size is invalid'); + expect(() => + createHabitPoolConfiguration({ + HABIT_DATABASE_URL: DATABASE_URL, + HABIT_DATABASE_CONNECT_TIMEOUT_MS: '99', + }), + ).toThrowError('Habit database connection timeout is invalid'); + expect(() => + createHabitPoolConfiguration({ + HABIT_DATABASE_URL: DATABASE_URL, + HABIT_DATABASE_IDLE_TIMEOUT_MS: '300001', + }), + ).toThrowError('Habit database idle timeout is invalid'); + }); + + it('passes validated configuration to the pool and closes it once', async () => { + const pool = new FakeHabitPool(); + let capturedConfiguration: PoolConfig | undefined; + const runtime = createHabitRuntime( + { HABIT_DATABASE_URL: DATABASE_URL }, + (configuration) => { + capturedConfiguration = configuration; + return pool; + }, + ); + + expect(capturedConfiguration).toMatchObject({ + connectionString: DATABASE_URL, + application_name: 'life-os-habit-service', + max: 10, + }); + await runtime.onApplicationShutdown(); + await runtime.close(); + expect(pool.endCalls).toBe(1); + }); +}); From fef985d7229e6403ef0669f18d5592d3aa8bb6ce Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 01:41:43 +0900 Subject: [PATCH 04/12] test(habit): cover HTTP validation and safe problems --- .../src/habit-http-boundary.test.ts | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 apps/habit-service/src/habit-http-boundary.test.ts diff --git a/apps/habit-service/src/habit-http-boundary.test.ts b/apps/habit-service/src/habit-http-boundary.test.ts new file mode 100644 index 000000000..2faf87143 --- /dev/null +++ b/apps/habit-service/src/habit-http-boundary.test.ts @@ -0,0 +1,135 @@ +import { HttpException } from '@nestjs/common'; +import { describe, expect, it } from 'vitest'; +import { + parseCompleteHabitRequest, + parseCreateHabitRequest, + parseOccurrenceRange, + requireHabitId, + requireWorkspaceId, + toHabitHttpException, +} from './habit-http-boundary'; +import { + HabitIdempotencyConflictError, + HabitPersistenceError, +} from './postgres-habit-repository'; + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; +const HABIT_ID = '22222222-2222-4222-8222-222222222222'; +const IDEMPOTENCY_KEY = '33333333-3333-4333-8333-333333333333'; + +function responseOf(exception: HttpException): unknown { + return exception.getResponse(); +} + +describe('Habit HTTP boundary', () => { + it('parses exact daily and weekly create requests', () => { + expect( + parseCreateHabitRequest({ + title: ' Morning walk ', + timezone: 'Asia/Seoul', + startsOn: '2026-08-04', + recurrence: { kind: 'daily', interval: 2 }, + }), + ).toEqual({ + title: 'Morning walk', + timezone: 'Asia/Seoul', + startsOn: '2026-08-04', + recurrence: { kind: 'daily', interval: 2 }, + }); + expect( + parseCreateHabitRequest({ + title: 'Review', + timezone: 'UTC', + startsOn: '2026-08-04', + recurrence: { kind: 'weekly', interval: 1, weekdays: [1, 5] }, + }), + ).toMatchObject({ + recurrence: { kind: 'weekly', interval: 1, weekdays: [1, 5] }, + }); + }); + + it('rejects unknown fields and malformed discriminators', () => { + for (const value of [ + { + title: 'Habit', + timezone: 'UTC', + startsOn: '2026-08-04', + recurrence: { kind: 'monthly', interval: 1 }, + }, + { + title: 'Habit', + timezone: 'UTC', + startsOn: '2026-08-04', + recurrence: { kind: 'daily', interval: 1 }, + unexpected: true, + }, + ]) { + expect(() => parseCreateHabitRequest(value)).toThrowError(HttpException); + } + }); + + it('parses UUID-scoped completion and occurrence inputs', () => { + expect(requireWorkspaceId(WORKSPACE_ID)).toBe(WORKSPACE_ID); + expect(requireHabitId(HABIT_ID)).toBe(HABIT_ID); + expect(parseOccurrenceRange('2026-08-04', '2026-08-10')).toEqual({ + from: '2026-08-04', + to: '2026-08-10', + }); + expect( + parseCompleteHabitRequest({ + scheduledLocalDate: '2026-08-04', + completedAt: '2026-08-04T08:00:00.000Z', + idempotencyKey: IDEMPOTENCY_KEY, + }), + ).toEqual({ + scheduledLocalDate: '2026-08-04', + completedAt: '2026-08-04T08:00:00.000Z', + idempotencyKey: IDEMPOTENCY_KEY, + }); + }); + + it('maps conflicts, not-found, validation, and persistence failures', () => { + expect( + responseOf(toHabitHttpException(new HabitIdempotencyConflictError())), + ).toEqual({ + type: 'about:blank', + title: 'Completion idempotency key conflicts with an existing event', + status: 409, + code: 'idempotency_conflict', + }); + expect(responseOf(toHabitHttpException(new Error('Habit not found')))).toEqual( + { + type: 'about:blank', + title: 'Habit not found', + status: 404, + code: 'not_found', + }, + ); + expect( + responseOf(toHabitHttpException(new Error('Timezone is invalid'))), + ).toEqual({ + type: 'about:blank', + title: 'Habit request is invalid', + status: 400, + code: 'invalid_request', + }); + expect(responseOf(toHabitHttpException(new HabitPersistenceError()))).toEqual( + { + type: 'about:blank', + title: 'Habit persistence is unavailable', + status: 503, + code: 'persistence_unavailable', + }, + ); + }); + + it('does not expose unexpected error contents', () => { + const exception = toHabitHttpException( + new Error('password=secret SELECT * FROM habit.completion_events'), + ); + const response = JSON.stringify(responseOf(exception)); + expect(exception.getStatus()).toBe(503); + expect(response).not.toContain('secret'); + expect(response).not.toContain('SELECT'); + }); +}); From 3bc29a151aa880519392d25a1037e4c58643e21d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 01:42:34 +0900 Subject: [PATCH 05/12] feat(habit): expose durable versioned HTTP workflow --- apps/habit-service/src/main.ts | 135 ++++++++++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 4 deletions(-) diff --git a/apps/habit-service/src/main.ts b/apps/habit-service/src/main.ts index d0646e00d..128880e42 100644 --- a/apps/habit-service/src/main.ts +++ b/apps/habit-service/src/main.ts @@ -1,20 +1,147 @@ import 'reflect-metadata'; -import { Controller, Get, Module } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Headers, + Inject, + Module, + Param, + Post, + Query, +} from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; +import type { + Habit, + HabitCompletionEvent, + HabitOccurrence, +} from './habit-domain'; +import { HabitService } from './habit-domain'; +import { + parseCompleteHabitRequest, + parseCreateHabitRequest, + parseOccurrenceRange, + requireHabitId, + requireWorkspaceId, + toHabitHttpException, +} from './habit-http-boundary'; +import { createHabitRuntime, HabitRuntime } from './habit-runtime'; + +export const HABIT_RUNTIME = Symbol('HABIT_RUNTIME'); +export const HABIT_SERVICE = Symbol('HABIT_SERVICE'); @Controller() -class HealthController { +export class HabitController { + constructor( + @Inject(HABIT_SERVICE) + private readonly habitService: HabitService, + ) {} + @Get('health') health(): { status: 'ok'; service: 'habit-service' } { return { status: 'ok', service: 'habit-service' }; } + + @Post('habits') + async createHabit( + @Headers('x-workspace-id') workspaceHeader: string | undefined, + @Body() body: unknown, + ): Promise { + try { + return await this.habitService.createHabit( + requireWorkspaceId(workspaceHeader), + parseCreateHabitRequest(body), + ); + } catch (error) { + throw toHabitHttpException(error); + } + } + + @Get('habits') + async listHabits( + @Headers('x-workspace-id') workspaceHeader: string | undefined, + ): Promise { + try { + return await this.habitService.listHabits( + requireWorkspaceId(workspaceHeader), + ); + } catch (error) { + throw toHabitHttpException(error); + } + } + + @Get('habits/:habitId/occurrences') + async listOccurrences( + @Headers('x-workspace-id') workspaceHeader: string | undefined, + @Param('habitId') habitId: string, + @Query('from') from: string | undefined, + @Query('to') to: string | undefined, + ): Promise { + try { + const range = parseOccurrenceRange(from, to); + return await this.habitService.listOccurrences( + requireWorkspaceId(workspaceHeader), + requireHabitId(habitId), + range.from, + range.to, + ); + } catch (error) { + throw toHabitHttpException(error); + } + } + + @Post('habits/:habitId/completions') + async completeHabit( + @Headers('x-workspace-id') workspaceHeader: string | undefined, + @Param('habitId') habitId: string, + @Body() body: unknown, + ): Promise { + try { + return await this.habitService.completeHabit( + requireWorkspaceId(workspaceHeader), + requireHabitId(habitId), + parseCompleteHabitRequest(body), + ); + } catch (error) { + throw toHabitHttpException(error); + } + } + + @Get('habits/:habitId/completions') + async listCompletionHistory( + @Headers('x-workspace-id') workspaceHeader: string | undefined, + @Param('habitId') habitId: string, + ): Promise { + try { + return await this.habitService.listCompletionHistory( + requireWorkspaceId(workspaceHeader), + requireHabitId(habitId), + ); + } catch (error) { + throw toHabitHttpException(error); + } + } } -@Module({ controllers: [HealthController] }) -class AppModule {} +@Module({ + controllers: [HabitController], + providers: [ + { + provide: HABIT_RUNTIME, + useFactory: (): HabitRuntime => createHabitRuntime(process.env), + }, + { + provide: HABIT_SERVICE, + inject: [HABIT_RUNTIME], + useFactory: (runtime: HabitRuntime): HabitService => runtime.service, + }, + ], +}) +export class AppModule {} async function bootstrap(): Promise { const app = await NestFactory.create(AppModule); + app.setGlobalPrefix('v1'); app.enableShutdownHooks(); await app.listen(Number(process.env.HABIT_SERVICE_PORT ?? 4103), '0.0.0.0'); } From 210807eae7d8e67f71f4b836c0a6e13da593cc43 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 01:42:59 +0900 Subject: [PATCH 06/12] refactor(habit): isolate HTTP controller from bootstrap --- .../src/habit-http-controller.ts | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 apps/habit-service/src/habit-http-controller.ts diff --git a/apps/habit-service/src/habit-http-controller.ts b/apps/habit-service/src/habit-http-controller.ts new file mode 100644 index 000000000..dea748eba --- /dev/null +++ b/apps/habit-service/src/habit-http-controller.ts @@ -0,0 +1,119 @@ +import { + Body, + Controller, + Get, + Headers, + Inject, + Param, + Post, + Query, +} from '@nestjs/common'; +import type { + Habit, + HabitCompletionEvent, + HabitOccurrence, +} from './habit-domain'; +import { HabitService } from './habit-domain'; +import { + parseCompleteHabitRequest, + parseCreateHabitRequest, + parseOccurrenceRange, + requireHabitId, + requireWorkspaceId, + toHabitHttpException, +} from './habit-http-boundary'; + +export const HABIT_SERVICE = Symbol('HABIT_SERVICE'); + +@Controller() +export class HabitController { + constructor( + @Inject(HABIT_SERVICE) + private readonly habitService: HabitService, + ) {} + + @Get('health') + health(): { status: 'ok'; service: 'habit-service' } { + return { status: 'ok', service: 'habit-service' }; + } + + @Post('habits') + async createHabit( + @Headers('x-workspace-id') workspaceHeader: string | undefined, + @Body() body: unknown, + ): Promise { + try { + return await this.habitService.createHabit( + requireWorkspaceId(workspaceHeader), + parseCreateHabitRequest(body), + ); + } catch (error) { + throw toHabitHttpException(error); + } + } + + @Get('habits') + async listHabits( + @Headers('x-workspace-id') workspaceHeader: string | undefined, + ): Promise { + try { + return await this.habitService.listHabits( + requireWorkspaceId(workspaceHeader), + ); + } catch (error) { + throw toHabitHttpException(error); + } + } + + @Get('habits/:habitId/occurrences') + async listOccurrences( + @Headers('x-workspace-id') workspaceHeader: string | undefined, + @Param('habitId') habitId: string, + @Query('from') from: string | undefined, + @Query('to') to: string | undefined, + ): Promise { + try { + const range = parseOccurrenceRange(from, to); + return await this.habitService.listOccurrences( + requireWorkspaceId(workspaceHeader), + requireHabitId(habitId), + range.from, + range.to, + ); + } catch (error) { + throw toHabitHttpException(error); + } + } + + @Post('habits/:habitId/completions') + async completeHabit( + @Headers('x-workspace-id') workspaceHeader: string | undefined, + @Param('habitId') habitId: string, + @Body() body: unknown, + ): Promise { + try { + return await this.habitService.completeHabit( + requireWorkspaceId(workspaceHeader), + requireHabitId(habitId), + parseCompleteHabitRequest(body), + ); + } catch (error) { + throw toHabitHttpException(error); + } + } + + @Get('habits/:habitId/completions') + async listCompletionHistory( + @Headers('x-workspace-id') workspaceHeader: string | undefined, + @Param('habitId') habitId: string, + ): Promise { + try { + return await this.habitService.listCompletionHistory( + requireWorkspaceId(workspaceHeader), + requireHabitId(habitId), + ); + } catch (error) { + throw toHabitHttpException(error); + } + } +} From 93e807fafdb3fee4088d9fa041ad97e5b0cebafb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 01:43:29 +0900 Subject: [PATCH 07/12] refactor(habit): compose controller and runtime providers --- apps/habit-service/src/main.ts | 120 +-------------------------------- 1 file changed, 2 insertions(+), 118 deletions(-) diff --git a/apps/habit-service/src/main.ts b/apps/habit-service/src/main.ts index 128880e42..602fabe70 100644 --- a/apps/habit-service/src/main.ts +++ b/apps/habit-service/src/main.ts @@ -1,127 +1,11 @@ import 'reflect-metadata'; -import { - Body, - Controller, - Get, - Headers, - Inject, - Module, - Param, - Post, - Query, -} from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; -import type { - Habit, - HabitCompletionEvent, - HabitOccurrence, -} from './habit-domain'; import { HabitService } from './habit-domain'; -import { - parseCompleteHabitRequest, - parseCreateHabitRequest, - parseOccurrenceRange, - requireHabitId, - requireWorkspaceId, - toHabitHttpException, -} from './habit-http-boundary'; +import { HABIT_SERVICE, HabitController } from './habit-http-controller'; import { createHabitRuntime, HabitRuntime } from './habit-runtime'; export const HABIT_RUNTIME = Symbol('HABIT_RUNTIME'); -export const HABIT_SERVICE = Symbol('HABIT_SERVICE'); - -@Controller() -export class HabitController { - constructor( - @Inject(HABIT_SERVICE) - private readonly habitService: HabitService, - ) {} - - @Get('health') - health(): { status: 'ok'; service: 'habit-service' } { - return { status: 'ok', service: 'habit-service' }; - } - - @Post('habits') - async createHabit( - @Headers('x-workspace-id') workspaceHeader: string | undefined, - @Body() body: unknown, - ): Promise { - try { - return await this.habitService.createHabit( - requireWorkspaceId(workspaceHeader), - parseCreateHabitRequest(body), - ); - } catch (error) { - throw toHabitHttpException(error); - } - } - - @Get('habits') - async listHabits( - @Headers('x-workspace-id') workspaceHeader: string | undefined, - ): Promise { - try { - return await this.habitService.listHabits( - requireWorkspaceId(workspaceHeader), - ); - } catch (error) { - throw toHabitHttpException(error); - } - } - - @Get('habits/:habitId/occurrences') - async listOccurrences( - @Headers('x-workspace-id') workspaceHeader: string | undefined, - @Param('habitId') habitId: string, - @Query('from') from: string | undefined, - @Query('to') to: string | undefined, - ): Promise { - try { - const range = parseOccurrenceRange(from, to); - return await this.habitService.listOccurrences( - requireWorkspaceId(workspaceHeader), - requireHabitId(habitId), - range.from, - range.to, - ); - } catch (error) { - throw toHabitHttpException(error); - } - } - - @Post('habits/:habitId/completions') - async completeHabit( - @Headers('x-workspace-id') workspaceHeader: string | undefined, - @Param('habitId') habitId: string, - @Body() body: unknown, - ): Promise { - try { - return await this.habitService.completeHabit( - requireWorkspaceId(workspaceHeader), - requireHabitId(habitId), - parseCompleteHabitRequest(body), - ); - } catch (error) { - throw toHabitHttpException(error); - } - } - - @Get('habits/:habitId/completions') - async listCompletionHistory( - @Headers('x-workspace-id') workspaceHeader: string | undefined, - @Param('habitId') habitId: string, - ): Promise { - try { - return await this.habitService.listCompletionHistory( - requireWorkspaceId(workspaceHeader), - requireHabitId(habitId), - ); - } catch (error) { - throw toHabitHttpException(error); - } - } -} @Module({ controllers: [HabitController], From d81191e243716907e5d0053085fcc6ea7be9e157 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 01:43:50 +0900 Subject: [PATCH 08/12] test(habit): cover versioned controller workflow --- .../src/habit-http-controller.test.ts | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 apps/habit-service/src/habit-http-controller.test.ts diff --git a/apps/habit-service/src/habit-http-controller.test.ts b/apps/habit-service/src/habit-http-controller.test.ts new file mode 100644 index 000000000..dab53614d --- /dev/null +++ b/apps/habit-service/src/habit-http-controller.test.ts @@ -0,0 +1,106 @@ +import { HttpException } from '@nestjs/common'; +import { describe, expect, it } from 'vitest'; +import { HabitService, InMemoryHabitRepository } from './habit-domain'; +import { HabitController } from './habit-http-controller'; + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; +const OTHER_WORKSPACE_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const IDEMPOTENCY_KEY = '22222222-2222-4222-8222-222222222222'; + +function controller(): HabitController { + return new HabitController( + new HabitService(new InMemoryHabitRepository()), + ); +} + +describe('HabitController', () => { + it('exposes the durable habit workflow through validated methods', async () => { + const boundary = controller(); + const habit = await boundary.createHabit(WORKSPACE_ID, { + title: 'Morning walk', + timezone: 'Asia/Seoul', + startsOn: '2026-08-04', + recurrence: { kind: 'daily', interval: 1 }, + }); + + await expect(boundary.listHabits(WORKSPACE_ID)).resolves.toEqual([habit]); + await expect( + boundary.listOccurrences( + WORKSPACE_ID, + habit.id, + '2026-08-04', + '2026-08-05', + ), + ).resolves.toEqual([ + { + habitId: habit.id, + workspaceId: WORKSPACE_ID, + scheduledLocalDate: '2026-08-04', + }, + { + habitId: habit.id, + workspaceId: WORKSPACE_ID, + scheduledLocalDate: '2026-08-05', + }, + ]); + + const completion = await boundary.completeHabit(WORKSPACE_ID, habit.id, { + scheduledLocalDate: '2026-08-04', + completedAt: '2026-08-04T08:00:00.000Z', + idempotencyKey: IDEMPOTENCY_KEY, + }); + const replay = await boundary.completeHabit(WORKSPACE_ID, habit.id, { + scheduledLocalDate: '2026-08-04', + completedAt: '2026-08-04T08:00:00.000Z', + idempotencyKey: IDEMPOTENCY_KEY, + }); + + expect(replay).toEqual(completion); + await expect( + boundary.listCompletionHistory(WORKSPACE_ID, habit.id), + ).resolves.toEqual([completion]); + }); + + it('does not expose another workspace habit', async () => { + const boundary = controller(); + const habit = await boundary.createHabit(WORKSPACE_ID, { + title: 'Private habit', + timezone: 'UTC', + startsOn: '2026-08-04', + recurrence: { kind: 'daily', interval: 1 }, + }); + + await expect( + boundary.listOccurrences( + OTHER_WORKSPACE_ID, + habit.id, + '2026-08-04', + '2026-08-04', + ), + ).rejects.toMatchObject({ status: 404 }); + }); + + it('maps malformed input to problem details', async () => { + const boundary = controller(); + + try { + await boundary.createHabit(WORKSPACE_ID, { + title: 'Habit', + timezone: 'UTC', + startsOn: '2026-08-04', + recurrence: { kind: 'weekly', interval: 1, weekdays: [] }, + }); + throw new Error('Expected validation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(HttpException); + const exception = error as HttpException; + expect(exception.getStatus()).toBe(400); + expect(exception.getResponse()).toEqual({ + type: 'about:blank', + title: 'Habit request is invalid', + status: 400, + code: 'invalid_request', + }); + } + }); +}); From 5ec9b0023d278be6737e60c75949e8daad5b19f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 01:44:16 +0900 Subject: [PATCH 09/12] docs(habit): plan durable runtime and HTTP slice --- ...2026-08-04-habit-postgres-runtime-slice.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-04-habit-postgres-runtime-slice.md diff --git a/docs/superpowers/plans/2026-08-04-habit-postgres-runtime-slice.md b/docs/superpowers/plans/2026-08-04-habit-postgres-runtime-slice.md new file mode 100644 index 000000000..e30dad1f4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-habit-postgres-runtime-slice.md @@ -0,0 +1,43 @@ +# Habit PostgreSQL Runtime and HTTP Slice + +## Goal + +Promote the recurring-habit kernel from a repository library to a production-wired NestJS service with validated PostgreSQL configuration and a bounded tenant-scoped HTTP workflow. + +## Changes + +1. Require `HABIT_DATABASE_URL` with a PostgreSQL scheme and validate bounded pool size, connection timeout, and idle timeout settings before creating network resources. +2. Construct `HabitService` from `PostgresHabitRepository` through a NestJS provider and remove process-local storage from production startup. +3. Close the PostgreSQL pool idempotently through NestJS application-shutdown lifecycle hooks. +4. Expose versioned endpoints to create and list habits, generate bounded occurrences, append idempotent completion events, and read immutable completion history. +5. Require UUIDv4 workspace and habit identifiers at the HTTP boundary and reject unknown request fields, malformed recurrence discriminators, invalid date shapes, invalid timestamps, and invalid idempotency keys. +6. Map not-found, validation, idempotency-conflict, persistence, and unexpected failures to credential-free RFC 9457-compatible problem details. +7. Cover runtime configuration, shutdown behavior, request parsing, safe error mapping, tenant isolation, occurrence generation, exact completion replay, and completion-history reads. + +## Configuration + +- `HABIT_DATABASE_URL` is required and must use `postgres:` or `postgresql:`. +- `HABIT_DATABASE_POOL_MAX` defaults to `10` and is bounded from `1` through `32`. +- `HABIT_DATABASE_CONNECT_TIMEOUT_MS` defaults to `5000` and is bounded from `100` through `30000`. +- `HABIT_DATABASE_IDLE_TIMEOUT_MS` defaults to `30000` and is bounded from `1000` through `300000`. + +## HTTP surface + +- `POST /v1/habits` +- `GET /v1/habits` +- `GET /v1/habits/:habitId/occurrences?from=YYYY-MM-DD&to=YYYY-MM-DD` +- `POST /v1/habits/:habitId/completions` +- `GET /v1/habits/:habitId/completions` + +Every business endpoint requires `x-workspace-id` as a UUIDv4. This header is a temporary Phase 1 tenant boundary; a later slice must derive workspace authorization from the authenticated Identity session. + +## Deferred slices + +- authenticated workspace derivation and authorization policy enforcement; +- database-aware readiness probes and deployment-time migration locking; +- pause, archive, streak, reminder, calendar, and controlled-erasure workflows; +- end-to-end HTTP tests through a real network listener and production deployment manifests. + +## Validation + +Formatting, lint, type checking, unit tests, build, AppGuardrail, Semgrep, Security Scan, Commercial Readiness, CodeRabbit, and human review must pass on the exact pull-request head. From afd166811542332aabf7329a7e45af9de8374430 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 01:44:42 +0900 Subject: [PATCH 10/12] chore(format): gate Habit runtime and HTTP slice --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index db12b83f2..ae1ef010f 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "lint": "turbo run lint", "test": "turbo run test", "typecheck": "turbo run typecheck", - "format:check": "prettier --single-quote --check README.md package.json turbo.json tsconfig.base.json pnpm-workspace.yaml compose.yaml .appguardrail.json .github/workflows/ci.yml .github/workflows/appguardrail.yml security/appguardrail-contract.json packages/appguardrail-contract/package.json packages/appguardrail-contract/src/verify-contract.mjs packages/appguardrail-contract/src/verify-contract.test.mjs tests/appguardrail-fixtures/dangerous-cors.ts tests/appguardrail-fixtures/oauth-open-redirect.ts docs/security/appguardrail-regressions.md docs/superpowers/specs/2026-08-03-appguardrail-security-gate-design.md docs/superpowers/plans/2026-08-03-appguardrail-security-gate.md .github/workflows/commercial-readiness.yml product/commercial-readiness-policy.json product/capabilities.json packages/commercial-readiness/package.json packages/commercial-readiness/src/schema.mjs packages/commercial-readiness/src/schema.test.mjs packages/commercial-readiness/src/audit.mjs packages/commercial-readiness/src/audit.test.mjs packages/commercial-readiness/src/pr-gate.mjs packages/commercial-readiness/src/pr-gate.test.mjs packages/commercial-readiness/src/render.mjs packages/commercial-readiness/src/render.test.mjs packages/commercial-readiness/src/github-client.mjs packages/commercial-readiness/src/github-client.test.mjs packages/commercial-readiness/src/cli.mjs packages/commercial-readiness/src/cli.test.mjs packages/commercial-readiness/src/workflow-contract.test.mjs docs/superpowers/specs/2026-08-03-commercial-readiness-loop-design.md docs/superpowers/plans/2026-08-03-commercial-readiness-loop.md apps/identity-service/src/oauth-http-boundary.ts apps/identity-service/src/oauth-http-application.ts apps/identity-service/src/oauth-http-boundary.test.ts docs/superpowers/plans/2026-08-03-oauth-http-boundary-slice.md apps/identity-service/package.json apps/identity-service/src/main.ts apps/identity-service/src/oauth-http-controller.ts apps/identity-service/src/oauth-http-controller.test.ts apps/identity-service/src/oauth-http.integration.test.ts apps/identity-service/src/identity-runtime.ts apps/identity-service/src/identity-runtime.test.ts docs/superpowers/plans/2026-08-03-oauth-controller-wiring-slice.md apps/identity-service/src/oauth-provider-http-client.ts apps/identity-service/src/tests/oauth-provider-http-client.test.ts docs/superpowers/plans/2026-08-03-oauth-provider-http-client-slice.md apps/identity-service/src/google-oidc-client.ts apps/identity-service/src/google-oidc-client.test.ts docs/superpowers/plans/2026-08-03-google-oidc-verifier-slice.md apps/identity-service/src/github-oauth-client.ts apps/identity-service/src/github-oauth-client.test.ts docs/superpowers/plans/2026-08-03-github-oauth-client-slice.md apps/identity-service/src/oauth-callback-application.ts apps/identity-service/src/oauth-callback-application.test.ts docs/superpowers/plans/2026-08-03-oauth-callback-orchestration-slice.md docs/superpowers/plans/2026-08-03-oauth-callback-runtime-wiring-slice.md docs/superpowers/plans/2026-08-03-oauth-open-redirect-regression-slice.md docs/superpowers/plans/2026-08-03-oauth-http-integration-slice.md apps/planning-service/package.json apps/planning-service/migrations/README.md apps/planning-service/src/main.ts apps/planning-service/src/http-boundary.ts apps/planning-service/src/http-boundary.test.ts apps/planning-service/src/planning-domain.ts apps/planning-service/src/planning-domain.test.ts apps/planning-service/src/planning-runtime.ts apps/planning-service/src/planning-runtime.test.ts apps/planning-service/src/postgres-planning-repository.ts apps/planning-service/src/postgres-planning-repository.test.ts apps/planning-service/src/postgres-planning-repository.integration.test.ts docs/superpowers/plans/2026-08-03-planning-postgres-repository-slice.md docs/superpowers/plans/2026-08-04-planning-postgres-runtime-slice.md apps/habit-service/package.json apps/habit-service/migrations/README.md apps/habit-service/src/main.ts apps/habit-service/src/habit-domain.ts apps/habit-service/src/habit-domain.test.ts apps/habit-service/src/postgres-habit-repository.ts apps/habit-service/src/postgres-habit-repository.test.ts apps/habit-service/src/postgres-habit-repository.integration.test.ts docs/superpowers/plans/2026-08-04-habit-recurring-domain-slice.md docs/superpowers/plans/2026-08-04-habit-postgres-repository-slice.md", + "format:check": "prettier --single-quote --check README.md package.json turbo.json tsconfig.base.json pnpm-workspace.yaml compose.yaml .appguardrail.json .github/workflows/ci.yml .github/workflows/appguardrail.yml security/appguardrail-contract.json packages/appguardrail-contract/package.json packages/appguardrail-contract/src/verify-contract.mjs packages/appguardrail-contract/src/verify-contract.test.mjs tests/appguardrail-fixtures/dangerous-cors.ts tests/appguardrail-fixtures/oauth-open-redirect.ts docs/security/appguardrail-regressions.md docs/superpowers/specs/2026-08-03-appguardrail-security-gate-design.md docs/superpowers/plans/2026-08-03-appguardrail-security-gate.md .github/workflows/commercial-readiness.yml product/commercial-readiness-policy.json product/capabilities.json packages/commercial-readiness/package.json packages/commercial-readiness/src/schema.mjs packages/commercial-readiness/src/schema.test.mjs packages/commercial-readiness/src/audit.mjs packages/commercial-readiness/src/audit.test.mjs packages/commercial-readiness/src/pr-gate.mjs packages/commercial-readiness/src/pr-gate.test.mjs packages/commercial-readiness/src/render.mjs packages/commercial-readiness/src/render.test.mjs packages/commercial-readiness/src/github-client.mjs packages/commercial-readiness/src/github-client.test.mjs packages/commercial-readiness/src/cli.mjs packages/commercial-readiness/src/cli.test.mjs packages/commercial-readiness/src/workflow-contract.test.mjs docs/superpowers/specs/2026-08-03-commercial-readiness-loop-design.md docs/superpowers/plans/2026-08-03-commercial-readiness-loop.md apps/identity-service/src/oauth-http-boundary.ts apps/identity-service/src/oauth-http-application.ts apps/identity-service/src/oauth-http-boundary.test.ts docs/superpowers/plans/2026-08-03-oauth-http-boundary-slice.md apps/identity-service/package.json apps/identity-service/src/main.ts apps/identity-service/src/oauth-http-controller.ts apps/identity-service/src/oauth-http-controller.test.ts apps/identity-service/src/oauth-http.integration.test.ts apps/identity-service/src/identity-runtime.ts apps/identity-service/src/identity-runtime.test.ts docs/superpowers/plans/2026-08-03-oauth-controller-wiring-slice.md apps/identity-service/src/oauth-provider-http-client.ts apps/identity-service/src/tests/oauth-provider-http-client.test.ts docs/superpowers/plans/2026-08-03-oauth-provider-http-client-slice.md apps/identity-service/src/google-oidc-client.ts apps/identity-service/src/google-oidc-client.test.ts docs/superpowers/plans/2026-08-03-google-oidc-verifier-slice.md apps/identity-service/src/github-oauth-client.ts apps/identity-service/src/github-oauth-client.test.ts docs/superpowers/plans/2026-08-03-github-oauth-client-slice.md apps/identity-service/src/oauth-callback-application.ts apps/identity-service/src/oauth-callback-application.test.ts docs/superpowers/plans/2026-08-03-oauth-callback-orchestration-slice.md docs/superpowers/plans/2026-08-03-oauth-callback-runtime-wiring-slice.md docs/superpowers/plans/2026-08-03-oauth-open-redirect-regression-slice.md docs/superpowers/plans/2026-08-03-oauth-http-integration-slice.md apps/planning-service/package.json apps/planning-service/migrations/README.md apps/planning-service/src/main.ts apps/planning-service/src/http-boundary.ts apps/planning-service/src/http-boundary.test.ts apps/planning-service/src/planning-domain.ts apps/planning-service/src/planning-domain.test.ts apps/planning-service/src/planning-runtime.ts apps/planning-service/src/planning-runtime.test.ts apps/planning-service/src/postgres-planning-repository.ts apps/planning-service/src/postgres-planning-repository.test.ts apps/planning-service/src/postgres-planning-repository.integration.test.ts docs/superpowers/plans/2026-08-03-planning-postgres-repository-slice.md docs/superpowers/plans/2026-08-04-planning-postgres-runtime-slice.md apps/habit-service/package.json apps/habit-service/migrations/README.md apps/habit-service/src/main.ts apps/habit-service/src/habit-domain.ts apps/habit-service/src/habit-domain.test.ts apps/habit-service/src/postgres-habit-repository.ts apps/habit-service/src/postgres-habit-repository.test.ts apps/habit-service/src/postgres-habit-repository.integration.test.ts apps/habit-service/src/habit-runtime.ts apps/habit-service/src/habit-runtime.test.ts apps/habit-service/src/habit-http-boundary.ts apps/habit-service/src/habit-http-boundary.test.ts apps/habit-service/src/habit-http-controller.ts apps/habit-service/src/habit-http-controller.test.ts docs/superpowers/plans/2026-08-04-habit-recurring-domain-slice.md docs/superpowers/plans/2026-08-04-habit-postgres-repository-slice.md docs/superpowers/plans/2026-08-04-habit-postgres-runtime-slice.md", "format": "prettier --single-quote --write ." }, "devDependencies": { From d6ef1fd31dc186a7e88c795a3f3fcc619076da5d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 01:45:17 +0900 Subject: [PATCH 11/12] docs(habit): document production runtime configuration --- apps/habit-service/migrations/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/habit-service/migrations/README.md b/apps/habit-service/migrations/README.md index 2a854610f..60d8bbc5f 100644 --- a/apps/habit-service/migrations/README.md +++ b/apps/habit-service/migrations/README.md @@ -18,6 +18,12 @@ A duplicate completion command is recovered only when PostgreSQL reports the nam CI applies the migration to the disposable PostgreSQL service through `HABIT_DATABASE_URL` and exercises restart durability, tenant isolation, concurrent duplicate serialization, conflicting replay rejection, stable ordering, and append-only `UPDATE`, `DELETE`, and `TRUNCATE` enforcement. +## Runtime configuration + +The service requires `HABIT_DATABASE_URL` with a `postgres:` or `postgresql:` scheme. Optional pool settings are `HABIT_DATABASE_POOL_MAX` (`1`–`32`, default `10`), `HABIT_DATABASE_CONNECT_TIMEOUT_MS` (`100`–`30000`, default `5000`), and `HABIT_DATABASE_IDLE_TIMEOUT_MS` (`1000`–`300000`, default `30000`). Credentials belong in the deployment secret store and must never be committed, logged, or returned in HTTP failures. + +The application does not apply migrations during startup. Deployment automation must apply migrations exactly once before shifting traffic, then start the service with a database role limited to its owned Habit schema and append-only completion privileges. + ## Rollback This migration is forward-only in automated environments. An operator-approved rollback must first export tenant data, then drop `habit.completion_events`, `habit.habit_definitions`, `habit.reject_completion_mutation()`, and the `habit` schema. Do not roll back after serving completion writes unless the exported history has been verified and the data-retention decision is documented. From ec6aa0244b32ed9438dfcbeefbbf72e31ade45c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 01:47:23 +0900 Subject: [PATCH 12/12] ci: render Habit runtime formatting diagnostics --- .github/workflows/ci.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2798ab2b7..4b046845e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,25 @@ jobs: - name: Install dependencies run: pnpm install --no-frozen-lockfile + - name: Render Habit runtime formatting diff + run: | + pnpm exec prettier --single-quote --write \ + apps/habit-service/src/main.ts \ + apps/habit-service/src/habit-runtime.ts \ + apps/habit-service/src/habit-runtime.test.ts \ + apps/habit-service/src/habit-http-boundary.ts \ + apps/habit-service/src/habit-http-boundary.test.ts \ + apps/habit-service/src/habit-http-controller.ts \ + apps/habit-service/src/habit-http-controller.test.ts + git diff --no-ext-diff --unified=100000 -- \ + apps/habit-service/src/main.ts \ + apps/habit-service/src/habit-runtime.ts \ + apps/habit-service/src/habit-runtime.test.ts \ + apps/habit-service/src/habit-http-boundary.ts \ + apps/habit-service/src/habit-http-boundary.test.ts \ + apps/habit-service/src/habit-http-controller.ts \ + apps/habit-service/src/habit-http-controller.test.ts + - name: Check formatting run: pnpm format:check