@owlmeans/redis-resource 0.1.18-rc.8 → 0.1.18
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 +182 -50
- package/agent-meta/manifest.json +3 -3
- package/agent-meta/skills/redis-resource/SKILL.md +96 -31
- package/build/consts.d.ts +20 -1
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +20 -1
- package/build/consts.js.map +1 -1
- package/build/resource.d.ts +11 -2
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +263 -221
- package/build/resource.js.map +1 -1
- package/build/types.d.ts +27 -12
- package/build/types.d.ts.map +1 -1
- package/build/types.local.d.ts +4 -0
- package/build/types.local.d.ts.map +1 -0
- package/build/types.local.js +2 -0
- package/build/types.local.js.map +1 -0
- package/build/utils/options.d.ts.map +1 -1
- package/package.json +8 -7
- package/src/consts.ts +27 -2
- package/src/resource.ts +305 -241
- package/src/types.local.ts +5 -0
- package/src/types.ts +28 -18
- package/build/.gitkeep +0 -0
- package/src/utils/index.ts +0 -2
- package/src/utils/options.ts +0 -17
package/README.md
CHANGED
|
@@ -1,92 +1,224 @@
|
|
|
1
1
|
# @owlmeans/redis-resource
|
|
2
2
|
|
|
3
|
-
Redis-backed
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
|
|
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).
|
|
10
15
|
|
|
11
16
|
## Installation
|
|
12
17
|
|
|
13
18
|
```bash
|
|
14
|
-
bun add @owlmeans/redis-resource
|
|
19
|
+
bun add @owlmeans/redis-resource@^0.1.18
|
|
15
20
|
```
|
|
16
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
|
+
|
|
17
40
|
## Usage
|
|
18
41
|
|
|
19
|
-
Register
|
|
42
|
+
### Register and type once
|
|
20
43
|
|
|
21
|
-
```
|
|
44
|
+
```ts
|
|
22
45
|
import { makeRedisResource } from '@owlmeans/redis-resource'
|
|
46
|
+
import type { RedisResource } from '@owlmeans/redis-resource'
|
|
23
47
|
import { AUTH_CACHE } from '@owlmeans/server-auth'
|
|
24
48
|
|
|
49
|
+
// in the server context factory, after appendRedis(context)
|
|
25
50
|
context.registerResource(makeRedisResource(AUTH_CACHE))
|
|
26
|
-
context.registerResource(makeRedisResource(
|
|
27
|
-
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)
|
|
28
56
|
```
|
|
29
57
|
|
|
30
|
-
|
|
58
|
+
### Expiring, single-use records
|
|
31
59
|
|
|
32
|
-
```
|
|
33
|
-
|
|
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
|
|
63
|
+
|
|
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
|
+
}
|
|
34
73
|
|
|
35
|
-
|
|
36
|
-
|
|
74
|
+
// a renewal rewrites the key, so it must carry the TTL again
|
|
75
|
+
await sessions.update({ ...session, seenAt: Date.now() }, { ttl: 3600 })
|
|
37
76
|
```
|
|
38
77
|
|
|
39
|
-
|
|
78
|
+
`UnknownRecordError` comes from `@owlmeans/resource`; `LinkExpired` stands for an app error.
|
|
79
|
+
|
|
80
|
+
### Pub/sub between processes
|
|
40
81
|
|
|
41
|
-
```
|
|
42
|
-
const
|
|
82
|
+
```ts
|
|
83
|
+
const events = context.resource<RedisResource<ProjectEvent>>(PROJECT_EVENT_PUBSUB)
|
|
43
84
|
|
|
44
|
-
//
|
|
45
|
-
await
|
|
85
|
+
// producer: one channel per organization entity
|
|
86
|
+
await events.publish({ type: 'updated', projectId }, entityId)
|
|
46
87
|
|
|
47
|
-
//
|
|
48
|
-
const unsubscribe = await
|
|
49
|
-
|
|
50
|
-
}
|
|
88
|
+
// consumer (a socket handler, another service)
|
|
89
|
+
const unsubscribe = await events.subscribe(
|
|
90
|
+
event => socket.send(JSON.stringify(event)),
|
|
91
|
+
{ channel: entityId }
|
|
92
|
+
)
|
|
93
|
+
socket.on('close', () => { void unsubscribe() })
|
|
51
94
|
```
|
|
52
95
|
|
|
53
|
-
|
|
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.
|
|
54
99
|
|
|
55
|
-
|
|
56
|
-
const stream = context.resource<AgentThinkingStream>(AGENT_THINKING_STREAM)
|
|
100
|
+
### Watch one record
|
|
57
101
|
|
|
58
|
-
|
|
59
|
-
await
|
|
102
|
+
```ts
|
|
103
|
+
const unwatch = await cache.watch(id, record => {
|
|
104
|
+
// the current value on every write; `null` once the key is gone (deleted or expired)
|
|
105
|
+
render(record)
|
|
106
|
+
}, { once: true })
|
|
107
|
+
```
|
|
60
108
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
109
|
+
### Streams with a consumer group
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
const thinking = context.resource<RedisResource<ThinkingEvent>>(THINKING_STREAM)
|
|
113
|
+
|
|
114
|
+
await thinking.stream(`run:${runId}`, { step: 'reasoning', content: text })
|
|
115
|
+
|
|
116
|
+
for await (const event of thinking.consume(`run:${runId}`, { group: 'renderers', consumer: podName })) {
|
|
117
|
+
await deliver(event)
|
|
64
118
|
}
|
|
65
119
|
```
|
|
66
120
|
|
|
67
|
-
|
|
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.
|
|
68
123
|
|
|
69
|
-
|
|
124
|
+
## Reads
|
|
70
125
|
|
|
71
|
-
|
|
126
|
+
`load(id)` / `get(id)` / `delete(id)` / `take(id)` are O(1) commands on one key. `load({ ... })`,
|
|
127
|
+
`get({ ... })`, `list`, `count` and `purge` walk this resource's **own namespace** with `SCAN`
|
|
128
|
+
(never `KEYS`, which blocks the server for the whole sweep), read the values with `MGET`, and
|
|
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.
|
|
72
132
|
|
|
73
|
-
|
|
133
|
+
Redis is **unpaged**: `list(where)` with no `size` returns every match, and `list(where, { page })`
|
|
134
|
+
without a `size` throws `UnsupportedArgumentError('page-without-size')`. `ListResult.total` always
|
|
135
|
+
counts every match.
|
|
136
|
+
|
|
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.
|
|
74
139
|
|
|
75
|
-
|
|
76
|
-
- `subscribe<T>(handler, key?): Promise<() => Promise<void>>` — subscribe to pub/sub channel
|
|
77
|
-
- `publish<T>(value, key?): Promise<void>` — publish to pub/sub channel
|
|
78
|
-
- `stream<T>(key, data): Promise<void>` — append to a Redis Stream
|
|
79
|
-
- `consume<T>(key, group?, consumer?): AsyncGenerator<T>` — consume from a Redis Stream
|
|
140
|
+
## TTL
|
|
80
141
|
|
|
81
|
-
|
|
142
|
+
| `opts.ttl` | Meaning | Command |
|
|
143
|
+
|---|---|---|
|
|
144
|
+
| `number` | seconds from now | `EXPIRE` |
|
|
145
|
+
| `Date` | the absolute instant to expire at | `PEXPIREAT` (milliseconds, which is what `Date.getTime()` gives) |
|
|
146
|
+
| omitted | persistent (`PTTL` reports `-1`) | none |
|
|
82
147
|
|
|
83
|
-
|
|
148
|
+
A `Date` already in the past drops the record immediately. `update` and `save` rewrite the key, so
|
|
149
|
+
a renewal has to pass its TTL again. Otherwise the record becomes persistent.
|
|
84
150
|
|
|
85
|
-
##
|
|
151
|
+
## API
|
|
152
|
+
|
|
153
|
+
### `makeRedisResource<R, T>(alias, dbAlias?, serviceAlias?): T`
|
|
154
|
+
|
|
155
|
+
Creates a Redis resource (`T` defaults to `RedisResource<R>`). Both alias arguments default to
|
|
156
|
+
`DEFAULT_DB_ALIAS` (`'redis'`).
|
|
157
|
+
|
|
158
|
+
### `RedisResource<T>`
|
|
86
159
|
|
|
87
|
-
|
|
88
|
-
-
|
|
89
|
-
-
|
|
160
|
+
`Resource<T>` plus:
|
|
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
|
|
165
|
+
sharing an instance isolate on the key prefix, and the server needs `notify-keyspace-events`
|
|
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`
|
|
170
|
+
|
|
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.
|
|
175
|
+
|
|
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) |
|
|
192
|
+
|
|
193
|
+
## Requirements
|
|
194
|
+
|
|
195
|
+
Redis 6.2 or newer: `delete` and `take` use `GETDEL`.
|
|
196
|
+
|
|
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
|
|
90
222
|
|
|
91
223
|
<!-- owlmeans:agent-guidance:start -->
|
|
92
224
|
## Agent guidance
|
|
@@ -96,7 +228,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
|
|
|
96
228
|
your project's skill store (`.agents/skills/`):
|
|
97
229
|
|
|
98
230
|
```sh
|
|
99
|
-
npx @owlmeans/agent-skills
|
|
231
|
+
npx @owlmeans/agent-skills@^0.1.18
|
|
100
232
|
```
|
|
101
233
|
|
|
102
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
|
-
"schemaVersion":
|
|
2
|
+
"schemaVersion": 3,
|
|
3
3
|
"package": "@owlmeans/redis-resource",
|
|
4
|
-
"version": "0.1.18
|
|
5
|
-
"generatedAt": "2026-
|
|
4
|
+
"version": "0.1.18",
|
|
5
|
+
"generatedAt": "2026-10-28T00:31:34.738Z",
|
|
6
6
|
"canonicalRepo": "https://github.com/owlmeans/common",
|
|
7
7
|
"entries": [
|
|
8
8
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: redis-resource
|
|
3
|
-
description: How to use @owlmeans/redis-resource — Redis-backed Resource implementation for caching/state with TTL
|
|
3
|
+
description: How to use @owlmeans/redis-resource — Redis-backed Resource implementation for caching/state with TTL, pub/sub, keyspace watching and streams. Auto-invoked when defining a resource backed by Redis.
|
|
4
4
|
user-invocable: false
|
|
5
5
|
---
|
|
6
6
|
<!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
|
|
@@ -8,17 +8,23 @@ user-invocable: false
|
|
|
8
8
|
# @owlmeans/redis-resource
|
|
9
9
|
|
|
10
10
|
**Layer:** Infra
|
|
11
|
-
**Install:** `"@owlmeans/redis-resource": "^0.1.18
|
|
11
|
+
**Install:** `"@owlmeans/redis-resource": "^0.1.18"` in `dependencies`
|
|
12
12
|
|
|
13
13
|
## Key Exports
|
|
14
14
|
|
|
15
15
|
| Export | Description |
|
|
16
16
|
|--------|-------------|
|
|
17
|
-
| `makeRedisResource<R>(alias, dbAlias?, serviceAlias
|
|
18
|
-
| `RedisResource<R
|
|
19
|
-
| `
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
| `makeRedisResource<R>(alias, dbAlias?, serviceAlias?)` | Factory for a Redis-backed `Resource<R>`. Both alias defaults are `DEFAULT_DB_ALIAS` (`'redis'`) |
|
|
18
|
+
| `RedisResource<R>` | `Resource<R>` plus the `PubSubResource`, `WatchableResource` and `StreamResource` capabilities, `db`, `name` and `key(id?)` |
|
|
19
|
+
| `RedisDb`, `RedisClient`, `RedisDbService`, `RedisConnection` | The db and connection contracts — `@owlmeans/redis` implements them |
|
|
20
|
+
| `DEFAULT_DB_ALIAS`, `DEFAULT_PAGE_SIZE`, `SCAN_BATCH`, `READ_BATCH` | Constants for the record half |
|
|
21
|
+
| `STREAM_MAX_LENGTH`, `DEFAULT_STREAM_BLOCK`, `RECLAIM_IDLE`, `RECLAIM_COUNT`, `CONSUMER_ID_LENGTH` | Constants for the stream half |
|
|
22
|
+
|
|
23
|
+
Keys are namespaced by the db config's key prefix plus the resource's name:
|
|
24
|
+
`resource.key(id)` → `<prefix>-<name>:<id>`, and `key()` with no argument yields the namespace's
|
|
25
|
+
glob. The name is `resource.name` when set and the registration alias otherwise, so two resources
|
|
26
|
+
can share one namespace deliberately — and a `name` given by accident quietly merges two id spaces.
|
|
27
|
+
Every non-word character in either half is collapsed to `_`.
|
|
22
28
|
|
|
23
29
|
## Usage
|
|
24
30
|
|
|
@@ -27,47 +33,106 @@ import { makeRedisResource } from '@owlmeans/redis-resource'
|
|
|
27
33
|
|
|
28
34
|
context.registerResource(makeRedisResource<SessionData>('sessions'))
|
|
29
35
|
|
|
36
|
+
// Type the RESOURCE once — never a method
|
|
30
37
|
const sessions = context.resource<RedisResource<SessionData>>('sessions')
|
|
31
38
|
await sessions.create({ id, token }, { ttl: 3600 }) // seconds, or a Date
|
|
39
|
+
const live = await sessions.load({ token }) // criteria, not just an id
|
|
32
40
|
```
|
|
33
41
|
|
|
34
|
-
TTL is a **per-call option** on `create
|
|
35
|
-
comes from the `dbs[]` config entry (`schema`), not from the resource.
|
|
42
|
+
TTL is a **per-call option** on `create` / `update` / `save`, not a property of the factory. The
|
|
43
|
+
prefix comes from the `dbs[]` config entry (`schema`), not from the resource.
|
|
44
|
+
|
|
45
|
+
## Reads: one key is cheap, everything else is a walk
|
|
46
|
+
|
|
47
|
+
`load(id)`, `get(id)`, `delete(id)`, `take(id)` are single-key commands.
|
|
48
|
+
|
|
49
|
+
`load(where)`, `get(where)`, `list`, `count` and `purge` walk the resource's **own namespace** with
|
|
50
|
+
`SCAN` + `MGET` and evaluate the criteria in memory with `firstMatch` / `filterRecords` /
|
|
51
|
+
`applyQuery` from `@owlmeans/resource` — the same engine every other store without a query planner
|
|
52
|
+
uses, so a criteria object means here exactly what it means in SQL. That is O(N) over the
|
|
53
|
+
namespace, and `{ sort }` orders the records after the walk, not in redis.
|
|
54
|
+
|
|
55
|
+
- Use it for a small namespaced set — a session cache, an OTP store, an adapter store.
|
|
56
|
+
- Never for an unbounded keyspace or a hot path. A resource that needs real queries belongs in
|
|
57
|
+
mongo or postgres.
|
|
58
|
+
- `SCAN`, never `KEYS`: `KEYS` blocks the server for the whole sweep.
|
|
59
|
+
- The walk reaches one server, so `list` / `count` / `purge` are **not cluster-safe**. In a
|
|
60
|
+
clustered deployment stay on the by-id operations.
|
|
61
|
+
- `load({ id })` is recognised and answered by key — the walk only starts for a real criteria.
|
|
62
|
+
- A key in the namespace holding something other than a string — this resource's own streams
|
|
63
|
+
included — reads back as `null` from `MGET` and is skipped, so a stream never shows up as a
|
|
64
|
+
record.
|
|
65
|
+
|
|
66
|
+
## Paging
|
|
67
|
+
|
|
68
|
+
Redis is **UNPAGED**: `DEFAULT_PAGE_SIZE` is `0`, `list(where)` with no `size` returns every match,
|
|
69
|
+
and `ListResult.total` always counts every match. `list(where, { page })` without a `size` throws
|
|
70
|
+
`UnsupportedArgumentError('page-without-size')` — there is no implied default to take a window of.
|
|
36
71
|
|
|
37
72
|
## TTL
|
|
38
73
|
|
|
39
74
|
| `opts.ttl` | Meaning | Command |
|
|
40
75
|
|---|---|---|
|
|
41
|
-
| `number`
|
|
76
|
+
| `number` | seconds from now | `EXPIRE` |
|
|
42
77
|
| `Date` | the absolute instant to expire at | `PEXPIREAT` (milliseconds — what `Date.getTime()` gives) |
|
|
43
78
|
| omitted | persistent (`PTTL` reports `-1`) | — |
|
|
44
79
|
|
|
45
|
-
A `Date` already in the past drops the record immediately. `update`
|
|
46
|
-
renewal must pass
|
|
80
|
+
A `Date` already in the past drops the record immediately. `update` and `save` rewrite the key, so
|
|
81
|
+
a renewal must pass a TTL again — otherwise the record becomes persistent.
|
|
47
82
|
|
|
48
83
|
## Rules
|
|
49
84
|
|
|
50
|
-
- `delete(id)`
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
- `create`
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
- `
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
- `
|
|
61
|
-
|
|
62
|
-
-
|
|
63
|
-
the
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
-
|
|
85
|
+
- `delete(id)` returns the removed record or `null`; `take(id)` is the same read but **throws**
|
|
86
|
+
`UnknownRecordError` when the key is gone. Both use `GETDEL`, so two callers can never be handed
|
|
87
|
+
the same record. Choose by whether absence is an error.
|
|
88
|
+
- `create` generates an id when the record carries none, and refuses an id that is already there
|
|
89
|
+
(`RecordExists`) using `SET NX` — no read-then-write race. The id is written into the stored
|
|
90
|
+
record, so what comes back always carries one.
|
|
91
|
+
- `update` **replaces** the whole record and throws `UnknownRecordError` for an unknown id (and
|
|
92
|
+
`MisshapedRecord` when the record carries no id at all). It does not merge over what was stored;
|
|
93
|
+
pass every field that must survive.
|
|
94
|
+
- `save` creates when the record carries no id and replaces otherwise — that is the upsert.
|
|
95
|
+
- `purge(where)` refuses an empty criteria object. Use it instead of a page loop that deletes each
|
|
96
|
+
item.
|
|
97
|
+
- `publish(value, channel?)` / `subscribe(handler, { channel?, once?, ttl? })` are channel pub/sub;
|
|
98
|
+
the channel name is namespaced like a key, and an omitted channel listens to the resource's whole
|
|
99
|
+
namespace. `watch(id, handler, { once?, ttl? })` is the keyspace-notification form for ONE
|
|
100
|
+
record — the handler receives the current value on every write, and `null` once the key is gone.
|
|
101
|
+
They are separate methods; a channel is never passed as a bare string.
|
|
102
|
+
- `watch` subscribes to `__keyspace@0__:`, while redis publishes keyspace events per database as
|
|
103
|
+
`__keyspace@<db>__:`. So a resource on a connection whose `meta.dbIndex` is anything but 0 gets
|
|
104
|
+
no notifications at all and its `watch` handler silently never fires — deployments sharing one
|
|
105
|
+
instance isolate on the key prefix, not on the db index. The server also needs
|
|
106
|
+
`notify-keyspace-events` enabled; it emits nothing otherwise.
|
|
107
|
+
- `stream(key, value)` appends to a namespaced redis stream, trimmed approximately to
|
|
108
|
+
`STREAM_MAX_LENGTH` so trimming stays cheap. `consume(key, { group?, consumer?, block? })` is the
|
|
109
|
+
async generator that reads it — with no `group` it tails from now with `XREAD`, with one it joins
|
|
110
|
+
the consumer group and reclaims stale entries under the **passed** consumer name. Claiming them
|
|
111
|
+
for a fixed name leaves them owned by a consumer that never reads.
|
|
112
|
+
- Requires redis 6.2 or newer (`GETDEL`).
|
|
113
|
+
- Assert an expiry by reading `PTTL` back from redis, never by trusting that a command was issued.
|
|
114
|
+
An expiry set in the wrong unit is still an expiry — just one tens of millennia out — so only the
|
|
115
|
+
value redis reports back proves anything.
|
|
68
116
|
|
|
69
117
|
## Tests
|
|
70
118
|
|
|
71
119
|
Integration specs live in the `@owlmeans/redis` package's own `tests/` — the db package that backs
|
|
72
120
|
this contract; putting them here would need a devDependency on this package's own dependent.
|
|
73
|
-
Gated on `REDIS_URL` (see `/.env.example`); each suite namespaces its keys and flushes them
|
|
121
|
+
Gated on `REDIS_URL` (see `/.env.example`); each suite namespaces its keys and flushes them, and a
|
|
122
|
+
spec that walks the namespace boots under its own resource alias so it never sees another test's
|
|
123
|
+
records.
|
|
124
|
+
|
|
125
|
+
## Depends On
|
|
126
|
+
|
|
127
|
+
- `@owlmeans/resource` · `@owlmeans/context` · `@owlmeans/server-context` · `@owlmeans/basic-ids`
|
|
128
|
+
- peer `ioredis` (the client types every method is written against), `ajv`
|
|
129
|
+
|
|
130
|
+
`@owlmeans/redis` is NOT a dependency — it depends on this package, and that is the direction the
|
|
131
|
+
connection contracts flow in.
|
|
132
|
+
|
|
133
|
+
## Related
|
|
134
|
+
|
|
135
|
+
- `redis` — the connection service implementing `RedisDbService`, and the `cfg.dbs` entry a
|
|
136
|
+
resource takes its prefix and db index from
|
|
137
|
+
- `redis-queue` — BullMQ queues on the same connections
|
|
138
|
+
- `resource` — the criteria language, paging and the error classes raised here
|
package/build/consts.d.ts
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
1
|
export declare const DEFAULT_DB_ALIAS = "redis";
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Redis is UNPAGED: `list()` without a `size` returns every match, and `0` says exactly that.
|
|
4
|
+
* There is no server side LIMIT to fall back on — answering a criteria already walked the whole
|
|
5
|
+
* namespace — so an implied default would only hide records without saving any work.
|
|
6
|
+
*/
|
|
7
|
+
export declare const DEFAULT_PAGE_SIZE = 0;
|
|
8
|
+
/** Keys per SCAN round trip. Bounded so one sweep never blocks the server the way KEYS does. */
|
|
9
|
+
export declare const SCAN_BATCH = 200;
|
|
10
|
+
/** Keys per MGET / DEL round trip once SCAN has collected them. */
|
|
11
|
+
export declare const READ_BATCH = 200;
|
|
12
|
+
/** Entries kept in a stream, trimmed approximately (`MAXLEN ~`) so trimming stays O(1)-ish. */
|
|
13
|
+
export declare const STREAM_MAX_LENGTH = 10000;
|
|
14
|
+
/** How long a stream read blocks before the consumer loops and looks for pending entries. */
|
|
15
|
+
export declare const DEFAULT_STREAM_BLOCK = 1000;
|
|
16
|
+
/** How long an unacknowledged entry may sit with its consumer before another may reclaim it. */
|
|
17
|
+
export declare const RECLAIM_IDLE = 60000;
|
|
18
|
+
/** Entries reclaimed per XAUTOCLAIM pass. */
|
|
19
|
+
export declare const RECLAIM_COUNT = 10;
|
|
20
|
+
/** Length of the generated consumer name when `consume` is not given one. */
|
|
21
|
+
export declare const CONSUMER_ID_LENGTH = 15;
|
|
3
22
|
//# sourceMappingURL=consts.d.ts.map
|
package/build/consts.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,gBAAgB,UAAU,CAAA;AAEvC;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,IAAI,CAAA;AAElC,gGAAgG;AAChG,eAAO,MAAM,UAAU,MAAM,CAAA;AAE7B,mEAAmE;AACnE,eAAO,MAAM,UAAU,MAAM,CAAA;AAE7B,+FAA+F;AAC/F,eAAO,MAAM,iBAAiB,QAAQ,CAAA;AAEtC,6FAA6F;AAC7F,eAAO,MAAM,oBAAoB,OAAO,CAAA;AAExC,gGAAgG;AAChG,eAAO,MAAM,YAAY,QAAQ,CAAA;AAEjC,6CAA6C;AAC7C,eAAO,MAAM,aAAa,KAAK,CAAA;AAE/B,6EAA6E;AAC7E,eAAO,MAAM,kBAAkB,KAAK,CAAA"}
|
package/build/consts.js
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
1
|
export const DEFAULT_DB_ALIAS = 'redis';
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Redis is UNPAGED: `list()` without a `size` returns every match, and `0` says exactly that.
|
|
4
|
+
* There is no server side LIMIT to fall back on — answering a criteria already walked the whole
|
|
5
|
+
* namespace — so an implied default would only hide records without saving any work.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_PAGE_SIZE = 0;
|
|
8
|
+
/** Keys per SCAN round trip. Bounded so one sweep never blocks the server the way KEYS does. */
|
|
9
|
+
export const SCAN_BATCH = 200;
|
|
10
|
+
/** Keys per MGET / DEL round trip once SCAN has collected them. */
|
|
11
|
+
export const READ_BATCH = 200;
|
|
12
|
+
/** Entries kept in a stream, trimmed approximately (`MAXLEN ~`) so trimming stays O(1)-ish. */
|
|
13
|
+
export const STREAM_MAX_LENGTH = 10000;
|
|
14
|
+
/** How long a stream read blocks before the consumer loops and looks for pending entries. */
|
|
15
|
+
export const DEFAULT_STREAM_BLOCK = 1000;
|
|
16
|
+
/** How long an unacknowledged entry may sit with its consumer before another may reclaim it. */
|
|
17
|
+
export const RECLAIM_IDLE = 60000;
|
|
18
|
+
/** Entries reclaimed per XAUTOCLAIM pass. */
|
|
19
|
+
export const RECLAIM_COUNT = 10;
|
|
20
|
+
/** Length of the generated consumer name when `consume` is not given one. */
|
|
21
|
+
export const CONSUMER_ID_LENGTH = 15;
|
|
3
22
|
//# sourceMappingURL=consts.js.map
|
package/build/consts.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,gBAAgB,GAAG,OAAO,CAAA;AAEvC;;;;GAIG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,CAAA;AAElC,gGAAgG;AAChG,MAAM,CAAC,MAAM,UAAU,GAAG,GAAG,CAAA;AAE7B,mEAAmE;AACnE,MAAM,CAAC,MAAM,UAAU,GAAG,GAAG,CAAA;AAE7B,+FAA+F;AAC/F,MAAM,CAAC,MAAM,iBAAiB,GAAG,KAAK,CAAA;AAEtC,6FAA6F;AAC7F,MAAM,CAAC,MAAM,oBAAoB,GAAG,IAAI,CAAA;AAExC,gGAAgG;AAChG,MAAM,CAAC,MAAM,YAAY,GAAG,KAAK,CAAA;AAEjC,6CAA6C;AAC7C,MAAM,CAAC,MAAM,aAAa,GAAG,EAAE,CAAA;AAE/B,6EAA6E;AAC7E,MAAM,CAAC,MAAM,kBAAkB,GAAG,EAAE,CAAA"}
|
package/build/resource.d.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { type ResourceRecord } from '@owlmeans/resource';
|
|
2
2
|
import type { RedisResource } from './types.js';
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* A `Resource` over plain redis strings: one JSON document per namespaced key.
|
|
5
|
+
*
|
|
6
|
+
* Reads by id are O(1) GETs. Everything else — a criteria read, a listing, a count, a purge —
|
|
7
|
+
* walks this resource's OWN namespace with SCAN and evaluates the criteria in memory, because
|
|
8
|
+
* redis has no index to ask. That is affordable for a small namespaced set (a session cache, an
|
|
9
|
+
* adapter store) and wrong for anything unbounded or hot; a resource that needs real queries
|
|
10
|
+
* belongs in mongo or postgres.
|
|
11
|
+
*/
|
|
12
|
+
export declare const makeRedisResource: <R extends ResourceRecord, T extends RedisResource<R> = RedisResource<R>>(alias: string, dbAlias?: string, serviceAlias?: string) => T;
|
|
4
13
|
//# sourceMappingURL=resource.d.ts.map
|
package/build/resource.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../src/resource.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../src/resource.ts"],"names":[],"mappings":"AAAA,OAAO,EAAuE,KAAK,cAAc,EAAwK,MAAM,oBAAoB,CAAA;AAGnS,OAAO,KAAK,EAAkB,aAAa,EAAE,MAAM,YAAY,CAAA;AAY/D;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,GAC5B,CAAC,SAAS,cAAc,EAAE,CAAC,SAAS,aAAa,CAAC,CAAC,CAAC,4BAE7C,MAAM,8CACZ,CAgaF,CAAA"}
|