@siglo/postgres 1.0.1 → 1.1.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.
Files changed (2) hide show
  1. package/README.md +365 -87
  2. package/package.json +15 -4
package/README.md CHANGED
@@ -1,72 +1,85 @@
1
1
  # @siglo/postgres Adapter Guide
2
2
 
3
- `@siglo/postgres` provides an enterprise ACID-compliant persistence adapter for `Siglo` workflows using PostgreSQL. It offers durable execution checkpointing, immutable context mutation audit event logging, automated table initialization, schema indexing, retention policy cleanup, and distributed atomic idempotency claim locking.
3
+ `@siglo/postgres` provides an enterprise ACID-compliant persistence, event auditing, distributed locking, and idempotency coordination layer for `Siglo` workflows using PostgreSQL. It is engineered for mission-critical transactional workflows requiring zero state loss across crashes, network partitions, and multi-node worker clusters.
4
4
 
5
5
  ---
6
6
 
7
7
  ## 1. Overview & Architecture
8
8
 
9
- When workflows coordinate mission-critical financial, compliance, or enterprise transactions, state persistence must survive node failures and network partitions:
9
+ When workflows coordinate financial, compliance, or enterprise operations, persistence must survive node failures and process crashes. `@siglo/postgres` implements two primary classes:
10
+
11
+ 1. **`PostgresStorageAdapter`**: Implements the Siglo `StorageAdapter` interface for durable execution checkpoints with Optimistic Concurrency Control (OCC), immutable context mutation audit logging, durable asynchronous wait/continuation records, and distributed session-level advisory workflow locking.
12
+ 2. **`PostgresIdempotencyStore`**: Implements the Siglo `IdempotencyStore` interface for atomic, distributed single-flight execution claims, cached result retrieval, and automated lock re-claiming upon TTL expiration.
10
13
 
11
14
  ```mermaid
12
15
  flowchart TD
13
- Engine["Siglo Engine"]
16
+ Engine["Siglo Workflow Engine"]
14
17
 
15
- subgraph Adapters["@siglo/postgres"]
16
- direction LR
17
- SA["PostgresStorageAdapter<br/>• saveCheckpoint()<br/>• getCheckpoint()<br/>• onContextChange()<br/>• cleanupOldCheckpoints()"]
18
- IS["PostgresIdempotencyStore<br/>• claim()<br/>• complete()<br/>• fail()<br/>• cleanupExpired()"]
18
+ subgraph Adapters["@siglo/postgres Adapter Suite"]
19
+ direction TB
20
+ SA["PostgresStorageAdapter<br/>• saveCheckpoint() (OCC Versioned)<br/>• getCheckpoint()<br/>• onContextChange()<br/>• withWorkflowLock() (Advisory Lock)<br/>• saveWaitRecord() / claimWaitRecord()<br/>• completeWaitRecord() / cancelWorkflow()<br/>• cleanExpiredCheckpoints()<br/>• cleanupOldCheckpoints() / Events / Waits"]
21
+ IS["PostgresIdempotencyStore<br/>• claim() (Atomic Insert/Reclaim)<br/>• complete() / fail()<br/>• getResult() / get() / getRecord()<br/>• cleanupExpired()"]
19
22
  end
20
23
 
21
24
  subgraph DB["PostgreSQL Database"]
22
- direction LR
23
- T1["siglo_checkpoints<br/>• stack_id (PK)<br/>• namespace<br/>• state (JSONB)<br/>• created_at / updated"]
24
- T2["siglo_context_events<br/>• id (BIGSERIAL PK)<br/>• stack_id, step_id<br/>• namespace, field<br/>• value (JSONB), created_at"]
25
- T3["siglo_idempotency<br/>• key (VARCHAR(512) PK)<br/>• status, result (JSONB)<br/>• expires_at, created_at"]
25
+ direction TB
26
+ T1[("siglo_checkpoints<br/>• stack_id (PK)<br/>• namespace<br/>• state (JSONB)<br/>• version (BIGINT OCC)<br/>• created_at / updated_at")]
27
+ T2[("siglo_context_events<br/>• id (BIGSERIAL PK)<br/>• stack_id, step_id<br/>• namespace, field<br/>• value (JSONB), created_at")]
28
+ T3[("siglo_wait_records<br/>• correlation_key (PK)<br/>• stack_id, status<br/>• data, resume_payload<br/>• locked_by, locked_at<br/>• expires_at, created_at")]
29
+ T4[("siglo_idempotency<br/>• key (VARCHAR(512) PK)<br/>• status, result (JSONB)<br/>• expires_at, created_at")]
30
+ AL["PostgreSQL Advisory Locks<br/>pg_try_advisory_lock(hashtextextended('siglo:workflow:' || stackId, 0))"]
26
31
  end
27
32
 
28
33
  Engine --> SA
29
34
  Engine --> IS
30
35
  SA --> T1
31
36
  SA --> T2
32
- IS --> T3
37
+ SA --> T3
38
+ SA -.-> AL
39
+ IS --> T4
33
40
  ```
34
41
 
35
42
  ---
