tr-pg-name-value-store 0.0.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -8
- package/README.md +75 -11
- package/dist/pg-name-value-store.d.ts +25 -11
- package/dist/pg-name-value-store.js +0 -0
- package/package.json +8 -7
package/CHANGELOG.md
CHANGED
|
@@ -4,20 +4,35 @@ All notable changes to this project are documented in this file. The format is
|
|
|
4
4
|
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
|
|
5
5
|
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
-
## [
|
|
7
|
+
## [1.0.0] - 2026-09-07
|
|
8
8
|
|
|
9
|
-
Initial
|
|
9
|
+
Initial release.
|
|
10
10
|
|
|
11
11
|
### Added
|
|
12
12
|
|
|
13
13
|
- `PgNameValueStore` class constructed from a pg `Pool`, with a self-maintaining,
|
|
14
14
|
never-migrated schema (one `name_value_store` table per namespace; pre-existing
|
|
15
|
-
tables are verified, never altered
|
|
15
|
+
tables are verified, never altered; a matching operator-created table is
|
|
16
|
+
adopted as-is).
|
|
16
17
|
- Async API: `init`, `get`, `set`, `remove`, `update`, `removeAll`.
|
|
17
18
|
- JSONB value storage for any JSON value; `undefined` (no value) is kept distinct
|
|
18
19
|
from a stored JSON `null` via row presence.
|
|
19
|
-
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
- Exact previous-value contract: `set`, `remove` and `update` each resolve to the
|
|
21
|
+
value they replaced. All writes to the same name run in a transaction on a
|
|
22
|
+
dedicated connection under a per-`(namespace, name)` advisory lock, so they are
|
|
23
|
+
strictly serialized across processes and the previous value is always the one
|
|
24
|
+
left by the immediately preceding write. Writes to different names, and all
|
|
25
|
+
reads, are never blocked.
|
|
26
|
+
- Atomic `update(name, callback)` read-modify-write with graceful cancel
|
|
27
|
+
(callback throws `null`/`undefined`), delete (callback returns `undefined`),
|
|
28
|
+
and error pass-through.
|
|
29
|
+
- Name validation: a non-empty string of at most 1024 bytes of UTF-8 without
|
|
30
|
+
`U+0000`, rejected with `TypeError` before any I/O.
|
|
31
|
+
- `SchemaMismatchError` for a conflicting pre-existing table. A failed `init()`
|
|
32
|
+
is not cached; the next call retries.
|
|
33
|
+
- Integration test suite (vitest) against a real PostgreSQL — a throwaway local
|
|
34
|
+
cluster by default, or any server via `TR_PG_NAME_VALUE_STORE_TEST_URL` (a
|
|
35
|
+
compose file for PostgreSQL 16 is included). Verified on PostgreSQL 9.6, 16
|
|
36
|
+
and 18.
|
|
37
|
+
|
|
38
|
+
[1.0.0]: https://github.com/rinne/node-tr-pg-name-value-store/releases/tag/v1.0.0
|
package/README.md
CHANGED
|
@@ -10,6 +10,10 @@ returned as the corresponding JavaScript value.
|
|
|
10
10
|
*verified, never altered*.
|
|
11
11
|
- **Multiple named stores.** A namespace gives each store its own table in the
|
|
12
12
|
same database.
|
|
13
|
+
- **Exact previous values.** Every write (`set`, `remove`, `update`) resolves to
|
|
14
|
+
the value it replaced. Writes to the same name are serialized across all
|
|
15
|
+
processes sharing the database, so that previous value is always the one left
|
|
16
|
+
by the immediately preceding write.
|
|
13
17
|
- **Atomic read-modify-write.** `update()` runs a callback inside a transaction,
|
|
14
18
|
serialized per name, so concurrent updates compose correctly.
|
|
15
19
|
- **Presence vs. `null`.** The store distinguishes *no value* (`undefined`) from
|
|
@@ -22,7 +26,8 @@ npm install tr-pg-name-value-store pg
|
|
|
22
26
|
```
|
|
23
27
|
|
|
24
28
|
`pg` is a peer dependency (`>= 8`). Requires Node `>= 18` and PostgreSQL `>= 9.5`
|
|
25
|
-
(for `INSERT … ON CONFLICT`).
|
|
29
|
+
(for `INSERT … ON CONFLICT`). The test suite runs against PostgreSQL 9.6, 16 and
|
|
30
|
+
18.
|
|
26
31
|
|
|
27
32
|
## Quick start
|
|
28
33
|
|
|
@@ -60,6 +65,15 @@ A stored value is any JSON-serializable JavaScript value: `number`, `string`,
|
|
|
60
65
|
are reordered, insignificant whitespace is dropped, duplicate keys collapse to
|
|
61
66
|
the last, and numbers are canonicalized. A value read back is *semantically*
|
|
62
67
|
equal but may not be textually identical to the value written (e.g. key order).
|
|
68
|
+
- **No `U+0000` in strings.** PostgreSQL's `jsonb` cannot store the NUL character
|
|
69
|
+
inside a string; the server's error propagates unchanged and nothing is written.
|
|
70
|
+
|
|
71
|
+
## Names
|
|
72
|
+
|
|
73
|
+
A name is any non-empty string of at most **1024 bytes of UTF-8** (so up to 1024
|
|
74
|
+
ASCII characters, fewer for non-ASCII) that does not contain `U+0000`. Anything
|
|
75
|
+
else throws `TypeError` before any I/O. Within those bounds names are opaque:
|
|
76
|
+
case-sensitive, whitespace-significant, any punctuation or Unicode.
|
|
63
77
|
|
|
64
78
|
## API
|
|
65
79
|
|
|
@@ -76,11 +90,13 @@ Constructs a store over a pg `Pool`. Performs no I/O. Options:
|
|
|
76
90
|
Idempotently ensures the schema (creating the table if absent, verifying it if
|
|
77
91
|
present). Called automatically on first use of any method; call it explicitly to
|
|
78
92
|
surface schema/connection errors at startup. Safe to call repeatedly and
|
|
79
|
-
concurrently (across processes too).
|
|
93
|
+
concurrently (across processes too). A failed `init()` is not cached; the next
|
|
94
|
+
call retries.
|
|
80
95
|
|
|
81
96
|
### `get(name): Promise<value>`
|
|
82
97
|
|
|
83
|
-
Resolves to the current value, or `undefined` if `name` has no value.
|
|
98
|
+
Resolves to the current value, or `undefined` if `name` has no value. A single
|
|
99
|
+
lock-free `SELECT` that reads the latest committed value.
|
|
84
100
|
|
|
85
101
|
### `set(name, value): Promise<previous>`
|
|
86
102
|
|
|
@@ -103,14 +119,9 @@ based on what the callback does:
|
|
|
103
119
|
| returns a JSON-serializable value (not `undefined`) | stores it | resolves to the previous value |
|
|
104
120
|
| returns `undefined` | removes the name | resolves to the previous value |
|
|
105
121
|
| returns a non-serializable value (function, `BigInt`, …) | nothing (rolled back) | throws `TypeError` |
|
|
106
|
-
| throws `null` or `undefined` | nothing (
|
|
122
|
+
| throws `null` or `undefined` | nothing (graceful cancel) | resolves to the previous value |
|
|
107
123
|
| throws anything else | nothing (rolled back) | re-throws that value unchanged |
|
|
108
124
|
|
|
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
125
|
```ts
|
|
115
126
|
// atomic counter (creates from absent, then increments)
|
|
116
127
|
await store.update('hits', (n) => (typeof n === 'number' ? n : 0) + 1);
|
|
@@ -125,10 +136,33 @@ await store.update('config', (cfg) => {
|
|
|
125
136
|
await store.update('stale', () => undefined);
|
|
126
137
|
```
|
|
127
138
|
|
|
139
|
+
The callback must not write to the **same name** through this or any other
|
|
140
|
+
store instance — it would wait forever on the lock the callback itself holds.
|
|
141
|
+
Writes to other names, and reads of anything, are fine.
|
|
142
|
+
|
|
128
143
|
### `removeAll(): Promise<void>`
|
|
129
144
|
|
|
130
145
|
Removes every pair from this store's namespace. Other namespaces are untouched.
|
|
131
146
|
|
|
147
|
+
## Concurrency
|
|
148
|
+
|
|
149
|
+
Every write to a given name — `set`, `remove`, and `update` — runs in its own
|
|
150
|
+
transaction on a dedicated pooled connection and holds a per-`(namespace, name)`
|
|
151
|
+
transaction-scoped advisory lock (`pg_advisory_xact_lock`) for its duration.
|
|
152
|
+
Writes to the same name are therefore strictly serialized, across every process
|
|
153
|
+
that shares the database, and each one observes exactly the committed result of
|
|
154
|
+
the one before it. That is what makes the *previous value* contract exact and
|
|
155
|
+
what lets `update` callbacks compose (including the create-from-absent case,
|
|
156
|
+
which a row lock alone cannot cover).
|
|
157
|
+
|
|
158
|
+
Writes to different names never block each other. `get` is a single lock-free
|
|
159
|
+
statement. `removeAll` is a single bulk `DELETE` that does not take per-name
|
|
160
|
+
locks; it waits for in-flight writes on individual rows like any other statement
|
|
161
|
+
would.
|
|
162
|
+
|
|
163
|
+
Advisory-lock keys are derived in Node (SHA-256 of the namespace and name) — no
|
|
164
|
+
server-side extension is required.
|
|
165
|
+
|
|
132
166
|
## Multiple stores
|
|
133
167
|
|
|
134
168
|
Each namespace is an independent store with its own table:
|
|
@@ -139,15 +173,20 @@ const settings = new PgNameValueStore(pool, { namespace: 'settings' });
|
|
|
139
173
|
// sessions.* and settings.* never collide; removeAll() on one leaves the other intact
|
|
140
174
|
```
|
|
141
175
|
|
|
176
|
+
Several instances over the same namespace (in one process or many) share the
|
|
177
|
+
same data and the same per-name serialization.
|
|
178
|
+
|
|
142
179
|
## Errors
|
|
143
180
|
|
|
144
181
|
| Error | When |
|
|
145
182
|
|-----------------------|------|
|
|
146
|
-
| `TypeError` | Invalid namespace; invalid name (not a non-empty string,
|
|
183
|
+
| `TypeError` | Invalid namespace; invalid name (not a non-empty string, more than 1024 bytes of UTF-8, or containing `U+0000`); a `set` value or `update` return that is not JSON-serializable; an `update` callback that is not a function. |
|
|
147
184
|
| `SchemaMismatchError` | A table with this namespace's name already exists with a different shape. It is left untouched. Exported by the package. |
|
|
148
185
|
| re-thrown value | `update` re-throws any non-`null`/`undefined` value its callback throws. |
|
|
149
186
|
|
|
150
187
|
Operational/connection failures propagate from the underlying `pg` calls.
|
|
188
|
+
Argument validation errors are thrown synchronously by the constructor and as
|
|
189
|
+
rejected promises by every other method, always before any I/O.
|
|
151
190
|
|
|
152
191
|
## Schema
|
|
153
192
|
|
|
@@ -163,7 +202,32 @@ CREATE TABLE {{ns}}name_value_store (
|
|
|
163
202
|
```
|
|
164
203
|
|
|
165
204
|
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`.
|
|
205
|
+
a pre-existing table against this shape — it never runs `ALTER` or `DROP`. A
|
|
206
|
+
matching table created by an operator (with any existing rows) is adopted as-is.
|
|
207
|
+
`updated_at` is maintained on every write for operational inspection; it has no
|
|
208
|
+
public accessor.
|
|
209
|
+
|
|
210
|
+
## Development
|
|
211
|
+
|
|
212
|
+
```sh
|
|
213
|
+
npm install
|
|
214
|
+
npm test # embeds sql/, typechecks src + tests, runs vitest
|
|
215
|
+
npm run build # emits dist/
|
|
216
|
+
npm pack --dry-run # audit the tarball contents before publishing
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
The tests run against a real PostgreSQL. By default a throwaway cluster is
|
|
220
|
+
bootstrapped with `initdb`/`pg_ctl` in a temp directory (PostgreSQL server
|
|
221
|
+
binaries must be on `PATH`) and torn down afterwards. To use an existing server
|
|
222
|
+
instead — for example the bundled compose file — point
|
|
223
|
+
`TR_PG_NAME_VALUE_STORE_TEST_URL` at an admin connection; a scratch database
|
|
224
|
+
named `tr_pg_name_value_store_test` is (re)created on it:
|
|
225
|
+
|
|
226
|
+
```sh
|
|
227
|
+
docker compose -f test/docker-compose.yml up -d
|
|
228
|
+
TR_PG_NAME_VALUE_STORE_TEST_URL=postgres://postgres:postgres@localhost:5434/postgres npm test
|
|
229
|
+
docker compose -f test/docker-compose.yml down
|
|
230
|
+
```
|
|
167
231
|
|
|
168
232
|
## License
|
|
169
233
|
|
|
@@ -32,6 +32,15 @@ export type UpdateCallback = (current: unknown) => unknown | Promise<unknown>;
|
|
|
32
32
|
* The constructor takes a pg `Pool`; the schema (one table per namespace) is
|
|
33
33
|
* created automatically and idempotently on first use, or via an explicit
|
|
34
34
|
* {@link init} call. An existing table is never altered — only verified.
|
|
35
|
+
*
|
|
36
|
+
* **Concurrency.** Every write to a given name — {@link set}, {@link remove},
|
|
37
|
+
* and {@link update} — runs in its own transaction on a dedicated pooled
|
|
38
|
+
* connection, serialized with all other writes to the same name (in any
|
|
39
|
+
* process sharing the database) by a per-(namespace, name) advisory lock. The
|
|
40
|
+
* *previous value* each of them resolves to is therefore exact: it is the value
|
|
41
|
+
* left by the immediately preceding write. Writes to different names never
|
|
42
|
+
* block each other. {@link get} is a single lock-free statement that reads the
|
|
43
|
+
* latest committed value.
|
|
35
44
|
*/
|
|
36
45
|
export declare class PgNameValueStore {
|
|
37
46
|
#private;
|
|
@@ -54,7 +63,7 @@ export declare class PgNameValueStore {
|
|
|
54
63
|
/**
|
|
55
64
|
* Resolves to the current value of `name`, or `undefined` if `name` has no
|
|
56
65
|
* value. A name explicitly set to JSON `null` resolves to `null` (distinct
|
|
57
|
-
* from `undefined`).
|
|
66
|
+
* from `undefined`). A single lock-free `SELECT`.
|
|
58
67
|
*/
|
|
59
68
|
get(name: string): Promise<unknown>;
|
|
60
69
|
/**
|
|
@@ -62,14 +71,15 @@ export declare class PgNameValueStore {
|
|
|
62
71
|
* resolves to the **previous** value, or `undefined` if there was none.
|
|
63
72
|
*
|
|
64
73
|
* `value` must be JSON-serializable and must not be `undefined`; otherwise
|
|
65
|
-
* `set` throws `TypeError` and writes nothing.
|
|
66
|
-
*
|
|
74
|
+
* `set` throws `TypeError` and writes nothing. Serialized with every other
|
|
75
|
+
* write to the same name (see the class docs), so the previous value is
|
|
76
|
+
* exactly the value left by the preceding write.
|
|
67
77
|
*/
|
|
68
78
|
set(name: string, value: unknown): Promise<unknown>;
|
|
69
79
|
/**
|
|
70
80
|
* Removes `name` from the store and resolves to its previous value, or
|
|
71
81
|
* `undefined` if `name` had no value. Removing an absent name is a no-op that
|
|
72
|
-
* resolves to `undefined`.
|
|
82
|
+
* resolves to `undefined`. Serialized with every other write to the same name.
|
|
73
83
|
*/
|
|
74
84
|
remove(name: string): Promise<unknown>;
|
|
75
85
|
/**
|
|
@@ -78,19 +88,23 @@ export declare class PgNameValueStore {
|
|
|
78
88
|
*
|
|
79
89
|
* - `cb` returns a JSON-serializable value (not `undefined`) → store it;
|
|
80
90
|
* - `cb` returns `undefined` → remove `name`;
|
|
81
|
-
* - `cb` returns a non-serializable value →
|
|
82
|
-
* - `cb` throws `null`/`undefined` →
|
|
83
|
-
* - `cb` throws anything else →
|
|
91
|
+
* - `cb` returns a non-serializable value → no change, throw `TypeError`;
|
|
92
|
+
* - `cb` throws `null`/`undefined` → no change (graceful cancel), no error;
|
|
93
|
+
* - `cb` throws anything else → no change, re-throw that value unchanged.
|
|
84
94
|
*
|
|
85
95
|
* On every non-throwing outcome (store, remove, or graceful cancel) `update`
|
|
86
96
|
* resolves to the value that was in the database **before** the call (or
|
|
87
97
|
* `undefined` if there was none).
|
|
88
98
|
*
|
|
89
99
|
* The whole sequence runs in one transaction on a dedicated pooled
|
|
90
|
-
* connection
|
|
91
|
-
* per-(namespace, name)
|
|
92
|
-
*
|
|
93
|
-
*
|
|
100
|
+
* connection, serialized with every other write to the same name by the
|
|
101
|
+
* per-(namespace, name) advisory lock, so each callback sees the committed
|
|
102
|
+
* result of the previous write — including the create-from-absent case. An
|
|
103
|
+
* existing row is additionally taken `FOR UPDATE`.
|
|
104
|
+
*
|
|
105
|
+
* The callback must not write to the **same name** through this or any other
|
|
106
|
+
* store instance (it would wait on the lock the callback itself holds); writes
|
|
107
|
+
* to other names are fine.
|
|
94
108
|
*/
|
|
95
109
|
update(name: string, cb: UpdateCallback): Promise<unknown>;
|
|
96
110
|
/**
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tr-pg-name-value-store",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"name-value",
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"embed-sql": "node scripts/embed-sql.mjs",
|
|
30
30
|
"prebuild": "npm run embed-sql",
|
|
31
31
|
"build": "tsc -p tsconfig.json",
|
|
32
|
-
"
|
|
32
|
+
"typecheck": "tsc -p tsconfig.test.json",
|
|
33
|
+
"pretest": "npm run embed-sql && npm run typecheck",
|
|
33
34
|
"test": "vitest run",
|
|
34
35
|
"prepack": "npm run build"
|
|
35
36
|
},
|
|
@@ -37,11 +38,11 @@
|
|
|
37
38
|
"pg": ">=8"
|
|
38
39
|
},
|
|
39
40
|
"devDependencies": {
|
|
40
|
-
"@types/node": "^20.
|
|
41
|
-
"@types/pg": "^8.
|
|
42
|
-
"pg": "^8.
|
|
43
|
-
"typescript": "^5.
|
|
44
|
-
"vitest": "^4.1.
|
|
41
|
+
"@types/node": "^20.19.0",
|
|
42
|
+
"@types/pg": "^8.23.0",
|
|
43
|
+
"pg": "^8.23.0",
|
|
44
|
+
"typescript": "^5.9.0",
|
|
45
|
+
"vitest": "^4.1.0"
|
|
45
46
|
},
|
|
46
47
|
"repository": {
|
|
47
48
|
"type": "git",
|