Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ LOG_LEVEL=info # error | warn | info | debug | verbose
# Console output format. Default: json in production (containers, log aggregators),
# human-readable pretty otherwise. Force one with: json | pretty
# LOG_FORMAT=pretty
# Process time zone. Left commented because the app reads nothing from it: stored timestamps are UTC
# on both databases (the PostgreSQL data connection pins its own session), so this only sets the zone
# log stamps and the "today" stats window are read in. The Docker image runs UTC.
# TZ=UTC

# Auto-start previously authenticated sessions on server boot. Recommended `true` for a SINGLE-instance
# production deployment so a crash-restart self-heals authenticated sessions; keep `false` if you run
Expand Down
19 changes: 14 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,9 @@ jobs:
POSTGRES_USER: openwa
POSTGRES_PASSWORD: openwa
POSTGRES_DB: openwa
# initdb takes the server's default TimeZone from this, so the session pin has something to
# override. A UTC server would make the UTC-pin spec pass without the pin doing anything.
TZ: Asia/Jakarta
ports:
- 5432:5432
options: >-
Expand Down Expand Up @@ -237,17 +240,23 @@ jobs:
# database), that a paged message list has a total order (Postgres sorts a tie group
# differently between two statements, so an unstable order drops rows from a page walk), and
# that the data entities can build their own schema (raw SQL the ORM passes through verbatim,
# such as a partial index predicate, only case-folds on a real server). All four self-skip
# unless DATABASE_TYPE=postgres, so they are a no-op in the default test job and only execute
# here against the postgres:16 service.
- name: Postgres specs (FTS provider + boot-migration lock + list ordering + entity synchronize)
# such as a partial index predicate, only case-folds on a real server), and that the UTC pin
# holds across a backup round trip, a retention window and a local-midnight count. All five
# self-skip unless DATABASE_TYPE=postgres, so they are a no-op in the default test job and only
# execute here against the postgres:16 service.
#
# TZ is deliberately NOT UTC: the pin's whole job is to make a `timestamp` column mean the same
# instant on a host that runs in another zone, and on a UTC runner every assertion about it
# would pass for the wrong reason (the UTC-pin spec fails outright rather than pass vacuously).
- name: Postgres specs (FTS provider + boot-migration lock + list ordering + entity synchronize + UTC pin)
# --runInBand: the specs issue DDL against the one shared service database, and jest's
# default file-parallelism races their catalog writes (pg_type duplicate-key). The lock
# spec's own two-migrator race is in-file (Promise.all) and unaffected by runInBand. The
# synchronize spec additionally CREATEs and DROPs a scratch database of its own, which is
# only safe serialized.
run: npx jest --runInBand src/database/migrations/__tests__/1782400000000-AddMessagesFts.pg.spec.ts src/database/migrations/__tests__/pg-boot-migrations.pg.spec.ts src/modules/message/message-list-ordering.pg.spec.ts src/database/migrations/__tests__/pg-entity-synchronize.pg.spec.ts
run: npx jest --runInBand src/database/migrations/__tests__/1782400000000-AddMessagesFts.pg.spec.ts src/database/migrations/__tests__/pg-boot-migrations.pg.spec.ts src/modules/message/message-list-ordering.pg.spec.ts src/database/migrations/__tests__/pg-entity-synchronize.pg.spec.ts src/database/postgres-utc.pg.spec.ts
env:
TZ: Asia/Jakarta
DATABASE_TYPE: postgres
DATABASE_HOST: localhost
DATABASE_PORT: '5432'
Expand Down
16 changes: 11 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,9 @@ jobs:
POSTGRES_USER: openwa
POSTGRES_PASSWORD: openwa
POSTGRES_DB: openwa
# Same as ci.yml: initdb takes the server's default TimeZone from this, so the data
# connection's session pin has something to override.
TZ: Asia/Jakarta
ports:
- 5432:5432
options: >-
Expand Down Expand Up @@ -216,19 +219,22 @@ jobs:

