tr-pg-name-value-store 1.0.0 → 2.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,6 +4,23 @@ 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
+ ## [2.0.0] - 2026-09-07
8
+
9
+ No breaking changes; every existing method, guarantee and the frozen schema are
10
+ untouched.
11
+
12
+ ### Added
13
+
14
+ - `list(options?)`: pages through the store's names — with their values, or
15
+ without (`values: false`) for a cheap walk over large documents — in byte
16
+ order of the name (`COLLATE "C"`, identical on every database), with keyset
17
+ paging (`after`, `have_more`, `limit` up to 1000) and a literal `prefix`
18
+ filter in which `%`, `_` and `\` match themselves. Each page is one
19
+ lock-free statement over its own snapshot; no index is added.
20
+ - `count(options?)`: the number of names, optionally under the same literal
21
+ prefix.
22
+ - Exported types `ListOptions`, `ListEntry`, `ListResult` and `CountOptions`.
23
+
7
24
  ## [1.0.0] - 2026-09-07
8
25
 
9
26
  Initial release.
@@ -35,4 +52,5 @@ Initial release.
35
52
  compose file for PostgreSQL 16 is included). Verified on PostgreSQL 9.6, 16
36
53
  and 18.
37
54
 
55
+ [2.0.0]: https://github.com/rinne/node-tr-pg-name-value-store/releases/tag/v2.0.0
38
56
  [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,9 @@ 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
+ - **Listing.** `list()` pages through the names (with values, or without for a
14
+ cheap walk) in byte order with keyset paging, and `count()` counts them, both
15
+ optionally under a literal prefix — so nobody needs to query the table.
13
16
  - **Exact previous values.** Every write (`set`, `remove`, `update`) resolves to
14
17
  the value it replaced. Writes to the same name are serialized across all
15
18
  processes sharing the database, so that previous value is always the one left
@@ -73,7 +76,8 @@ A stored value is any JSON-serializable JavaScript value: `number`, `string`,
73
76
  A name is any non-empty string of at most **1024 bytes of UTF-8** (so up to 1024
74
77
  ASCII characters, fewer for non-ASCII) that does not contain `U+0000`. Anything
75
78
  else throws `TypeError` before any I/O. Within those bounds names are opaque:
76
- case-sensitive, whitespace-significant, any punctuation or Unicode.
79
+ case-sensitive, whitespace-significant, any punctuation or Unicode. Listing
80
+ orders them by byte order of their UTF-8 encoding (see [Listing](#listing)).
77
81
 
78
82
  ## API
79
83
 
@@ -144,6 +148,61 @@ Writes to other names, and reads of anything, are fine.
144
148
 
145
149
  Removes every pair from this store's namespace. Other namespaces are untouched.
146
150
 
151
+ ### Listing
152
+
153
+ #### `list(options?): Promise<ListResult>`
154
+
155
+ Lists one page of names, in ascending **byte order** of the name, with their
156
+ values unless `values: false`.
157
+
158
+ | Option | Type | Default | Meaning |
159
+ |----------|-----------|---------|---------|
160
+ | `prefix` | `string` | none | Only names starting with this exact string. Literal, not a pattern: `%`, `_` and `\` match themselves. Validated like a name (≤ 1024 bytes, no `U+0000`); empty means no filter. |
161
+ | `after` | `string` | none | Keyset cursor: only names strictly after this one in byte order — normally the last name of the previous page. Need not be an existing name. Validated like `prefix`; empty means start from the beginning. |
162
+ | `limit` | `number` | 1000 | Page size, a positive integer. The maximum is 1000; larger values clamp silently (keep paging while `have_more` is true). A non-integer or a value below 1 throws `TypeError`. |
163
+ | `values` | `boolean` | `true` | With `false`, `value` is omitted from every entry and the value column is not read, so a store of large documents can be walked cheaply. |
164
+
165
+ The result:
166
+
167
+ | Field | Type | Meaning |
168
+ |-------------|---------------|---------|
169
+ | `entries` | `ListEntry[]` | The page, each `{ name, value?, updated_at }`. `value` is present (and may be `null`) whenever `values` is on; a name with no value has no entry at all. `updated_at` is a `Date` (millisecond precision) of the last write. |
170
+ | `have_more` | `boolean` | `true` if more names followed this page in the same snapshot; continue with `after` set to the last entry's name. |
171
+
172
+ Paging loop:
173
+
174
+ ```ts
175
+ const names: string[] = [];
176
+ let after: string | undefined;
177
+ for (;;) {
178
+ const page = await store.list({ prefix: 'job:', after, values: false });
179
+ names.push(...page.entries.map((e) => e.name));
180
+ if (!page.have_more) break;
181
+ after = page.entries.at(-1)!.name; // a page with have_more is never empty
182
+ }
183
+ ```
184
+
185
+ Each page is one lock-free statement over its own snapshot. Names written
186
+ between two pages may or may not appear, and a name removed in between is
187
+ simply absent (so `have_more: true` followed by an empty page is possible).
188
+ There are no offsets: a name that exists for the whole walk is returned exactly
189
+ once.
190
+
191
+ Ordering is byte order of the UTF-8 name (`COLLATE "C"`), identical on every
192
+ database regardless of its locale — so `'B' < '_x' < 'a'`. It differs from
193
+ JavaScript's default string comparison only for characters outside the Basic
194
+ Multilingual Plane, which matters if you merge pages with locally sorted data.
195
+ On databases whose default collation is `C` the primary-key index serves
196
+ `after` and `prefix` directly; on other databases each page is a scan and sort
197
+ of the store's names, which is fine for the sizes a per-service store reaches.
198
+ No index is added — the schema stays frozen.
199
+
200
+ #### `count(options?): Promise<number>`
201
+
202
+ Resolves to the number of names in the store, or of those starting with
203
+ `options.prefix` (same semantics as above). A single lock-free statement that
204
+ scans the matching names.
205
+
147
206
  ## Concurrency
148
207
 
149
208
  Every write to a given name — `set`, `remove`, and `update` — runs in its own
@@ -155,10 +214,11 @@ the one before it. That is what makes the *previous value* contract exact and
155
214
  what lets `update` callbacks compose (including the create-from-absent case,
156
215
  which a row lock alone cannot cover).
157
216
 
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.
217
+ Writes to different names never block each other. Reads — `get`, `list` and
218
+ `count` are single lock-free statements that see the latest committed state
219
+ and neither block writes nor are blocked by them. `removeAll` is a single bulk
220
+ `DELETE` that does not take per-name locks; it waits for in-flight writes on
221
+ individual rows like any other statement would.
162
222
 
163
223
  Advisory-lock keys are derived in Node (SHA-256 of the namespace and name) — no
164
224
  server-side extension is required.
@@ -180,7 +240,7 @@ same data and the same per-name serialization.
180
240
 
181
241
  | Error | When |
182
242
  |-----------------------|------|
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. |
243
+ | `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; invalid `list`/`count` options (a `prefix` or `after` that is not a string within the name bounds, a `limit` that is not a positive integer, a non-boolean `values`). |
184
244
  | `SchemaMismatchError` | A table with this namespace's name already exists with a different shape. It is left untouched. Exported by the package. |