36
43
 
37
44
  ## 2. Installation & Requirements
38
45
 
39
- Install `@siglo/postgres` and `pg`:
46
+ Install `@siglo/postgres` alongside `@siglo/core` and the `pg` driver:
40
47
 
41
48
  ```bash
42
49
  npm install @siglo/core @siglo/postgres pg
43
50
  npm install --save-dev @types/pg
44
51
  ```
45
52
 
46
- Peer dependency matrix:
47
- - `Siglo`: `*` (v1.0.0+)
48
- - `pg`: `^8.0.0`
53
+ ### Peer Dependency Matrix
54
+
55
+ | Package | Version | Requirement |
56
+ | :--- | :--- | :--- |
57
+ | `@siglo/core` | `^1.0.0` | Core workflow engine & interfaces |
58
+ | `pg` | `^8.0.0` | PostgreSQL client driver (supports both `Pool` and `Client`) |
59
+ | PostgreSQL Server | `12+` | Requires JSONB, Advisory Locks (`hashtextextended`), and `TIMESTAMPTZ` |
49
60
 
50
61
  ---
51
62
 
52
- ## 3. Database Schema
63
+ ## 3. Database Schema & Automated DDL
53
64
 
54
- Both `PostgresStorageAdapter` and `PostgresIdempotencyStore` support automated table creation via `ensureTables()` / `ensureTable()`.
65
+ Both `PostgresStorageAdapter` and `PostgresIdempotencyStore` provide automated schema synchronization via `ensureTables()` and `ensureTable()`. These methods execute idempotent DDL statements (`CREATE TABLE IF NOT EXISTS`, `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`).
55
66
 
56
- ### DDL Definitions:
67
+ ### DDL Definitions
57
68
 
58
69
  ```sql
59
- -- 1. Checkpoint Storage Table
70
+ -- 1. Checkpoint Storage Table (with OCC version tracking)
60
71
  CREATE TABLE IF NOT EXISTS siglo_checkpoints (
61
72
  stack_id VARCHAR(255) PRIMARY KEY,
62
73
  namespace VARCHAR(255),
63
74
  state JSONB NOT NULL,
75
+ version BIGINT NOT NULL DEFAULT 1,
64
76
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
65
77
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
66
78
  );
79
+ ALTER TABLE siglo_checkpoints ADD COLUMN IF NOT EXISTS version BIGINT NOT NULL DEFAULT 1;
67
80
  CREATE INDEX IF NOT EXISTS idx_siglo_checkpoints_namespace ON siglo_checkpoints(namespace);
68
81
 
69
- -- 2. Context Mutation Audit Log
82
+ -- 2. Step Context Mutation Audit Event Log
70
83
  CREATE TABLE IF NOT EXISTS siglo_context_events (
71
84
  id BIGSERIAL PRIMARY KEY,
72
85
  stack_id VARCHAR(255) NOT NULL,
@@ -78,7 +91,29 @@ CREATE TABLE IF NOT EXISTS siglo_context_events (
78
91
  );
79
92
  CREATE INDEX IF NOT EXISTS idx_siglo_context_events_stack ON siglo_context_events(stack_id);
80
93
 
81
- -- 3. Distributed Idempotency Table
94
+ -- 3. Durable Wait and Continuation Records Table
95
+ CREATE TABLE IF NOT EXISTS siglo_wait_records (
96
+ correlation_key VARCHAR(255) PRIMARY KEY,
97
+ stack_id VARCHAR(255) NOT NULL,
98
+ namespace VARCHAR(255),
99
+ event_name VARCHAR(255),
100
+ status VARCHAR(50) NOT NULL DEFAULT 'WAITING',
101
+ continuation_step_index INT NOT NULL DEFAULT 0,
102
+ version VARCHAR(50),
103
+ data JSONB,
104
+ resumed_by VARCHAR(255),
105
+ resumed_at TIMESTAMPTZ,
106
+ expires_at TIMESTAMPTZ,
107
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
108
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
109
+ );
110
+ ALTER TABLE siglo_wait_records ADD COLUMN IF NOT EXISTS resume_payload JSONB;
111
+ ALTER TABLE siglo_wait_records ADD COLUMN IF NOT EXISTS locked_by TEXT;
112
+ ALTER TABLE siglo_wait_records ADD COLUMN IF NOT EXISTS locked_at TIMESTAMPTZ;
113
+ CREATE INDEX IF NOT EXISTS idx_siglo_wait_records_stack ON siglo_wait_records(stack_id);
114
+ CREATE INDEX IF NOT EXISTS idx_siglo_wait_records_status ON siglo_wait_records(status);
115
+
116
+ -- 4. Distributed Idempotency Table
82
117
  CREATE TABLE IF NOT EXISTS siglo_idempotency (
83
118
  key VARCHAR(512) PRIMARY KEY,
84
119
  status VARCHAR(50) NOT NULL,
@@ -89,11 +124,18 @@ CREATE TABLE IF NOT EXISTS siglo_idempotency (
89
124
  CREATE INDEX IF NOT EXISTS idx_siglo_idempotency_expires ON siglo_idempotency(expires_at);
90
125
  ```
