diff --git a/CHANGELOG.md b/CHANGELOG.md index 1458ab52b..815f047f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ ## [Unreleased] +## [1.31.4] — 2026-07-21 + +- **Web and background processes now start and recover predictably.** Invalid + encryption-key configuration fails before the service accepts traffic, + worker-only processes report their real health, connection limits are + divided safely across replicas, and provider backfills keep their bounded + queues during reconnects. +- **Published images are reproducible and verified before promotion.** The + release pipeline builds one multi-architecture image, validates its digest, + startup, migrations, health, and reported version, and only then promotes + that exact artifact to production. +- **CI now uses isolated, deterministic test environments.** Integration tests + receive their own database and cryptographic settings before application + code loads, worktrees install hooks consistently, and browser tests use the + intended application process on every platform. +- **Concurrent iOS registrations no longer fail intermittently.** Device and + APNs notification-channel reconciliation now share one database lock and + transaction, so simultaneous first registration remains idempotent. + +No migrations. No breaking changes. + ## [1.31.3] — 2026-07-20 - **Document summaries now stay in the language you chose.** The same account diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index facc77ecb..2dd09cddc 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.31.3 + version: 1.31.4 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index c1651b073..38b3eb619 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.31.3", + "version": "1.31.4", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 8ab47acb8..975888820 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.31.3"; + /* @sw-version-fallback */ "v1.31.4"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/scripts/__tests__/ci-determinism.test.ts b/scripts/__tests__/ci-determinism.test.ts index 312fb5fb8..79e00d3b5 100644 --- a/scripts/__tests__/ci-determinism.test.ts +++ b/scripts/__tests__/ci-determinism.test.ts @@ -38,10 +38,7 @@ describe("Playwright CI determinism", () => { expect(config.retries).toBe(2); expect(config.failOnFlakyTests).toBe(true); - expect(config.reporter).toEqual([ - ["github"], - ["html", { open: "never" }], - ]); + expect(config.reporter).toEqual([["github"], ["html", { open: "never" }]]); }); it("retains the Playwright report after every workflow outcome", () => { diff --git a/scripts/__tests__/prepare.test.ts b/scripts/__tests__/prepare.test.ts index 70b45136d..be48b7bb6 100644 --- a/scripts/__tests__/prepare.test.ts +++ b/scripts/__tests__/prepare.test.ts @@ -33,8 +33,7 @@ function runPrepareFixture( lefthookStatus = 0, ): PrepareResult { const testPath = expect.getState().testPath; - if (!testPath) - throw new Error("Vitest did not expose the current test path"); + if (!testPath) throw new Error("Vitest did not expose the current test path"); const root = join(dirname(testPath), "../.."); const directory = mkdtempSync(join(tmpdir(), "healthlog-prepare-")); temporaryDirectories.push(directory); @@ -70,7 +69,7 @@ function runPrepareFixture( lefthook, process.platform === "win32" ? '@echo off\r\necho %*>>"%FIXTURE_LEFTHOOK_LOG%"\r\nexit /b %FIXTURE_LEFTHOOK_STATUS%\r\n' - : "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$FIXTURE_LEFTHOOK_LOG\"\nexit \"$FIXTURE_LEFTHOOK_STATUS\"\n", + : '#!/bin/sh\nprintf \'%s\\n\' "$*" >> "$FIXTURE_LEFTHOOK_LOG"\nexit "$FIXTURE_LEFTHOOK_STATUS"\n', ); if (process.platform !== "win32") chmodSync(lefthook, 0o755); diff --git a/scripts/__tests__/release-workflows.test.ts b/scripts/__tests__/release-workflows.test.ts index 7a9b2ec7d..19966d3e2 100644 --- a/scripts/__tests__/release-workflows.test.ts +++ b/scripts/__tests__/release-workflows.test.ts @@ -48,7 +48,11 @@ function loadWorkflow(name: string): Workflow { return parse(readRepoFile(join(".github/workflows", name))) as Workflow; } -function stepScript(workflow: Workflow, jobName: string, stepName: string): string { +function stepScript( + workflow: Workflow, + jobName: string, + stepName: string, +): string { const step = workflow.jobs[jobName]?.steps?.find( (candidate) => candidate.name === stepName, ); @@ -93,7 +97,12 @@ if [ "$FIXTURE_PRIOR_CI" = "yes" ]; then printf '1\\n'; else printf '0\\n'; fi const workflow = loadWorkflow("docker-publish.yml"); return spawnSync( "bash", - ["-euo", "pipefail", "-c", stepScript(workflow, "release-policy", "Validate release policy")], + [ + "-euo", + "pipefail", + "-c", + stepScript(workflow, "release-policy", "Validate release policy"), + ], { cwd: directory, encoding: "utf8", @@ -114,9 +123,10 @@ if [ "$FIXTURE_PRIOR_CI" = "yes" ]; then printf '1\\n'; else printf '0\\n'; fi ); } -function runExactPull( - resolvedDigest: string, -): { result: SpawnSyncReturns; commands: string } { +function runExactPull(resolvedDigest: string): { + result: SpawnSyncReturns; + commands: string; +} { const directory = mkdtempSync(join(tmpdir(), "healthlog-image-pull-")); temporaryDirectories.push(directory); const dockerPath = join(directory, "docker"); @@ -138,7 +148,12 @@ exit 1 const workflow = loadWorkflow("post-publish-verify.yml"); const result = spawnSync( "bash", - ["-euo", "pipefail", "-c", stepScript(workflow, "verify", "Pull exact image")], + [ + "-euo", + "pipefail", + "-c", + stepScript(workflow, "verify", "Pull exact image"), + ], { encoding: "utf8", env: { @@ -179,7 +194,12 @@ printf '{\"deployment_uuid\":\"fixture\"}\\n__HTTP_STATUS__:%s' "$FIXTURE_HTTP_S const workflow = loadWorkflow("docker-publish.yml"); return spawnSync( "bash", - ["-euo", "pipefail", "-c", stepScript(workflow, "promote", "Trigger Coolify deploy")], + [ + "-euo", + "pipefail", + "-c", + stepScript(workflow, "promote", "Trigger Coolify deploy"), + ], { encoding: "utf8", env: { @@ -219,9 +239,9 @@ describe("release container inputs", () => { }; expect(packageJson.packageManager).toBe("pnpm@10.31.0"); expect(dockerfile).not.toContain("pnpm@latest"); - expect(dockerfile.match(/corepack prepare pnpm@10\.31\.0 --activate/g)).toHaveLength( - 3, - ); + expect( + dockerfile.match(/corepack prepare pnpm@10\.31\.0 --activate/g), + ).toHaveLength(3); }); it("relies on and verifies standalone dependency resolution", () => { @@ -352,10 +372,10 @@ describe("exact post-publish verification", () => { } expect(stepScript(workflow, "verify", "Run migrations")).toContain( - '${IMAGE_REF}@${IMAGE_DIGEST}', + "${IMAGE_REF}@${IMAGE_DIGEST}", ); expect(stepScript(workflow, "verify", "Start exact image")).toContain( - '${IMAGE_REF}@${IMAGE_DIGEST}', + "${IMAGE_REF}@${IMAGE_DIGEST}", ); expect(stepScript(workflow, "verify", "Verify exact version")).toContain( 'jq -e --arg expected "$IMAGE_TAG"', diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts index f9fb33118..09f25831c 100644 --- a/src/app/api/devices/route.ts +++ b/src/app/api/devices/route.ts @@ -274,6 +274,22 @@ export const POST = apiHandler(async (request: NextRequest) => { select: { id: true }, }); + // Keep channel reconciliation under the same identity locks as the device + // row. Moving it after commit lets two first-time registrations race the + // compound unique key and intermittently turns one valid request into 500. + if (apnsToken) { + await tx.notificationChannel.upsert({ + where: { userId_type: { userId: user.id, type: "APNS" } }, + create: { + userId: user.id, + type: "APNS", + enabled: true, + config: encrypt("{}"), + }, + update: {}, + }); + } + return { id: device.id }; }); @@ -292,22 +308,6 @@ export const POST = apiHandler(async (request: NextRequest) => { const { id } = registration; - // Reconcile APNs channel state only after the canonical device transaction - // commits. Config is an encrypted empty record by design — the per-device - // token and environment live on the Device row. - if (apnsToken) { - await prisma.notificationChannel.upsert({ - where: { userId_type: { userId: user.id, type: "APNS" } }, - create: { - userId: user.id, - type: "APNS", - enabled: true, - config: encrypt("{}"), - }, - update: {}, - }); - } - await auditLog("devices.register", { userId: user.id, details: { diff --git a/src/lib/__tests__/db.test.ts b/src/lib/__tests__/db.test.ts index 95d2fac1e..baf6e00f0 100644 --- a/src/lib/__tests__/db.test.ts +++ b/src/lib/__tests__/db.test.ts @@ -42,9 +42,7 @@ describe("per-process database connection budget", () => { expect(getConnectionBudget()).toBe(20); expect(getPrismaPoolMax()).toBe(18); expect(getPgBossPoolMax()).toBe(2); - expect(getPrismaPoolMax() + getPgBossPoolMax()).toBe( - getConnectionBudget(), - ); + expect(getPrismaPoolMax() + getPgBossPoolMax()).toBe(getConnectionBudget()); }); it("uses the existing pool_timeout seconds for both pool constructors", () => { diff --git a/src/lib/jobs/__tests__/boss-instance.test.ts b/src/lib/jobs/__tests__/boss-instance.test.ts index 1afc12fad..08b70ed83 100644 --- a/src/lib/jobs/__tests__/boss-instance.test.ts +++ b/src/lib/jobs/__tests__/boss-instance.test.ts @@ -3,9 +3,7 @@ import { beforeEach, describe, expect, it, vi, type Mock } from "vitest"; interface MockBoss { start: Mock<() => Promise>; stop: Mock<(options?: { graceful?: boolean }) => Promise>; - on: Mock< - (event: string, handler: (error: Error) => void) => MockBoss - >; + on: Mock<(event: string, handler: (error: Error) => void) => MockBoss>; emitError(error: Error): void; } diff --git a/src/lib/jobs/boss-instance.ts b/src/lib/jobs/boss-instance.ts index 7e29c2b95..b807477d5 100644 --- a/src/lib/jobs/boss-instance.ts +++ b/src/lib/jobs/boss-instance.ts @@ -7,10 +7,7 @@ */ import { PgBoss } from "pg-boss"; -import { - getPgBossPoolMax, - getPoolConnectionTimeoutMs, -} from "@/lib/db"; +import { getPgBossPoolMax, getPoolConnectionTimeoutMs } from "@/lib/db"; const BOSS_KEY = "__healthlog_pgboss__" as const; const BOSS_START_KEY = "__healthlog_pgboss_start__" as const; @@ -36,7 +33,9 @@ function wait(ms: number): Promise { return promise; } -function resolveRetryPolicy(options: ProducerStartOptions): ProducerRetryPolicy { +function resolveRetryPolicy( + options: ProducerStartOptions, +): ProducerRetryPolicy { const maxAttempts = Math.max( 1, Math.trunc(options.maxAttempts ?? PRODUCER_START_MAX_ATTEMPTS), @@ -52,7 +51,10 @@ function resolveRetryPolicy(options: ProducerStartOptions): ProducerRetryPolicy return { maxAttempts, baseDelayMs, maxDelayMs }; } -function retryDelayMs(failedAttempt: number, policy: ProducerRetryPolicy): number { +function retryDelayMs( + failedAttempt: number, + policy: ProducerRetryPolicy, +): number { return Math.min( policy.maxDelayMs, policy.baseDelayMs * 2 ** Math.max(0, failedAttempt - 1), @@ -164,8 +166,7 @@ export async function startGlobalBossProducer( if (existing) return existing; const starting = globalState[BOSS_START_KEY] as - | Promise - | undefined; + Promise | undefined; if (starting) return starting; const startPromise = startProducerAttempts( diff --git a/tests/integration/ci-environment.test.ts b/tests/integration/ci-environment.test.ts index 405791f95..19b5cf3f9 100644 --- a/tests/integration/ci-environment.test.ts +++ b/tests/integration/ci-environment.test.ts @@ -8,23 +8,20 @@ const TEST_HMAC_KEY = "integration-test-hmac-key-32-bytes-minimum"; const TEST_SESSION_SECRET = "integration-test-session-secret-32-bytes"; describe("integration worker environment", () => { - it( - "replaces ambient database and encryption configuration with test values", - async () => { - expect(process.env.DATABASE_URL).toContain("healthlog_test"); - expect(process.env.DATABASE_URL).not.toContain("ambient_database"); - expect(process.env.ENCRYPTION_KEY).toBe(TEST_ENCRYPTION_KEY); - expect(process.env.ENCRYPTION_KEYS).toBeUndefined(); - expect(process.env.ENCRYPTION_ACTIVE_KEY_ID).toBeUndefined(); - expect(process.env.API_TOKEN_HMAC_KEY).toBe(TEST_HMAC_KEY); - expect(process.env.SESSION_SECRET).toBe(TEST_SESSION_SECRET); + it("replaces ambient database and encryption configuration with test values", async () => { + expect(process.env.DATABASE_URL).toContain("healthlog_test"); + expect(process.env.DATABASE_URL).not.toContain("ambient_database"); + expect(process.env.ENCRYPTION_KEY).toBe(TEST_ENCRYPTION_KEY); + expect(process.env.ENCRYPTION_KEYS).toBeUndefined(); + expect(process.env.ENCRYPTION_ACTIVE_KEY_ID).toBeUndefined(); + expect(process.env.API_TOKEN_HMAC_KEY).toBe(TEST_HMAC_KEY); + expect(process.env.SESSION_SECRET).toBe(TEST_SESSION_SECRET); - const rows = await getPrismaClient().$queryRaw< - Array<{ database_name: string }> - >` + const rows = await getPrismaClient().$queryRaw< + Array<{ database_name: string }> + >` SELECT current_database() AS database_name `; - expect(rows).toEqual([{ database_name: "healthlog_test" }]); - }, - ); + expect(rows).toEqual([{ database_name: "healthlog_test" }]); + }); });