@owlmeans/redis-resource 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,294 @@
1
+ import type { ResourceMaker, ResourceRecord } from '@owlmeans/resource'
2
+ import { MisshapedRecord, prepareListOptions, RecordExists, UnknownRecordError, UnsupportedArgumentError } from '@owlmeans/resource'
3
+ import type { RedisClient, RedisDbService, RedisResource } from './types.js'
4
+ import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
5
+ import { DEFAULT_DB_ALIAS, DEFAULT_PAGE_SIZE } from './consts.js'
6
+ import { appendContextual, assertContext } from '@owlmeans/context'
7
+ import type { BasicContext, Contextual } from '@owlmeans/context'
8
+ import { prepareSubOptions } from './utils/options.js'
9
+
10
+ type Config = ServerConfig
11
+ type Context<C extends Config = Config> = ServerContext<C>
12
+
13
+ export const makeRedisResource = <
14
+ R extends ResourceRecord, T extends RedisResource<R> = RedisResource<R>
15
+ >(
16
+ alias: string, dbAlias = DEFAULT_DB_ALIAS, serviceAlias = DEFAULT_DB_ALIAS,
17
+ makeCustomResource?: ResourceMaker<R, T>
18
+ ): T => {
19
+ const location = `redis-resource:${alias}`
20
+
21
+ const resource: T = appendContextual<T>(alias, {
22
+ key: key => `${resource.db.prefix}:${key ?? '*'}`,
23
+
24
+ get: async (id, field, opts) => {
25
+ const record = await resource.load(id, field, opts)
26
+ if (record == null) {
27
+ throw new UnknownRecordError(id)
28
+ }
29
+
30
+ return record
31
+ },
32
+
33
+ load: async (id, field, opts) => {
34
+ if (typeof field === 'object') {
35
+ opts = field
36
+ field = field.field
37
+ }
38
+ if (opts != null) {
39
+ throw new UnsupportedArgumentError('load:opts')
40
+ }
41
+ if (field === 'id' || field == null) {
42
+ const val = await resource.db.client.get(resource.key(id))
43
+ if (val == null) {
44
+ return null
45
+ }
46
+
47
+ return JSON.parse(val)
48
+ }
49
+ const list = await resource.list({ [field]: id }, { pager: { size: 1 } })
50
+
51
+ return list.items[0] ?? null
52
+ },
53
+
54
+ save: async (record, opts) => {
55
+ if (record.id == null) {
56
+ throw new MisshapedRecord('id')
57
+ }
58
+ const existing = await resource.load(record.id)
59
+ if (existing != null) {
60
+ return resource.update(record, opts)
61
+ }
62
+
63
+ return resource.create(record, typeof opts !== 'string' ? opts : undefined)
64
+ },
65
+
66
+ update: async (record, opts) => {
67
+ let field = 'id'
68
+ if (typeof opts === 'string') {
69
+ field = opts
70
+ } else if (typeof opts === 'object') {
71
+ field = opts.field ?? field
72
+ }
73
+ if (field !== 'id' && record[field as keyof typeof record] == null) {
74
+ throw new MisshapedRecord('no-field-value')
75
+ }
76
+ const id = field == 'id' ? record.id : record[field as keyof typeof record] as string
77
+ if (id == null) {
78
+ throw new MisshapedRecord('id')
79
+ }
80
+ const _opts = typeof opts === 'object' ? opts : {}
81
+ const existing = await resource.get(id, field)
82
+ const updated = { ...existing, ...record }
83
+ await resource.db.client.set(resource.key(id), JSON.stringify(updated))
84
+ if (_opts.ttl != null) {
85
+ if (_opts.ttl instanceof Date) {
86
+ await resource.db.client.expireat(resource.key(id), _opts.ttl.getTime())
87
+ } else {
88
+ await resource.db.client.expire(resource.key(id), _opts.ttl)
89
+ }
90
+ }
91
+
92
+ return updated
93
+ },
94
+
95
+ create: async (record, opts) => {
96
+ if (record.id == null) {
97
+ throw new UnknownRecordError('id')
98
+ }
99
+ if (await resource.load(record.id) != null) {
100
+ throw new RecordExists(record.id)
101
+ }
102
+
103
+ const id = record.id
104
+ await resource.db.client.set(resource.key(id), JSON.stringify(record))
105
+ if (opts?.ttl != null) {
106
+ if (opts.ttl instanceof Date) {
107
+ await resource.db.client.expireat(resource.key(id), opts.ttl.getTime())
108
+ } else {
109
+ await resource.db.client.expire(resource.key(id), opts.ttl)
110
+ }
111
+ }
112
+
113
+ return record
114
+ },
115
+
116
+ delete: async (id, opts) => {
117
+ let record: R | null = null
118
+ if (typeof id === 'object') {
119
+ if (id.id == null) {
120
+ throw new MisshapedRecord('id')
121
+ }
122
+ record = await resource.load(id.id)
123
+ } else if (typeof opts === 'string') {
124
+ record = await resource.load(id, opts)
125
+ }
126
+
127
+ if (record == null) {
128
+ return null
129
+ }
130
+
131
+ let field = 'id'
132
+ if (typeof opts === 'string') {
133
+ field = opts
134
+ opts = undefined
135
+ } else if (typeof opts === 'object') {
136
+ field = opts.field ?? field
137
+ }
138
+
139
+ id = record.id ?? ''
140
+
141
+ if (id === '') {
142
+ throw new MisshapedRecord('id')
143
+ }
144
+
145
+ if (await resource.db.client.del(resource.key(id)) > 0) {
146
+ return record
147
+ }
148
+
149
+ return null
150
+ },
151
+
152
+ pick: async (id, opts) => {
153
+ let record: R | null = null
154
+ if (typeof id === 'object') {
155
+ if (id.id == null) {
156
+ throw new MisshapedRecord('id')
157
+ }
158
+ record = await resource.load(id.id)
159
+ } else {
160
+ record = await resource.load(id, opts)
161
+ }
162
+
163
+ if (record == null) {
164
+ return null
165
+ }
166
+
167
+ let field = 'id'
168
+ if (typeof opts === 'string') {
169
+ field = opts
170
+ opts = undefined
171
+ } else if (typeof opts === 'object') {
172
+ field = opts.field ?? field
173
+ }
174
+
175
+ id = record.id ?? ''
176
+
177
+ if (id === '') {
178
+ throw new MisshapedRecord('id')
179
+ }
180
+
181
+ if (await resource.db.client.del(resource.key(id)) > 0) {
182
+ return record
183
+ }
184
+
185
+ throw new UnknownRecordError(id)
186
+ },
187
+
188
+ list: async (criteria, opts) => {
189
+ const options = prepareListOptions(DEFAULT_PAGE_SIZE, criteria, opts)
190
+ criteria = options.criteria
191
+ const pager = options.pager ?? {}
192
+
193
+ const keys = await resource.db.client.keys(resource.key())
194
+ pager.total = keys.length
195
+ pager.page = pager.page ?? 0
196
+ pager.size = pager.size ?? DEFAULT_PAGE_SIZE
197
+
198
+ if (keys.length === 0) {
199
+ return { items: [], pager }
200
+ }
201
+
202
+ const start = pager.page * pager.size
203
+ const slice = keys.toSpliced(start, pager.size)
204
+
205
+ return {
206
+ pager, items: (await Promise.all(slice.map(
207
+ async key => await resource.db.client.get(key)
208
+ ))).filter(val => typeof val === 'string').map(
209
+ val => JSON.parse(val as string)
210
+ )
211
+ }
212
+ },
213
+
214
+ subscribe: async (handler, opts) => {
215
+ opts = prepareSubOptions(opts)
216
+
217
+ let _unsubascribeCalled = false
218
+ const unsusbscibe = async () => {
219
+ if (_unsubascribeCalled) {
220
+ return
221
+ }
222
+ _unsubascribeCalled = true
223
+ try {
224
+ await subscriber.punsubscribe(resource.key(opts.key)).then(() => subscriber.quit())
225
+ } catch (e) {
226
+ console.error('Try to unsubscribe redis resource', e)
227
+ }
228
+ }
229
+
230
+ const listener = (value: R) => {
231
+ handler(value as any)
232
+ if (opts.once === true) {
233
+ void unsusbscibe()
234
+ }
235
+ }
236
+
237
+ const subscriber = (resource.db.client as RedisClient).duplicate()
238
+ if (opts.key != null && null != await resource.load(opts.key)) {
239
+ await subscriber.psubscribe(`__keyspace@0__:${resource.key(opts.key)}`)
240
+ subscriber.on('pmessage', async (_, channel) => {
241
+ const [, , ...key] = (channel as string).split(':')
242
+ const record = await resource.load(key.join(':'))
243
+ listener(record ?? { id: key, __null: true } as any as R)
244
+ })
245
+ } else {
246
+ await subscriber.psubscribe(resource.key(opts.key))
247
+ subscriber.on('pmessage', (_, __, message) => listener(JSON.parse(message)))
248
+ }
249
+
250
+
251
+ if (opts.ttl != null) {
252
+ const ttl = opts.ttl instanceof Date
253
+ ? opts.ttl.getTime() - Date.now()
254
+ : typeof opts.ttl === 'number'
255
+ ? opts.ttl * 1000
256
+ : null
257
+ if (ttl == null) {
258
+ throw new UnsupportedArgumentError(`subscribe:ttl:${typeof opts.ttl}`)
259
+ }
260
+ setTimeout(unsusbscibe, ttl)
261
+ }
262
+
263
+ return unsusbscibe
264
+ },
265
+
266
+ publish: async (record, key) => {
267
+ await resource.db.client.publish(resource.key(key), JSON.stringify(record))
268
+ }
269
+ } as Partial<T>)
270
+
271
+ resource.init = async () => {
272
+ const context = assertContext<Config, Context>(resource.ctx as Context, location)
273
+ const redis = context.service<RedisDbService>(serviceAlias ?? dbAlias)
274
+ await redis.ready()
275
+ const db = await redis.db(dbAlias)
276
+ const _pref = (val: string) => val.replaceAll(/\W+/g, '_')
277
+
278
+ resource.db = {
279
+ client: db.client,
280
+ prefix: `${_pref(db.prefix)}-${_pref(resource.name ?? alias)}`
281
+ }
282
+ }
283
+
284
+ resource.reinitializeContext = <Type extends Contextual>(context: BasicContext<Config>) => {
285
+ const resource = (makeCustomResource?.(dbAlias, serviceAlias)
286
+ ?? makeRedisResource<R, T>(alias, dbAlias, serviceAlias)) as unknown as Type
287
+
288
+ resource.ctx = context
289
+
290
+ return resource as Type
291
+ }
292
+
293
+ return resource
294
+ }
package/src/types.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type { LifecycleOptions, Resource, ResourceDbService, ResourceRecord } from '@owlmeans/resource'
2
+
3
+ import type { RedisCommander, Redis, Cluster } from 'ioredis'
4
+
5
+ export interface RedisDbService extends ResourceDbService<RedisDb, RedisClient> {
6
+ }
7
+
8
+ export type RedisClient = Redis | Cluster
9
+
10
+ export interface RedisDb {
11
+ client: RedisCommander
12
+ prefix: string
13
+ }
14
+
15
+ export interface RedisResource<T extends ResourceRecord> extends Resource<T> {
16
+ name?: string
17
+
18
+ db: RedisDb
19
+
20
+ key: (key?: string) => string
21
+
22
+ subscribe: <Type extends T>(handler: (value: Type) => Promise<void>, key?: SubOpts) => Promise<() => Promise<void>>
23
+
24
+ publish: <Type extends T>(value: Type, key?: string) => Promise<void>
25
+ }
26
+
27
+ export type SubOpts = number |string | boolean | SubscriptionOptions
28
+
29
+ export interface SubscriptionOptions extends LifecycleOptions {
30
+ key?: string
31
+ once?: boolean
32
+ }
@@ -0,0 +1,2 @@
1
+
2
+ export * from './options.js'
@@ -0,0 +1,17 @@
1
+
2
+ import type { SubOpts, SubscriptionOptions } from '../types.js'
3
+
4
+ export const prepareSubOptions = (opts?: SubOpts): SubscriptionOptions => {
5
+ let _opts: SubscriptionOptions = {}
6
+ if (typeof opts === 'string') {
7
+ _opts.key = opts
8
+ } else if (typeof opts === 'number') {
9
+ _opts.ttl = opts
10
+ } else if (typeof opts === 'boolean') {
11
+ _opts.once = opts
12
+ } else if (typeof opts === 'object') {
13
+ _opts = opts
14
+ }
15
+
16
+ return _opts
17
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": [
3
+ "../tsconfig.default.json",
4
+ ],
5
+ "compilerOptions": {
6
+ "rootDir": "./src/", /* Specify the root folder within your source files. */
7
+ "outDir": "./build/", /* Specify an output folder for all emitted files. */
8
+ },
9
+ "exclude": [
10
+ "./dist/**/*",
11
+ "./build/**/*",
12
+ "./*.ts"
13
+ ]
14
+ }
@@ -0,0 +1 @@
1
+ {"root":["./src/consts.ts","./src/index.ts","./src/resource.ts","./src/types.ts","./src/utils/index.ts","./src/utils/options.ts"],"version":"5.6.3"}