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 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
- ## [0.0.0] - Unreleased
7
+ ## [1.0.0] - 2026-09-07
8
8
 
9
- Initial implementation, ready for testing.
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
- - 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.
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 (rolled back) | resolves to the previous value |
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, or > 1024 chars); a `set` value or `update` return that is not JSON-serializable. |
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. Performed as a single
66
- * statement, so it is atomic.
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`. A single `DELETE RETURNING`; atomic.
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 → 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.
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. 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`.
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": "0.0.0",
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
- "pretest": "npm run embed-sql",
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.0.0",
41
- "@types/pg": "^8.10.0",
42
- "pg": "^8.11.0",
43
- "typescript": "^5.4.0",
44
- "vitest": "^4.1.8"
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",