tr-pg-name-value-store 0.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/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file. The format is
4
+ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
5
+ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.0.0] - Unreleased
8
+
9
+ Initial implementation, ready for testing.
10
+
11
+ ### Added
12
+
13
+ - `PgNameValueStore` class constructed from a pg `Pool`, with a self-maintaining,
14
+ never-migrated schema (one `name_value_store` table per namespace; pre-existing
15
+ tables are verified, never altered).
16
+ - Async API: `init`, `get`, `set`, `remove`, `update`, `removeAll`.
17
+ - JSONB value storage for any JSON value; `undefined` (no value) is kept distinct
18
+ from a stored JSON `null` via row presence.
19
+ - Atomic `update(name, callback)` read-modify-write, serialized per name by a
20
+ PostgreSQL advisory lock, with graceful cancel (callback throws `null`/
21
+ `undefined`), delete (callback returns `undefined`), and error pass-through.
22
+ - `SchemaMismatchError` for a conflicting pre-existing table.
23
+ - Integration test suite (vitest) and documentation.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Timo J. Rinne <tri@iki.fi>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # tr-pg-name-value-store
2
+
3
+ A persistent **name → value store** backed by a single PostgreSQL table. A thin,
4
+ durable getter/setter: values are stored as `JSONB`, so a value may be anything
5
+ JSON can carry (number, string, boolean, object, array, or `null`) and is
6
+ returned as the corresponding JavaScript value.
7
+
8
+ - **Self-maintaining schema.** The table is created idempotently by the class
9
+ itself on first use — no manual setup, no migrations. A pre-existing table is
10
+ *verified, never altered*.
11
+ - **Multiple named stores.** A namespace gives each store its own table in the
12
+ same database.
13
+ - **Atomic read-modify-write.** `update()` runs a callback inside a transaction,
14
+ serialized per name, so concurrent updates compose correctly.
15
+ - **Presence vs. `null`.** The store distinguishes *no value* (`undefined`) from
16
+ the value `null`.
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ npm install tr-pg-name-value-store pg
22
+ ```
23
+
24
+ `pg` is a peer dependency (`>= 8`). Requires Node `>= 18` and PostgreSQL `>= 9.5`
25
+ (for `INSERT … ON CONFLICT`).
26
+
27
+ ## Quick start
28
+
29
+ ```ts
30
+ import { Pool } from 'pg';
31
+ import { PgNameValueStore } from 'tr-pg-name-value-store';
32
+
33
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
34
+ const store = new PgNameValueStore(pool, { namespace: 'myapp' });
35
+
36
+ await store.set('greeting', 'hello');
37
+ await store.get('greeting'); // → 'hello'
38
+
39
+ await store.set('config', { retries: 3, tags: ['a', 'b'] });
40
+ await store.get('config'); // → { retries: 3, tags: ['a', 'b'] }
41
+
42
+ const previous = await store.set('greeting', 'hi'); // → 'hello' (the old value)
43
+ await store.remove('greeting'); // → 'hi' (the removed value)
44
+ await store.get('greeting'); // → undefined
45
+ ```
46
+
47
+ ## Value model
48
+
49
+ A stored value is any JSON-serializable JavaScript value: `number`, `string`,
50
+ `boolean`, `object`, `array`, or `null`.
51
+
52
+ - **Presence vs. `null`.** A name that has never been set (or has been removed)
53
+ reads back as `undefined`. A name explicitly set to JSON `null` reads back as
54
+ `null`. "The value does not exist" is always represented as the row being
55
+ absent — there is no SQL `NULL` value.
56
+ - **`undefined` is not a value.** `set(name, undefined)` throws `TypeError`; use
57
+ `remove(name)` to delete. (Inside `update`, a callback that *returns*
58
+ `undefined` is the one place `undefined` is meaningful — it means *delete*.)
59
+ - **JSONB normalization.** PostgreSQL normalizes JSONB on storage: object keys
60
+ are reordered, insignificant whitespace is dropped, duplicate keys collapse to
61
+ the last, and numbers are canonicalized. A value read back is *semantically*
62
+ equal but may not be textually identical to the value written (e.g. key order).
63
+
64
+ ## API
65
+
66
+ ### `new PgNameValueStore(pool, options?)`
67
+
68
+ Constructs a store over a pg `Pool`. Performs no I/O. Options:
69
+
70
+ | Option | Type | Default | Meaning |
71
+ |-------------|----------|---------|---------|
72
+ | `namespace` | `string` | none | Backing table is `<namespace>_name_value_store`. Must match `/^[a-z][a-z0-9_]*$/`, ≤ 41 chars. Throws `TypeError` if invalid. Omit for the bare table `name_value_store`. |
73
+
74
+ ### `init(): Promise<void>`
75
+
76
+ Idempotently ensures the schema (creating the table if absent, verifying it if
77
+ present). Called automatically on first use of any method; call it explicitly to
78
+ surface schema/connection errors at startup. Safe to call repeatedly and
79
+ concurrently (across processes too).
80
+
81
+ ### `get(name): Promise<value>`
82
+
83
+ Resolves to the current value, or `undefined` if `name` has no value.
84
+
85
+ ### `set(name, value): Promise<previous>`
86
+
87
+ Stores `value` (insert or overwrite); resolves to the previous value, or
88
+ `undefined` if there was none. Throws `TypeError` if `value` is `undefined` or
89
+ not JSON-serializable (writing nothing).
90
+
91
+ ### `remove(name): Promise<previous>`
92
+
93
+ Deletes `name`; resolves to its previous value, or `undefined` if it had none.
94
+
95
+ ### `update(name, callback): Promise<previous>`
96
+
97
+ Atomic read-modify-write. Fetches the current value, calls
98
+ `callback(current)` (awaiting a returned promise), then commits one outcome
99
+ based on what the callback does:
100
+
101
+ | Callback… | Effect | `update`… |
102
+ |--------------------------------------------------------|---------------------------------|------------------------------------|
103
+ | returns a JSON-serializable value (not `undefined`) | stores it | resolves to the previous value |
104
+ | returns `undefined` | removes the name | resolves to the previous value |
105
+ | returns a non-serializable value (function, `BigInt`, …) | nothing (rolled back) | throws `TypeError` |
106
+ | throws `null` or `undefined` | nothing (rolled back) | resolves to the previous value |
107
+ | throws anything else | nothing (rolled back) | re-throws that value unchanged |
108
+
109
+ The whole sequence runs in one transaction on a dedicated pooled connection.
110
+ Concurrent updates of the **same** name are serialized by a per-name advisory
111
+ lock, so each callback sees the committed result of the previous one — including
112
+ the create-from-absent case.
113
+
114
+ ```ts
115
+ // atomic counter (creates from absent, then increments)
116
+ await store.update('hits', (n) => (typeof n === 'number' ? n : 0) + 1);
117
+
118
+ // conditional update; abort with no change and no error
119
+ await store.update('config', (cfg) => {
120
+ if (!cfg) throw null; // graceful cancel
121
+ return { ...(cfg as object), seen: true };
122
+ });
123
+
124
+ // delete via update
125
+ await store.update('stale', () => undefined);
126
+ ```
127
+
128
+ ### `removeAll(): Promise<void>`
129
+
130
+ Removes every pair from this store's namespace. Other namespaces are untouched.
131
+
132
+ ## Multiple stores
133
+
134
+ Each namespace is an independent store with its own table:
135
+
136
+ ```ts
137
+ const sessions = new PgNameValueStore(pool, { namespace: 'sessions' });
138
+ const settings = new PgNameValueStore(pool, { namespace: 'settings' });
139
+ // sessions.* and settings.* never collide; removeAll() on one leaves the other intact
140
+ ```
141
+
142
+ ## Errors
143
+
144
+ | Error | When |
145
+ |-----------------------|------|
146
+ | `TypeError` | Invalid namespace; invalid name (not a non-empty string, or > 1024 chars); a `set` value or `update` return that is not JSON-serializable. |
147
+ | `SchemaMismatchError` | A table with this namespace's name already exists with a different shape. It is left untouched. Exported by the package. |
148
+ | re-thrown value | `update` re-throws any non-`null`/`undefined` value its callback throws. |
149
+
150
+ Operational/connection failures propagate from the underlying `pg` calls.
151
+
152
+ ## Schema
153
+
154
+ One table per namespace (`{{ns}}` is `<namespace>_`, or empty):
155
+
156
+ ```sql
157
+ CREATE TABLE {{ns}}name_value_store (
158
+ name TEXT NOT NULL,
159
+ v JSONB NOT NULL,
160
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
161
+ PRIMARY KEY (name)
162
+ );
163
+ ```
164
+
165
+ The module issues only `CREATE TABLE IF NOT EXISTS` and verifies the columns of
166
+ a pre-existing table against this shape — it never runs `ALTER` or `DROP`.
167
+
168
+ ## License
169
+
170
+ MIT © Timo J. Rinne &lt;tri@iki.fi&gt;
@@ -0,0 +1 @@
1
+ export declare const TABLES_SQL = "-- tr-pg-name-value-store: bootstrap DDL.\n--\n-- FROZEN: this file must never change in a way that alters the shape of an\n-- existing table. Everything here is idempotent (create-if-absent); the module\n-- verifies the shape of a pre-existing table and refuses to touch it on any\n-- mismatch. No ALTER, no DROP \u2014 ever.\n--\n-- {{ns}} is the namespace prefix (e.g. \"myapp_\"), empty by default. It is\n-- expanded by the module after strict identifier validation.\n\n-- One row per name. The value lives in `v` as JSONB; a JSON null is stored as\n-- the JSONB scalar 'null' (still NOT NULL at the SQL level), which lets the\n-- store tell a stored null apart from an absent name. \"The value does not\n-- exist\" is always represented as row absence \u2014 a SQL NULL never appears in v.\nCREATE TABLE IF NOT EXISTS {{ns}}name_value_store (\n name TEXT NOT NULL,\n v JSONB NOT NULL,\n updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (name)\n);\n";
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ // AUTO-GENERATED by scripts/embed-sql.mjs from sql/*.sql — do not edit.
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.TABLES_SQL = void 0;
5
+ exports.TABLES_SQL = "-- tr-pg-name-value-store: bootstrap DDL.\n--\n-- FROZEN: this file must never change in a way that alters the shape of an\n-- existing table. Everything here is idempotent (create-if-absent); the module\n-- verifies the shape of a pre-existing table and refuses to touch it on any\n-- mismatch. No ALTER, no DROP — ever.\n--\n-- {{ns}} is the namespace prefix (e.g. \"myapp_\"), empty by default. It is\n-- expanded by the module after strict identifier validation.\n\n-- One row per name. The value lives in `v` as JSONB; a JSON null is stored as\n-- the JSONB scalar 'null' (still NOT NULL at the SQL level), which lets the\n-- store tell a stored null apart from an absent name. \"The value does not\n-- exist\" is always represented as row absence — a SQL NULL never appears in v.\nCREATE TABLE IF NOT EXISTS {{ns}}name_value_store (\n name TEXT NOT NULL,\n v JSONB NOT NULL,\n updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (name)\n);\n";
@@ -0,0 +1,2 @@
1
+ export { PgNameValueStore, type PgNameValueStoreOptions, type UpdateCallback, } from './pg-name-value-store';
2
+ export { SchemaMismatchError } from './schema';
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SchemaMismatchError = exports.PgNameValueStore = void 0;
4
+ var pg_name_value_store_1 = require("./pg-name-value-store");
5
+ Object.defineProperty(exports, "PgNameValueStore", { enumerable: true, get: function () { return pg_name_value_store_1.PgNameValueStore; } });
6
+ var schema_1 = require("./schema");
7
+ Object.defineProperty(exports, "SchemaMismatchError", { enumerable: true, get: function () { return schema_1.SchemaMismatchError; } });
@@ -0,0 +1,102 @@
1
+ import type { Pool } from 'pg';
2
+ /** Construction options for {@link PgNameValueStore}. */
3
+ export interface PgNameValueStoreOptions {
4
+ /**
5
+ * Optional store namespace, allowing multiple independent stores in the same
6
+ * database. The backing table is named `<namespace>_name_value_store`. Must
7
+ * match `/^[a-z][a-z0-9_]*$/` and be at most 41 characters. Default: no prefix
8
+ * (the bare table name `name_value_store`).
9
+ */
10
+ namespace?: string;
11
+ }
12
+ /**
13
+ * The mutation callback passed to {@link PgNameValueStore.update}. Receives the
14
+ * current value (`undefined` if the name has no value) and returns the next
15
+ * value. See {@link PgNameValueStore.update} for the full outcome contract:
16
+ *
17
+ * - return a JSON-serializable value (not `undefined`) → store it;
18
+ * - return `undefined` → remove the name;
19
+ * - throw `null` or `undefined` → cancel (no change), no error surfaced;
20
+ * - throw anything else → cancel (no change), the error is re-thrown;
21
+ * - return a non-serializable value → cancel (no change), a `TypeError` is thrown.
22
+ */
23
+ export type UpdateCallback = (current: unknown) => unknown | Promise<unknown>;
24
+ /**
25
+ * A persistent name → value store backed by a single PostgreSQL table.
26
+ *
27
+ * Values are stored as JSONB, so a value may be anything JSON can carry
28
+ * (number, string, boolean, object, array, or `null`) and is returned as the
29
+ * corresponding JavaScript value. The store distinguishes *no value*
30
+ * (`undefined`) from the value `null`.
31
+ *
32
+ * The constructor takes a pg `Pool`; the schema (one table per namespace) is
33
+ * created automatically and idempotently on first use, or via an explicit
34
+ * {@link init} call. An existing table is never altered — only verified.
35
+ */
36
+ export declare class PgNameValueStore {
37
+ #private;
38
+ /**
39
+ * @param pool a connected pg `Pool`; the store does not manage its lifecycle.
40
+ * @param options see {@link PgNameValueStoreOptions}. Throws `TypeError` on an
41
+ * invalid `namespace`. Performs no I/O.
42
+ */
43
+ constructor(pool: Pool, options?: PgNameValueStoreOptions);
44
+ /**
45
+ * Idempotently ensures the schema exists and matches the expected shape
46
+ * (throws `SchemaMismatchError` on a conflicting pre-existing table), then
47
+ * resolves. Safe to call repeatedly and concurrently across processes
48
+ * (serialized by a per-namespace advisory lock). Every other method calls
49
+ * this internally on first use, so calling it explicitly is optional — useful
50
+ * to surface schema/connection errors eagerly at startup. A failed `init()`
51
+ * is not cached; a subsequent call retries.
52
+ */
53
+ init(): Promise<void>;
54
+ /**
55
+ * Resolves to the current value of `name`, or `undefined` if `name` has no
56
+ * value. A name explicitly set to JSON `null` resolves to `null` (distinct
57
+ * from `undefined`).
58
+ */
59
+ get(name: string): Promise<unknown>;
60
+ /**
61
+ * Stores `value` as the new value of `name` (insert or overwrite) and
62
+ * resolves to the **previous** value, or `undefined` if there was none.
63
+ *
64
+ * `value` must be JSON-serializable and must not be `undefined`; otherwise
65
+ * `set` throws `TypeError` and writes nothing. Performed as a single
66
+ * statement, so it is atomic.
67
+ */
68
+ set(name: string, value: unknown): Promise<unknown>;
69
+ /**
70
+ * Removes `name` from the store and resolves to its previous value, or
71
+ * `undefined` if `name` had no value. Removing an absent name is a no-op that
72
+ * resolves to `undefined`. A single `DELETE … RETURNING`; atomic.
73
+ */
74
+ remove(name: string): Promise<unknown>;
75
+ /**
76
+ * Atomic read-modify-write. Fetches the current value, invokes `cb(current)`
77
+ * (awaiting a returned promise), then commits exactly one outcome:
78
+ *
79
+ * - `cb` returns a JSON-serializable value (not `undefined`) → store it;
80
+ * - `cb` returns `undefined` → remove `name`;
81
+ * - `cb` returns a non-serializable value → roll back, throw `TypeError`;
82
+ * - `cb` throws `null`/`undefined` → roll back (graceful cancel), no error;
83
+ * - `cb` throws anything else → roll back, re-throw that value unchanged.
84
+ *
85
+ * On every non-throwing outcome (store, remove, or graceful cancel) `update`
86
+ * resolves to the value that was in the database **before** the call (or
87
+ * `undefined` if there was none).
88
+ *
89
+ * The whole sequence runs in one transaction on a dedicated pooled
90
+ * connection. Concurrent updates of the same `name` are serialized by a
91
+ * per-(namespace, name) transaction-scoped advisory lock, so each callback
92
+ * sees the committed result of the previous update — including the
93
+ * create-from-absent case. An existing row is additionally taken `FOR UPDATE`.
94
+ */
95
+ update(name: string, cb: UpdateCallback): Promise<unknown>;
96
+ /**
97
+ * Removes every name/value pair from this store (a single `DELETE`). Resolves
98
+ * to `void`. Affects only this store's namespace; other namespaces' tables
99
+ * are untouched.
100
+ */
101
+ removeAll(): Promise<void>;
102
+ }
Binary file
@@ -0,0 +1,30 @@
1
+ import type { Pool } from 'pg';
2
+ /**
3
+ * Thrown when a pre-existing table with this store's (namespaced) name does not
4
+ * match the shape this module expects. The module never alters or drops an
5
+ * existing table; resolving a mismatch is up to the operator.
6
+ */
7
+ export declare class SchemaMismatchError extends Error {
8
+ constructor(message: string);
9
+ }
10
+ /**
11
+ * Validates a namespace and returns the identifier prefix ("" or "<ns>_").
12
+ *
13
+ * The namespace is interpolated into DDL/DML, so this validation is the
14
+ * injection guard: reject, never quote or escape around it.
15
+ */
16
+ export declare function namespacePrefix(namespace?: string): string;
17
+ /**
18
+ * Deterministically derives a 64-bit signed advisory-lock key from a string.
19
+ * Used both for per-namespace init serialization and for per-name update
20
+ * serialization. A (vanishingly improbable) collision only over-serializes
21
+ * unrelated keys; it never affects correctness.
22
+ */
23
+ export declare function lockKey(s: string): bigint;
24
+ /**
25
+ * Idempotently ensures the store schema for the given namespace prefix: a
26
+ * pre-existing table is shape-verified (never altered), an absent one is
27
+ * created. Runs in a single transaction serialized by a per-namespace advisory
28
+ * lock, so concurrent initializers across processes are safe.
29
+ */
30
+ export declare function ensureSchema(pool: Pool, prefix: string): Promise<void>;
package/dist/schema.js ADDED
@@ -0,0 +1,114 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SchemaMismatchError = void 0;
4
+ exports.namespacePrefix = namespacePrefix;
5
+ exports.lockKey = lockKey;
6
+ exports.ensureSchema = ensureSchema;
7
+ const node_crypto_1 = require("node:crypto");
8
+ const sql_1 = require("./generated/sql");
9
+ /**
10
+ * Thrown when a pre-existing table with this store's (namespaced) name does not
11
+ * match the shape this module expects. The module never alters or drops an
12
+ * existing table; resolving a mismatch is up to the operator.
13
+ */
14
+ class SchemaMismatchError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = 'SchemaMismatchError';
18
+ }
19
+ }
20
+ exports.SchemaMismatchError = SchemaMismatchError;
21
+ const NAMESPACE_RE = /^[a-z][a-z0-9_]*$/;
22
+ // The longest identifier the module causes PostgreSQL to generate is the
23
+ // auto-named primary-key index "<ns>name_value_store_pkey". To stay within the
24
+ // 63-byte identifier limit (beyond which PostgreSQL silently truncates), the
25
+ // namespace may use at most 63 - len("name_value_store_pkey") - 1 chars. Derived
26
+ // from the constant so it stays correct if a longer identifier is ever added.
27
+ const LONGEST_SUFFIX = 'name_value_store_pkey';
28
+ const NAMESPACE_MAX_LENGTH = 63 - LONGEST_SUFFIX.length - 1;
29
+ /**
30
+ * Validates a namespace and returns the identifier prefix ("" or "<ns>_").
31
+ *
32
+ * The namespace is interpolated into DDL/DML, so this validation is the
33
+ * injection guard: reject, never quote or escape around it.
34
+ */
35
+ function namespacePrefix(namespace) {
36
+ if (namespace === undefined || namespace === '')
37
+ return '';
38
+ if (typeof namespace !== 'string' || !NAMESPACE_RE.test(namespace)) {
39
+ throw new TypeError(`invalid namespace ${JSON.stringify(namespace)}: must match ${NAMESPACE_RE}`);
40
+ }
41
+ if (namespace.length > NAMESPACE_MAX_LENGTH) {
42
+ throw new TypeError(`invalid namespace: at most ${NAMESPACE_MAX_LENGTH} characters allowed ` +
43
+ `(got ${namespace.length})`);
44
+ }
45
+ return `${namespace}_`;
46
+ }
47
+ function expand(sql, prefix) {
48
+ return sql.replace(/\{\{ns\}\}/g, prefix);
49
+ }
50
+ /**
51
+ * Deterministically derives a 64-bit signed advisory-lock key from a string.
52
+ * Used both for per-namespace init serialization and for per-name update
53
+ * serialization. A (vanishingly improbable) collision only over-serializes
54
+ * unrelated keys; it never affects correctness.
55
+ */
56
+ function lockKey(s) {
57
+ return (0, node_crypto_1.createHash)('sha256').update(s).digest().readBigInt64BE(0);
58
+ }
59
+ function namespaceLockKey(prefix) {
60
+ return lockKey(`tr-pg-name-value-store:init:${prefix}`);
61
+ }
62
+ // The frozen shape of the store table (see sql/tables.sql). A pre-existing
63
+ // table is verified against this; any mismatch is a hard error.
64
+ const EXPECTED_COLUMNS = [
65
+ { name: 'name', dataType: 'text', nullable: false },
66
+ { name: 'v', dataType: 'jsonb', nullable: false },
67
+ { name: 'updated_at', dataType: 'timestamp with time zone', nullable: false },
68
+ ];
69
+ /**
70
+ * Verifies that a pre-existing store table matches the frozen shape. Runs
71
+ * BEFORE the bootstrap DDL so a wrong table fails with a clear error instead of
72
+ * a confusing one from a later insert. Absent table: a no-op (the
73
+ * create-if-absent DDL will create it).
74
+ */
75
+ async function verifyTableShape(client, prefix) {
76
+ const table = `${prefix}name_value_store`;
77
+ const res = await client.query(`SELECT column_name, data_type, is_nullable
78
+ FROM information_schema.columns
79
+ WHERE table_schema = current_schema() AND table_name = $1
80
+ ORDER BY ordinal_position`, [table]);
81
+ if (res.rowCount === 0)
82
+ return; // Absent: bootstrap DDL will create it.
83
+ const got = res.rows.map((c) => `${c.column_name} ${c.data_type}${c.is_nullable === 'YES' ? '' : ' not null'}`);
84
+ const want = EXPECTED_COLUMNS.map((c) => `${c.name} ${c.dataType}${c.nullable ? '' : ' not null'}`);
85
+ if (got.join(', ') !== want.join(', ')) {
86
+ throw new SchemaMismatchError(`existing table "${table}" does not match the expected shape and will ` +
87
+ `not be touched.\n expected: ${want.join(', ')}\n found: ${got.join(', ')}`);
88
+ }
89
+ }
90
+ /**
91
+ * Idempotently ensures the store schema for the given namespace prefix: a
92
+ * pre-existing table is shape-verified (never altered), an absent one is
93
+ * created. Runs in a single transaction serialized by a per-namespace advisory
94
+ * lock, so concurrent initializers across processes are safe.
95
+ */
96
+ async function ensureSchema(pool, prefix) {
97
+ const client = await pool.connect();
98
+ try {
99
+ await client.query('BEGIN');
100
+ await client.query('SELECT pg_advisory_xact_lock($1)', [
101
+ namespaceLockKey(prefix).toString(),
102
+ ]);
103
+ await verifyTableShape(client, prefix);
104
+ await client.query(expand(sql_1.TABLES_SQL, prefix));
105
+ await client.query('COMMIT');
106
+ }
107
+ catch (err) {
108
+ await client.query('ROLLBACK').catch(() => { });
109
+ throw err;
110
+ }
111
+ finally {
112
+ client.release();
113
+ }
114
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "tr-pg-name-value-store",
3
+ "version": "0.0.0",
4
+ "description": "Persistent name/value store on PostgreSQL. JSONB values, self-maintaining never-migrated schema, atomic read-modify-write, multiple named stores per database.",
5
+ "keywords": [
6
+ "name-value",
7
+ "key-value",
8
+ "kv",
9
+ "store",
10
+ "persistent",
11
+ "jsonb",
12
+ "postgresql",
13
+ "postgres",
14
+ "pg"
15
+ ],
16
+ "main": "dist/index.js",
17
+ "types": "dist/index.d.ts",
18
+ "files": [
19
+ "dist",
20
+ "sql",
21
+ "README.md",
22
+ "CHANGELOG.md",
23
+ "LICENSE"
24
+ ],
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "scripts": {
29
+ "embed-sql": "node scripts/embed-sql.mjs",
30
+ "prebuild": "npm run embed-sql",
31
+ "build": "tsc -p tsconfig.json",
32
+ "pretest": "npm run embed-sql",
33
+ "test": "vitest run",
34
+ "prepack": "npm run build"
35
+ },
36
+ "peerDependencies": {
37
+ "pg": ">=8"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^20.0.0",
41
+ "@types/pg": "^8.10.0",
42
+ "pg": "^8.11.0",
43
+ "typescript": "^5.4.0",
44
+ "vitest": "^4.1.8"
45
+ },
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/rinne/node-tr-pg-name-value-store.git"
49
+ },
50
+ "author": {
51
+ "name": "Timo J. Rinne",
52
+ "email": "tri@iki.fi",
53
+ "url": "https://github.com/rinne/"
54
+ },
55
+ "license": "MIT",
56
+ "bugs": {
57
+ "url": "https://github.com/rinne/node-tr-pg-name-value-store/issues"
58
+ },
59
+ "homepage": "https://github.com/rinne/node-tr-pg-name-value-store#readme"
60
+ }
package/sql/tables.sql ADDED
@@ -0,0 +1,20 @@
1
+ -- tr-pg-name-value-store: bootstrap DDL.
2
+ --
3
+ -- FROZEN: this file must never change in a way that alters the shape of an
4
+ -- existing table. Everything here is idempotent (create-if-absent); the module
5
+ -- verifies the shape of a pre-existing table and refuses to touch it on any
6
+ -- mismatch. No ALTER, no DROP — ever.
7
+ --
8
+ -- {{ns}} is the namespace prefix (e.g. "myapp_"), empty by default. It is
9
+ -- expanded by the module after strict identifier validation.
10
+
11
+ -- One row per name. The value lives in `v` as JSONB; a JSON null is stored as
12
+ -- the JSONB scalar 'null' (still NOT NULL at the SQL level), which lets the
13
+ -- store tell a stored null apart from an absent name. "The value does not
14
+ -- exist" is always represented as row absence — a SQL NULL never appears in v.
15
+ CREATE TABLE IF NOT EXISTS {{ns}}name_value_store (
16
+ name TEXT NOT NULL,
17
+ v JSONB NOT NULL,
18
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
19
+ PRIMARY KEY (name)
20
+ );