91
126
 
127
+ ### SQL Injection Protection
128
+
129
+ Custom table names passed to `PostgresStorageAdapter` or `PostgresIdempotencyStore` are validated against strict SQL identifier constraints:
130
+ - Must match `/^[a-zA-Z_][a-zA-Z0-9_]*$/`.
131
+ - Must not exceed 63 characters (PostgreSQL standard identifier length limit).
132
+ - Throws an descriptive `Error` if invalid characters or injection attempts are detected.
133
+
92
134
  ---
93
135
 
94
136
  ## 4. `PostgresStorageAdapter` API Reference
95
137
 
96
- Implements the standard `StorageAdapter` interface.
138
+ Implements the Siglo `StorageAdapter` interface (`name = 'postgres'`).
97
139
 
98
140
  ```typescript
99
141
  import { PostgresStorageAdapter } from '@siglo/postgres';
@@ -103,36 +145,134 @@ import { PostgresStorageAdapter } from '@siglo/postgres';
103
145
 
104
146
  | Option | Type | Default | Description |
105
147
  | :--- | :--- | :--- | :--- |
106
- | `client` | `PostgresClientInterface \| Pool` | *Required* | Active PostgreSQL `Pool` or `Client` instance. |
107
- | `checkpointsTable` | `string` | `'siglo_checkpoints'` | Name of checkpoints table. Must match `/^[a-zA-Z_][a-zA-Z0-9_]*$/` (up to 63 chars) for SQL injection protection. |
108
- | `eventsTable` | `string` | `'siglo_context_events'` | Name of context change audit table. Must match `/^[a-zA-Z_][a-zA-Z0-9_]*$/` (up to 63 chars) for SQL injection protection. |
109
- | `recordContextChanges` | `boolean` | `true` | If `true`, writes all step context mutations into `eventsTable`. |
148
+ | `client` | `PostgresClientInterface \| Pool \| Client` | *Required* | Active PostgreSQL `Pool`, `Client`, or compatible wrapper with a `.query(text, values)` method. |
149
+ | `checkpointsTable` | `string` | `'siglo_checkpoints'` | Table name for execution checkpoints. |
150
+ | `eventsTable` | `string` | `'siglo_context_events'` | Table name for context mutation audit logs. |
151
+ | `waitRecordsTable` | `string` | `'siglo_wait_records'` | Table name for durable wait and continuation records. |
152
+ | `recordContextChanges` | `boolean` | `true` | When `true`, automatically streams step context mutations into `eventsTable`. Set `false` for low-latency pipelines that do not require fine-grained field auditing. |
110
153
 
111
- ### Methods
154
+ ### Checkpointing & Optimistic Concurrency Control (OCC)
112
155
 
113
156
  #### `ensureTables(): Promise<void>`
114
- Executes DDL statements to create tables and indexes if they do not already exist.
115
-
116
- #### `saveCheckpoint(checkpoint: SerializedExecutionStack): Promise<void>`
117
- Upserts a serialized execution stack by `stackId`, updating the `state` JSONB and `updated_at` timestamp.
157
+ Initializes `checkpointsTable`, `eventsTable`, and `waitRecordsTable` along with indexes and columns if they do not exist.
158
+
159
+ #### `saveCheckpoint(checkpoint: SerializedExecutionStack, expectedVersion?: number): Promise<void>`
160
+ Persists or updates an execution stack.
161
+ - **OCC Enforcement**: If `expectedVersion` is specified (or exists on `checkpoint.occVersion`), executes an atomic version-guarded update:
162
+ ```sql
163
+ UPDATE siglo_checkpoints
164
+ SET state = CASE WHEN state->>'status' = 'cancelled' THEN jsonb_set($1::jsonb, '{status}', '"cancelled"') ELSE $1::jsonb END,
165
+ namespace = $2,
166
+ version = version + 1,
167
+ updated_at = NOW()
168
+ WHERE stack_id = $3 AND version = $4
169
+ RETURNING version
170
+ ```
171
+ - **Conflict Handling**: If `rowCount === 0`, it fetches the current database version and throws an `OptimisticLockException(stackId, currentVersion, expectedVersion)`.
172
+ - **Cancellation Preservation**: If a workflow was previously cancelled, updating the checkpoint preserves the `'cancelled'` status atomically.
173
+ - **Version Tracking**: On success, assigns the new incremented version to `checkpoint.occVersion` and `checkpoint.revision`.
174
+ - **Write Tracking**: Automatically serialized under `withWorkflowLock` scopes to drain all pending writes before lock release.
118
175
 
119
176
  #### `getCheckpoint(stackId: string): Promise<SerializedExecutionStack | undefined>`
120
- Fetches the checkpoint by `stackId`. Returns `undefined` if no checkpoint is found.
177
+ Retrieves the serialized execution stack by `stackId`.
178
+ - Automatically assigns `occVersion` and `revision` from the database `version` column.
179
+ - Returns `undefined` if `stackId` is empty or not found.
180
+
181
+ #### `cleanExpiredCheckpoints(): Promise<number>`
182
+ Deletes checkpoints where `(state->>'expiresAt')::bigint <= (EXTRACT(EPOCH FROM NOW()) * 1000)::bigint`. Returns the count of deleted rows.
183
+
184
+ ---
185
+
186
+ ### Context Mutation Audit Logging
121
187
 
122
188
  #### `onContextChange(entry): Promise<void>`
123
- Streams a field modification into `siglo_context_events` with step ID, field name, JSONB value, and high-resolution timestamp.
189
+ Records step mutation audit events into `eventsTable`.
190
+ - Parameter `entry`:
191
+ ```typescript
192
+ {
193
+ namespace?: string;
194
+ stackId: string;
195
+ stepId: string;
196
+ field: string;
197
+ value: any;
198
+ timestamp: number;
199
+ }
200
+ ```
201
+ - Converts `timestamp` (in milliseconds) using `to_timestamp(timestamp / 1000.0)`.
202
+ - Operates as a non-blocking stream: internal errors are caught to avoid disrupting primary workflow execution.
203
+
204
+ ---
124
205
 
125
- #### `cleanupOldCheckpoints(retentionDays?: number): Promise<number>`
126
- Deletes checkpoints older than `retentionDays` (default `30` days). Returns the count of deleted rows.
206
+ ### Distributed Workflow Locking
127
207
 
128
- #### `cleanupOldEvents(retentionDays?: number): Promise<number>`
129
- Deletes audit records older than `retentionDays` (default `30` days). Returns the count of deleted rows.
208
+ #### `withWorkflowLock<T>(stackId: string, action: () => Promise<T>): Promise<T>`
209
+ Executes `action` inside a PostgreSQL session-level advisory lock using `pg_try_advisory_lock`:
210
+
211
+ ```sql
212
+ SELECT pg_try_advisory_lock(hashtextextended('siglo:workflow:' || stackId, 0)) AS locked
213
+ ```
214
+
215
+ - **Distributed Mutex**: Serializes step continuations, event resumptions, and compensations across multi-instance worker fleets.
216
+ - **Auto-Release on Process Failure**: Because PostgreSQL advisory locks are bound to the database session/connection, if a worker node crashes or disconnects, PostgreSQL automatically releases the lock immediately.
217
+ - **Re-entrancy**: Safely allows nested invocations for the same `stackId` within the same execution context.
218
+ - **Pool Awareness**: Detects pooled clients (`'totalCount' in pool`) and calls `pool.connect()`, ensuring a dedicated connection is held for the lock duration and released back to the pool in a `finally` block.
219
+ - **Non-Pooled / Direct Client Guard**: Guards direct `Client` instances against concurrent re-use via `directClientBusy`.
220
+ - **Write Queue Draining**: Uses `AsyncLocalStorage` and internal write tracking (`tail` promise) to ensure all pending checkpoint and wait record writes are completely flushed to PostgreSQL before the advisory lock is unlocked.
221
+ - **Throws**: `DuplicateEventException(stackId)` if the lock cannot be acquired or if the direct client is busy.
222
+
223
+ ---
224
+
225
+ ### Durable Wait Records & Resumption
226
+
227
+ #### `saveWaitRecord(record: any): Promise<void>`
228
+ Persists or updates a durable wait record in `waitRecordsTable` and embeds the record into the checkpoint metadata (`checkpoint.metadata.waitRecord`).
229
+ - Key properties on `record`: `correlationKey`, `stackId`, `namespace`, `eventName`, `status`, `continuationStepIndex`, `version`, `data`, `expiresAt`.
230
+
231
+ #### `getWaitRecord(correlationKeyOrStackId: string): Promise<any | undefined>`
232
+ Retrieves a wait record from `waitRecordsTable` matching either `correlation_key` or `stack_id`. Falls back to querying `siglo_checkpoints.state->'metadata'->'waitRecord'` if not found in the wait records table.
233
+
234
+ #### `claimWaitRecord(correlationKey: string, claimantId?: string, options?: { allowExpired?: boolean; eventPayload?: any }): Promise<any>`
235
+ Atomically claims a wait record for resumption via a single Compare-And-Swap (CAS) update:
236
+ ```sql
237
+ UPDATE siglo_wait_records
238
+ SET status = 'RESUMING', locked_at = NOW(), locked_by = $1,
239
+ resume_payload = COALESCE(resume_payload, $3::jsonb), updated_at = NOW()
240
+ WHERE correlation_key = $2
241
+ AND (status = 'WAITING' OR (status = 'RESUMING' AND $5))
242
+ AND (resume_payload IS NOT NULL OR $4 OR expires_at IS NULL OR expires_at > NOW())
243
+ RETURNING *
244
+ ```
245
+ - **Expiration Safety**: If the record has expired and `allowExpired` is not set, throws `ExpiredWaitException(correlationKey, expiresAt, eventName)`.
246
+ - **Duplicate Safety**: If the record is already claimed or resumed, throws `DuplicateEventException(correlationKey)`.
247
+ - **Returns**: The claimed wait record with `status: 'RESUMING'`, `lockedBy`, and parsed JSON `resumePayload`.
248
+
249
+ #### `completeWaitRecord(correlationKey: string, claimantId: string): Promise<void>`
250
+ Marks the claimed wait record as `'RESUMED'`, setting `resumed_by` and `resumed_at = NOW()`. Drains pending async writes before completing.
251
+
252
+ #### `cancelWaitRecord(correlationKey: string): Promise<void>`
253
+ Transitions a wait record status to `'CANCELLED'`.
254
+
255
+ #### `cancelWorkflow(stackId: string): Promise<void>`
256
+ Atomically cancels both active wait records and the execution checkpoint for `stackId`:
257
+ - Cancels all wait records: `UPDATE siglo_wait_records SET status = 'CANCELLED' WHERE stack_id = $1`.
258
+ - Updates the checkpoint state: `UPDATE siglo_checkpoints SET state = jsonb_set(state, '{status}', '"cancelled"'), version = version + 1 WHERE stack_id = $1`.
259
+
260
+ ---
261
+
262
+ ### Retention & Cleanup
263
+
264
+ | Method | Parameters | Default | Description |
265
+ | :--- | :--- | :--- | :--- |
266
+ | `cleanupOldCheckpoints` | `retentionDays?: number` | `30` | Deletes checkpoints where `updated_at < NOW() - make_interval(days => $1)`. |
267
+ | `cleanupOldEvents` | `retentionDays?: number` | `30` | Deletes audit events where `created_at < NOW() - make_interval(days => $1)`. |
268
+ | `cleanupOldWaitRecords` | `retentionDays?: number` | `30` | Deletes wait records where `updated_at < NOW() - make_interval(days => $1)`. |
269
+ | `cleanExpiredCheckpoints` | *(none)* | *(none)* | Deletes checkpoints whose embedded `state->>'expiresAt'` timestamp has passed. |
130
270
 
131
271
  ---
132
272
 
133
273
  ## 5. `PostgresIdempotencyStore` API Reference
134
274
 
135
- Implements the standard `IdempotencyStore` interface.
275
+ Implements the Siglo `IdempotencyStore` interface.
136
276
 
137
277
  ```typescript
