@owlmeans/redis-resource 0.1.18-rc.20 → 0.1.18-rc.22

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/README.md CHANGED
@@ -1,139 +1,224 @@
1
1
  # @owlmeans/redis-resource
2
2
 
3
- Redis-backed resource with pub/sub, keyspace watching and streams for real-time features.
4
-
5
- ## Overview
6
-
7
- - `makeRedisResource(alias, dbAlias?, serviceAlias?)` — creates a Redis resource
8
- - `RedisResource<T>` — `Resource<T>` composed with `PubSubResource`, `WatchableResource` and
9
- `StreamResource`
10
- - One JSON document per namespaced key: `resource.key(id)` → `<schema>-<alias>:<id>`
11
- - Used for authentication caches, pub/sub channels, and async event streams
3
+ Redis-backed `Resource<T>` for server apps, with pub/sub, keyspace watching and streams. Use it
4
+ when data has a Redis-shaped property:
5
+ - **Expiry**: sessions, OTPs, nonces, idempotency keys.
6
+ - **Cache**: a miss must stay invisible to the user.
7
+ - **Lock or counter.**
8
+ - **Fan-out between processes**: pub/sub channels and streams.
9
+
10
+ It is never the source of truth, and it does not fit an unbounded keyspace, because anything other
11
+ than a read by id walks the namespace. Records the product queries belong in
12
+ [`@owlmeans/postgres-resource`](../postgres-resource) or
13
+ [`@owlmeans/mongo-resource`](../mongo-resource). Job queues are
14
+ [`@owlmeans/queue`](../queue) with [`@owlmeans/redis-queue`](../redis-queue).
12
15
 
13
16
  ## Installation
14
17
 
15
18
  ```bash
16
- bun add @owlmeans/redis-resource@^0.1.18-rc.11
19
+ bun add @owlmeans/redis-resource@^0.1.18-rc.21
17
20
  ```
18
21
 
22
+ `ioredis` and `ajv` are peer dependencies. The connection service comes from
23
+ [`@owlmeans/redis`](../redis) (`appendRedis`).
24
+
25
+ ## Concepts
26
+
27
+ - **Namespace**: each record is one JSON document under `<prefix>-<name>:<id>`. The prefix comes
28
+ from the `cfg.dbs` entry's `schema`; the name is `resource.name` or the registration alias.
29
+ `resource.key(id)` builds a key, and `key()` gives the namespace glob.
30
+ - **By-id vs walk**: `load(id)`, `get(id)`, `delete(id)` and `take(id)` are single-key commands.
31
+ `load(where)`, `list`, `count` and `purge` `SCAN` the namespace and evaluate criteria in memory.
32
+ - **TTL**: a per-call write option. A number is seconds from now and a `Date` is an absolute
33
+ instant. It is not a property of the resource, and every rewrite must pass it again.
34
+ - **Channel** (`publish`/`subscribe`): namespaced pub/sub between processes. **Watch** follows
35
+ one key through keyspace notifications.
36
+ - **Stream** (`stream`/`consume`): an append-only Redis Stream, trimmed to `STREAM_MAX_LENGTH`,
37
+ read by tailing or through a consumer group.
38
+ - **Unpaged**: `list(where)` returns every match. A `page` requires a `size`.
39
+
19
40
  ## Usage
20
41
 
21
- Register Redis resources in context setup:
42
+ ### Register and type once
22
43
 
23
- ```typescript
44
+ ```ts
24
45
  import { makeRedisResource } from '@owlmeans/redis-resource'
46
+ import type { RedisResource } from '@owlmeans/redis-resource'
25
47
  import { AUTH_CACHE } from '@owlmeans/server-auth'
26
48
 
49
+ // in the server context factory, after appendRedis(context)
27
50
  context.registerResource(makeRedisResource(AUTH_CACHE))
