@rotorsoft/act-pg 0.24.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,28 +4,27 @@
4
4
  [![NPM Downloads](https://img.shields.io/npm/dm/@rotorsoft/act-pg.svg)](https://www.npmjs.com/package/@rotorsoft/act-pg)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
 
7
- PostgreSQL event store adapter for [@rotorsoft/act](https://www.npmjs.com/package/@rotorsoft/act). Provides persistent, production-ready event storage with ACID guarantees, connection pooling, and distributed stream processing.
7
+ _PostgreSQL event store for [@rotorsoft/act](https://www.npmjs.com/package/@rotorsoft/act). ACID, connection-pooled, multi-process production default for Act deployments. Lane-aware claim/ack via `streams.lane` + `streams_lane_ix` since v0.25.0 ([ACT-1103](https://github.com/Rotorsoft/act-root/issues/733))._
8
8
 
9
- > **Stability:** Public API governed by the [Act Stability Charter](../../STABILITY.md). Charter takes effect at 1.0 (gated on [milestone 1.0](https://github.com/Rotorsoft/act-root/milestone/1)).
9
+ ## Why this package
10
+
11
+ Act's in-memory store is fine for development and tests, but production needs durable events, cross-process coordination, and a query path that scales past a single Node process. `PostgresStore` is the canonical production implementation of Act's `Store` port: full ACID guarantees from PG, atomic stream claiming via `FOR UPDATE SKIP LOCKED` (no application-layer locking required), optional `LISTEN`/`NOTIFY` for sub-poll cross-process wakeup, and auto-managed schema via `seed()`.
12
+
13
+ The adapter passes the same conformance suite (`@rotorsoft/act-tck`) as InMemoryStore and SqliteStore, so swapping it in is a one-line bootstrap change.
10
14
 
11
15
  ## Installation
12
16
 
13
- ```sh
14
- npm install @rotorsoft/act @rotorsoft/act-pg
15
- # or
17
+ ```bash
16
18
  pnpm add @rotorsoft/act @rotorsoft/act-pg
17
19
  ```
18
20
 
19
- **Requirements:** Node.js >= 22.18.0, PostgreSQL >= 14
21
+ ## Quick start
20
22
 
21
- ## Usage
22
-
23
- ```typescript
23
+ ```ts
24
24
  import { act, state, store } from "@rotorsoft/act";
25
25
  import { PostgresStore } from "@rotorsoft/act-pg";
26
26
  import { z } from "zod";
27
27
 
28
- // Inject the PostgreSQL store before building your app
29
28
  store(new PostgresStore({
30
29
  host: "localhost",
31
30
  port: 5432,
@@ -34,84 +33,67 @@ store(new PostgresStore({
34
33
  password: "secret",
35
34
  }));
36
35
 
37
- // Initialize tables (creates schema, events table, streams table, and indexes)
36
+ // One-time schema setup (idempotent safe to leave in your bootstrap).
38
37
  await store().seed();
39
38
 
40
- // Build and use your app as normal
39
+ // From here, the framework is identical to the InMemory version.
41
40
  const Counter = state({ Counter: z.object({ count: z.number() }) })
42
41
  .init(() => ({ count: 0 }))
43
42
  .emits({ Incremented: z.object({ amount: z.number() }) })
44
- .patch({ Incremented: ({ data }, s) => ({ count: s.count + data.amount }) }) // optional — only for custom reducers
43
+ .patch({ Incremented: ({ data }, s) => ({ count: s.count + data.amount }) })
45
44
  .on({ increment: z.object({ by: z.number() }) })
46
- .emit((action) => ["Incremented", { amount: action.by }])
45
+ .emit((a) => ["Incremented", { amount: a.by }])
47
46
  .build();
48
47
 
49
48
  const app = act().withState(Counter).build();
50
- await app.do("increment", { stream: "counter1", actor: { id: "1", name: "User" } }, { by: 1 });
49
+ await app.do("increment", { stream: "c1", actor: { id: "1", name: "u" } }, { by: 1 });
51
50
  ```
52
51
 
52
+ ## API
53
+
54
+ - **`PostgresStore`** — class implementing Act's `Store` port. Construct once, pass to `store()`.
55
+ - **`PostgresConfig`** — constructor options (host/port/db/user/password/schema/table/notify).
56
+
57
+ Full type reference: [typedoc](https://github.com/Rotorsoft/act-root/blob/master/docs/docs/api/act-pg/src/README.md).
58
+
53
59
  ## Configuration
54
60
 
55
- All configuration fields are optional and have sensible defaults:
61
+ All fields are optional and have sensible defaults:
56
62
 
57
63
  | Option | Default | Description |
58
- |--------|---------|-------------|
64
+ |---|---|---|
59
65
  | `host` | `localhost` | PostgreSQL host |
60
66
  | `port` | `5432` | PostgreSQL port |
61
67
  | `database` | `postgres` | Database name |
62
68
  | `user` | `postgres` | Database user |
63
69
  | `password` | `postgres` | Database password |
64
- | `schema` | `public` | Schema for event tables |
65
- | `table` | `events` | Base name for event tables |
70
+ | `schema` | `public` | Schema for event + streams tables |
71
+ | `table` | `events` | Base name (`<table>` for events, `<table>_streams` for subscriptions) |
72
+ | `notify` | `false` | Opt-in `LISTEN`/`NOTIFY` for cross-process commit wakeup (see below) |
73
+ | `max`, `idleTimeoutMillis`, …pg.PoolConfig | (pg defaults) | Pass-through to node-postgres pool config |
66
74
 
67
- ### Custom Schema and Table Names
68
-
69
- ```typescript
70
- const pgStore = new PostgresStore({
71
- host: "db.example.com",
72
- database: "production",
73
- user: "app_user",
75
+ ```ts
76
+ // Production deployment via env vars
77
+ store(new PostgresStore({
78
+ host: process.env.DB_HOST,
79
+ port: Number(process.env.DB_PORT ?? 5432),
80
+ database: process.env.DB_NAME,
81
+ user: process.env.DB_USER,
74
82
  password: process.env.DB_PASSWORD,
75
- schema: "events", // custom schema
76
- table: "act_events", // creates act_events and act_events_streams tables
77
- });
78
- ```
79
-
80
- ### Environment-Based Configuration
81
-
82
- ```typescript
83
- if (process.env.NODE_ENV === "production") {
84
- store(new PostgresStore({
85
- host: process.env.DB_HOST,
86
- port: parseInt(process.env.DB_PORT || "5432"),
87
- database: process.env.DB_NAME,
88
- user: process.env.DB_USER,
89
- password: process.env.DB_PASSWORD,
90
- }));
91
- }
92
- // In development, the default InMemoryStore is used
83
+ schema: process.env.DB_SCHEMA ?? "public",
84
+ max: 20, // pool size raise for drain-heavy workloads
85
+ }));
93
86
  ```
94
87
 
95
- ## Features
88
+ Multi-tenant deployments often want one schema per tenant. The store accepts both — use them rather than namespacing stream IDs.
96
89
 
97
- - **ACID Transactions** - Events are committed atomically within PostgreSQL transactions
98
- - **Optimistic Concurrency** - Version-based conflict detection prevents lost updates
99
- - **Connection Pooling** - Uses [node-postgres](https://node-postgres.com/) Pool for efficient connection management
100
- - **Atomic Stream Claiming** - Zero-contention competing consumers via `FOR UPDATE SKIP LOCKED`
101
- - **Auto Schema Setup** - `seed()` creates all required tables, indexes, and schema
102
- - **Cross-Process `LISTEN`/`NOTIFY`** (opt-in) - Set `notify: true` to wake `settle()` immediately on remote commits — no polling lag for horizontally-scaled deployments. Off by default. See [PERFORMANCE.md](./PERFORMANCE.md) for the latency benchmark.
103
- - **Multi-Tenant** - Isolate tenants using separate schemas
90
+ ## Common patterns
104
91
 
105
- ## Cross-Process Reactions (opt-in)
92
+ ### Cross-process `LISTEN`/`NOTIFY` (opt-in)
106
93
 
107
- For multi-instance deployments, `PostgresStore` implements the optional `Store.notify` hook via `LISTEN`/`NOTIFY` so the orchestrator wakes `settle()` immediately on commits from other processes — no polling delay.
108
-
109
- **Opt-in via the `notify: true` config flag.** The cost (per-commit `pg_notify`, dedicated `LISTEN` client per process) is wasted in single-instance deployments, so it defaults to **off** — existing callers see zero behavior change after upgrading. Multi-process apps that need sub-poll wakeup enable it on every store instance involved (writers and listeners both):
94
+ For multi-instance deployments, `PostgresStore` implements the optional `Store.notify` hook via `LISTEN`/`NOTIFY` so the orchestrator wakes `settle()` immediately on commits from other processes — no polling delay. Off by default to keep single-instance deployments allocation-free; enable on every store instance in a multi-process app:
110
95
 
111
96
  ```ts
112
- import { act, store } from "@rotorsoft/act";
113
- import { PostgresStore } from "@rotorsoft/act-pg";
114
-
115
97
  const config = { schema: "myapp", table: "events", notify: true };
116
98
 
117
99
  // Worker A (writer)
@@ -119,91 +101,81 @@ store(new PostgresStore(config));
119
101
  const app = act().withState(Order).build();
120
102
  await app.do("placeOrder", { stream: "order-1", actor }, payload);
121
103
 
122
- // Worker B (reactions, separate process / pod / box)
123
- store(new PostgresStore(config)); // same DB, same opt-in
104
+ // Worker B (reactions, separate process)
105
+ store(new PostgresStore(config));
124
106
  const app = act()
125
107
  .withState(Order)
126
108
  .on("OrderPlaced").do(reduceInventory).to("inventory-1")
127
109
  .build();
128
- // On Worker A's commit, Worker B wakes within ~10 ms (vs. polling: ≥ poll interval).
129
- // Optional: tap the lifecycle event for fan-out.
130
- app.on("notified", (n) => sse.broadcast(n));
110
+ // Worker B wakes within ~10ms of Worker A's commit (vs. ≥ poll interval).
111
+ app.on("notified", (n) => sse.broadcast(n)); // optional fan-out
131
112
  ```
132
113
 
133
- When `notify: true`:
134
- - `commit()` issues one `NOTIFY act_commit_<schema>_<table>` per transaction with the full event batch as a JSON payload.
135
- - The orchestrator auto-subscribes once at `build()` (one dedicated PG client per process — size your pool accordingly).
136
- - The store self-filters its own commits (per-instance UUID in the payload), so the `notified` lifecycle event surfaces only **cross-process** activity. Local commits already arm drain via `do()`.
114
+ When `notify: true`: `commit()` issues one `NOTIFY act_commit_<schema>_<table>` per transaction with the full event batch as JSON. The store self-filters its own commits (per-instance UUID), so the `"notified"` lifecycle event surfaces only cross-process activity. Size your pool to account for one extra dedicated LISTEN client per process.
137
115
 
138
- When `notify: false` (the default): `commit()` skips the `pg_notify` SQL entirely, and `notify` is undefined on the store instance the orchestrator's auto-wire short-circuits, no LISTEN client is allocated.
116
+ `notify` is a hint, not a contract lost notifications fall back to the existing debounce/poll path. Correctness is preserved.
139
117
 
140
- `notify` is a hint, not a contract: lost notifications fall back to the existing debounce/poll path. Correctness is preserved.
118
+ **Build-time contract:** call `store(adapter)` *before* `act()…build()`. The orchestrator binds notify to whichever store is current at construction time.
141
119
 
142
- **Build-time contract:** call `store(adapter)` *before* `act()...build()`. The orchestrator binds notify to whichever store is current at construction; late injection won't take effect.
120
+ ### Competing consumer (free horizontal scaling)
143
121
 
144
- ## Database Schema
122
+ `claim()` uses `FOR UPDATE SKIP LOCKED` — the idiomatic Postgres competing-consumer pattern. Workers never block each other; locked rows are silently skipped. Same approach as pgBoss and Graphile Worker.
145
123
 
146
- Calling `seed()` creates two tables:
124
+ Add a second pod, run the same Act app — drain workload splits with zero application-layer coordination. No external job queue, no Redis lock.
147
125
 
148
- **Events table** (`{schema}.{table}`) - stores all committed events:
149
- - `id` (serial) - global event sequence
150
- - `name` - event type name
151
- - `data` (jsonb) - event payload
152
- - `stream` - stream identifier
153
- - `version` - per-stream sequence number
154
- - `created` (timestamptz) - event timestamp
155
- - `meta` (jsonb) - correlation, causation, and actor metadata
126
+ ### Schema setup
156
127
 
157
- **Streams table** (`{schema}.{table}_streams`) - tracks stream processing state for reactions:
158
- - `stream` - stream identifier
159
- - `at` - last processed event position
160
- - `leased_by` / `leased_until` - distributed processing claim info
161
- - `blocked` / `error` - error tracking for failed streams
162
- - `priority` - scheduling priority (default 0; higher wins lagging-frontier ties — see [Priority lanes](https://rotorsoft.github.io/act-root/docs/architecture/priority-lanes))
128
+ ```ts
129
+ await store().seed();
130
+ ```
163
131
 
164
- The `priority` column is added by `seed()` via `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`, so existing tables migrate transparently. A composite index on `(blocked, priority DESC, at)` supports the saturated-claim ORDER BY without a sort step.
132
+ Idempotent. Creates the events table, the streams (subscription) table, and the indexes that support the claim and notify paths. Safe to leave in your bootstrap. The store transparently runs `ADD COLUMN IF NOT EXISTS` migrations for new optional columns (e.g. `priority` for [priority lanes](https://rotorsoft.github.io/act-root/docs/architecture/priority-lanes)), so existing deployments upgrade in place.
165
133
 
166
- ## Competing Consumer Pattern
134
+ ### Database schema reference
167
135
 
168
- The PostgreSQL adapter uses `FOR UPDATE SKIP LOCKED` for atomic stream claiming — the idiomatic PostgreSQL competing consumer pattern. The `claim()` method discovers streams with pending events and locks them in a single query:
136
+ Created by `seed()`:
169
137
 
170
- - Workers never block each other locked rows are silently skipped
171
- - No race between discovery and locking (unlike a separate poll + lease)
172
- - Same pattern used by pgBoss, Graphile Worker, and other production job queues
173
- - Enables horizontal scaling by simply adding more workers
138
+ - **Events** (`{schema}.{table}`): `id` (serial PK), `name`, `data` (jsonb), `stream`, `version`, `created` (timestamptz), `meta` (jsonb). Unique index on `(stream, version)`.
139
+ - **Streams** (`{schema}.{table}_streams`): `stream` (PK), `source`, `at`, `retry`, `blocked`, `error`, `leased_by`, `leased_until`, `priority`. Composite index on `(blocked, priority DESC, at)` for the saturated-claim ordering.
174
140
 
175
- This replaces the previous two-step poll/lease approach, eliminating contention and simplifying the drain cycle.
141
+ ## When to use this vs `act-sqlite`
176
142
 
177
- ## Testing
143
+ | You want… | Use |
144
+ |---|---|
145
+ | Multi-server deployment, distributed processing | `act-pg` |
146
+ | Sub-poll cross-process reaction latency | `act-pg` (with `notify: true`) |
147
+ | Embedded / single-server / edge | `act-sqlite` |
148
+ | Zero-config local dev / tests | The default `InMemoryStore` |
178
149
 
179
- Validated against the executable Store contract in [`@rotorsoft/act-tck`](https://www.npmjs.com/package/@rotorsoft/act-tck):
150
+ Both adapters pass the same conformance suite your application code doesn't change.
180
151
 
181
- ```ts
182
- import { runStoreTck } from "@rotorsoft/act-tck";
183
- import { PostgresStore } from "@rotorsoft/act-pg";
152
+ ## Compatibility
184
153
 
185
- runStoreTck({
186
- name: "PostgresStore",
187
- factory: () =>
188
- new PostgresStore({
189
- port: 5431,
190
- schema: "tck",
191
- table: "tck_store",
192
- notify: true,
193
- }),
194
- capabilities: { notify: true },
195
- });
196
- ```
154
+ - **Node**: >=22.18.0
155
+ - **PostgreSQL**: >=14 (uses `FOR UPDATE SKIP LOCKED`, `LISTEN`/`NOTIFY`, JSONB)
156
+ - **Peer**: `@rotorsoft/act` >=0.39.0, `zod` ^4.4.3
157
+ - **Bundled deps**: `pg` ^8.20.0
158
+ - **Module formats**: ESM + CJS
159
+
160
+ ## Stability
161
+
162
+ Public API governed by the [Act Stability Charter](../../STABILITY.md). `PostgresStore` implements the `Store` contract from `@rotorsoft/act` and is validated against `@rotorsoft/act-tck` across PostgreSQL 14/15/16/17 in CI. Charter is **in effect as of 1.0.0**; the milestone tracker is [milestone 1.0](https://github.com/Rotorsoft/act-root/milestone/1).
163
+
164
+ ## Related packages
197
165
 
198
- See [Writing a custom Store adapter](https://github.com/Rotorsoft/act-root/blob/master/docs/docs/guides/writing-a-store.md) for the third-party authoring guide.
166
+ - **[@rotorsoft/act](https://www.npmjs.com/package/@rotorsoft/act)** the framework whose `Store` port this implements.
167
+ - **[@rotorsoft/act-sqlite](https://www.npmjs.com/package/@rotorsoft/act-sqlite)** — sibling store adapter for single-node / edge deployments.
168
+ - **[@rotorsoft/act-tck](https://www.npmjs.com/package/@rotorsoft/act-tck)** — conformance suite. `PostgresStore` passes `runStoreTck` with `capabilities: { notify: true }`.
169
+ - **[@rotorsoft/act-pino](https://www.npmjs.com/package/@rotorsoft/act-pino)** — pino logger adapter, common pairing for production deployments.
199
170
 
200
- ## Related
171
+ ## Documentation
201
172
 
202
- - [@rotorsoft/act](https://www.npmjs.com/package/@rotorsoft/act) - Core framework
203
- - [@rotorsoft/act-tck](https://www.npmjs.com/package/@rotorsoft/act-tck) - Test Compatibility Kit
204
- - [Documentation](https://rotorsoft.github.io/act-root/)
205
- - [Examples](https://github.com/rotorsoft/act-root/tree/master/packages)
173
+ - **[Production checklist](https://rotorsoft.github.io/act-root/docs/guides/production-checklist)** — operator-facing guide for taking an Act app to production with this store.
174
+ - **[Cross-process reactions](https://rotorsoft.github.io/act-root/docs/architecture/cross-process-reactions)** when to enable `notify`, what the latency looks like.
175
+ - **[Concurrency model](https://rotorsoft.github.io/act-root/docs/architecture/concurrency-model)** — lease lifecycle, `claim`/`ack`/`block`/timeout, optimistic concurrency.
176
+ - **[Writing a custom Store adapter](https://rotorsoft.github.io/act-root/docs/guides/writing-a-store)** — the recipe `PostgresStore` itself follows, for authors building against other databases.
177
+ - **[PERFORMANCE.md](./PERFORMANCE.md)** — measured throughput numbers, including the `notify` latency benchmark.
206
178
 
207
179
  ## License
208
180
 
209
- [MIT](https://github.com/rotorsoft/act-root/blob/master/LICENSE)
181
+ MIT