138
278
  import { PostgresIdempotencyStore } from '@siglo/postgres';
@@ -142,40 +282,122 @@ import { PostgresIdempotencyStore } from '@siglo/postgres';
142
282
 
143
283
  | Option | Type | Default | Description |
144
284
  | :--- | :--- | :--- | :--- |
145
- | `client` | `PostgresClientInterface \| Pool` | *Required* | Active PostgreSQL `Pool` or `Client` instance. |
146
- | `tableName` | `string` | `'siglo_idempotency'` | Name of idempotency table. Must match `/^[a-zA-Z_][a-zA-Z0-9_]*$/` (up to 63 chars) for SQL injection protection. |
147
- | `defaultTtlMs` | `number` | `86400000` (24h) | Fallback expiration TTL in milliseconds. |
285
+ | `client` | `PostgresClientInterface \| Pool \| Client` | *Required* | Active PostgreSQL client or connection pool. |
286
+ | `tableName` | `string` | `'siglo_idempotency'` | Name of idempotency table. Must match `/^[a-zA-Z_][a-zA-Z0-9_]*$/`. |
287
+ | `defaultTtlMs` | `number` | `86400000` (24 hours) | Default lock and result expiration TTL in milliseconds. |
148
288
 
149
289
  ### Methods
150
290
 