28
- context.registerResource(makeRedisResource(AGENT_THINKING_STREAM))
29
- context.registerResource(makeRedisResource(AGENT_WATCHER_PUBSUB))
51
+ context.registerResource(makeRedisResource<ResetToken>(RES_RESET_TOKEN))
52
+ context.registerResource(makeRedisResource<ProjectEvent>(PROJECT_EVENT_PUBSUB))
53
+
54
+ // type the RESOURCE where it is taken out of the context, never per method call
55
+ const tokens = context.resource<RedisResource<ResetToken>>(RES_RESET_TOKEN)
30
56
  ```
31
57
 
32
- Type the resource once, at the point it is taken out of the context:
58
+ ### Expiring, single-use records
33
59
 
34
- ```typescript
35
- import type { RedisResource } from '@owlmeans/redis-resource'
60
+ ```ts
61
+ const created = await tokens.create({ userId, email }, { ttl: 15 * 60 }) // id generated, SET NX
62
+ const byEmail = await tokens.load({ email }) // walks the namespace
36
63
 
37
- const cache = context.resource<RedisResource<SessionData>>(AUTH_CACHE)
64
+ try {
65
+ const token = await tokens.take(tokenId) // GETDEL: one caller only
66
+ await resetPassword(token.userId, password)
67
+ } catch (error) {
68
+ if (error instanceof UnknownRecordError) {
69
+ throw new LinkExpired() // already used or expired
70
+ }
71
+ throw error
72
+ }
38
73
 
39
- await cache.create({ id, token }, { ttl: 3600 }) // seconds, or a Date
40
- const session = await cache.load({ token }) // criteria, not just an id
41
- await cache.take(id) // delete-and-return
74
+ // a renewal rewrites the key, so it must carry the TTL again
75
+ await sessions.update({ ...session, seenAt: Date.now() }, { ttl: 3600 })
42
76
  ```
43
77
 
44
- Publish and subscribe:
78
+ `UnknownRecordError` comes from `@owlmeans/resource`; `LinkExpired` stands for an app error.
79
+
80
+ ### Pub/sub between processes
45
81
 
46
- ```typescript
47
- const pubsub = context.resource<RedisResource<AgentWatcherEvent>>(AGENT_WATCHER_PUBSUB)
82
+ ```ts
83
+ const events = context.resource<RedisResource<ProjectEvent>>(PROJECT_EVENT_PUBSUB)
48
84
 
49
- await pubsub.publish({ type: 'update', payload: data }, channel)
85
+ // producer: one channel per organization entity
86
+ await events.publish({ type: 'updated', projectId }, entityId)
50
87
 