# Runtime-proves BuiltInFtsProvider on Postgres (websearch_to_tsquery + ts_headline against the
# STORED body_ts tsvector), the boot-migration advisory lock (two concurrent migrators on one
# database), that a paged message list has a total order, and that the data entities can build
# database), that a paged message list has a total order, that the data entities can build
# their own schema (raw SQL the ORM passes through verbatim, such as a partial index predicate,
# only case-folds on a real server). All four self-skip unless DATABASE_TYPE=postgres, so they
# are a no-op in the default test job and only execute here against the postgres:16 service.
- name: Postgres specs (FTS provider + boot-migration lock + list ordering + entity synchronize)
# only case-folds on a real server), and that the UTC pin holds across a backup round trip, a
# retention window and a local-midnight count. All five self-skip unless DATABASE_TYPE=postgres,
# so they are a no-op in the default test job and only execute here against the postgres:16
# service. TZ is deliberately not UTC, for the reason ci.yml states.
- name: Postgres specs (FTS provider + boot-migration lock + list ordering + entity synchronize + UTC pin)
# --runInBand: same reason the CI job passes it — both specs issue DDL against the one
# shared service database, and jest's default file-parallelism races their catalog writes
# (the first v0.19.0 tag failed this step on `relation "messages" already exists` over a
# tree CI had passed minutes earlier). The lock spec's own two-migrator race is in-file
# (Promise.all) and unaffected by runInBand. The synchronize spec additionally CREATEs and
# DROPs a scratch database of its own, which is only safe serialized.
run: npx jest --runInBand src/database/migrations/__tests__/1782400000000-AddMessagesFts.pg.spec.ts src/database/migrations/__tests__/pg-boot-migrations.pg.spec.ts src/modules/message/message-list-ordering.pg.spec.ts src/database/migrations/__tests__/pg-entity-synchronize.pg.spec.ts
run: npx jest --runInBand src/database/migrations/__tests__/1782400000000-AddMessagesFts.pg.spec.ts src/database/migrations/__tests__/pg-boot-migrations.pg.spec.ts src/modules/message/message-list-ordering.pg.spec.ts src/database/migrations/__tests__/pg-entity-synchronize.pg.spec.ts src/database/postgres-utc.pg.spec.ts
env:
TZ: Asia/Jakarta
DATABASE_TYPE: postgres
DATABASE_HOST: localhost
DATABASE_PORT: '5432'
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- The PostgreSQL data connection is pinned to UTC: parameters bind as UTC, naive timestamps read back as UTC, every pooled connection sets its session `TimeZone`, and boot fails when the effective zone is not UTC year round.

### Fixed