151
291
  #### `ensureTable(): Promise<void>`
152
- Initializes the idempotency schema and expiration index.
292
+ Creates `tableName` and the index on `expires_at` if they do not exist.
153
293
 
154
294
  #### `claim(key: string, ttlMs?: number): Promise<'claimed' | 'completed' | 'in-progress'>`
155
- Atomically attempts to insert an `'in-progress'` record. If the key exists:
156
- - If expired, reclaims the lock and returns `'claimed'`.
157
- - If completed, returns `'completed'`.
158
- - If currently running, returns `'in-progress'`.
295
+ Atomically attempts to claim the key:
296
+ 1. **Initial Insert**: Executes `INSERT ... VALUES ($1, 'in-progress', $2, NOW()) ON CONFLICT (key) DO NOTHING`. Returns `'claimed'` if row inserted.
297
+ 2. **Expired Lock Reclamation**: If the key exists but `expires_at <= NOW()`, updates status back to `'in-progress'`, updates `expires_at`, clears `result`, and returns `'claimed'`.
298
+ 3. **Completed Result**: If `status === 'completed'`, returns `'completed'`.
299
+ 4. **Active Claim**: If actively running and unexpired, returns `'in-progress'`.
159
300
 
160
301
  #### `complete(key: string, result: unknown, ttlMs?: number): Promise<void>`
161
- Transitions key status to `'completed'`, serializes the result into JSONB, and sets expiration.
302
+ Transitions key status to `'completed'`, serializes `result` into `result JSONB`, and sets `expires_at = NOW() + ttlMs`.
162
303
 