51
- const unsubscribe = await pubsub.subscribe(
52
- event => handleEvent(event), { channel, once: true }
88
+ // consumer (a socket handler, another service)
89
+ const unsubscribe = await events.subscribe(
90
+ event => socket.send(JSON.stringify(event)),
91
+ { channel: entityId }
53
92
  )
93
+ socket.on('close', () => { void unsubscribe() })
54
94
  ```
55
95
 
56
- Watch one record for changes (keyspace notifications):
96
+ A shared bus is just a fixed channel name (`publish(event, 'bus')`,
97
+ `subscribe(handler, { channel: 'bus' })`). Omitting the channel on `subscribe` listens to the
98
+ resource's whole namespace.
57
99
 
58
- ```typescript
100
+ ### Watch one record
101
+
102
+ ```ts
59
103
  const unwatch = await cache.watch(id, record => {
60
- // `null` once the key is gone — deleted or expired
104
+ // the current value on every write; `null` once the key is gone (deleted or expired)
61
105
  render(record)
62
106
  }, { once: true })
63
107
  ```
64
108
 
65
- Stream events (Redis Streams):
109
+ ### Streams with a consumer group
66
110
 
67
- ```typescript
68
- const stream = context.resource<RedisResource<AgentThinkingEvent>>(AGENT_THINKING_STREAM)
111
+ ```ts
112
+ const thinking = context.resource<RedisResource<ThinkingEvent>>(THINKING_STREAM)
69
113
 
70
- await stream.stream('thinking-key', { step: 'reasoning', content: text })
114
+ await thinking.stream(`run:${runId}`, { step: 'reasoning', content: text })
71
115
 
72
- for await (const event of stream.consume('thinking-key', { group: 'workers', consumer: 'worker-1' })) {
73
- process(event)
116
+ for await (const event of thinking.consume(`run:${runId}`, { group: 'renderers', consumer: podName })) {
117
+ await deliver(event)
74
118
  }
75
119
  ```
76
120
 
121
+ Without `group`, `consume` tails from now with `XREAD`. With one, it joins the group and reclaims
122
+ stale entries under the consumer name you pass.
123
+
77
124
  ## Reads
78
125
 
79
126
  `load(id)` / `get(id)` / `delete(id)` / `take(id)` are O(1) commands on one key. `load({ ... })`,
80
127
  `get({ ... })`, `list`, `count` and `purge` walk this resource's **own namespace** with `SCAN`
81
128
  (never `KEYS`, which blocks the server for the whole sweep), read the values with `MGET`, and
82
- evaluate the criteria in memory. That is O(N) over the namespace: right for a small namespaced set
83
- — a session cache, an adapter store — and wrong for anything unbounded or on a hot path. A
84
- resource that needs real queries belongs in `@owlmeans/mongo-resource` or
85
- `@owlmeans/postgres-resource`.
129
+ evaluate the criteria in memory with the engine from `@owlmeans/resource`. That is O(N) over the
130
+ namespace: right for a small namespaced set, such as a session cache or an adapter store, and wrong
131
+ for anything unbounded or on a hot path. `load({ id })` is recognised and answered by key.
86
132
 
87
133
  Redis is **unpaged**: `list(where)` with no `size` returns every match, and `list(where, { page })`
88
134
  without a `size` throws `UnsupportedArgumentError('page-without-size')`. `ListResult.total` always
89
135
  counts every match.
90
136
 
91
- The namespace walk reaches one server, so `list` / `count` / `purge` are not cluster-safe — in a
92
- clustered deployment use the by-id operations only.
137
+ The namespace walk reaches one server, so `list` / `count` / `purge` are not cluster-safe. In a
138
+ clustered deployment, use the by-id operations only.
93
139
 
94
140
  ## TTL
95
141
 
96
142
  | `opts.ttl` | Meaning | Command |
97
143
  |---|---|---|
98
144
  | `number` | seconds from now | `EXPIRE` |
99
- | `Date` | the absolute instant to expire at | `PEXPIREAT` (milliseconds — what `Date.getTime()` gives) |
100
- | omitted | persistent (`PTTL` reports `-1`) | — |
145
+ | `Date` | the absolute instant to expire at | `PEXPIREAT` (milliseconds, which is what `Date.getTime()` gives) |
146
+ | omitted | persistent (`PTTL` reports `-1`) | none |
101
147
 
102
148
  A `Date` already in the past drops the record immediately. `update` and `save` rewrite the key, so
103
- a renewal has to pass its TTL again — otherwise the record becomes persistent.
149
+ a renewal has to pass its TTL again. Otherwise the record becomes persistent.
104
150
 
105
151
  ## API
106
152
 
107
- ### `makeRedisResource<R>(alias, dbAlias?, serviceAlias?): RedisResource<R>`
153
+ ### `makeRedisResource<R, T>(alias, dbAlias?, serviceAlias?): T`
108
154
 
109
- Creates a Redis resource. Both alias arguments default to `DEFAULT_DB_ALIAS` (`'redis'`).
155
+ Creates a Redis resource (`T` defaults to `RedisResource<R>`). Both alias arguments default to
156
+ `DEFAULT_DB_ALIAS` (`'redis'`).
110
157
 
111
158
  ### `RedisResource<T>`
112
159
 
113
160
  `Resource<T>` plus:
114
- - `publish(value, channel?): Promise<void>` — publish to a namespaced pub/sub channel
115
- - `subscribe(handler, { channel?, once?, ttl? }): Promise<Unsubscribe>` — channel pub/sub
116
- - `watch(id, handler, { once?, ttl? }): Promise<Unsubscribe>` — keyspace notifications for ONE
117
- record; the handler is given `null` once the key is gone. Bound to **db 0**, so deployments
161
+ - `publish(value, channel?): Promise<void>`, which publishes to a namespaced pub/sub channel
162
+ - `subscribe(handler, { channel?, once?, ttl? }): Promise<Unsubscribe>`, channel pub/sub
163
+ - `watch(id, handler, { once?, ttl? }): Promise<Unsubscribe>`, keyspace notifications for ONE
164
+ record. The handler is given `null` once the key is gone. It is bound to **db 0**, so deployments
118
165
  sharing an instance isolate on the key prefix, and the server needs `notify-keyspace-events`
119
- - `stream(key, value): Promise<void>` — append to a Redis Stream
120
- - `consume(key, { group?, consumer?, block? }): AsyncGenerator<T>` — consume from a Redis Stream
121
- - `key(key?): string` — the namespaced key, or the namespace's glob
166
+ - `stream(key, value): Promise<void>`, which appends to a Redis Stream
167
+ - `consume(key, { group?, consumer?, block? }): AsyncGenerator<T>`, which consumes from a Redis Stream
168
+ - `key(key?): string`, the namespaced key, or the namespace's glob
169
+ - `db: RedisDb`, `name?: string`
122
170
 
123
- ### Constants
171
+ `create` generates an id when none is given and refuses an existing one (`RecordExists`, via
172
+ `SET NX`). `update` replaces the whole record and throws `UnknownRecordError` for an unknown id.
173
+ `save` is the upsert. `delete` returns the removed record or `null`; `take` throws when the key is
174
+ gone. `purge` refuses an empty criteria object.
124
175
 
125
- - `DEFAULT_DB_ALIAS` — `'redis'`
126
- - `DEFAULT_PAGE_SIZE` — `0`, meaning unpaged
176
+ ### Exports
177
+
178
+ | Symbol | Kind | Purpose |
179
+ |---|---|---|
180
+ | `makeRedisResource` | function | The resource factory |
181
+ | `RedisResource<T>` | type | `Resource<T>` + `PubSubResource`, `WatchableResource`, `StreamResource`, `db`, `name`, `key` |
182
+ | `RedisDbService` | type | Connection service contract implemented by `@owlmeans/redis`, including `options(alias?)` |
183
+ | `RedisConnection` | type | Single-node or cluster connection settings plus the key prefix |
184
+ | `RedisDb`, `RedisClient` | type | `{ client, prefix }` and `Redis \| Cluster` |
185
+ | `DEFAULT_DB_ALIAS` | const | `'redis'` |
186
+ | `DEFAULT_PAGE_SIZE` | const | `0`, meaning unpaged |
187
+ | `SCAN_BATCH`, `READ_BATCH` | const | Keys per `SCAN` and per `MGET`/`DEL` round trip (200) |
188
+ | `STREAM_MAX_LENGTH` | const | Approximate stream trim length (10 000) |
189
+ | `DEFAULT_STREAM_BLOCK` | const | Stream read block time in ms (1 000) |
190
+ | `RECLAIM_IDLE`, `RECLAIM_COUNT` | const | When (60 000 ms idle) and how many (10) pending entries a consumer reclaims |
191
+ | `CONSUMER_ID_LENGTH` | const | Length of a generated consumer name (15) |
127
192
 
128
193
  ## Requirements
129
194
 
130
195
  Redis 6.2 or newer: `delete` and `take` use `GETDEL`.
131
196
 
132
- ## Related Packages
133
-
134
- - [`@owlmeans/redis`](../redis) — Redis connection service
135
- - [`@owlmeans/resource`](../resource) — `Resource<T>` base interface and the criteria engine
136
- - [`@owlmeans/server-socket`](../server-socket) — WebSocket handlers often use Redis resources for streaming
197
+ ## Common pitfalls
198
+
199
+ - **Using it as a source of truth.** If losing the record changes behaviour, it belongs in the
200
+ database.
201
+ - **Criteria reads on a large or unbounded namespace.** `load(where)`, `list`, `count` and `purge`
202
+ are O(N) walks, unpaged and not cluster-safe.
203
+ - **Renewing without a TTL.** `update`/`save` rewrite the key and the record becomes permanent.
204
+ - **Expecting `update` to merge.** It replaces the whole record.
205
+ - **Setting `resource.name` by accident.** Two resources with the same name share one id space.
206
+ - **`watch` on a connection with `meta.dbIndex` other than 0**, or on a server without
207
+ `notify-keyspace-events`. The handler silently never fires.
208
+ - **Claiming stream entries under a fixed consumer name.** Pass the real consumer name to
209
+ `consume`, or entries end up owned by a consumer that never reads.
210
+ - **Asserting an expiry by trusting the command.** Read `PTTL` back; a TTL set in the wrong unit is
211
+ still an expiry, just millennia out.
212
+ - **Simulating a queue with lists or streams.** Retried, restart-safe work is
213
+ `@owlmeans/queue`.
214
+
215
+ ## Related packages
216
+
217
+ - [`@owlmeans/redis`](../redis): the Redis connection service and the `cfg.dbs` entry the prefix comes from
218
+ - [`@owlmeans/resource`](../resource): `Resource<T>`, the criteria engine, the capability interfaces
219
+ - [`@owlmeans/redis-queue`](../redis-queue): BullMQ queues on the same connections
220
+ - [`@owlmeans/server-socket`](../server-socket): socket handlers that relay pub/sub and stream events
221
+ - [`@owlmeans/postgres-resource`](../postgres-resource): where durable records live
137
222
 
138
223
  <!-- owlmeans:agent-guidance:start -->
139
224
  ## Agent guidance
@@ -143,7 +228,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
143
228
  your project's skill store (`.agents/skills/`):
144
229
 
145
230
  ```sh
146
- npx @owlmeans/agent-skills@^0.1.18-rc.20
231
+ npx @owlmeans/agent-skills@^0.1.18-rc.22
147
232
  ```
148
233
 
149
234
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/redis-resource",
4
- "version": "0.1.18-rc.20",
5
- "generatedAt": "2026-09-12T14:21:25.465Z",
4
+ "version": "0.1.18-rc.22",
5
+ "generatedAt": "2026-09-15T12:38:15.189Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -8,7 +8,7 @@ user-invocable: false
8
8
  # @owlmeans/redis-resource
9
9
 
10
10
  **Layer:** Infra
11
- **Install:** `"@owlmeans/redis-resource": "^0.1.18-rc.20"` in `dependencies`
11
+ **Install:** `"@owlmeans/redis-resource": "^0.1.18-rc.22"` in `dependencies`
12
12
 
13
13
  ## Key Exports
14
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/redis-resource",
3
- "version": "0.1.18-rc.20",
3
+ "version": "0.1.18-rc.22",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -25,10 +25,10 @@
25
25
  "ioredis": "*"
26
26
  },
27
27
  "dependencies": {
28
- "@owlmeans/basic-ids": "^0.1.18-rc.17",
29
- "@owlmeans/context": "^0.1.18-rc.16",
30
- "@owlmeans/resource": "^0.1.18-rc.17",
31
- "@owlmeans/server-context": "^0.1.18-rc.20"
28
+ "@owlmeans/basic-ids": "^0.1.18-rc.19",
29
+ "@owlmeans/context": "^0.1.18-rc.18",
30
+ "@owlmeans/resource": "^0.1.18-rc.19",
31
+ "@owlmeans/server-context": "^0.1.18-rc.22"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@owlmeans/dep-config": "workspace:*",