@webda/postgres 4.0.0-beta.1 → 4.0.0-beta.3

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 ADDED
@@ -0,0 +1,125 @@
1
+ # @webda/postgres module
2
+
3
+ This module is part of Webda Application Framework that allows you to quickly develop applications with all modern prerequisites: Security, Extensibility, GraphQL, REST, CloudNative [https://webda.io](https://webda.io)
4
+
5
+ <img src="https://webda.io/images/webda.svg" width="128" />
6
+
7
+ ![CI](https://github.com/loopingz/webda.io/workflows/CI/badge.svg)
8
+
9
+ [![Join the chat at https://gitter.im/loopingz/webda](https://badges.gitter.im/loopingz/webda.svg)](https://gitter.im/loopingz/webda?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
10
+ [![codecov](https://codecov.io/gh/loopingz/webda.io/branch/main/graph/badge.svg?token=8N9DNM3K3O)](https://codecov.io/gh/loopingz/webda.io)
11
+ [![SonarCloud.io](https://sonarcloud.io/api/project_badges/measure?project=loopingz_webda.io&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=loopingz_webda.io)
12
+ ![CodeQL](https://github.com/loopingz/webda.io/workflows/CodeQL/badge.svg)
13
+
14
+ <!-- README_HEADER -->
15
+
16
+ # @webda/postgres
17
+
18
+ > PostgreSQL-backed Store for Webda — persists model instances as JSONB rows in Postgres with automatic table creation, view generation, and full WebdaQL query support.
19
+
20
+ ## When to use it
21
+
22
+ - You want a relational database backing store for Webda models with ACID guarantees.
23
+ - You are already running PostgreSQL and want Webda models to live alongside existing tables.
24
+ - You need to query across models using familiar SQL tooling while keeping the Webda model API.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pnpm add @webda/postgres
30
+ ```
31
+
32
+ ## Configuration
33
+
34
+ ```json
35
+ {
36
+ "services": {
37
+ "postStore": {
38
+ "type": "PostgresStore",
39
+ "model": "MyApp/Post",
40
+ "postgresqlServer": {
41
+ "host": "localhost",
42
+ "port": 5432,
43
+ "database": "myapp",
44
+ "user": "webda",
45
+ "password": "${POSTGRES_PASSWORD}"
46
+ },
47
+ "autoCreateTable": true
48
+ }
49
+ }
50
+ }
51
+ ```
52
+
53
+ Or rely on libpq environment variables (`PGHOST`, `PGPORT`, `PGDATABASE`, `PGUSER`, `PGPASSWORD`):
54
+
55
+ ```json
56
+ {
57
+ "services": {
58
+ "postStore": {
59
+ "type": "PostgresStore",
60
+ "model": "MyApp/Post",
61
+ "autoCreateTable": true
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ | Parameter | Type | Default | Required | Description |
68
+ |---|---|---|---|---|
69
+ | `postgresqlServer` | `ClientConfig` \| `PoolConfig` | libpq env vars | No | PostgreSQL connection config (host, port, database, user, password) |
70
+ | `usePool` | boolean | `false` | No | Use a connection pool (`pg.Pool`) instead of a single client |
71
+ | `autoCreateTable` | boolean | `true` | No | Automatically create the JSONB table if it does not exist |
72
+ | `viewPrefix` | string | `""` | No | Prefix for auto-generated SQL views |
73
+ | `views` | string[] | `["regex:.*"]` | No | Regexp list of model names to generate views for |
74
+
75
+ ## Usage
76
+
77
+ ```typescript
78
+ import { CoreModel, Model, Expose } from "@webda/core";
79
+
80
+ @Model()
81
+ export class Post extends CoreModel {
82
+ @Expose() title: string;
83
+ @Expose() content: string;
84
+ @Expose() publishedAt: Date;
85
+ }
86
+
87
+ // With PostgresStore configured, the standard Webda model API is backed by Postgres:
88
+ const post = new Post();
89
+ post.title = "Hello Postgres";
90
+ await post.save(); // INSERT INTO posts ...
91
+
92
+ const found = await Post.ref(post.uuid).get(); // SELECT ... WHERE uuid = ?
93
+ const results = await Post.query("title CONTAINS 'Hello'"); // filtered query
94
+
95
+ // The table schema uses JSONB:
96
+ // CREATE TABLE IF NOT EXISTS posts (
97
+ // uuid TEXT PRIMARY KEY,
98
+ // data JSONB NOT NULL
99
+ // );
100
+ ```
101
+
102
+ ## Reference
103
+
104
+ - API reference: see the auto-generated typedoc at `docs/pages/Modules/postgres/`.
105
+ - Source: [`packages/postgres`](https://github.com/loopingz/webda.io/tree/main/packages/postgres)
106
+ - Related: [`@webda/mongodb`](../mongodb) for a document-store alternative; [`@webda/aws`](../aws) for `DynamoStore`; [`@webda/core`](../core) for the `Store` base class.
107
+
108
+ <!-- README_FOOTER -->
109
+ ## Sponsors
110
+
111
+ <!--
112
+ Support this project by becoming a sponsor. Your logo will show up here with a link to your website. [Become a sponsor](mailto:sponsor@webda.io)
113
+ -->
114
+
115
+ Arize AI is a machine learning observability and model monitoring platform. It helps you visualize, monitor, and explain your machine learning models. [Learn more](https://arize.com)
116
+
117
+ [<img src="https://arize.com/hubfs/arize/brand/arize-logomark-1.png" width="200">](https://arize.com)
118
+
119
+ Loopingz is a software development company that provides consulting and development services. [Learn more](https://loopingz.com)
120
+
121
+ [<img src="https://loopingz.com/images/logo.png" width="200">](https://loopingz.com)
122
+
123
+ Tellae is an innovative consulting firm specialized in cities transportation issues. We provide our clients, both public and private, with solutions to support your strategic and operational decisions. [Learn more](https://tellae.fr)
124
+
125
+ [<img src="https://tellae.fr/" width="200">](https://tellae.fr)
package/lib/index.d.ts CHANGED
@@ -1,2 +1,5 @@
1
- export * from "./postgresstore.js";
1
+ export * from "./postgrespubsub.service.js";
2
+ export * from "./postgresqueue.service.js";
3
+ export * from "./postgresstore.service.js";
2
4
  export * from "./sqlstore.js";
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,6BAA6B,CAAC;AAC5C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,eAAe,CAAC"}
package/lib/index.js CHANGED
@@ -1,3 +1,5 @@
1
- export * from "./postgresstore.js";
1
+ export * from "./postgrespubsub.service.js";
2
+ export * from "./postgresqueue.service.js";
3
+ export * from "./postgresstore.service.js";
2
4
  export * from "./sqlstore.js";
3
5
  //# sourceMappingURL=index.js.map
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC;AAChC,cAAc,YAAY,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,6BAA6B,CAAC;AAC5C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,eAAe,CAAC"}
@@ -0,0 +1,40 @@
1
+ import pg, { PoolConfig } from "pg";
2
+ /**
3
+ * Build the registry key for a config. Exposed for tests.
4
+ *
5
+ * @param config - the pg pool config (may be undefined — pg falls back to PG* env)
6
+ * @returns the registry key
7
+ */
8
+ export declare function poolKey(config: PoolConfig | undefined): string;
9
+ /**
10
+ * Acquire a shared `pg.Pool` for the given config. Pools are cached by
11
+ * stable config-hash; callers that pass the same config (regardless of
12
+ * key order) get the same pool back. Each `acquirePool` increments a
13
+ * refcount; the pool is `end()`ed when the last consumer calls
14
+ * {@link releasePool}.
15
+ *
16
+ * Use this for stateless workloads (queries, queue receive, pub/sub
17
+ * publish). LISTEN must use a dedicated `pg.Client` because the
18
+ * subscription is bound to a single connection — pooled connections
19
+ * rotate and would silently drop the subscription.
20
+ *
21
+ * @param config - the pg pool config
22
+ * @returns the shared pool
23
+ */
24
+ export declare function acquirePool(config: PoolConfig | undefined): pg.Pool;
25
+ /**
26
+ * Release a previously-acquired pool. When the refcount reaches zero,
27
+ * the pool is `end()`ed and removed from the registry. Releasing a
28
+ * config that was never acquired (or already drained) is a no-op so
29
+ * `stop()` paths can call this unconditionally.
30
+ *
31
+ * @param config - the same config that was passed to {@link acquirePool}
32
+ * @returns a promise that resolves once the pool has been ended (or immediately if still in use / unknown)
33
+ */
34
+ export declare function releasePool(config: PoolConfig | undefined): Promise<void>;
35
+ /**
36
+ * Test-only: drain and forget every shared pool. Used between specs to
37
+ * keep the registry from leaking handles across files.
38
+ */
39
+ export declare function __resetPools(): Promise<void>;
40
+ //# sourceMappingURL=pgpool.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pgpool.d.ts","sourceRoot":"","sources":["../src/pgpool.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,EAAE,EAAE,UAAU,EAAE,MAAM,IAAI,CAAC;AAyCpC;;;;;GAKG;AACH,wBAAgB,OAAO,CAAC,MAAM,EAAE,UAAU,GAAG,SAAS,GAAG,MAAM,CAE9D;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,UAAU,GAAG,SAAS,GAAG,EAAE,CAAC,IAAI,CAUnE;AAED;;;;;;;;GAQG;AACH,wBAAsB,WAAW,CAAC,MAAM,EAAE,UAAU,GAAG,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/E;AAED;;;GAGG;AACH,wBAAsB,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CAUlD"}
package/lib/pgpool.js ADDED
@@ -0,0 +1,101 @@
1
+ import pg from "pg";
2
+ /**
3
+ * Stable JSON serializer that sorts object keys so two configs with the
4
+ * same content but different key order hash to the same registry key.
5
+ *
6
+ * Functions and classes (e.g. `pg.Pool` custom types) are dropped — pools
7
+ * configured with non-serializable hooks must be created directly.
8
+ *
9
+ * @param value - any JSON-serializable value
10
+ * @returns a stable string representation
11
+ */
12
+ function stableStringify(value) {
13
+ if (value === null || typeof value !== "object") {
14
+ return JSON.stringify(value);
15
+ }
16
+ if (Array.isArray(value)) {
17
+ return "[" + value.map(stableStringify).join(",") + "]";
18
+ }
19
+ const keys = Object.keys(value).sort();
20
+ return ("{" +
21
+ keys
22
+ .map(k => {
23
+ const v = value[k];
24
+ if (typeof v === "function")
25
+ return undefined;
26
+ return JSON.stringify(k) + ":" + stableStringify(v);
27
+ })
28
+ .filter(s => s !== undefined)
29
+ .join(",") +
30
+ "}");
31
+ }
32
+ const registry = new Map();
33
+ /**
34
+ * Build the registry key for a config. Exposed for tests.
35
+ *
36
+ * @param config - the pg pool config (may be undefined — pg falls back to PG* env)
37
+ * @returns the registry key
38
+ */
39
+ export function poolKey(config) {
40
+ return stableStringify(config ?? {});
41
+ }
42
+ /**
43
+ * Acquire a shared `pg.Pool` for the given config. Pools are cached by
44
+ * stable config-hash; callers that pass the same config (regardless of
45
+ * key order) get the same pool back. Each `acquirePool` increments a
46
+ * refcount; the pool is `end()`ed when the last consumer calls
47
+ * {@link releasePool}.
48
+ *
49
+ * Use this for stateless workloads (queries, queue receive, pub/sub
50
+ * publish). LISTEN must use a dedicated `pg.Client` because the
51
+ * subscription is bound to a single connection — pooled connections
52
+ * rotate and would silently drop the subscription.
53
+ *
54
+ * @param config - the pg pool config
55
+ * @returns the shared pool
56
+ */
57
+ export function acquirePool(config) {
58
+ const key = poolKey(config);
59
+ const entry = registry.get(key);
60
+ if (entry) {
61
+ entry.refCount++;
62
+ return entry.pool;
63
+ }
64
+ const pool = new pg.Pool(config);
65
+ registry.set(key, { pool, refCount: 1 });
66
+ return pool;
67
+ }
68
+ /**
69
+ * Release a previously-acquired pool. When the refcount reaches zero,
70
+ * the pool is `end()`ed and removed from the registry. Releasing a
71
+ * config that was never acquired (or already drained) is a no-op so
72
+ * `stop()` paths can call this unconditionally.
73
+ *
74
+ * @param config - the same config that was passed to {@link acquirePool}
75
+ * @returns a promise that resolves once the pool has been ended (or immediately if still in use / unknown)
76
+ */
77
+ export async function releasePool(config) {
78
+ const key = poolKey(config);
79
+ const entry = registry.get(key);
80
+ if (!entry)
81
+ return;
82
+ entry.refCount--;
83
+ if (entry.refCount > 0)
84
+ return;
85
+ registry.delete(key);
86
+ await entry.pool.end().catch(() => {
87
+ /* already ended */
88
+ });
89
+ }
90
+ /**
91
+ * Test-only: drain and forget every shared pool. Used between specs to
92
+ * keep the registry from leaking handles across files.
93
+ */
94
+ export async function __resetPools() {
95
+ const entries = [...registry.values()];
96
+ registry.clear();
97
+ await Promise.all(entries.map(e => e.pool.end().catch(() => {
98
+ /* already ended */
99
+ })));
100
+ }
101
+ //# sourceMappingURL=pgpool.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pgpool.js","sourceRoot":"","sources":["../src/pgpool.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,131 @@
1
+ import { PubSubService, ServiceParameters } from "@webda/core";
2
+ import { CancelablePromise } from "@webda/utils";
3
+ import pg, { ClientConfig } from "pg";
4
+ /**
5
+ * Configuration for {@link PostgresPubSubService}.
6
+ */
7
+ export declare class PostgresPubSubParameters extends ServiceParameters {
8
+ /**
9
+ * Channel name passed to LISTEN / NOTIFY. Must be a valid Postgres
10
+ * identifier (lowercased, no quoting). Defaults to the service name.
11
+ * @pattern /^[a-z_][a-z0-9_]*$/
12
+ * @example "myapp_events"
13
+ * @example "user_notifications"
14
+ * @example "cache_invalidation"
15
+ * @example "orders"
16
+ */
17
+ channel?: string;
18
+ /**
19
+ * Connection settings forwarded to `pg.Client`. By default `pg` reads
20
+ * standard PG* environment variables.
21
+ */
22
+ postgresqlServer?: ClientConfig;
23
+ /**
24
+ * Reconnect delay in milliseconds when the LISTEN connection drops. A
25
+ * randomized jitter is added to keep crash-loop reconnects from
26
+ * stampeding.
27
+ *
28
+ * @default 500
29
+ */
30
+ reconnectDelay?: number;
31
+ /**
32
+ * @override
33
+ * @param params - the input parameters
34
+ * @returns this
35
+ */
36
+ load(params?: any): this;
37
+ }
38
+ interface Subscriber<T> {
39
+ callback: (event: T) => Promise<void>;
40
+ proto?: {
41
+ new (): T;
42
+ };
43
+ }
44
+ /**
45
+ * Pub/sub backed by Postgres' native LISTEN / NOTIFY. A long-lived
46
+ * `pg.Client` (NOT a pool — pools rotate connections, but each LISTEN is
47
+ * scoped to the connection that issued it) holds the subscription;
48
+ * publishes go through `pg_notify(channel, payload)`. The 8 kB NOTIFY
49
+ * payload cap is enforced in {@link sendMessage} — for larger payloads,
50
+ * stash them in a row and notify the row id.
51
+ *
52
+ * Disconnects trigger a randomized-backoff reconnect so the listener
53
+ * survives transient network or restart blips.
54
+ *
55
+ * @WebdaModda PostgresPubSub
56
+ */
57
+ export default class PostgresPubSubService<T = any, K extends PostgresPubSubParameters = PostgresPubSubParameters> extends PubSubService<T, K> {
58
+ /**
59
+ * Long-lived listener client. One per service instance because LISTEN is
60
+ * scoped to the connection that issued it — a pool would silently rotate
61
+ * and drop the subscription.
62
+ */
63
+ protected client?: pg.Client;
64
+ /**
65
+ * Shared pool used for the publish side (`pg_notify`). Acquired from
66
+ * the per-config pool registry so multiple services that target the
67
+ * same database share connections instead of each spinning up their
68
+ * own pool. Subscribe-only services skip this and never publish.
69
+ */
70
+ protected publishPool?: pg.Pool;
71
+ /**
72
+ * Local callback registrations. Notifications dispatch to all of them.
73
+ */
74
+ protected callbacks: Set<Subscriber<T>>;
75
+ /**
76
+ * Set during {@link stop} so reconnect handlers don't try to come back
77
+ * after teardown.
78
+ */
79
+ protected stopping: boolean;
80
+ /**
81
+ * Channel name used for LISTEN/NOTIFY. Resolved at init time so we can
82
+ * default to the service's name when not configured.
83
+ * @returns the channel name
84
+ */
85
+ protected channel(): string;
86
+ /**
87
+ * @override
88
+ * @returns this service
89
+ */
90
+ init(): Promise<this>;
91
+ /**
92
+ * Open a fresh client, run LISTEN, and wire the notification handler.
93
+ */
94
+ protected connect(): Promise<void>;
95
+ /**
96
+ * Schedule a reconnect after a short randomized backoff.
97
+ */
98
+ protected handleDisconnect(): void;
99
+ /**
100
+ * Decode a notification payload and run every registered callback
101
+ * against it.
102
+ * @param payload - the raw NOTIFY payload string
103
+ */
104
+ protected dispatch(payload: string): void;
105
+ /**
106
+ * @override
107
+ * @param event - the event to publish
108
+ */
109
+ sendMessage(event: T): Promise<void>;
110
+ /**
111
+ * @override
112
+ * @returns 0 — pub/sub is transient, no queueing
113
+ */
114
+ size(): Promise<number>;
115
+ /**
116
+ * @override
117
+ * @param callback - invoked with each event received
118
+ * @param eventPrototype - optional class to rehydrate JSON into
119
+ * @param onBind - invoked once the subscription is registered
120
+ * @returns a cancelable subscription handle
121
+ */
122
+ consume(callback: (event: T) => Promise<void>, eventPrototype?: {
123
+ new (): T;
124
+ }, onBind?: () => void): CancelablePromise;
125
+ /**
126
+ * @override
127
+ */
128
+ stop(): Promise<void>;
129
+ }
130
+ export { PostgresPubSubService };
131
+ //# sourceMappingURL=postgrespubsub.service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgrespubsub.service.d.ts","sourceRoot":"","sources":["../src/postgrespubsub.service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAC/D,OAAO,EAAE,iBAAiB,EAAa,MAAM,cAAc,CAAC;AAE5D,OAAO,EAAE,EAAE,EAAE,YAAY,EAAc,MAAM,IAAI,CAAC;AAUlD;;GAEG;AACH,qBAAa,wBAAyB,SAAQ,iBAAiB;IAC7D;;;;;;;;OAQG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,YAAY,CAAC;IAChC;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB;;;;OAIG;IACH,IAAI,CAAC,MAAM,GAAE,GAAQ,GAAG,IAAI,CAI3B;CACF;AAED,UAAU,UAAU,CAAC,CAAC;IACpB,QAAQ,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,KAAK,CAAC,EAAE;QAAE,QAAQ,CAAC,CAAA;KAAE,CAAC;CACvB;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,OAAO,OAAO,qBAAqB,CACxC,CAAC,GAAG,GAAG,EACP,CAAC,SAAS,wBAAwB,GAAG,wBAAwB,CAC7D,SAAQ,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC;IAC3B;;;;OAIG;IACH,SAAS,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC;IAC7B;;;;;OAKG;IACH,SAAS,CAAC,WAAW,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC;IAChC;;OAEG;IACH,SAAS,CAAC,SAAS,EAAE,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAa;IACpD;;;OAGG;IACH,SAAS,CAAC,QAAQ,UAAS;IAE3B;;;;OAIG;IACH,SAAS,CAAC,OAAO,IAAI,MAAM,CAE1B;IAED;;;OAGG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAI1B;IAED;;OAEG;IACH,UAAgB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAmBvC;IAED;;OAEG;IACH,SAAS,CAAC,gBAAgB,IAAI,IAAI,CAQjC;IAED;;;;OAIG;IACH,SAAS,CAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAuBxC;IAED;;;OAGG;IACG,WAAW,CAAC,KAAK,EAAE,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBzC;IAED;;;OAGG;IACG,IAAI,IAAI,OAAO,CAAC,MAAM,CAAC,CAE5B;IAED;;;;;;OAMG;IACH,OAAO,CACL,QAAQ,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,EACrC,cAAc,CAAC,EAAE;QAAE,QAAQ,CAAC,CAAA;KAAE,EAC9B,MAAM,CAAC,EAAE,MAAM,IAAI,GAClB,iBAAiB,CAUnB;IAED;;OAEG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAoB1B;CACF;AAED,OAAO,EAAE,qBAAqB,EAAE,CAAC"}
@@ -0,0 +1,208 @@
1
+ import { PubSubService, ServiceParameters } from "@webda/core";
2
+ import { CancelablePromise, JSONUtils } from "@webda/utils";
3
+ import { useLog } from "@webda/workout";
4
+ import pg from "pg";
5
+ import { acquirePool, releasePool } from "./pgpool.js";
6
+ /**
7
+ * NOTIFY's payload limit is 8000 bytes (the underlying NAMEDATALEN /
8
+ * NOTIFY_PAYLOAD_LIMIT). We keep a small safety margin to leave room for
9
+ * the protocol overhead.
10
+ */
11
+ const NOTIFY_PAYLOAD_MAX = 7900;
12
+ /**
13
+ * Configuration for {@link PostgresPubSubService}.
14
+ */
15
+ export class PostgresPubSubParameters extends ServiceParameters {
16
+ /**
17
+ * @override
18
+ * @param params - the input parameters
19
+ * @returns this
20
+ */
21
+ load(params = {}) {
22
+ super.load(params);
23
+ this.reconnectDelay ?? (this.reconnectDelay = 500);
24
+ return this;
25
+ }
26
+ }
27
+ /**
28
+ * Pub/sub backed by Postgres' native LISTEN / NOTIFY. A long-lived
29
+ * `pg.Client` (NOT a pool — pools rotate connections, but each LISTEN is
30
+ * scoped to the connection that issued it) holds the subscription;
31
+ * publishes go through `pg_notify(channel, payload)`. The 8 kB NOTIFY
32
+ * payload cap is enforced in {@link sendMessage} — for larger payloads,
33
+ * stash them in a row and notify the row id.
34
+ *
35
+ * Disconnects trigger a randomized-backoff reconnect so the listener
36
+ * survives transient network or restart blips.
37
+ *
38
+ * @WebdaModda PostgresPubSub
39
+ */
40
+ export default class PostgresPubSubService extends PubSubService {
41
+ constructor() {
42
+ super(...arguments);
43
+ /**
44
+ * Local callback registrations. Notifications dispatch to all of them.
45
+ */
46
+ this.callbacks = new Set();
47
+ /**
48
+ * Set during {@link stop} so reconnect handlers don't try to come back
49
+ * after teardown.
50
+ */
51
+ this.stopping = false;
52
+ }
53
+ /**
54
+ * Channel name used for LISTEN/NOTIFY. Resolved at init time so we can
55
+ * default to the service's name when not configured.
56
+ * @returns the channel name
57
+ */
58
+ channel() {
59
+ return this.parameters.channel ?? this.getName().toLowerCase();
60
+ }
61
+ /**
62
+ * @override
63
+ * @returns this service
64
+ */
65
+ async init() {
66
+ await super.init();
67
+ await this.connect();
68
+ return this;
69
+ }
70
+ /**
71
+ * Open a fresh client, run LISTEN, and wire the notification handler.
72
+ */
73
+ async connect() {
74
+ // Validate the channel name before opening any connection: LISTEN
75
+ // can't be parameterized so the channel name gets inlined into SQL,
76
+ // which makes it a query-injection vector. Failing fast here keeps
77
+ // the bad-input case independent of database reachability.
78
+ const ch = this.channel();
79
+ if (!/^[a-z_][a-z0-9_]*$/.test(ch)) {
80
+ throw new Error(`Invalid channel name "${ch}" — must match /^[a-z_][a-z0-9_]*$/`);
81
+ }
82
+ const client = new pg.Client(this.parameters.postgresqlServer);
83
+ client.on("notification", (msg) => {
84
+ if (msg.channel !== this.channel())
85
+ return;
86
+ this.dispatch(msg.payload ?? "");
87
+ });
88
+ client.on("error", err => useLog("WARN", `PostgresPubSub client error: ${err.message}`));
89
+ client.on("end", () => this.handleDisconnect());
90
+ await client.connect();
91
+ await client.query(`LISTEN ${ch}`);
92
+ this.client = client;
93
+ }
94
+ /**
95
+ * Schedule a reconnect after a short randomized backoff.
96
+ */
97
+ handleDisconnect() {
98
+ if (this.stopping)
99
+ return;
100
+ this.client = undefined;
101
+ const delay = this.parameters.reconnectDelay + Math.floor(Math.random() * 250);
102
+ setTimeout(() => {
103
+ if (this.stopping)
104
+ return;
105
+ this.connect().catch(err => useLog("WARN", `PostgresPubSub reconnect failed: ${err.message}`));
106
+ }, delay);
107
+ }
108
+ /**
109
+ * Decode a notification payload and run every registered callback
110
+ * against it.
111
+ * @param payload - the raw NOTIFY payload string
112
+ */
113
+ dispatch(payload) {
114
+ let raw;
115
+ try {
116
+ raw = payload === "" ? undefined : JSONUtils.parse(payload);
117
+ }
118
+ catch (err) {
119
+ this.metrics?.errors?.inc();
120
+ useLog("WARN", `PostgresPubSub invalid payload: ${err.message}`);
121
+ return;
122
+ }
123
+ this.metrics?.messages_received?.inc();
124
+ for (const sub of this.callbacks) {
125
+ const event = sub.proto && raw ? Object.assign(new sub.proto(), raw) : raw;
126
+ const start = Date.now();
127
+ sub
128
+ .callback(event)
129
+ .catch(err => {
130
+ this.metrics?.errors?.inc();
131
+ useLog("ERROR", `PostgresPubSub callback failed: ${err.message}`);
132
+ })
133
+ .finally(() => {
134
+ this.metrics?.processing_duration?.observe((Date.now() - start) / 1000);
135
+ });
136
+ }
137
+ }
138
+ /**
139
+ * @override
140
+ * @param event - the event to publish
141
+ */
142
+ async sendMessage(event) {
143
+ if (!this.client)
144
+ throw new Error("PostgresPubSub not connected");
145
+ const payload = JSONUtils.stringify(event);
146
+ if (Buffer.byteLength(payload, "utf-8") > NOTIFY_PAYLOAD_MAX) {
147
+ throw new Error(`PostgresPubSub payload exceeds Postgres' 8 kB NOTIFY limit. Stash large payloads in a row and notify the row id instead.`);
148
+ }
149
+ this.metrics?.messages_sent?.inc();
150
+ // Lazily acquire a shared publish pool: subscribe-only services never
151
+ // call sendMessage and so never pay the connection cost. Acquired
152
+ // pools are reference-counted in the registry, so multiple postgres
153
+ // services pointing at the same database share connections.
154
+ if (!this.publishPool) {
155
+ this.publishPool = acquirePool(this.parameters.postgresqlServer);
156
+ }
157
+ await this.publishPool.query("SELECT pg_notify($1, $2)", [this.channel(), payload]);
158
+ }
159
+ /**
160
+ * @override
161
+ * @returns 0 — pub/sub is transient, no queueing
162
+ */
163
+ async size() {
164
+ return 0;
165
+ }
166
+ /**
167
+ * @override
168
+ * @param callback - invoked with each event received
169
+ * @param eventPrototype - optional class to rehydrate JSON into
170
+ * @param onBind - invoked once the subscription is registered
171
+ * @returns a cancelable subscription handle
172
+ */
173
+ consume(callback, eventPrototype, onBind) {
174
+ const entry = { callback, proto: eventPrototype };
175
+ this.callbacks.add(entry);
176
+ onBind?.();
177
+ return new CancelablePromise(resolve => resolve(), async () => {
178
+ this.callbacks.delete(entry);
179
+ });
180
+ }
181
+ /**
182
+ * @override
183
+ */
184
+ async stop() {
185
+ this.stopping = true;
186
+ this.callbacks.clear();
187
+ if (this.client) {
188
+ const client = this.client;
189
+ this.client = undefined;
190
+ try {
191
+ await client.query(`UNLISTEN ${this.channel()}`);
192
+ }
193
+ catch {
194
+ /* connection may already be dead */
195
+ }
196
+ await client.end().catch(() => {
197
+ /* already ended */
198
+ });
199
+ }
200
+ if (this.publishPool) {
201
+ this.publishPool = undefined;
202
+ await releasePool(this.parameters.postgresqlServer);
203
+ }
204
+ await super.stop();
205
+ }
206
+ }
207
+ export { PostgresPubSubService };
208
+ //# sourceMappingURL=postgrespubsub.service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgrespubsub.service.js","sourceRoot":"","sources":["../src/postgrespubsub.service.ts"],"names":[],"mappings":"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"}