163
- #### `fail(key: string, error: unknown): Promise<void>`
164
- Deletes in-progress claims so subsequent retries can proceed.
304
+ #### `fail(key: string, _error?: unknown): Promise<void>`
305
+ Deletes in-progress claims (`WHERE key = $1 AND status = 'in-progress'`) so subsequent retries can proceed immediately.
165
306
 
166
307
  #### `getResult(key: string): Promise<unknown | undefined>`
167
- Retrieves the stored JSONB result for completed keys.
308
+ Retrieves and deserializes the stored `result` JSONB for completed keys. Returns `undefined` if key is not completed or missing.
309
+
310
+ #### `get(key: string): Promise<unknown | undefined>`
311
+ Convenience alias for `getResult(key)`.
312
+
313
+ #### `getRecord(key: string): Promise<any | undefined>`
314
+ Inspects the full raw idempotency record:
315
+ ```typescript
316
+ {
317
+ status: string; // 'in-progress' | 'completed'
318
+ result?: any; // Parsed JSON result
319
+ expiresAt?: number; // Expiry timestamp (ms)
320
+ claimedAt: number; // Created timestamp (ms)
321
+ }
322
+ ```
168
323
 
169
324
  #### `cleanupExpired(): Promise<number>`
170
- Prunes expired idempotency records (`WHERE expires_at < NOW()`).
325
+ Deletes expired idempotency records (`DELETE FROM siglo_idempotency WHERE expires_at < NOW()`). Returns the count of deleted rows.
326
+
327
+ ---
328
+
329
+ ## 6. Concurrency & Locking Architecture
330
+
331
+ Siglo workflows employ a defense-in-depth concurrency model combining PostgreSQL advisory session locks with table-level Optimistic Concurrency Control (OCC):
332
+
333
+ ```mermaid
334
+ sequenceDiagram
335
+ autonumber
336
+ participant W1 as Worker Pod A
337
+ participant PG as PostgreSQL
338
+ participant W2 as Worker Pod B
339
+
340
+ Note over W1,PG: 1. Advisory Workflow Locking (Distributed Mutex)
341
+ W1->>PG: SELECT pg_try_advisory_lock(hashtextextended('siglo:workflow:stack-42', 0))
342
+ PG-->>W1: locked = true
343
+
344
+ W2->>PG: SELECT pg_try_advisory_lock(hashtextextended('siglo:workflow:stack-42', 0))
345
+ PG-->>W2: locked = false (Throws DuplicateEventException)
346
+
347
+ Note over W1,PG: 2. Execution & OCC Checkpoint Persistence
348
+ W1->>PG: UPDATE siglo_checkpoints SET state = $1, version = version + 1 WHERE stack_id = 'stack-42' AND version = 1 RETURNING version
349
+ PG-->>W1: version = 2
350
+
351
+ Note over W1,PG: 3. Automatic Write Queue Flush & Session Unlock
352
+ W1->>W1: Await pending writes (scope.tail)
353
+ W1->>PG: SELECT pg_advisory_unlock(hashtextextended('siglo:workflow:stack-42', 0))
354
+ PG-->>W1: unlocked = true
355
+ ```
356
+
357
+ ### Advisory Locks vs. Optimistic Concurrency Control (OCC)
358
+
359
+ - **`withWorkflowLock` (PostgreSQL Advisory Locks)**: Used around step executions, timer/event resumptions, and compensations. Guarantees that only **one worker** executes steps for a given `stackId` at any time. If the worker crashes, the PostgreSQL session lock automatically drops.
360
+ - **OCC (`version` column)**: Used by `saveCheckpoint`. Guards against stale writes or late-arriving updates across worker partitions. If a write conflicts with an unexpected version, an `OptimisticLockException` is thrown.
171
361
 
172
362
  ---
173
363
 
174
- ## 6. Complete Production Example
364
+ ## 7. Core Exception Types
365
+
366
+ All exceptions extend `SigloError` and can be imported from `@siglo/core`:
367
+
368
+ ```typescript
369
+ import {
370
+ OptimisticLockException,
371
+ DuplicateEventException,
372
+ ExpiredWaitException
373
+ } from '@siglo/core';
374
+ ```
375
+
376
+ ### 1. `OptimisticLockException`
377
+ Thrown by `saveCheckpoint` when an update fails because the version in the database differs from `expectedVersion`:
378
+ - `err.stackId`: The workflow execution stack ID.
379
+ - `err.currentVersion`: The version currently in PostgreSQL.
380
+ - `err.expectedVersion`: The version expected by the writer.
381
+
382
+ ### 2. `DuplicateEventException`
383
+ Thrown by `withWorkflowLock` when another worker currently holds the workflow lock, or by `claimWaitRecord` when a wait record is already claimed or resumed:
384
+ - `err.correlationKey` / `err.stackId`: The resource key that could not be locked.
385
+
386
+ ### 3. `ExpiredWaitException`
387
+ Thrown by `claimWaitRecord` when attempting to claim a wait record whose `expires_at` timestamp has passed and `allowExpired` is not set:
388
+ - `err.correlationKey`: The wait record correlation key.
389
+ - `err.expiresAt`: Expiration timestamp in milliseconds.
390
+ - `err.eventName`: Name of the expected event.
391
+
392
+ ---
393
+
394
+ ## 8. Complete Production Example
395
+
396
+ Below is a complete, production-ready example demonstrating connection pool configuration, automatic schema initialization, workflow definition, idempotency guards, OCC error handling, and scheduled retention cleanup.
175
397
 
