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 +23 -0
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/dist/generated/sql.d.ts +1 -0
- package/dist/generated/sql.js +5 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +7 -0
- package/dist/pg-name-value-store.d.ts +102 -0
- package/dist/pg-name-value-store.js +0 -0
- package/dist/schema.d.ts +30 -0
- package/dist/schema.js +114 -0
- package/package.json +60 -0
- package/sql/tables.sql +20 -0
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 <tri@iki.fi>
|
|
@@ -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";
|
package/dist/index.d.ts
ADDED
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
|
package/dist/schema.d.ts
ADDED
|
@@ -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
|
+
);
|