- Restoring a data archive into PostgreSQL from a gateway that does not run in UTC no longer shifts every timestamp by the host offset, and no longer shifts it again on each further restore ([#1624](https://github.com/rmyndharis/OpenWA/issues/1624)).
- Retention sweeps on PostgreSQL delete the rows their window names instead of taking up to the host's UTC offset of younger rows with them, and the `today` message counts cover the host's local day.
- Session leases on PostgreSQL compare as instants across nodes in different time zones and across a daylight-saving change.

### Upgrade notes (behavior changes)

- PostgreSQL deployments: the data connection issues `SET TIME ZONE 'UTC'` per connection and verifies the result at boot. A deployment where that cannot hold (a pooler that drops session state) now fails to start, naming the effective zone; set the default instead with `ALTER DATABASE "<database>" SET TimeZone='UTC'`. SQLite deployments, and any gateway already running in UTC, are unaffected and no data moves.
- PostgreSQL deployments whose **gateway** ran outside UTC before this release: the twelve columns the app writes itself hold that host's local wall time and now read as UTC, so they appear shifted by the offset. They are `sessions.connectedAt`, `sessions.lastActiveAt`, `sessions.claimedAt`, `sessions.leaseExpiresAt`, `webhooks.lastTriggeredAt`, `webhook_outbox_events.lastAttemptAt`, `ingress_events.lastDispatchAt`, `message_batches.started_at`, `message_batches.completed_at`, `lid_mappings.updatedAt`, `chat_states.updatedAt` and `baileys_stored_messages.createdAt`. The last three carry a `DEFAULT now()` that never fires, because their only writer passes the value. With the gateway stopped, convert each with the host's old zone, which resolves daylight saving per row: `UPDATE sessions SET "connectedAt" = ("connectedAt" AT TIME ZONE 'Asia/Jakarta') AT TIME ZONE 'UTC' WHERE "connectedAt" IS NOT NULL;`. `claimedAt` and `leaseExpiresAt` are cluster runtime state: clear them, do not convert them, with every node stopped and before the first start on this release, or the lease reads shifted by the old offset and the session is unusable until it lapses. East of UTC it reads hours into the future, so every node treats the session as held elsewhere and `POST /sessions/{id}/start` answers `409`; west of UTC it reads already lapsed, so a peer can adopt a session that is still running. `UPDATE sessions SET "nodeId" = NULL, "claimedAt" = NULL, "leaseExpiresAt" = NULL, "nodeUrl" = NULL;`. Leaving `lid_mappings.updatedAt` and `chat_states.updatedAt` unconverted also mis-ranks the boot preload of both caches, which orders by that column under a cap.
- The remaining eighteen `createdAt`/`updatedAt` columns are written by PostgreSQL itself (`DEFAULT now()`) in the **server's** zone, not the gateway's. On a UTC server, which is the image default and what the bundled Compose file starts, they are already correct and must not be converted; convert them only if the server itself ran outside UTC, with the server's old zone.
- A table that has had an archive from a **SQLite** gateway restored into it holds those rows in correct UTC while the app wrote its own in local time. The two are indistinguishable within the column, so a blanket `UPDATE` would move the rows that are already right; correct such a column row by row against a known archive, or leave it as it is.
- An archive a **PostgreSQL** gateway outside UTC exported before this release is shifted the other way, and restoring it moved the whole table, `DEFAULT now()` columns included: the export read every value back through the local-time parser and the import bound the resulting ISO text into a column that drops the zone, so each restore took the table one offset backward. No row in such a table is correct, and the conversion above would move those rows a further offset the wrong way. Where the table holds nothing but restored rows, apply the inverse once per restore taken: `UPDATE sessions SET "createdAt" = ("createdAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Jakarta';`. Where it also holds rows written after the restore, no blanket conversion is safe.
- Re-export after upgrading for an archive whose stamps are the instants they claim; restoring a pre-release archive carries its shift in as it is.

## [0.23.5] - 2026-09-15

### Security
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ services:
# must match the image/entrypoint. Same convention as the production docker-compose.yml.
environment:
- NODE_ENV=${NODE_ENV:-development}
# Explicit for the same reason as the production compose file: the data is UTC either way, this
# only sets the zone the logs and the "today" stats window read in.
- TZ=${TZ:-UTC}
- PORT=2785
- HOME=/tmp
# Chromium reads its home from the passwd entry (no /home/openwa), so it needs writable, existing
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ services:
environment:
# Core
- NODE_ENV=${NODE_ENV:-production}
# The image already runs on UTC; stated here so it is a decision rather than an accident. Stored
# timestamps are UTC on either database whatever this says (the Postgres data connection pins its
# own session), so overriding it only moves log stamps and the local day the "today" stats count.
- TZ=${TZ:-UTC}
# Writable HOME on tmpfs so Chromium's HOME-relative writes don't hit the read_only rootfs
- HOME=/tmp
# Chromium resolves its home from the passwd entry (no /home/openwa), ignoring $HOME, so without
Expand Down
12 changes: 12 additions & 0 deletions docs/05-database-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,18 @@ connectedAt: Date | null;
> [!NOTE]
> Main DB entities (api_keys, audit_logs) use native SQLite `datetime` type since they always remain in SQLite.

#### Timestamps on PostgreSQL are UTC

Every timestamp column on the PostgreSQL data connection is `timestamp without time zone`, which stores no zone: the value means whatever the writer intended. OpenWA pins that meaning to **UTC**, on the connection rather than on the deployment (`src/database/postgres-utc.ts`):

- a JS `Date` parameter is bound as UTC (`parseInputDatesAsUTC`), so an app-written column such as `sessions.connectedAt` holds UTC wall-clock time whatever zone the host runs in;
- a naive timestamp is parsed back as UTC, through a parser registered for the scalar `timestamp` OID only (the `timestamp[]` OID keeps the driver's array parser; the schema has no such column);
- every pooled connection issues `SET TIME ZONE 'UTC'` on connect, through the pool's own connect hook so that a socket failure or a refused statement fails the acquire instead of the process. That is what makes the server-side `DEFAULT now()` behind each `@CreateDateColumn`/`@UpdateDateColumn` write UTC too. Boot reads the effective `TimeZone` back, at two instants six months apart so a zone that merely reads +00 in winter is caught, and fails if it is not UTC; a pin that a pooler or a server-side default overrode cannot pass unnoticed.

Three `@CreateDateColumn`/`@UpdateDateColumn` columns are filled by the app rather than by that default: `lid_mappings.updatedAt`, `chat_states.updatedAt` and `baileys_stored_messages.createdAt` reach the database through an `upsert` that passes the value, so the default behind them never fires and they follow the binding rule above instead of the session zone.

Comparisons therefore mean the same thing on both dialects: a retention `LessThan(cutoff)`, a lease deadline written by another node, and a backup restored from any host all line up. SQLite is unaffected; it already stores ISO text in UTC.

## 5.2 Entity Relationship Diagram

```mermaid
Expand Down
7 changes: 4 additions & 3 deletions docs/10-devops-infrastructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,9 +275,10 @@ volumes:
> [13 - Horizontal Scaling Guide](./13-horizontal-scaling.md) for the full list and the design
> sketch. What multi-node eventually buys is engine capacity, not shared engine state: live engine
> handles live in exactly one process's `EngineRegistry` (`src/engine/engine-registry.service.ts`),
> and the hard requirements include a stable `NODE_ID` across restarts, NTP-synced clocks in one
> time zone without daylight saving (lease skew beyond the TTL wrongfully transfers a session),
> sticky sessions, `TRUSTED_PROXIES` for forwarded calls, Redis and Postgres.
> and the hard requirements include a stable `NODE_ID` across restarts, NTP-synced clocks (lease skew
> beyond the TTL wrongfully transfers a session; the zone each node runs in no longer matters, since
> the Postgres data connection is pinned to UTC), sticky sessions, `TRUSTED_PROXIES` for forwarded
> calls, Redis and Postgres.

### Helm Chart (Kubernetes)

Expand Down
7 changes: 7 additions & 0 deletions docs/11-operational-runbooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,13 @@ docker compose up -d
curl -s -X POST -H "X-API-Key: <an-existing-key>" http://localhost:2785/api/auth/validate
```

> **PostgreSQL restores are read as UTC.** From 0.23.6 the data connection binds, parses and defaults
> every timestamp in UTC, and refuses to boot when its session is not on UTC
> ([05 - Database Design](./05-database-design.md#timestamps-on-postgresql-are-utc)). A `database.sql`
> taken from a gateway that ran off UTC before 0.23.6 holds that host's local wall time in the columns
> the app wrote, so those rows read as shifted by the offset once restored. The 0.23.6 upgrade notes in
> `CHANGELOG.md` carry the conversion and name the columns it must not touch.

> `main.sqlite` carries the hashed API keys and audit log; `.api-key`, when retained by the original
> installation, carries the plaintext bootstrap admin key. After restore, verify that both expected files
> were present in the archive and that the client is using the original plaintext key. Re-running backup
Expand Down
17 changes: 8 additions & 9 deletions docs/13-horizontal-scaling.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,14 @@
> server images — and treat a skew larger than `SESSION_LEASE_TTL_MS` as a misconfiguration.
> The status correction reads the same timestamps: a node whose clock runs more than three TTLs minus
> one heartbeat ahead (160s at defaults) marks a healthy peer's sessions disconnected, even with
> `AUTO_START_SESSIONS` off, and nothing writes them back. On PostgreSQL the lease columns hold the
> writer's local wall time with no zone, so every node must run in one zone without daylight saving,
> `TZ=UTC` (the image default) being the simple choice; sharing a zone that observes daylight saving is
> not enough. Across zones the error runs both ways. A node east of a peer reads
> that peer's leases as expired by the offset, so it marks the peer's live sessions disconnected, and
> again after each of their status changes (with auto-start on, it also takes them over). A node west
> of a peer reads its leases as live for the offset, so a dead peer's sessions stay uncorrected and
> unadopted for that long. In a shared zone with daylight saving, a lease renewed in the last TTL
> before the clocks go back is stored an hour early, and peers mark that live session disconnected.
> `AUTO_START_SESSIONS` off, and nothing writes them back. The zone each node runs in is no longer
> part of this: on PostgreSQL the data connection binds, parses and defaults every timestamp in UTC
> ([05 - Database Design](./05-database-design.md#timestamps-on-postgresql-are-utc)), so two nodes in
> different zones, or one zone that observes daylight saving, still
> read each other's leases as the instants they were written at. Only the clocks have to agree.
> One exception, during an upgrade: a node still on 0.23.5 or earlier writes the lease in its own
> local wall time, so while versions are mixed the old cross-zone error above is back for as long as
> the older node keeps renewing. Running every node in `TZ=UTC` (the image default) removes it.
>
> **A forwarded request is throttled on both nodes.** The receiving node counts it before
> forwarding, and the owner counts it again on arrival; with `REDIS_ENABLED=true` both counts land
Expand Down
9 changes: 9 additions & 0 deletions docs/14-migration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,15 @@ curl -X POST 'http://localhost:2785/api/infra/import-data' \
> [!NOTE]
> `skippedTables` lists optional tables absent from an older schema; the import tolerates them.

> [!NOTE]
> **Timestamps travel as UTC.** Every stamp in the archive is ISO 8601 with an explicit `Z`, and both
> dialects store it as the same instant, so an archive moves between SQLite and PostgreSQL in either
> direction without shifting and a repeated restore is a no-op. On PostgreSQL that is the connection's
> UTC pin ([05 - Database Design](./05-database-design.md#timestamps-on-postgresql-are-utc)), not a
> property of the host: a gateway restoring under `TZ=Asia/Jakarta` writes the same rows as one on UTC.
> An archive taken by 0.23.5 or earlier from a PostgreSQL gateway that ran off UTC carries that host's
> offset in its stamps; read the 0.23.6 upgrade notes in `CHANGELOG.md` before restoring one.

### Storage Migration (Local ↔ S3/MinIO)

OpenWA v0.2+ supports migrating media files between storage backends:
Expand Down
Loading
Loading