176
398
  ```typescript
177
399
  import { Pool } from 'pg';
178
- import { Step, flow } from '@siglo/core';
400
+ import { Step, flow, OptimisticLockException, DuplicateEventException } from '@siglo/core';
179
401
  import { PostgresStorageAdapter, PostgresIdempotencyStore } from '@siglo/postgres';
180
402
 
181
403
  // 1. Initialize PostgreSQL Connection Pool
@@ -183,78 +405,134 @@ const pool = new Pool({
183
405
  connectionString: process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/orders_db',
184
406
  max: 20,
185
407
  idleTimeoutMillis: 30000,
408
+ connectionTimeoutMillis: 5000,
409
+ });
410
+
411
+ pool.on('error', (err) => {
412
+ console.error('Unexpected idle PostgreSQL client error:', err);
186
413
  });
187
414
 
188
415
  // 2. Initialize and Register Storage Adapter
189
416
  const storageAdapter = new PostgresStorageAdapter({
190
417
  client: pool,
418
+ checkpointsTable: 'siglo_checkpoints',
419
+ eventsTable: 'siglo_context_events',
420
+ waitRecordsTable: 'siglo_wait_records',
191
421
  recordContextChanges: true,
192
422
  });
193
- await storageAdapter.ensureTables();
194
- Step.useStorage(storageAdapter);
195
423
 
196
424
  // 3. Initialize Idempotency Store
197
425
  const idempotencyStore = new PostgresIdempotencyStore({
198
426
  client: pool,
427
+ tableName: 'siglo_idempotency',
199
428
  defaultTtlMs: 86400000, // 24 hours
200
429
  });
201
- await idempotencyStore.ensureTable();
202
430
 
203
- // 4. Define Payment Settlement Workflow
204
- interface SettlementInput {
205
- settlementId: string;
431
+ // 4. Schema Initialization on Application Startup
432
+ export async function initializeDatabase() {
433
+ await storageAdapter.ensureTables();
434
+ await idempotencyStore.ensureTable();
435
+ console.log('PostgreSQL workflow schemas initialized successfully.');
436
+ }
437
+
438
+ // Register global storage adapter
439
+ Step.useStorage(storageAdapter);
440
+
441
+ // 5. Define Production Workflow Steps
442
+ interface OrderInput {
443
+ orderId: string;
206
444
  accountId: string;
207
- amount: number;
445
+ amountCents: number;
208
446
  }
209
447
 
210
- const debitSender = Step.create('debitSender', async (param: SettlementInput) => {
211
- // Perform database debit operation within pool
212
- return { debitReference: `dr_${param.settlementId}`, debited: true };
448
+ const debitAccount = Step.create('debitAccount', async (param: OrderInput) => {
449
+ // Execute debit operation
450
+ return {
451
+ debitReference: `dr_${param.orderId}`,
452
+ debitedAmount: param.amountCents,
453
+ };
213
454
  });
214
455
 
215
- const creditReceiver = Step.create('creditReceiver', async (param: any) => {
216
- // Perform receiver credit
217
- return { creditReference: `cr_${param.settlementId}`, credited: true };
456
+ const emitReceipt = Step.create('emitReceipt', async (param: any) => {
457
+ return {
458
+ receiptNumber: `rcpt_${param.orderId}`,
459
+ completedAt: new Date().toISOString(),
460
+ };
218
461
  });
219
462
 
220
- const settlementFlow = flow(debitSender, creditReceiver);
463
+ const paymentWorkflow = flow(debitAccount, emitReceipt);
221
464
 
222
- // 5. Execute with Distributed Idempotency Guard
223
- async function runIdempotentSettlement(input: SettlementInput) {
224
- const idempKey = `settle:${input.settlementId}`;
225
- const claimStatus = await idempotencyStore.claim(idempKey, 30000); // 30s claim lock
465
+ // 6. Execute Workflow with Distributed Idempotency and Workflow Lock
466
+ export async function processOrder(input: OrderInput) {
467
+ const idempKey = `order:payment:${input.orderId}`;
468
+
469
+ // Attempt 60-second atomic claim lock
470
+ const claimStatus = await idempotencyStore.claim(idempKey, 60000);
226
471
 
227
472
  if (claimStatus === 'completed') {
228
- console.log('Settlement already processed. Returning cached result.');
473
+ console.log(`Order ${input.orderId} already completed. Returning cached result.`);
229
474
  return await idempotencyStore.getResult(idempKey);
230
475
  }
231
476
 
232
477
  if (claimStatus === 'in-progress') {
233
- throw new Error(`Settlement ${input.settlementId} is already running concurrently.`);
478
+ throw new Error(`Order ${input.orderId} is currently being processed by another worker.`);
234
479
  }
235
480
 
236
481
  try {
237
- const result = await settlementFlow.run(input, {
238
- namespace: 'finance-prod',
482
+ // Execute inside distributed advisory workflow lock
483
+ const stackId = `stack_${input.orderId}`;
484
+ const output = await storageAdapter.withWorkflowLock(stackId, async () => {
485
+ return await paymentWorkflow.run(input, {
486
+ namespace: 'ecommerce-prod',
487
+ });
239
488
  });
240
- const clean = {
241
- settlementId: input.settlementId,
242
- debitReference: result.debitReference(),
243
- creditReference: result.creditReference(),
489
+
490
+ const finalResult = {
491
+ orderId: input.orderId,
492
+ debitReference: output.debitReference,
493
+ receiptNumber: output.receiptNumber,
494
+ completedAt: output.completedAt,
244
495
  };
245
- await idempotencyStore.complete(idempKey, clean);
246
- return clean;
496
+
497
+ // Complete idempotency record with final payload
498
+ await idempotencyStore.complete(idempKey, finalResult);
499
+ return finalResult;
247
500
  } catch (err) {
501
+ // Clear in-flight idempotency claim so retries are permitted
248
502
  await idempotencyStore.fail(idempKey, err);
503
+
504
+ if (err instanceof OptimisticLockException) {
505
+ console.error(`OCC Conflict on stack ${err.stackId}: expected v${err.expectedVersion}, got v${err.currentVersion}`);
506
+ } else if (err instanceof DuplicateEventException) {
507
+ console.warn(`Concurrent execution conflict on lock ${err.correlationKey}`);
508
+ }
509
+
249
510
  throw err;
250
511
  }
251
512
  }
252
513
 
253
- // 6. Maintenance Routine: Automated Data Retention
254
- async function runRetentionCleanup() {
255
- const prunedCheckpoints = await storageAdapter.cleanupOldCheckpoints(14); // Retain 14 days
256
- const prunedEvents = await storageAdapter.cleanupOldEvents(30); // Retain 30 days
514
+ // 7. Scheduled Maintenance & Retention Cleanup
515
+ export async function runNightlyRetentionCleanup() {
516
+ console.log('Starting automated PostgreSQL workflow data retention cleanup...');
517
+
518
+ const prunedCheckpoints = await storageAdapter.cleanupOldCheckpoints(14); // 14-day checkpoint retention
519
+ const prunedEvents = await storageAdapter.cleanupOldEvents(30); // 30-day audit log retention
520
+ const prunedWaits = await storageAdapter.cleanupOldWaitRecords(14); // 14-day wait record retention
521
+ const prunedExpiredCheckpoints = await storageAdapter.cleanExpiredCheckpoints();
257
522
  const prunedIdemp = await idempotencyStore.cleanupExpired();
258
- console.log(`Pruned: ${prunedCheckpoints} checkpoints, ${prunedEvents} events, ${prunedIdemp} idemp keys`);
523
+
524
+ console.log(`Maintenance Summary:
525
+ - Deleted Checkpoints: ${prunedCheckpoints}
526
+ - Expired Checkpoints: ${prunedExpiredCheckpoints}
527
+ - Deleted Context Events: ${prunedEvents}
528
+ - Deleted Wait Records: ${prunedWaits}
529
+ - Expired Idempotency Keys: ${prunedIdemp}`);
530
+ }
531
+
532
+ // 8. Graceful Shutdown
533
+ export async function shutdown() {
534
+ await pool.end();
535
+ console.log('PostgreSQL connection pool closed.');
259
536
  }
260
537
  ```
