@owlmeans/redis-resource 0.1.18-rc.2 → 0.1.18-rc.21
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 +2 -2
- 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 +243 -217
- package/build/resource.js.map +1 -1
- package/build/types.d.ts +27 -12
- package/build/types.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/consts.ts +27 -2
- package/src/resource.ts +289 -233
- package/src/types.ts +28 -18
- package/build/.gitkeep +0 -0
- package/build/utils/index.d.ts +0 -2
- package/build/utils/index.d.ts.map +0 -1
- package/build/utils/index.js +0 -2
- package/build/utils/index.js.map +0 -1
- package/build/utils/options.d.ts +0 -3
- package/build/utils/options.d.ts.map +0 -1
- package/build/utils/options.js +0 -17
- package/build/utils/options.js.map +0 -1
- package/src/utils/index.ts +0 -2
- package/src/utils/options.ts +0 -17
package/src/resource.ts
CHANGED
|
@@ -1,33 +1,47 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type {
|
|
2
|
+
Criteria, FirstOptions, ListOptions, ListResult, ResourceRecord, SubscribeOptions, Ttl,
|
|
3
|
+
Unsubscribe, WriteOptions
|
|
4
|
+
} from '@owlmeans/resource'
|
|
5
|
+
import {
|
|
6
|
+
applyQuery, filterRecords, firstMatch, matchCriteria, MisshapedRecord, RecordExists,
|
|
7
|
+
UnknownRecordError, UnsupportedArgumentError
|
|
8
|
+
} from '@owlmeans/resource'
|
|
3
9
|
import type { RedisClient, RedisDbService, RedisResource } from './types.js'
|
|
4
10
|
import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
|
|
5
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
CONSUMER_ID_LENGTH, DEFAULT_DB_ALIAS, DEFAULT_STREAM_BLOCK, READ_BATCH, RECLAIM_COUNT,
|
|
13
|
+
RECLAIM_IDLE, SCAN_BATCH, STREAM_MAX_LENGTH
|
|
14
|
+
} from './consts.js'
|
|
6
15
|
import { appendContextual, assertContext } from '@owlmeans/context'
|
|
7
|
-
import
|
|
8
|
-
import { prepareSubOptions } from './utils/options.js'
|
|
9
|
-
import { createIdOfLength } from '@owlmeans/basic-ids'
|
|
16
|
+
import { createIdOfLength, uuid } from '@owlmeans/basic-ids'
|
|
10
17
|
|
|
11
18
|
type Config = ServerConfig
|
|
12
19
|
type Context<C extends Config = Config> = ServerContext<C>
|
|
13
20
|
|
|
21
|
+
/**
|
|
22
|
+
* A `Resource` over plain redis strings: one JSON document per namespaced key.
|
|
23
|
+
*
|
|
24
|
+
* Reads by id are O(1) GETs. Everything else — a criteria read, a listing, a count, a purge —
|
|
25
|
+
* walks this resource's OWN namespace with SCAN and evaluates the criteria in memory, because
|
|
26
|
+
* redis has no index to ask. That is affordable for a small namespaced set (a session cache, an
|
|
27
|
+
* adapter store) and wrong for anything unbounded or hot; a resource that needs real queries
|
|
28
|
+
* belongs in mongo or postgres.
|
|
29
|
+
*/
|
|
14
30
|
export const makeRedisResource = <
|
|
15
31
|
R extends ResourceRecord, T extends RedisResource<R> = RedisResource<R>
|
|
16
32
|
>(
|
|
17
|
-
alias: string, dbAlias = DEFAULT_DB_ALIAS, serviceAlias = DEFAULT_DB_ALIAS
|
|
18
|
-
makeCustomResource?: ResourceMaker<R, T>
|
|
33
|
+
alias: string, dbAlias = DEFAULT_DB_ALIAS, serviceAlias = DEFAULT_DB_ALIAS
|
|
19
34
|
): T => {
|
|
20
35
|
const location = `redis-resource:${alias}`
|
|
21
36
|
|
|
22
37
|
/**
|
|
23
|
-
* A number
|
|
24
|
-
* expire at.
|
|
38
|
+
* A number is seconds from now; a `Date` is the absolute instant to expire at.
|
|
25
39
|
*
|
|
26
40
|
* The absolute form needs PEXPIREAT, not EXPIREAT: `Date.getTime()` is milliseconds while
|
|
27
41
|
* EXPIREAT reads its argument as seconds, so the timestamp landed tens of thousands of years
|
|
28
42
|
* out and the record never expired.
|
|
29
43
|
*/
|
|
30
|
-
const applyTtl = async (key: string, ttl:
|
|
44
|
+
const applyTtl = async (key: string, ttl: Ttl): Promise<void> => {
|
|
31
45
|
if (ttl instanceof Date) {
|
|
32
46
|
await resource.db.client.pexpireat(key, ttl.getTime())
|
|
33
47
|
} else {
|
|
@@ -35,289 +49,341 @@ export const makeRedisResource = <
|
|
|
35
49
|
}
|
|
36
50
|
}
|
|
37
51
|
|
|
38
|
-
|
|
39
|
-
|
|
52
|
+
/** Close a subscription once its lifetime runs out — seconds from now, or an instant. */
|
|
53
|
+
const armTtl = (close: Unsubscribe, ttl?: Ttl): void => {
|
|
54
|
+
if (ttl == null) {
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
setTimeout(() => void close(), ttl instanceof Date ? ttl.getTime() - Date.now() : ttl * 1000)
|
|
58
|
+
}
|
|
40
59
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (record == null) {
|
|
44
|
-
throw new UnknownRecordError(id)
|
|
45
|
-
}
|
|
60
|
+
/** The record as it goes into redis: whatever was passed, under the id it is keyed by. */
|
|
61
|
+
const withId = (record: Partial<R>, id: string): R => ({ ...record, id } as unknown as R)
|
|
46
62
|
|
|
47
|
-
|
|
48
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Write the record under its key. SET both creates and replaces, which is what lets `save`
|
|
65
|
+
* upsert without reading first.
|
|
66
|
+
*/
|
|
67
|
+
const put = async (id: string, record: Partial<R>, opts?: WriteOptions): Promise<R> => {
|
|
68
|
+
const key = resource.key(id)
|
|
69
|
+
const stored = withId(record, id)
|
|
70
|
+
await resource.db.client.set(key, JSON.stringify(stored))
|
|
71
|
+
if (opts?.ttl != null) {
|
|
72
|
+
await applyTtl(key, opts.ttl)
|
|
73
|
+
}
|
|
49
74
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
opts = field
|
|
53
|
-
field = field.field
|
|
54
|
-
}
|
|
55
|
-
if (opts != null) {
|
|
56
|
-
throw new UnsupportedArgumentError('load:opts')
|
|
57
|
-
}
|
|
58
|
-
if (field === 'id' || field == null) {
|
|
59
|
-
const val = await resource.db.client.get(resource.key(id))
|
|
60
|
-
if (val == null) {
|
|
61
|
-
return null
|
|
62
|
-
}
|
|
75
|
+
return stored
|
|
76
|
+
}
|
|
63
77
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Every key under this resource's prefix, collected with SCAN.
|
|
80
|
+
*
|
|
81
|
+
* Never KEYS: KEYS blocks the server for the length of the whole sweep, while SCAN walks the
|
|
82
|
+
* keyspace in bounded bites. SCAN may hand the same key back twice across iterations, which is
|
|
83
|
+
* why the keys land in a Set.
|
|
84
|
+
*/
|
|
85
|
+
const scanKeys = async (): Promise<string[]> => {
|
|
86
|
+
const found = new Set<string>()
|
|
87
|
+
let cursor = '0'
|
|
88
|
+
do {
|
|
89
|
+
const [next, batch] = await resource.db.client.scan(
|
|
90
|
+
cursor, 'MATCH', resource.key(), 'COUNT', SCAN_BATCH
|
|
91
|
+
)
|
|
92
|
+
cursor = next
|
|
93
|
+
batch.forEach(key => found.add(key))
|
|
94
|
+
} while (cursor !== '0')
|
|
67
95
|
|
|
68
|
-
|
|
69
|
-
|
|
96
|
+
return [...found]
|
|
97
|
+
}
|
|
70
98
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
99
|
+
/**
|
|
100
|
+
* The namespace as key/record pairs, read in MGET batches.
|
|
101
|
+
*
|
|
102
|
+
* O(N) over the resource's own namespace — every criteria that is not a bare id is answered
|
|
103
|
+
* from this in memory. A key holding something other than a string, this resource's own streams
|
|
104
|
+
* included, reads back as `null` from MGET and is skipped.
|
|
105
|
+
*/
|
|
106
|
+
const readNamespace = async (): Promise<Array<[string, R]>> => {
|
|
107
|
+
const keys = await scanKeys()
|
|
108
|
+
const entries: Array<[string, R]> = []
|
|
109
|
+
for (let from = 0; from < keys.length; from += READ_BATCH) {
|
|
110
|
+
const batch = keys.slice(from, from + READ_BATCH)
|
|
111
|
+
const values = await resource.db.client.mget(batch)
|
|
112
|
+
values.forEach((value, index) => {
|
|
113
|
+
if (typeof value === 'string') {
|
|
114
|
+
entries.push([batch[index], JSON.parse(value)])
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
}
|
|
79
118
|
|
|
80
|
-
|
|
81
|
-
|
|
119
|
+
return entries
|
|
120
|
+
}
|
|
82
121
|
|
|
83
|
-
|
|
84
|
-
let field = 'id'
|
|
85
|
-
if (typeof opts === 'string') {
|
|
86
|
-
field = opts
|
|
87
|
-
} else if (typeof opts === 'object') {
|
|
88
|
-
field = opts.field ?? field
|
|
89
|
-
}
|
|
90
|
-
if (field !== 'id' && record[field as keyof typeof record] == null) {
|
|
91
|
-
throw new MisshapedRecord('no-field-value')
|
|
92
|
-
}
|
|
93
|
-
const id = field == 'id' ? record.id : record[field as keyof typeof record] as string
|
|
94
|
-
if (id == null) {
|
|
95
|
-
throw new MisshapedRecord('id')
|
|
96
|
-
}
|
|
97
|
-
const _opts = typeof opts === 'object' ? opts : {}
|
|
98
|
-
const existing = await resource.get(id, field)
|
|
99
|
-
const updated = { ...existing, ...record }
|
|
100
|
-
await resource.db.client.set(resource.key(id), JSON.stringify(updated))
|
|
101
|
-
if (_opts.ttl != null) {
|
|
102
|
-
await applyTtl(resource.key(id), _opts.ttl)
|
|
103
|
-
}
|
|
122
|
+
const readRecords = async (): Promise<R[]> => (await readNamespace()).map(([, record]) => record)
|
|
104
123
|
|
|
105
|
-
|
|
106
|
-
|
|
124
|
+
/** `{ id: 'x' }` is the one criteria redis can answer by key; anything else needs the walk. */
|
|
125
|
+
const soleId = (where: Criteria<any>): string | null => {
|
|
126
|
+
const keys = Object.keys(where)
|
|
107
127
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
throw new UnknownRecordError('id')
|
|
111
|
-
}
|
|
112
|
-
if (await resource.load(record.id) != null) {
|
|
113
|
-
throw new RecordExists(record.id)
|
|
114
|
-
}
|
|
128
|
+
return keys.length === 1 && keys[0] === 'id' && typeof where.id === 'string' ? where.id : null
|
|
129
|
+
}
|
|
115
130
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
131
|
+
const loadOne = async (
|
|
132
|
+
idOrWhere: string | Criteria<any>, opts?: FirstOptions<any>
|
|
133
|
+
): Promise<R | null> => {
|
|
134
|
+
const id = typeof idOrWhere === 'string' ? idOrWhere : soleId(idOrWhere)
|
|
135
|
+
if (id != null) {
|
|
136
|
+
const value = await resource.db.client.get(resource.key(id))
|
|
121
137
|
|
|
122
|
-
return
|
|
123
|
-
}
|
|
138
|
+
return value == null ? null : JSON.parse(value)
|
|
139
|
+
}
|
|
124
140
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (typeof id === 'object') {
|
|
128
|
-
if (id.id == null) {
|
|
129
|
-
throw new MisshapedRecord('id')
|
|
130
|
-
}
|
|
131
|
-
record = await resource.load(id.id)
|
|
132
|
-
} else {
|
|
133
|
-
// `opts` is optional in the contract: without this branch `delete(id)` — the plain,
|
|
134
|
-
// by-id form every caller uses — left `record` null and returned without touching redis.
|
|
135
|
-
record = await resource.load(id, opts)
|
|
136
|
-
}
|
|
141
|
+
return firstMatch(await readRecords(), idOrWhere as Criteria<any>, opts)
|
|
142
|
+
}
|
|
137
143
|
|
|
138
|
-
|
|
139
|
-
|
|
144
|
+
/**
|
|
145
|
+
* One dedicated connection per subscription: a redis client in subscriber mode accepts nothing
|
|
146
|
+
* but (un)subscribe commands, so the resource's own client has to stay out of it.
|
|
147
|
+
*/
|
|
148
|
+
const listen = async (
|
|
149
|
+
pattern: string, onMessage: (channel: string, message: string) => void,
|
|
150
|
+
opts?: { once?: boolean, ttl?: Ttl }
|
|
151
|
+
): Promise<Unsubscribe> => {
|
|
152
|
+
const subscriber = (resource.db.client as RedisClient).duplicate()
|
|
153
|
+
let closed = false
|
|
154
|
+
const unsubscribe: Unsubscribe = async () => {
|
|
155
|
+
if (closed) {
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
closed = true
|
|
159
|
+
try {
|
|
160
|
+
await subscriber.punsubscribe(pattern)
|
|
161
|
+
await subscriber.quit()
|
|
162
|
+
} catch (e) {
|
|
163
|
+
console.error(`${location}: failed to unsubscribe ${pattern}`, e)
|
|
140
164
|
}
|
|
165
|
+
}
|
|
141
166
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
167
|
+
await subscriber.psubscribe(pattern)
|
|
168
|
+
subscriber.on('pmessage', (_pattern, channel, message) => {
|
|
169
|
+
/**
|
|
170
|
+
* `once` closes before the handler's own work finishes on purpose: the handler may await a
|
|
171
|
+
* read, and a second message arriving meanwhile would fire a subscription that has already
|
|
172
|
+
* delivered its one message.
|
|
173
|
+
*/
|
|
174
|
+
if (opts?.once === true) {
|
|
175
|
+
void unsubscribe()
|
|
148
176
|
}
|
|
177
|
+
onMessage(channel, message)
|
|
178
|
+
})
|
|
179
|
+
armTtl(unsubscribe, opts?.ttl)
|
|
149
180
|
|
|
150
|
-
|
|
181
|
+
return unsubscribe
|
|
182
|
+
}
|
|
151
183
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
184
|
+
const resource: T = appendContextual<T>(alias, {
|
|
185
|
+
key: (key?: string) => `${resource.db.prefix}:${key ?? '*'}`,
|
|
155
186
|
|
|
156
|
-
|
|
157
|
-
|
|
187
|
+
/**
|
|
188
|
+
* @throws {UnknownRecordError}
|
|
189
|
+
*/
|
|
190
|
+
get: async (idOrWhere: string | Criteria<any>, opts?: FirstOptions<any>): Promise<R> => {
|
|
191
|
+
const record = await loadOne(idOrWhere, opts)
|
|
192
|
+
if (record == null) {
|
|
193
|
+
throw new UnknownRecordError(
|
|
194
|
+
typeof idOrWhere === 'string' ? idOrWhere : JSON.stringify(idOrWhere)
|
|
195
|
+
)
|
|
158
196
|
}
|
|
159
197
|
|
|
160
|
-
return
|
|
198
|
+
return record
|
|
161
199
|
},
|
|
162
200
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
201
|
+
load: loadOne,
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Unpaged by default: without a `size` every match comes back. A `page` on its own is a
|
|
205
|
+
* caller error rather than a window over an implied default — there is no default here to
|
|
206
|
+
* take a window of.
|
|
207
|
+
*
|
|
208
|
+
* @throws {UnsupportedArgumentError} on `page` without `size`.
|
|
209
|
+
*/
|
|
210
|
+
list: async (where?: Criteria<any>, opts?: ListOptions<any>): Promise<ListResult<R>> => {
|
|
211
|
+
if (opts?.page != null && opts.size == null) {
|
|
212
|
+
throw new UnsupportedArgumentError('page-without-size')
|
|
172
213
|
}
|
|
173
214
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
215
|
+
return applyQuery(await readRecords(), where, opts)
|
|
216
|
+
},
|
|
177
217
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
218
|
+
count: async (where?: Criteria<any>): Promise<number> =>
|
|
219
|
+
filterRecords(await readRecords(), where).length,
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* SET NX rather than a read followed by a write: two callers racing for the same id both saw
|
|
223
|
+
* an empty key, both "created" the record, and the loser's write silently won.
|
|
224
|
+
*
|
|
225
|
+
* A record with no id gets one — redis needs a key, and `save` relies on this to create.
|
|
226
|
+
*
|
|
227
|
+
* @throws {RecordExists}
|
|
228
|
+
*/
|
|
229
|
+
create: async (record: Partial<R>, opts?: WriteOptions): Promise<R> => {
|
|
230
|
+
const id = record.id ?? uuid()
|
|
231
|
+
const key = resource.key(id)
|
|
232
|
+
const stored = withId(record, id)
|
|
233
|
+
if (await resource.db.client.set(key, JSON.stringify(stored), 'NX') == null) {
|
|
234
|
+
throw new RecordExists(id)
|
|
235
|
+
}
|
|
236
|
+
if (opts?.ttl != null) {
|
|
237
|
+
await applyTtl(key, opts.ttl)
|
|
184
238
|
}
|
|
185
239
|
|
|
186
|
-
|
|
240
|
+
return stored
|
|
241
|
+
},
|
|
187
242
|
|
|
188
|
-
|
|
243
|
+
/**
|
|
244
|
+
* Replaces the whole record: what is stored afterwards is exactly what was passed, not a
|
|
245
|
+
* merge over what was there. A renewal has to pass its TTL again — SET drops the old expiry.
|
|
246
|
+
*
|
|
247
|
+
* @throws {MisshapedRecord} without an id, {@link UnknownRecordError} when the id is absent.
|
|
248
|
+
*/
|
|
249
|
+
update: async (record: Partial<R>, opts?: WriteOptions): Promise<R> => {
|
|
250
|
+
if (record.id == null) {
|
|
189
251
|
throw new MisshapedRecord('id')
|
|
190
252
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
return record
|
|
253
|
+
if (await resource.db.client.exists(resource.key(record.id)) < 1) {
|
|
254
|
+
throw new UnknownRecordError(record.id)
|
|
194
255
|
}
|
|
195
256
|
|
|
196
|
-
|
|
257
|
+
return put(record.id, record, opts)
|
|
197
258
|
},
|
|
198
259
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const pager = options.pager ?? {}
|
|
260
|
+
/** Creates when the record carries no id, replaces otherwise — SET does both. */
|
|
261
|
+
save: async (record: Partial<R>, opts?: WriteOptions): Promise<R> =>
|
|
262
|
+
record.id == null ? resource.create(record, opts) : put(record.id, record, opts),
|
|
203
263
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
264
|
+
/**
|
|
265
|
+
* GETDEL — reading the record and dropping the key in one command, so two callers cannot both
|
|
266
|
+
* be handed the same record. Needs redis 6.2 or newer.
|
|
267
|
+
*/
|
|
268
|
+
delete: async (id: string): Promise<R | null> => {
|
|
269
|
+
const value = await resource.db.client.getdel(resource.key(id))
|
|
208
270
|
|
|
209
|
-
|
|
210
|
-
return { items: [], pager }
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const start = pager.page * pager.size
|
|
214
|
-
const slice = keys.toSpliced(start, pager.size)
|
|
215
|
-
|
|
216
|
-
return {
|
|
217
|
-
pager, items: (await Promise.all(slice.map(
|
|
218
|
-
async key => await resource.db.client.get(key)
|
|
219
|
-
))).filter(val => typeof val === 'string').map(
|
|
220
|
-
val => JSON.parse(val as string)
|
|
221
|
-
)
|
|
222
|
-
}
|
|
271
|
+
return value == null ? null : JSON.parse(value)
|
|
223
272
|
},
|
|
224
273
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
try {
|
|
235
|
-
await subscriber.punsubscribe(resource.key(opts.key)).then(() => subscriber.quit())
|
|
236
|
-
} catch (e) {
|
|
237
|
-
console.error('Try to unsubscribe redis resource', e)
|
|
238
|
-
}
|
|
274
|
+
/**
|
|
275
|
+
* Delete-and-return where absence is an error — the consume-once read.
|
|
276
|
+
*
|
|
277
|
+
* @throws {UnknownRecordError}
|
|
278
|
+
*/
|
|
279
|
+
take: async (id: string): Promise<R> => {
|
|
280
|
+
const record = await resource.delete(id)
|
|
281
|
+
if (record == null) {
|
|
282
|
+
throw new UnknownRecordError(id)
|
|
239
283
|
}
|
|
240
284
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
if (opts.once === true) {
|
|
244
|
-
void unsusbscibe()
|
|
245
|
-
}
|
|
246
|
-
}
|
|
285
|
+
return record
|
|
286
|
+
},
|
|
247
287
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
await subscriber.psubscribe(resource.key(opts.key))
|
|
258
|
-
subscriber.on('pmessage', (_, __, message) => listener(JSON.parse(message)))
|
|
288
|
+
/**
|
|
289
|
+
* Bulk delete over the namespace walk. An empty criteria object is refused: wiping a whole
|
|
290
|
+
* namespace is what FLUSHDB is for, not something a filter that came back empty should do.
|
|
291
|
+
*
|
|
292
|
+
* @throws {UnsupportedArgumentError} on empty criteria.
|
|
293
|
+
*/
|
|
294
|
+
purge: async (where: Criteria<any>): Promise<number> => {
|
|
295
|
+
if (where == null || Object.keys(where).length < 1) {
|
|
296
|
+
throw new UnsupportedArgumentError('purge:empty-criteria')
|
|
259
297
|
}
|
|
298
|
+
const keys = (await readNamespace())
|
|
299
|
+
.filter(([, record]) => matchCriteria(record, where))
|
|
300
|
+
.map(([key]) => key)
|
|
260
301
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
? opts.ttl.getTime() - Date.now()
|
|
265
|
-
: typeof opts.ttl === 'number'
|
|
266
|
-
? opts.ttl * 1000
|
|
267
|
-
: null
|
|
268
|
-
if (ttl == null) {
|
|
269
|
-
throw new UnsupportedArgumentError(`subscribe:ttl:${typeof opts.ttl}`)
|
|
270
|
-
}
|
|
271
|
-
setTimeout(unsusbscibe, ttl)
|
|
302
|
+
let deleted = 0
|
|
303
|
+
for (let from = 0; from < keys.length; from += READ_BATCH) {
|
|
304
|
+
deleted += await resource.db.client.del(keys.slice(from, from + READ_BATCH))
|
|
272
305
|
}
|
|
273
306
|
|
|
274
|
-
return
|
|
307
|
+
return deleted
|
|
275
308
|
},
|
|
276
309
|
|
|
277
|
-
publish: async (
|
|
278
|
-
await resource.db.client.publish(resource.key(
|
|
310
|
+
publish: async (value: R, channel?: string): Promise<void> => {
|
|
311
|
+
await resource.db.client.publish(resource.key(channel), JSON.stringify(value))
|
|
279
312
|
},
|
|
280
313
|
|
|
281
|
-
|
|
314
|
+
/**
|
|
315
|
+
* Channel pub/sub. The channel name is namespaced like a key, and an omitted channel listens
|
|
316
|
+
* to the resource's whole namespace — `key()` yields the glob that PSUBSCRIBE matches on.
|
|
317
|
+
*/
|
|
318
|
+
subscribe: async (
|
|
319
|
+
handler: (value: R) => void | Promise<void>, opts?: SubscribeOptions
|
|
320
|
+
): Promise<Unsubscribe> => listen(
|
|
321
|
+
resource.key(opts?.channel),
|
|
322
|
+
(_channel, message) => void handler(JSON.parse(message)),
|
|
323
|
+
opts
|
|
324
|
+
),
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Keyspace notifications for ONE record: every write, expiry or delete on its key hands the
|
|
328
|
+
* handler the current value, or `null` once the key is gone.
|
|
329
|
+
*
|
|
330
|
+
* Keyspace events are published per database and this listens on db 0, so deployments sharing
|
|
331
|
+
* one instance isolate on the key prefix, never on the db index. The server also has to have
|
|
332
|
+
* `notify-keyspace-events` enabled — redis emits nothing otherwise.
|
|
333
|
+
*/
|
|
334
|
+
watch: async (
|
|
335
|
+
id: string, handler: (value: R | null) => void | Promise<void>,
|
|
336
|
+
opts?: Omit<SubscribeOptions, 'channel'>
|
|
337
|
+
): Promise<Unsubscribe> => listen(
|
|
338
|
+
`__keyspace@0__:${resource.key(id)}`,
|
|
339
|
+
() => void loadOne(id).then(record => handler(record)),
|
|
340
|
+
opts
|
|
341
|
+
),
|
|
342
|
+
|
|
343
|
+
stream: async (key: string, value: R): Promise<void> => {
|
|
282
344
|
await resource.db.client.xadd(
|
|
283
|
-
resource.key(key), 'MAXLEN', '~',
|
|
345
|
+
resource.key(key), 'MAXLEN', '~', STREAM_MAX_LENGTH, '*', 'payload', JSON.stringify(value)
|
|
284
346
|
)
|
|
285
347
|
},
|
|
286
348
|
|
|
287
|
-
consume: async function* (
|
|
288
|
-
|
|
349
|
+
consume: async function* (
|
|
350
|
+
key: string, opts?: { group?: string, consumer?: string, block?: number }
|
|
351
|
+
): AsyncGenerator<R> {
|
|
289
352
|
const streamKey = resource.key(key)
|
|
353
|
+
const group = opts?.group
|
|
354
|
+
const consumer = opts?.consumer ?? createIdOfLength(CONSUMER_ID_LENGTH)
|
|
355
|
+
const block = opts?.block ?? DEFAULT_STREAM_BLOCK
|
|
356
|
+
|
|
290
357
|
if (group == null) {
|
|
291
358
|
let id = '$'
|
|
292
359
|
do {
|
|
293
|
-
const resp = await resource.db.client.xread(
|
|
294
|
-
'BLOCK', 1000, 'STREAMS', streamKey, id
|
|
295
|
-
)
|
|
360
|
+
const resp = await resource.db.client.xread('BLOCK', block, 'STREAMS', streamKey, id)
|
|
296
361
|
if (!resp) continue
|
|
297
362
|
|
|
298
363
|
const [, entries] = resp[0]
|
|
299
364
|
for (const [entryId, fields] of entries) {
|
|
300
365
|
id = entryId
|
|
301
|
-
const line = fields[1]
|
|
302
366
|
try {
|
|
303
|
-
|
|
304
|
-
yield JSON.parse(line)
|
|
367
|
+
yield JSON.parse(fields[1])
|
|
305
368
|
} catch (e) {
|
|
306
369
|
console.error('Cannot parse redis stream entry', e)
|
|
307
370
|
}
|
|
308
371
|
}
|
|
309
372
|
} while (true)
|
|
310
373
|
} else {
|
|
311
|
-
|
|
374
|
+
/**
|
|
375
|
+
* Entries a consumer took and never acknowledged, claimed under THIS consumer's name.
|
|
376
|
+
* Claiming them for a fixed name instead left them owned by a consumer that never reads,
|
|
377
|
+
* so a crashed worker's entries moved once and then stalled for good.
|
|
378
|
+
*/
|
|
379
|
+
const reclaim = async function* (): AsyncGenerator<R> {
|
|
312
380
|
try {
|
|
313
381
|
const [, entries] = await resource.db.client.xautoclaim(
|
|
314
|
-
streamKey, group,
|
|
382
|
+
streamKey, group, consumer, RECLAIM_IDLE, '0-0', 'COUNT', RECLAIM_COUNT
|
|
315
383
|
)
|
|
316
384
|
for (const [id, fields] of entries as [string, string[]][]) {
|
|
317
|
-
console.log('Reclaimed', id)
|
|
318
|
-
const line = fields[1]
|
|
319
385
|
try {
|
|
320
|
-
yield JSON.parse(
|
|
386
|
+
yield JSON.parse(fields[1])
|
|
321
387
|
} catch (e) {
|
|
322
388
|
console.error('Cannot parse reclaimed redis stream entry', e)
|
|
323
389
|
}
|
|
@@ -333,18 +399,17 @@ export const makeRedisResource = <
|
|
|
333
399
|
do {
|
|
334
400
|
const resp = await resource.db.client.xreadgroup(
|
|
335
401
|
'GROUP', group, consumer,
|
|
336
|
-
'BLOCK',
|
|
402
|
+
'BLOCK', block, 'STREAMS', streamKey, '>'
|
|
337
403
|
)
|
|
338
404
|
if (!resp) {
|
|
339
|
-
yield*
|
|
405
|
+
yield* reclaim()
|
|
340
406
|
continue
|
|
341
407
|
}
|
|
342
408
|
|
|
343
409
|
const [, entries] = resp[0] as [string, Array<[string, string[]]>]
|
|
344
410
|
for (const [entryId, fields] of entries) {
|
|
345
|
-
const line = fields[1]
|
|
346
411
|
try {
|
|
347
|
-
yield JSON.parse(
|
|
412
|
+
yield JSON.parse(fields[1])
|
|
348
413
|
await resource.db.client.xack(streamKey, group, entryId)
|
|
349
414
|
} catch (e) {
|
|
350
415
|
console.error('Cannot parse redis stream entry', e)
|
|
@@ -353,11 +418,11 @@ export const makeRedisResource = <
|
|
|
353
418
|
} while (true)
|
|
354
419
|
}
|
|
355
420
|
}
|
|
356
|
-
} as Partial<T>)
|
|
421
|
+
} as unknown as Partial<T>)
|
|
357
422
|
|
|
358
423
|
resource.init = async () => {
|
|
359
424
|
const context = assertContext<Config, Context>(resource.ctx as Context, location)
|
|
360
|
-
const redis = context.service<RedisDbService>(serviceAlias
|
|
425
|
+
const redis = context.service<RedisDbService>(serviceAlias)
|
|
361
426
|
await redis.ready()
|
|
362
427
|
const db = await redis.db(dbAlias)
|
|
363
428
|
const _pref = (val: string) => val.replaceAll(/\W+/g, '_')
|
|
@@ -368,14 +433,5 @@ export const makeRedisResource = <
|
|
|
368
433
|
}
|
|
369
434
|
}
|
|
370
435
|
|
|
371
|
-
resource.reinitializeContext = <Type extends Contextual>(context: BasicContext<Config>) => {
|
|
372
|
-
const resource = (makeCustomResource?.(dbAlias, serviceAlias)
|
|
373
|
-
?? makeRedisResource<R, T>(alias, dbAlias, serviceAlias)) as unknown as Type
|
|
374
|
-
|
|
375
|
-
resource.ctx = context
|
|
376
|
-
|
|
377
|
-
return resource as Type
|
|
378
|
-
}
|
|
379
|
-
|
|
380
436
|
return resource
|
|
381
437
|
}
|