@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 +148 -63
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/redis-resource/SKILL.md +1 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -1,139 +1,224 @@
|
|
|
1
1
|
# @owlmeans/redis-resource
|
|
2
2
|
|
|
3
|
-
Redis-backed
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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.
|
|
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
|
|
42
|
+
### Register and type once
|
|
22
43
|
|
|
23
|
-
```
|
|
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(
|
|
29
|
-
context.registerResource(makeRedisResource(
|
|
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
|
-
|
|
58
|
+
### Expiring, single-use records
|
|
33
59
|
|
|
34
|
-
```
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
78
|
+
`UnknownRecordError` comes from `@owlmeans/resource`; `LinkExpired` stands for an app error.
|
|
79
|
+
|
|
80
|
+
### Pub/sub between processes
|
|
45
81
|
|
|
46
|
-
```
|
|
47
|
-
const
|
|
82
|
+
```ts
|
|
83
|
+
const events = context.resource<RedisResource<ProjectEvent>>(PROJECT_EVENT_PUBSUB)
|
|
48
84
|
|
|
49
|
-
|
|
85
|
+
// producer: one channel per organization entity
|
|
86
|
+
await events.publish({ type: 'updated', projectId }, entityId)
|
|
50
87
|
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
+
### Watch one record
|
|
101
|
+
|
|
102
|
+
```ts
|
|
59
103
|
const unwatch = await cache.watch(id, record => {
|
|
60
|
-
// `null` once the key is gone
|
|
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
|
-
|
|
109
|
+
### Streams with a consumer group
|
|
66
110
|
|
|
67
|
-
```
|
|
68
|
-
const
|
|
111
|
+
```ts
|
|
112
|
+
const thinking = context.resource<RedisResource<ThinkingEvent>>(THINKING_STREAM)
|
|
69
113
|
|
|
70
|
-
await
|
|
114
|
+
await thinking.stream(`run:${runId}`, { step: 'reasoning', content: text })
|
|
71
115
|
|
|
72
|
-
for await (const event of
|
|
73
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
|
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
|
|
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?):
|
|
153
|
+
### `makeRedisResource<R, T>(alias, dbAlias?, serviceAlias?): T`
|
|
108
154
|
|
|
109
|
-
Creates a Redis resource. Both alias arguments default to
|
|
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
|
|
115
|
-
- `subscribe(handler, { channel?, once?, ttl? }): Promise<Unsubscribe
|
|
116
|
-
- `watch(id, handler, { once?, ttl? }): Promise<Unsubscribe
|
|
117
|
-
record
|
|
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
|
|
120
|
-
- `consume(key, { group?, consumer?, block? }): AsyncGenerator<T
|
|
121
|
-
- `key(key?): string
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
##
|
|
133
|
-
|
|
134
|
-
-
|
|
135
|
-
|
|
136
|
-
-
|
|
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.
|
|
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
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/redis-resource",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-09-
|
|
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
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owlmeans/redis-resource",
|
|
3
|
-
"version": "0.1.18-rc.
|
|
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.
|
|
29
|
-
"@owlmeans/context": "^0.1.18-rc.
|
|
30
|
-
"@owlmeans/resource": "^0.1.18-rc.
|
|
31
|
-
"@owlmeans/server-context": "^0.1.18-rc.
|
|
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:*",
|