538
+
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@siglo/postgres",
3
- "version": "1.0.1",
4
- "description": "Production PostgreSQL durable checkpoint, event audit, and idempotency adapter for Siglo workflows",
3
+ "version": "1.1.0",
4
+ "description": "PostgreSQL persistence adapter for Siglo TypeScript workflows with durable checkpoints, event auditing, and idempotency.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "exports": {
@@ -37,7 +37,7 @@
37
37
  "bugs": {
38
38
  "url": "https://github.com/jsoftgem/siglo/issues"
39
39
  },
40
- "homepage": "https://github.com/jsoftgem/siglo#readme",
40
+ "homepage": "https://github.com/jsoftgem/siglo/tree/main/packages/postgres#readme",
41
41
  "dependencies": {
42
42
  "@siglo/core": "^1.0.0"
43
43
  },
@@ -57,5 +57,16 @@
57
57
  "mocha": "^12.0.3",
58
58
  "tsx": "^4.23.15",
59
59
  "typescript": "~5.7.0"
60
- }
60
+ },
61
+ "keywords": [
62
+ "siglo",
63
+ "workflow",
64
+ "typescript",
65
+ "postgres",
66
+ "postgresql",
67
+ "persistence",
68
+ "checkpoint",
69
+ "durable-execution",
70
+ "idempotency"
71
+ ]
61
72
  }