185
245
  | re-thrown value | `update` re-throws any non-`null`/`undefined` value its callback throws. |
186
246
 
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { PgNameValueStore, type PgNameValueStoreOptions, type UpdateCallback, } from './pg-name-value-store';
1
+ export { PgNameValueStore, type PgNameValueStoreOptions, type UpdateCallback, type ListOptions, type ListEntry, type ListResult, type CountOptions, } from './pg-name-value-store';
2
2
  export { SchemaMismatchError } from './schema';
@@ -21,6 +21,61 @@ export interface PgNameValueStoreOptions {
21
21
  * - return a non-serializable value → cancel (no change), a `TypeError` is thrown.
22
22
  */
23
23
  export type UpdateCallback = (current: unknown) => unknown | Promise<unknown>;
24
+ /** Options for {@link PgNameValueStore.list}. */
25
+ export interface ListOptions {
26
+ /**
27
+ * Only names that start with this exact string. Literal, not a pattern: `%`,
28
+ * `_` and `\` in the prefix match themselves. Validated like a name (at most
29
+ * 1024 bytes of UTF-8, no `U+0000`). Empty or omitted: no filter.
30
+ */
31
+ prefix?: string;
32
+ /**
33
+ * Keyset cursor: only names strictly after this one in byte order — normally
34
+ * the `name` of the last entry of the previous page. Need not be an existing
35
+ * name. Validated like `prefix`. Empty or omitted: start from the first name.
36
+ */
37
+ after?: string;
38
+ /**
39
+ * Page size. A positive integer; default and maximum 1000 (larger values are
40
+ * clamped silently — keep paging while `have_more` is true). A non-integer or
41
+ * a value below 1 throws `TypeError` before any I/O.
42
+ */
43
+ limit?: number;
44
+ /**
45
+ * Whether to include values (default `true`). With `false` the `value` field
46
+ * is omitted from every entry and the value column is not read at all, so a
47
+ * store of large documents can be walked cheaply.
48
+ */
49
+ values?: boolean;
50
+ }
51
+ /** One entry of a {@link ListResult}. */
52
+ export interface ListEntry {
53
+ /** The name. */
54
+ name: string;
55
+ /**
56
+ * The stored value — present (possibly `null`) whenever the page was listed
57
+ * with `values` on; omitted entirely with `values: false`. A name with no
58
+ * value has no entry at all, so this is never `undefined` when present.
59
+ */
60
+ value?: unknown;
61
+ /** When the value was last written (millisecond precision). */
62
+ updated_at: Date;
63
+ }
64
+ /** Result of {@link PgNameValueStore.list}. */
65
+ export interface ListResult {
66
+ /** The page, in ascending byte order of `name`. */
67
+ entries: ListEntry[];
68
+ /**
69
+ * `true` if more names followed this page in the same snapshot; continue with
70
+ * `after: entries.at(-1).name`.
71
+ */
72
+ have_more: boolean;
73
+ }
74
+ /** Options for {@link PgNameValueStore.count}. */
75
+ export interface CountOptions {
76
+ /** Same semantics as {@link ListOptions.prefix}. */
77
+ prefix?: string;
78
+ }
24
79
  /**
25
80
  * A persistent name → value store backed by a single PostgreSQL table.
26
81
  *
@@ -39,8 +94,9 @@ export type UpdateCallback = (current: unknown) => unknown | Promise<unknown>;
39
94
  * process sharing the database) by a per-(namespace, name) advisory lock. The
40
95
  * *previous value* each of them resolves to is therefore exact: it is the value
41
96
  * 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.
97
+ * block each other. Reads — {@link get}, {@link list}, {@link count} are
98
+ * single lock-free statements that see the latest committed state and neither
99
+ * block writes nor are blocked by them.
44
100
  */
45
101
  export declare class PgNameValueStore {
46
102
  #private;
@@ -113,4 +169,25 @@ export declare class PgNameValueStore {
113
169
  * are untouched.
114
170
  */
115
171
  removeAll(): Promise<void>;
172
+ /**
173
+ * Lists one page of names (with their values unless `values: false`) in
174
+ * ascending **byte order** of `name` (`COLLATE "C"`, so the order is the same
175
+ * on every database regardless of its locale). See {@link ListOptions}.
176
+ *
177
+ * Paging is keyset paging: pass the last `name` of a page as `after` to get
178
+ * the next one, while `have_more` is `true`. Each page is one lock-free
179
+ * statement over its own snapshot, so names written between two pages may or
180
+ * may not appear, and a name removed in between is simply absent.
181
+ *
182
+ * The primary-key index serves this query directly on databases whose
183
+ * default collation is `C`; elsewhere a page is a scan and sort of the
184
+ * store's names, which is fine for the sizes a per-service store reaches.
185
+ */
186
+ list(options?: ListOptions): Promise<ListResult>;
187
+ /**
188
+ * Resolves to the number of names in the store, or of those starting with
189
+ * `prefix` (same semantics as {@link ListOptions.prefix}). A single lock-free
190
+ * `SELECT count(*)`, which scans the matching names.
191
+ */
192
+ count(options?: CountOptions): Promise<number>;
116
193
  }
@@ -8,18 +8,51 @@ const schema_1 = require("./schema");
8
8
  // violations fail fast with a TypeError instead of a server error. 1024 bytes is
9
9
  // 1024 ASCII characters.
10
10
  const NAME_MAX_BYTES = 1024;
11
- function validateName(name) {
12
- if (typeof name !== 'string' || name.length === 0) {
13
- throw new TypeError('name must be a non-empty string');
11
+ // Default and maximum page size of `list`. Larger requests clamp silently;
12
+ // `have_more` tells the caller to keep paging.
13
+ const MAX_LIST = 1000;
14
+ /**
15
+ * Validates a name-like string: `name` itself (non-empty) or a `prefix`/`after`
16
+ * listing argument (may be empty). Rejects non-strings, over-long strings (in
17
+ * UTF-8 bytes) and strings containing NUL, which PostgreSQL `text` cannot hold.
18
+ */
19
+ function validateNameLike(value, what, allowEmpty) {
20
+ if (typeof value !== 'string' || (!allowEmpty && value.length === 0)) {
21
+ throw new TypeError(allowEmpty ? `${what} must be a string` : `${what} must be a non-empty string`);
14
22
  }
15
- const bytes = Buffer.byteLength(name, 'utf8');
23
+ const bytes = Buffer.byteLength(value, 'utf8');
16
24
  if (bytes > NAME_MAX_BYTES) {
17
- throw new TypeError(`name must be at most ${NAME_MAX_BYTES} bytes of UTF-8 (got ${bytes})`);
25
+ throw new TypeError(`${what} must be at most ${NAME_MAX_BYTES} bytes of UTF-8 (got ${bytes})`);
26
+ }
27
+ if (value.includes('\0')) {
28
+ throw new TypeError(`${what} must not contain U+0000`);
18
29
  }
19
- if (name.includes('\0')) {
20
- // PostgreSQL `text` cannot hold a NUL byte; reject it before any I/O.
21
- throw new TypeError('name must not contain U+0000');
30
+ }
31
+ function validateName(name) {
32
+ validateNameLike(name, 'name', false);
33
+ }
34
+ /** Validates `ListOptions.limit`; returns the effective (clamped) page size. */
35
+ function validateLimit(limit) {
36
+ if (limit === undefined)
37
+ return MAX_LIST;
38
+ if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1) {
39
+ throw new TypeError('limit must be a positive integer');
22
40
  }
41
+ return Math.min(limit, MAX_LIST);
42
+ }
43
+ function validateOptions(options, what) {
44
+ if (options === null || typeof options !== 'object') {
45
+ throw new TypeError(`${what} options must be an object`);
46
+ }
47
+ }
48
+ /**
49
+ * Turns a literal prefix into a `LIKE` pattern: the pattern metacharacters
50
+ * `\`, `%` and `_` are escaped with PostgreSQL's default escape character
51
+ * (backslash), then `%` is appended. Bound as a parameter, so no SQL escaping
52
+ * is involved.
53
+ */
54
+ function likePrefix(prefix) {
55
+ return prefix.replace(/[\\%_]/g, '\\$&') + '%';
23
56
  }
24
57
  /**
25
58
  * Serializes a value to the JSON text bound (with an explicit `::jsonb` cast)
@@ -58,8 +91,9 @@ function serialize(value) {
58
91
  * process sharing the database) by a per-(namespace, name) advisory lock. The
59
92
  * *previous value* each of them resolves to is therefore exact: it is the value
60
93
  * left by the immediately preceding write. Writes to different names never
61
- * block each other. {@link get} is a single lock-free statement that reads the
62
- * latest committed value.
94
+ * block each other. Reads — {@link get}, {@link list}, {@link count} are
95
+ * single lock-free statements that see the latest committed state and neither
96
+ * block writes nor are blocked by them.
63
97
  */
64
98
  class PgNameValueStore {
65
99
  #pool;
@@ -208,6 +242,68 @@ class PgNameValueStore {
208
242
  await this.init();
209
243
  await this.#pool.query(`DELETE FROM ${this.#table}`);
210
244
  }
245
+ /**
246
+ * Lists one page of names (with their values unless `values: false`) in
247
+ * ascending **byte order** of `name` (`COLLATE "C"`, so the order is the same
248
+ * on every database regardless of its locale). See {@link ListOptions}.
249
+ *
250
+ * Paging is keyset paging: pass the last `name` of a page as `after` to get
251
+ * the next one, while `have_more` is `true`. Each page is one lock-free
252
+ * statement over its own snapshot, so names written between two pages may or
253
+ * may not appear, and a name removed in between is simply absent.
254
+ *
255
+ * The primary-key index serves this query directly on databases whose
256
+ * default collation is `C`; elsewhere a page is a scan and sort of the
257
+ * store's names, which is fine for the sizes a per-service store reaches.
258
+ */
259
+ async list(options = {}) {
260
+ validateOptions(options, 'list');
261
+ const { prefix = '', after = '', values = true } = options;
262
+ validateNameLike(prefix, 'prefix', true);
263
+ validateNameLike(after, 'after', true);
264
+ const limit = validateLimit(options.limit);
265
+ if (typeof values !== 'boolean') {
266
+ throw new TypeError('values must be a boolean');
267
+ }
268
+ await this.init();
269
+ const conds = [];
270
+ const params = [];
271
+ if (prefix !== '') {
272
+ params.push(likePrefix(prefix));
273
+ conds.push(`name LIKE $${params.length}`);
274
+ }
275
+ if (after !== '') {
276
+ params.push(after);
277
+ conds.push(`name COLLATE "C" > $${params.length}`);
278
+ }
279
+ params.push(limit + 1); // one extra row tells us whether more follow
280
+ const where = conds.length > 0 ? `WHERE ${conds.join(' AND ')}` : '';
281
+ const res = await this.#pool.query(`SELECT name${values ? ', v' : ''}, updated_at FROM ${this.#table}
282
+ ${where}
283
+ ORDER BY name COLLATE "C"
284
+ LIMIT $${params.length}`, params);
285
+ const have_more = res.rows.length > limit;
286
+ const rows = have_more ? res.rows.slice(0, limit) : res.rows;
287
+ const entries = values
288
+ ? rows.map((r) => ({ name: r.name, value: r.v, updated_at: r.updated_at }))
289
+ : rows.map((r) => ({ name: r.name, updated_at: r.updated_at }));
290
+ return { entries, have_more };
291
+ }
292
+ /**
293
+ * Resolves to the number of names in the store, or of those starting with
294
+ * `prefix` (same semantics as {@link ListOptions.prefix}). A single lock-free
295
+ * `SELECT count(*)`, which scans the matching names.
296
+ */
297
+ async count(options = {}) {
298
+ validateOptions(options, 'count');
299
+ const { prefix = '' } = options;
300
+ validateNameLike(prefix, 'prefix', true);
301
+ await this.init();
302
+ const res = prefix === ''
303
+ ? await this.#pool.query(`SELECT count(*)::int AS n FROM ${this.#table}`)
304
+ : await this.#pool.query(`SELECT count(*)::int AS n FROM ${this.#table} WHERE name LIKE $1`, [likePrefix(prefix)]);
305
+ return res.rows[0].n;
306
+ }
211
307
  /**
212
308
  * Runs `fn` inside a transaction on a dedicated pooled connection while
213
309
  * holding the per-(namespace, name) transaction-scoped advisory lock, which
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tr-pg-name-value-store",
3
- "version": "1.0.0",
3
+ "version": "2.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",